|
|
const APP_BRAND_NAME = "S-lake高校雷达网";
|
|
|
|
|
|
const state = {
|
|
|
route: "home",
|
|
|
params: {},
|
|
|
user: JSON.parse(localStorage.getItem("slakeUser") || "null"),
|
|
|
profile: JSON.parse(localStorage.getItem("slakeProfile") || "{}"),
|
|
|
signedCourseIds: JSON.parse(localStorage.getItem("slakeSignedCourses") || "[1,2]"),
|
|
|
signedActivityIds: JSON.parse(localStorage.getItem("slakeSignedActivities") || "[1]"),
|
|
|
calendarMonth: "2026-05",
|
|
|
activityCalendarMonth: "2026-05",
|
|
|
selectedDate: "2026-05-15",
|
|
|
signupSessionId: null,
|
|
|
demands: [
|
|
|
{
|
|
|
id: 1,
|
|
|
type: "技术合作",
|
|
|
title: "寻找工业视觉缺陷检测场景",
|
|
|
status: "跟进中",
|
|
|
date: "2026-05-18",
|
|
|
description: "希望对接具备真实产线数据的企业,共同验证实验室算法。"
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
type: "产业资源",
|
|
|
title: "先进材料中试资源对接",
|
|
|
status: "已受理",
|
|
|
date: "2026-05-14",
|
|
|
description: "需要寻找小批量中试平台,验证材料稳定性和制备工艺。"
|
|
|
}
|
|
|
]
|
|
|
};
|
|
|
|
|
|
const data = {
|
|
|
banner: {
|
|
|
title: "连接高校科研与产业资源",
|
|
|
subtitle: "课程、活动、资讯与伙伴需求一站触达"
|
|
|
},
|
|
|
courses: [
|
|
|
{
|
|
|
id: 5,
|
|
|
title: "高校科技成果转化实战营",
|
|
|
category: "成果转化",
|
|
|
status: "进行中",
|
|
|
date: "2026-05-25",
|
|
|
time: "09:30-17:30",
|
|
|
location: "苏州工业园区 · 科创中心",
|
|
|
teacher: "林清源 老师",
|
|
|
university: "S-lake 先进技术发展中心",
|
|
|
seats: 80,
|
|
|
signed: 72,
|
|
|
signupStartDate: "2026-05-01",
|
|
|
signupEndDate: "2026-05-24",
|
|
|
summary: "围绕高校成果筛选、产业需求拆解和项目路演,进行全天实战训练。",
|
|
|
audience: ["高校科研团队负责人", "科技成果转化经理人", "产业投资与孵化服务人员"],
|
|
|
agenda: ["成果筛选方法", "产业需求访谈", "路演材料打磨"]
|
|
|
},
|
|
|
{
|
|
|
id: 1,
|
|
|
title: "AI 科研成果转化闭门课",
|
|
|
category: "AI",
|
|
|
status: "报名中",
|
|
|
date: "2026-06-12",
|
|
|
endDate: "2026-06-14",
|
|
|
time: "14:00-17:00",
|
|
|
location: "苏州工业园区 · 元禾会议中心",
|
|
|
teacher: "陈知远 教授",
|
|
|
university: "上海交通大学",
|
|
|
seats: 48,
|
|
|
signed: 31,
|
|
|
signupStartDate: "2026-05-01",
|
|
|
signupEndDate: "2026-06-11",
|
|
|
promoUrl: "assets/course-detail-banner.svg",
|
|
|
summary: "面向高校科研团队和产业伙伴,拆解 AI 技术从论文、样机到商业落地的关键路径。",
|
|
|
audience: ["AI 方向高校老师与博士后团队", "关注 AI 项目落地的企业研发负责人", "科技成果转化与投资机构代表"],
|
|
|
agenda: ["成果转化案例复盘", "产业需求匹配", "闭门交流"]
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
title: "先进材料项目投融资工作坊",
|
|
|
category: "材料",
|
|
|
status: "报名中",
|
|
|
date: "2026-06-20",
|
|
|
endDate: "2026-06-21",
|
|
|
time: "09:30-12:00",
|
|
|
location: "南京大学苏州校区",
|
|
|
teacher: "李明澈 教授",
|
|
|
university: "南京大学",
|
|
|
seats: 36,
|
|
|
signed: 18,
|
|
|
signupStartDate: "2026-05-10",
|
|
|
signupEndDate: "2026-06-19",
|
|
|
summary: "聚焦新材料项目早期验证、知识产权梳理和资本沟通材料准备。",
|
|
|
audience: ["先进材料科研团队", "材料项目创业者", "关注硬科技投资的产业方与基金代表"],
|
|
|
agenda: ["技术路线表达", "专利与样品验证", "融资路演点评"]
|
|
|
},
|
|
|
{
|
|
|
id: 3,
|
|
|
title: "机器人感知技术应用沙龙",
|
|
|
category: "机器人",
|
|
|
status: "未开始",
|
|
|
date: "2026-07-05",
|
|
|
time: "19:00-21:00",
|
|
|
location: "杭州未来科技城 · 智能制造中心",
|
|
|
teacher: "王亦然 博士",
|
|
|
university: "浙江大学",
|
|
|
seats: 100,
|
|
|
signed: 100,
|
|
|
signupStartDate: "2026-05-01",
|
|
|
signupEndDate: "2026-07-04",
|
|
|
summary: "邀请高校实验室与企业研发负责人交流多模态感知、边缘计算和场景验证。",
|
|
|
audience: ["机器人与智能制造方向科研人员", "企业算法、硬件与产品负责人", "关注机器人场景验证的产业伙伴"],
|
|
|
agenda: ["前沿论文导读", "企业应用分享", "自由问答"]
|
|
|
},
|
|
|
{
|
|
|
id: 4,
|
|
|
title: "高校成果转化基础训练营",
|
|
|
category: "成果转化",
|
|
|
status: "已结束",
|
|
|
date: "2026-05-15",
|
|
|
time: "10:00-12:00",
|
|
|
location: "苏州工业园区 · 路演厅",
|
|
|
teacher: "周行之 老师",
|
|
|
university: "S-lake 先进技术发展中心",
|
|
|
seats: 60,
|
|
|
signed: 56,
|
|
|
signupStartDate: "2026-04-01",
|
|
|
signupEndDate: "2026-05-14",
|
|
|
summary: "面向初次参与成果转化的科研团队,讲解需求识别、商业化路径和对接材料准备。",
|
|
|
audience: ["首次参与成果转化的科研团队", "高校院系科研秘书与项目经理", "早期科技创业团队"],
|
|
|
agenda: ["成果转化流程", "报名材料准备", "案例答疑"]
|
|
|
},
|
|
|
{
|
|
|
id: 6,
|
|
|
title: "生物医药转化专题课",
|
|
|
category: "生物医药",
|
|
|
status: "报名中",
|
|
|
date: "2026-06-24",
|
|
|
time: "14:00-16:30",
|
|
|
location: "上海张江科学会堂",
|
|
|
teacher: "赵闻笛 教授",
|
|
|
university: "复旦大学",
|
|
|
seats: 50,
|
|
|
signed: 22,
|
|
|
signupStartDate: "2026-05-01",
|
|
|
signupEndDate: "2026-06-23",
|
|
|
summary: "围绕生物医药项目临床前验证、产业合作和转化路径展开专题分享。",
|
|
|
audience: ["生物医药科研团队", "医疗器械与药企研发负责人", "关注生命科学项目的投资机构"],
|
|
|
agenda: ["临床前验证", "产业合作路径", "转化案例讨论"]
|
|
|
}
|
|
|
],
|
|
|
activities: [
|
|
|
{
|
|
|
id: 1,
|
|
|
title: "长三角高校科技成果对接日",
|
|
|
date: "2026-06-18",
|
|
|
time: "13:30-18:00",
|
|
|
location: "苏州国际科技园",
|
|
|
status: "报名中",
|
|
|
signupStartDate: "2026-05-01",
|
|
|
signupEndDate: "2026-06-17",
|
|
|
summary: "组织高校团队、投资机构、产业方集中对接,推动技术需求和科研成果精准匹配。",
|
|
|
sessions: [
|
|
|
{ id: 101, title: "上午对接专场", date: "2026-06-18", time: "13:30-16:00", venue: "苏州国际科技园 A厅", capacity: 80, signed: 72 },
|
|
|
{ id: 102, title: "下午路演专场", date: "2026-06-18", time: "16:00-18:00", venue: "苏州国际科技园 B厅", capacity: 60, signed: 60 }
|
|
|
]
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
title: "青年科学家伙伴交流晚宴",
|
|
|
date: "2026-06-28",
|
|
|
time: "18:30-20:30",
|
|
|
location: "苏州金鸡湖会议中心",
|
|
|
status: "邀请制",
|
|
|
summary: "面向正式伙伴开放的小规模交流活动,促进跨校、跨学科合作。"
|
|
|
},
|
|
|
{
|
|
|
id: 3,
|
|
|
title: "产业需求闭门沟通会",
|
|
|
date: "2026-05-12",
|
|
|
time: "14:00-16:00",
|
|
|
location: "元禾控股会议室",
|
|
|
status: "已结束",
|
|
|
summary: "围绕企业真实技术需求,与高校老师进行小范围闭门沟通。"
|
|
|
},
|
|
|
{
|
|
|
id: 4,
|
|
|
title: "高校青年 PI 技术路演",
|
|
|
date: "2026-06-24",
|
|
|
time: "15:00-17:30",
|
|
|
location: "苏州工业园区 · 路演中心",
|
|
|
status: "报名中",
|
|
|
signupStartDate: "2026-05-15",
|
|
|
signupEndDate: "2026-06-23",
|
|
|
summary: "邀请高校青年 PI 展示前沿技术方向,与产业方、投资机构进行现场交流。",
|
|
|
sessions: [
|
|
|
{ id: 401, title: "路演上半场", date: "2026-06-24", time: "15:00-16:15", venue: "路演中心 1号厅", capacity: 50, signed: 22 },
|
|
|
{ id: 402, title: "路演下半场", date: "2026-06-24", time: "16:15-17:30", venue: "路演中心 1号厅", capacity: 50, signed: 10 }
|
|
|
]
|
|
|
}
|
|
|
],
|
|
|
news: [
|
|
|
{
|
|
|
id: 1,
|
|
|
title: "高校雷达网完成首批 AI 方向老师画像更新",
|
|
|
tag: "平台动态",
|
|
|
date: "2026-05-20",
|
|
|
summary: "围绕论文、项目、专利和产业合作意向,平台完成长三角首批重点老师画像更新。",
|
|
|
content: "高校雷达网近期完成 AI 方向首批重点老师画像更新,覆盖多模态大模型、具身智能、智能制造等方向。后续平台将继续补充材料、生物医药和机器人方向数据。"
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
title: "元禾先进技术发展中心启动伙伴需求共创机制",
|
|
|
tag: "伙伴服务",
|
|
|
date: "2026-05-16",
|
|
|
summary: "正式伙伴可通过移动端发布技术、产业、资金、场地等类型需求。",
|
|
|
content: "为提升高校科研团队和产业资源的连接效率,平台面向正式伙伴开放需求发布入口。需求提交后,运营团队将在后台进行分派和跟进。"
|
|
|
}
|
|
|
]
|
|
|
};
|
|
|
|
|
|
const app = document.querySelector("#app");
|
|
|
const title = document.querySelector("#pageTitle");
|
|
|
const tabbar = document.querySelector("#tabbar");
|
|
|
const topAction = document.querySelector("#topAction");
|
|
|
const backBtn = document.querySelector("#backBtn");
|
|
|
const courseSignupModal = document.querySelector("#courseSignupModal");
|
|
|
const signupSuccessModal = document.querySelector("#signupSuccessModal");
|
|
|
const signupSuccessTitle = document.querySelector("#signupSuccessTitle");
|
|
|
const signupSuccessMessage = document.querySelector("#signupSuccessMessage");
|
|
|
const signupModalTitle = document.querySelector("#signupModalTitle");
|
|
|
const signupCourseTitle = document.querySelector("#signupCourseTitle");
|
|
|
const signupCourseId = document.querySelector("#signupCourseId");
|
|
|
const signupType = document.querySelector("#signupType");
|
|
|
const signupPhoneError = document.querySelector("#signupPhoneError");
|
|
|
const demoToday = "2026-05-25";
|
|
|
const minCalendarMonth = "2026-05";
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
return String(value ?? "")
|
|
|
.replaceAll("&", "&")
|
|
|
.replaceAll("<", "<")
|
|
|
.replaceAll(">", ">")
|
|
|
.replaceAll('"', """)
|
|
|
.replaceAll("'", "'");
|
|
|
}
|
|
|
|
|
|
function go(route, params = {}) {
|
|
|
state.route = route;
|
|
|
state.params = params;
|
|
|
history.pushState({ route, params }, "", `#${route}${params.id ? `/${params.id}` : ""}`);
|
|
|
render();
|
|
|
}
|
|
|
|
|
|
function replaceRoute(route, params = {}) {
|
|
|
state.route = route;
|
|
|
state.params = params;
|
|
|
history.replaceState({ route, params }, "", `#${route}${params.id ? `/${params.id}` : ""}`);
|
|
|
render();
|
|
|
}
|
|
|
|
|
|
function findById(list, id) {
|
|
|
return list.find((item) => String(item.id) === String(id));
|
|
|
}
|
|
|
|
|
|
function toast(message) {
|
|
|
openSignupSuccess(message, "操作成功");
|
|
|
}
|
|
|
|
|
|
function profileAvatarSrc() {
|
|
|
return state.profile?.avatar || "assets/profile-avatar.jpg";
|
|
|
}
|
|
|
|
|
|
function openSignupSuccess(message = "已为你同步到报名日程", modalTitle = "报名成功") {
|
|
|
signupSuccessTitle.textContent = modalTitle;
|
|
|
signupSuccessMessage.textContent = message;
|
|
|
signupSuccessModal.classList.add("visible");
|
|
|
signupSuccessModal.setAttribute("aria-hidden", "false");
|
|
|
}
|
|
|
|
|
|
function closeSignupSuccess() {
|
|
|
signupSuccessModal.classList.remove("visible");
|
|
|
signupSuccessModal.setAttribute("aria-hidden", "true");
|
|
|
}
|
|
|
|
|
|
function parseDateOnly(dateText) {
|
|
|
return new Date(`${dateText}T00:00:00`);
|
|
|
}
|
|
|
|
|
|
function isWithinSignupWindow(startText, endText, todayText = demoToday) {
|
|
|
const today = parseDateOnly(todayText);
|
|
|
if (startText && today < parseDateOnly(startText)) return false;
|
|
|
if (endText && today > parseDateOnly(endText)) return false;
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
function hasCourseCapacity(course) {
|
|
|
const capacity = Number(course.seats) || 0;
|
|
|
if (capacity <= 0) return true;
|
|
|
return Number(course.signed) < capacity;
|
|
|
}
|
|
|
|
|
|
function isCourseSignupOpen(course) {
|
|
|
if (!isWithinSignupWindow(course.signupStartDate, course.signupEndDate)) return false;
|
|
|
if (!hasCourseCapacity(course)) return false;
|
|
|
if (getCourseStatus(course) === "已结束") return false;
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
function canShowCourseSignupButton(course) {
|
|
|
return isCourseSignupOpen(course) && !state.signedCourseIds.includes(course.id);
|
|
|
}
|
|
|
|
|
|
function getActivityDisplayStatus(activity) {
|
|
|
if (isWithinSignupWindow(activity.signupStartDate, activity.signupEndDate)) return "报名中";
|
|
|
return getActivityStatus(activity);
|
|
|
}
|
|
|
|
|
|
function isActivitySignupOpen(activity) {
|
|
|
if (!isWithinSignupWindow(activity.signupStartDate, activity.signupEndDate)) return false;
|
|
|
if (getActivityDisplayStatus(activity) !== "报名中") return false;
|
|
|
return Array.isArray(activity.sessions) && activity.sessions.some((session) => hasSessionCapacity(session));
|
|
|
}
|
|
|
|
|
|
function hasSessionCapacity(session) {
|
|
|
const capacity = Number(session.capacity) || 0;
|
|
|
if (capacity <= 0) return true;
|
|
|
return Number(session.signed) < capacity;
|
|
|
}
|
|
|
|
|
|
function canShowActivitySignupButton(activity) {
|
|
|
return isActivitySignupOpen(activity) && !state.signedActivityIds.includes(activity.id);
|
|
|
}
|
|
|
|
|
|
function openCourseSignup(id) {
|
|
|
const course = findById(data.courses, id);
|
|
|
if (!course || !canShowCourseSignupButton(course)) return;
|
|
|
signupCourseId.value = id;
|
|
|
signupType.value = "course";
|
|
|
state.signupSessionId = null;
|
|
|
signupModalTitle.textContent = "课程报名";
|
|
|
signupCourseTitle.textContent = course.title;
|
|
|
signupPhoneError.textContent = "";
|
|
|
hideSignupSessionBlock();
|
|
|
courseSignupModal.classList.add("visible");
|
|
|
courseSignupModal.setAttribute("aria-hidden", "false");
|
|
|
}
|
|
|
|
|
|
function openActivitySignup(id) {
|
|
|
const activity = findById(data.activities, id);
|
|
|
if (!activity || !canShowActivitySignupButton(activity)) return;
|
|
|
signupCourseId.value = id;
|
|
|
signupType.value = "activity";
|
|
|
signupModalTitle.textContent = "活动报名";
|
|
|
signupCourseTitle.textContent = activity.title;
|
|
|
signupPhoneError.textContent = "";
|
|
|
renderSignupSessionOptions(activity);
|
|
|
state.signupSessionId = null;
|
|
|
courseSignupModal.classList.add("visible");
|
|
|
courseSignupModal.setAttribute("aria-hidden", "false");
|
|
|
}
|
|
|
|
|
|
function hideSignupSessionBlock() {
|
|
|
const block = document.querySelector("#signupSessionBlock");
|
|
|
if (block) block.hidden = true;
|
|
|
const err = document.querySelector("#signupSessionError");
|
|
|
if (err) err.textContent = "";
|
|
|
}
|
|
|
|
|
|
function renderSignupSessionOptions(activity) {
|
|
|
const block = document.querySelector("#signupSessionBlock");
|
|
|
const list = document.querySelector("#signupSessionOptions");
|
|
|
if (!block || !list) return;
|
|
|
const sessions = activity.sessions || [];
|
|
|
list.innerHTML = sessions.map((session) => {
|
|
|
const full = !hasSessionCapacity(session);
|
|
|
const active = state.signupSessionId === session.id;
|
|
|
return `
|
|
|
<button
|
|
|
type="button"
|
|
|
class="signup-session-option ${active ? "active" : ""}"
|
|
|
data-session-id="${session.id}"
|
|
|
${full ? "disabled" : ""}
|
|
|
>
|
|
|
<strong>${escapeHtml(session.title)}</strong>
|
|
|
<span>${escapeHtml(session.date)} ${escapeHtml(session.time)} · ${escapeHtml(session.venue)}</span>
|
|
|
<span>${full ? "名额已满" : `剩余 ${Math.max(Number(session.capacity) - Number(session.signed), 0)} / ${session.capacity} 人`}</span>
|
|
|
</button>
|
|
|
`;
|
|
|
}).join("");
|
|
|
block.hidden = sessions.length === 0;
|
|
|
}
|
|
|
|
|
|
function closeCourseSignup() {
|
|
|
courseSignupModal.classList.remove("visible");
|
|
|
courseSignupModal.setAttribute("aria-hidden", "true");
|
|
|
signupPhoneError.textContent = "";
|
|
|
state.signupSessionId = null;
|
|
|
hideSignupSessionBlock();
|
|
|
document.querySelector("#courseSignupForm").reset();
|
|
|
}
|
|
|
|
|
|
function isValidMobile(phone) {
|
|
|
return /^1[3-9]\d{9}$/.test(phone);
|
|
|
}
|
|
|
|
|
|
function tag(text, type = "") {
|
|
|
return `<span class="tag ${type}">${escapeHtml(text)}</span>`;
|
|
|
}
|
|
|
|
|
|
function persistSignup(type) {
|
|
|
if (type === "course") {
|
|
|
localStorage.setItem("slakeSignedCourses", JSON.stringify(state.signedCourseIds));
|
|
|
} else {
|
|
|
localStorage.setItem("slakeSignedActivities", JSON.stringify(state.signedActivityIds));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function getStatusByDate(dateText) {
|
|
|
if (dateText === demoToday) return "进行中";
|
|
|
if (dateText < demoToday) return "已结束";
|
|
|
return "未开始";
|
|
|
}
|
|
|
|
|
|
function getCourseEndDate(course) {
|
|
|
return course.endDate || course.date;
|
|
|
}
|
|
|
|
|
|
function getActivityEndDate(activity) {
|
|
|
return activity.endDate || activity.date;
|
|
|
}
|
|
|
|
|
|
function isCourseOnDate(course, dateText) {
|
|
|
return course.date <= dateText && dateText <= getCourseEndDate(course);
|
|
|
}
|
|
|
|
|
|
function isActivityOnDate(activity, dateText) {
|
|
|
return activity.date <= dateText && dateText <= getActivityEndDate(activity);
|
|
|
}
|
|
|
|
|
|
function daysBetween(startText, endText) {
|
|
|
const start = new Date(`${startText}T00:00:00`);
|
|
|
const end = new Date(`${endText}T00:00:00`);
|
|
|
return Math.round((end - start) / 86400000);
|
|
|
}
|
|
|
|
|
|
function getCourseStatus(course) {
|
|
|
if (course.date <= demoToday && demoToday <= getCourseEndDate(course)) return "进行中";
|
|
|
if (getCourseEndDate(course) < demoToday) return "已结束";
|
|
|
return "未开始";
|
|
|
}
|
|
|
|
|
|
function getActivityStatus(activity) {
|
|
|
if (activity.date <= demoToday && demoToday <= getActivityEndDate(activity)) return "进行中";
|
|
|
if (getActivityEndDate(activity) < demoToday) return "已结束";
|
|
|
return "未开始";
|
|
|
}
|
|
|
|
|
|
function statusTag(status) {
|
|
|
const map = { "未开始": "blue", "进行中": "green", "已结束": "" };
|
|
|
return tag(status, map[status] || "");
|
|
|
}
|
|
|
|
|
|
function statusClass(status) {
|
|
|
const map = { "未开始": "pending", "进行中": "active", "已结束": "done" };
|
|
|
return map[status] || "pending";
|
|
|
}
|
|
|
|
|
|
function coverClass(category) {
|
|
|
if (category.includes("AI")) return "cover-ai";
|
|
|
if (category.includes("材料")) return "cover-material";
|
|
|
if (category.includes("机器人")) return "cover-robot";
|
|
|
return "cover-transfer";
|
|
|
}
|
|
|
|
|
|
function sortByCourseTime(courses) {
|
|
|
return [...courses].sort((a, b) => {
|
|
|
const aTime = new Date(`${a.date} ${a.time.split("-")[0]}`.replace(/-/g, "/")).getTime();
|
|
|
const bTime = new Date(`${b.date} ${b.time.split("-")[0]}`.replace(/-/g, "/")).getTime();
|
|
|
return aTime - bTime;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function sortByStatusThenTime(courses) {
|
|
|
const order = { "进行中": 0, "未开始": 1, "已结束": 2 };
|
|
|
return [...courses].sort((a, b) => {
|
|
|
const statusDelta = order[getCourseStatus(a)] - order[getCourseStatus(b)];
|
|
|
if (statusDelta) return statusDelta;
|
|
|
const aTime = new Date(`${a.date} ${a.time.split("-")[0]}`.replace(/-/g, "/")).getTime();
|
|
|
const bTime = new Date(`${b.date} ${b.time.split("-")[0]}`.replace(/-/g, "/")).getTime();
|
|
|
return aTime - bTime;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function renderHome() {
|
|
|
const availableCourses = data.courses.filter((course) => getCourseStatus(course) !== "已结束");
|
|
|
const hotCourses = sortByCourseTime(availableCourses).slice(0, 3).map(courseCard).join("");
|
|
|
const latestCourses = sortByCourseTime(availableCourses).slice(0, 3).map(courseCard).join("");
|
|
|
return `
|
|
|
<section class="college-home">
|
|
|
<div class="wechat-status">
|
|
|
<span>09:11</span>
|
|
|
<span class="status-icons status-svg" aria-hidden="true">
|
|
|
<svg viewBox="0 0 94 22" width="62" height="15" role="img">
|
|
|
<rect x="0" y="13" width="4" height="7" rx="1.5" fill="#111"></rect>
|
|
|
<rect x="7" y="10" width="4" height="10" rx="1.5" fill="#111"></rect>
|
|
|
<rect x="14" y="6" width="4" height="14" rx="1.5" fill="#111"></rect>
|
|
|
<rect x="21" y="2" width="4" height="18" rx="1.5" fill="#111"></rect>
|
|
|
<path d="M36 8.5c5.2-4.5 12.8-4.5 18 0" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round"></path>
|
|
|
<path d="M40 13c2.9-2.4 7.1-2.4 10 0" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round"></path>
|
|
|
<circle cx="45" cy="17.5" r="2.2" fill="#111"></circle>
|
|
|
<rect x="63" y="5" width="25" height="13" rx="4" fill="none" stroke="#111" stroke-width="2"></rect>
|
|
|
<rect x="66.5" y="8.5" width="14.5" height="6" rx="3" fill="#24c867"></rect>
|
|
|
<path d="M75.5 5.8l-4.3 6.2h4l-3 5.6 7-7h-4.1l2.4-4.8z" fill="#ffdf4d" stroke="#111" stroke-width=".7" stroke-linejoin="round"></path>
|
|
|
<rect x="89" y="9" width="3" height="5" rx="1.5" fill="#111"></rect>
|
|
|
</svg>
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
<div class="mini-nav">
|
|
|
<div class="mini-title">${APP_BRAND_NAME}</div>
|
|
|
<div class="mini-capsule">
|
|
|
<span class="capsule-dot"><i></i><i></i><i></i></span>
|
|
|
<span class="capsule-line"></span>
|
|
|
<span class="capsule-circle"></span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="college-hero">
|
|
|
<button class="course-banner-v2" type="button" data-detail="course" data-id="1" aria-label="查看 AI科研成果转化闭门课详情">
|
|
|
<div class="course-banner-main">
|
|
|
<div class="course-banner-kicker">AI成果转化</div>
|
|
|
<div class="course-banner-title">AI科研成果转化闭门课</div>
|
|
|
<div class="course-banner-meta">
|
|
|
<span>报名中</span>
|
|
|
<i></i>
|
|
|
<span>31/48人</span>
|
|
|
<i></i>
|
|
|
<span>6月12日 14:00</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="course-banner-visual" aria-hidden="true">
|
|
|
<div class="campus-skyline">
|
|
|
<span></span><span></span><span></span><span></span>
|
|
|
</div>
|
|
|
<div class="ai-chip"></div>
|
|
|
<div class="radar-path"></div>
|
|
|
<div class="visual-dot dot-one"></div>
|
|
|
<div class="visual-dot dot-two"></div>
|
|
|
<div class="visual-dot dot-three"></div>
|
|
|
</div>
|
|
|
</button>
|
|
|
<div class="slider-count">
|
|
|
<span class="slider-dot active"></span>
|
|
|
<span class="slider-dot"></span>
|
|
|
<span class="slider-dot"></span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="home-content">
|
|
|
<section class="home-quick-grid">
|
|
|
${serviceItem("course", "课程列表", "", "courses")}
|
|
|
${serviceItem("activity", "活动列表", "", "activities")}
|
|
|
${serviceItem("calendar", "课程日历", "", "calendar")}
|
|
|
</section>
|
|
|
|
|
|
<section class="section">
|
|
|
<div class="section-head feature-section-head">
|
|
|
<div class="section-title-group">
|
|
|
<h2 class="section-title">热门课程</h2>
|
|
|
</div>
|
|
|
<button class="section-more" data-go="courses" type="button">全部</button>
|
|
|
</div>
|
|
|
<div class="stack">${hotCourses}</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="section">
|
|
|
<div class="section-head feature-section-head">
|
|
|
<div class="section-title-group">
|
|
|
<h2 class="section-title">最新课程</h2>
|
|
|
</div>
|
|
|
<button class="section-more" data-go="courses" type="button">全部</button>
|
|
|
</div>
|
|
|
<div class="stack">${latestCourses}</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderCalendarCells() {
|
|
|
const [year, month] = state.calendarMonth.split("-").map(Number);
|
|
|
const first = new Date(year, month - 1, 1);
|
|
|
const start = new Date(year, month - 1, 1 - first.getDay());
|
|
|
const cells = Array.from({ length: 35 }, (_, index) => {
|
|
|
const date = new Date(start);
|
|
|
date.setDate(start.getDate() + index);
|
|
|
const dateText = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
|
return {
|
|
|
day: String(date.getDate()),
|
|
|
dateText,
|
|
|
muted: date.getMonth() !== month - 1,
|
|
|
past: dateText < demoToday,
|
|
|
courses: data.courses.filter((item) => isCourseOnDate(item, dateText))
|
|
|
};
|
|
|
});
|
|
|
|
|
|
return cells.map((cell, index) => {
|
|
|
const weekStart = cells[Math.floor(index / 7) * 7].dateText;
|
|
|
const weekEnd = cells[Math.floor(index / 7) * 7 + 6].dateText;
|
|
|
const event = cell.courses.map((course) => {
|
|
|
const visibleStart = course.date > weekStart ? course.date : weekStart;
|
|
|
if (cell.dateText !== visibleStart) return "";
|
|
|
const visibleEnd = getCourseEndDate(course) < weekEnd ? getCourseEndDate(course) : weekEnd;
|
|
|
const spanDays = daysBetween(visibleStart, visibleEnd) + 1;
|
|
|
return `<div class="calendar-event ${statusClass(getCourseStatus(course))} span-event" style="--span-days:${spanDays}">${escapeHtml(course.title)}</div>`;
|
|
|
}).join("");
|
|
|
const hasCourse = cell.courses.length > 0;
|
|
|
const signedMark = cell.courses.some((course) => state.signedCourseIds.includes(course.id))
|
|
|
? `<i class="signed-check" aria-hidden="true"></i>`
|
|
|
: "";
|
|
|
return `
|
|
|
<button
|
|
|
class="date-cell ${cell.muted ? "prev-month" : ""} ${cell.past ? "past-date" : ""} ${hasCourse ? "has-course" : "no-course"} ${hasCourse && cell.dateText === state.selectedDate ? "selected" : ""}"
|
|
|
${hasCourse ? `data-date="${cell.dateText}"` : "disabled"}
|
|
|
type="button"
|
|
|
>
|
|
|
<span>${cell.day}</span>
|
|
|
${signedMark}
|
|
|
${event}
|
|
|
</button>
|
|
|
`;
|
|
|
}).join("");
|
|
|
}
|
|
|
|
|
|
function renderCourseCalendarBlock() {
|
|
|
const isMinMonth = state.calendarMonth <= minCalendarMonth;
|
|
|
return `
|
|
|
<section class="college-calendar inline-calendar">
|
|
|
<div class="month-bar">
|
|
|
<button type="button" ${isMinMonth ? "disabled" : 'data-month="-1"'}>‹</button>
|
|
|
<span>${escapeHtml(state.calendarMonth.replace("-", "年"))}月</span>
|
|
|
<button type="button" data-month="1">›</button>
|
|
|
</div>
|
|
|
<div class="calendar-legend">
|
|
|
<span><i class="legend-dot pending"></i>未开始</span>
|
|
|
<span><i class="legend-dot active"></i>进行中</span>
|
|
|
<span><i class="legend-dot done"></i>已结束</span>
|
|
|
</div>
|
|
|
<div class="week-grid">
|
|
|
<span>日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span>
|
|
|
</div>
|
|
|
<div class="date-grid" data-calendar-grid>
|
|
|
${renderCalendarCells()}
|
|
|
</div>
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderActivityCalendarCells() {
|
|
|
const [year, month] = state.activityCalendarMonth.split("-").map(Number);
|
|
|
const first = new Date(year, month - 1, 1);
|
|
|
const start = new Date(year, month - 1, 1 - first.getDay());
|
|
|
const cells = Array.from({ length: 35 }, (_, index) => {
|
|
|
const date = new Date(start);
|
|
|
date.setDate(start.getDate() + index);
|
|
|
const dateText = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
|
return {
|
|
|
day: String(date.getDate()),
|
|
|
dateText,
|
|
|
muted: date.getMonth() !== month - 1,
|
|
|
past: dateText < demoToday,
|
|
|
activities: data.activities.filter((item) => isActivityOnDate(item, dateText))
|
|
|
};
|
|
|
});
|
|
|
|
|
|
return cells.map((cell, index) => {
|
|
|
const weekStart = cells[Math.floor(index / 7) * 7].dateText;
|
|
|
const weekEnd = cells[Math.floor(index / 7) * 7 + 6].dateText;
|
|
|
const hasActivity = cell.activities.length > 0;
|
|
|
const event = cell.activities.map((activity) => {
|
|
|
const visibleStart = activity.date > weekStart ? activity.date : weekStart;
|
|
|
if (cell.dateText !== visibleStart) return "";
|
|
|
const visibleEnd = getActivityEndDate(activity) < weekEnd ? getActivityEndDate(activity) : weekEnd;
|
|
|
const spanDays = daysBetween(visibleStart, visibleEnd) + 1;
|
|
|
return `<div class="calendar-event ${statusClass(getActivityStatus(activity))} span-event" style="--span-days:${spanDays}">${escapeHtml(activity.title)}</div>`;
|
|
|
}).join("");
|
|
|
const signedMark = cell.activities.some((activity) => state.signedActivityIds.includes(activity.id))
|
|
|
? `<i class="signed-check" aria-hidden="true"></i>`
|
|
|
: "";
|
|
|
return `
|
|
|
<button
|
|
|
class="date-cell ${cell.muted ? "prev-month" : ""} ${cell.past ? "past-date" : ""} ${hasActivity ? "has-course" : "no-course"}"
|
|
|
${hasActivity ? `data-activity-date="${cell.dateText}"` : "disabled"}
|
|
|
type="button"
|
|
|
>
|
|
|
<span>${cell.day}</span>
|
|
|
${signedMark}
|
|
|
${event}
|
|
|
</button>
|
|
|
`;
|
|
|
}).join("");
|
|
|
}
|
|
|
|
|
|
function renderActivityCalendarBlock() {
|
|
|
const isMinMonth = state.activityCalendarMonth <= minCalendarMonth;
|
|
|
return `
|
|
|
<section class="college-calendar inline-calendar">
|
|
|
<div class="month-bar">
|
|
|
<button type="button" ${isMinMonth ? "disabled" : 'data-activity-month="-1"'}>‹</button>
|
|
|
<span>${escapeHtml(state.activityCalendarMonth.replace("-", "年"))}月</span>
|
|
|
<button type="button" data-activity-month="1">›</button>
|
|
|
</div>
|
|
|
<div class="calendar-legend">
|
|
|
<span><i class="legend-dot pending"></i>未开始</span>
|
|
|
<span><i class="legend-dot active"></i>进行中</span>
|
|
|
<span><i class="legend-dot done"></i>已结束</span>
|
|
|
</div>
|
|
|
<div class="week-grid">
|
|
|
<span>日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span>
|
|
|
</div>
|
|
|
<div class="date-grid" data-calendar-grid>
|
|
|
${renderActivityCalendarCells()}
|
|
|
</div>
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function serviceItem(icon, titleText, desc, route) {
|
|
|
return `
|
|
|
<button class="service-item" data-go="${route}">
|
|
|
<div class="icon-box">${serviceIcon(icon)}</div>
|
|
|
<div class="service-main">${escapeHtml(titleText)}</div>
|
|
|
${desc ? `<div class="service-sub">${escapeHtml(desc)}</div>` : ""}
|
|
|
</button>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function serviceIcon(icon) {
|
|
|
const icons = {
|
|
|
course: `
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
<path d="M5.5 5.8c0-1 0.8-1.8 1.8-1.8H18c0.6 0 1 .4 1 1v13.2c0 .6-.4 1-1 1H7.3c-1 0-1.8-.8-1.8-1.8V5.8Z"></path>
|
|
|
<path d="M8.2 7.2h7.4M8.2 10.2h6.2M7.4 16.4H19"></path>
|
|
|
</svg>
|
|
|
`,
|
|
|
activity: `
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
<path d="M7 14.2c2.3-3.8 4.5-5.7 7.2-6.6l2.8-.9-.9 2.8c-.9 2.7-2.8 4.9-6.6 7.2L7 14.2Z"></path>
|
|
|
<path d="M7.2 14.5 5 19l4.5-2.2M13.8 7.8l2.4 2.4"></path>
|
|
|
</svg>
|
|
|
`,
|
|
|
calendar: `
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
<path d="M6.5 5.5h11c.8 0 1.5.7 1.5 1.5v10.5c0 .8-.7 1.5-1.5 1.5h-11c-.8 0-1.5-.7-1.5-1.5V7c0-.8.7-1.5 1.5-1.5Z"></path>
|
|
|
<path d="M8 4v3M16 4v3M5 9h14M8.2 12.4h2.2M13.6 12.4h2.2M8.2 15.6h2.2M13.6 15.6h2.2"></path>
|
|
|
</svg>
|
|
|
`
|
|
|
};
|
|
|
return icons[icon] || "";
|
|
|
}
|
|
|
|
|
|
function courseCard(item) {
|
|
|
const status = getCourseStatus(item);
|
|
|
const signed = state.signedCourseIds.includes(item.id);
|
|
|
return `
|
|
|
<article class="card course-card" data-detail="course" data-id="${item.id}">
|
|
|
<div class="course-body">
|
|
|
<div class="course-card-top">
|
|
|
<div>
|
|
|
<div class="course-category-inline">${escapeHtml(item.category)}</div>
|
|
|
<div class="item-title">${escapeHtml(item.title)}</div>
|
|
|
</div>
|
|
|
${statusTag(status)}
|
|
|
</div>
|
|
|
<div class="course-foot">
|
|
|
<div class="course-foot-info">
|
|
|
<span>${escapeHtml(item.date)} ${escapeHtml(item.time)}</span>
|
|
|
<span>${escapeHtml(item.location)}</span>
|
|
|
<span>${escapeHtml(item.teacher)} · ${escapeHtml(item.university)}</span>
|
|
|
</div>
|
|
|
<div class="course-foot-action">
|
|
|
<div class="seat-count"><span class="seat-signed">${item.signed}</span><span class="seat-divider"></span><span class="seat-total">${item.seats}</span><span class="seat-unit">人</span></div>
|
|
|
${canShowCourseSignupButton(item) ? `<button class="signup-text-btn" data-signup-course="${item.id}" type="button">我要报名</button>` : ""}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderCourses() {
|
|
|
const categories = ["全部", ...new Set(data.courses.map((course) => course.category).filter((category) => category !== "机器人"))];
|
|
|
const active = state.params.category || "全部";
|
|
|
const activeStatus = state.params.courseStatus || "";
|
|
|
const keyword = state.params.keyword || "";
|
|
|
const courses = (active === "全部" ? data.courses : data.courses.filter((course) => course.category === active))
|
|
|
.filter((course) => {
|
|
|
const status = getCourseStatus(course);
|
|
|
const matchStatus = activeStatus ? status === activeStatus : status !== "已结束";
|
|
|
const matchKeyword = !keyword || `${course.title}${course.teacher}${course.location}`.includes(keyword);
|
|
|
return matchStatus && matchKeyword;
|
|
|
});
|
|
|
const sortedCourses = sortByStatusThenTime(courses);
|
|
|
const statuses = ["进行中", "未开始", "已结束"];
|
|
|
return `
|
|
|
<form class="search-form" id="courseSearchForm">
|
|
|
<input class="input" name="keyword" placeholder="搜索课程名称、讲师、地点" value="${escapeHtml(keyword)}">
|
|
|
<button class="btn btn-primary" type="submit">搜索</button>
|
|
|
<button class="btn btn-calendar" data-go="calendar" type="button">课程日历</button>
|
|
|
</form>
|
|
|
<div class="filter-bar">
|
|
|
${categories.map((item) => `<button class="filter ${item === active ? "active" : ""}" data-category="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="filter-bar status-filter">
|
|
|
${statuses.map((item) => `<button class="filter ${item === activeStatus ? "active" : ""}" data-course-status="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="stack">${sortedCourses.map(courseCard).join("")}</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderMyCourses() {
|
|
|
const signed = data.courses.filter((item) => state.signedCourseIds.includes(item.id));
|
|
|
const statuses = ["全部", "未开始", "进行中", "已结束"];
|
|
|
const activeStatus = statuses.includes(state.params.myCourseStatus) ? state.params.myCourseStatus : "全部";
|
|
|
const courses = activeStatus === "全部" ? signed : signed.filter((item) => getCourseStatus(item) === activeStatus);
|
|
|
return `
|
|
|
<div class="filter-bar status-filter my-status-filter">
|
|
|
${statuses.map((item) => `<button class="filter ${item === activeStatus ? "active" : ""}" data-my-course-status="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="stack">
|
|
|
${courses.length ? courses.map((item) => `
|
|
|
<article class="card course-card" data-detail="course" data-id="${item.id}">
|
|
|
<div class="course-body">
|
|
|
<div class="course-card-top">
|
|
|
<div>
|
|
|
<div class="course-category-inline">${escapeHtml(item.category)}</div>
|
|
|
<div class="item-title">${escapeHtml(item.title)}</div>
|
|
|
</div>
|
|
|
${statusTag(getCourseStatus(item))}
|
|
|
</div>
|
|
|
<div class="course-foot">
|
|
|
<div class="course-foot-info">
|
|
|
<span>${escapeHtml(item.date)} ${escapeHtml(item.time)}</span>
|
|
|
<span>${escapeHtml(item.location)}</span>
|
|
|
<span>${escapeHtml(item.teacher)} · ${escapeHtml(item.university)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</article>
|
|
|
`).join("") : `<div class="empty">暂无${activeStatus === "全部" ? "已报名" : activeStatus}课程</div>`}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderCourseDetailSignupButton(item) {
|
|
|
if (state.signedCourseIds.includes(item.id)) {
|
|
|
return `<button class="btn detail-signup-btn detail-signed-btn" type="button" disabled>已报名</button>`;
|
|
|
}
|
|
|
if (!isCourseSignupOpen(item)) {
|
|
|
return "";
|
|
|
}
|
|
|
return `<button class="btn btn-primary detail-signup-btn" data-signup-course="${item.id}" type="button">我要报名</button>`;
|
|
|
}
|
|
|
|
|
|
function renderCourseDetail() {
|
|
|
const item = findById(data.courses, state.params.id);
|
|
|
if (!item) return `<div class="empty">课程不存在</div>`;
|
|
|
const signupButton = renderCourseDetailSignupButton(item);
|
|
|
|
|
|
if (item.promoUrl) {
|
|
|
return `
|
|
|
<div class="course-detail-page course-detail-promo">
|
|
|
<article class="card detail-head course-promo-head">
|
|
|
<h1 class="course-detail-title">${escapeHtml(item.title)}</h1>
|
|
|
</article>
|
|
|
<div class="course-promo-image-wrap">
|
|
|
<img class="course-promo-image" src="${escapeHtml(item.promoUrl)}" alt="${escapeHtml(item.title)}">
|
|
|
</div>
|
|
|
${signupButton}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
return `
|
|
|
<div class="course-detail-page">
|
|
|
<article class="card detail-head">
|
|
|
<h1 class="course-detail-title">${escapeHtml(item.title)}</h1>
|
|
|
<div class="course-cover compact ${coverClass(item.category)}">
|
|
|
<img src="assets/course-detail-banner.svg" alt="" aria-hidden="true">
|
|
|
</div>
|
|
|
<div class="detail-summary">${escapeHtml(item.summary)}</div>
|
|
|
</article>
|
|
|
|
|
|
<section class="card section detail-module">
|
|
|
<h2 class="section-title">课程安排</h2>
|
|
|
<div class="course-schedule">
|
|
|
${scheduleItem("时间", `${item.date} ${item.time}`)}
|
|
|
${scheduleItem("地点", item.location)}
|
|
|
${scheduleItem("名额", `${item.signed}/${item.seats} 人`)}
|
|
|
${item.signupEndDate ? scheduleItem("报名截止", item.signupEndDate) : ""}
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="card section detail-module">
|
|
|
<h2 class="section-title">主讲师资</h2>
|
|
|
<div class="teacher-card">
|
|
|
<div class="teacher-name">${escapeHtml(item.teacher)}</div>
|
|
|
<div class="teacher-meta">${escapeHtml(item.university)}</div>
|
|
|
<div class="teacher-desc">长期关注${escapeHtml(item.category)}方向科研成果转化与产业应用。</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="card section detail-module">
|
|
|
<h2 class="section-title">招生对象</h2>
|
|
|
<div class="agenda-list">
|
|
|
${(item.audience || []).map((audience) => `<div class="agenda-item">${escapeHtml(audience)}</div>`).join("")}
|
|
|
</div>
|
|
|
</section>
|
|
|
${signupButton}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function infoRow(label, value) {
|
|
|
return `<div class="info-row"><span>${escapeHtml(label)}</span><span>${escapeHtml(value)}</span></div>`;
|
|
|
}
|
|
|
|
|
|
function scheduleItem(label, value) {
|
|
|
return `<div class="schedule-item">${escapeHtml(label)}:${escapeHtml(value)}</div>`;
|
|
|
}
|
|
|
|
|
|
function renderActivities() {
|
|
|
const categories = ["全部", "路演对接", "伙伴交流", "产业需求"];
|
|
|
const active = state.params.activityCategory || "全部";
|
|
|
const activeStatus = state.params.activityStatus || "";
|
|
|
const keyword = state.params.activityKeyword || "";
|
|
|
const activities = data.activities.filter((item) => {
|
|
|
const matchCategory = active === "全部" || item.title.includes(active.replace("交流", ""));
|
|
|
const status = getActivityStatus(item);
|
|
|
const matchStatus = activeStatus ? status === activeStatus : true;
|
|
|
const matchKeyword = !keyword || `${item.title}${item.location}${item.summary}`.includes(keyword);
|
|
|
return matchCategory && matchStatus && matchKeyword;
|
|
|
});
|
|
|
const statuses = ["未开始", "进行中", "已结束"];
|
|
|
return `
|
|
|
<form class="search-form" id="activitySearchForm">
|
|
|
<input class="input" name="keyword" placeholder="搜索活动名称、地点" value="${escapeHtml(keyword)}">
|
|
|
<button class="btn btn-primary" type="submit">搜索</button>
|
|
|
<button class="btn btn-calendar" data-go="activityCalendar" type="button">活动日历</button>
|
|
|
</form>
|
|
|
<div class="filter-bar">
|
|
|
${categories.map((item) => `<button class="filter ${item === active ? "active" : ""}" data-activity-category="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="filter-bar status-filter">
|
|
|
${statuses.map((item) => `<button class="filter ${item === activeStatus ? "active" : ""}" data-activity-status="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="stack">${activities.map(activityCard).join("")}</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderMyActivities() {
|
|
|
const signed = data.activities.filter((item) => state.signedActivityIds.includes(item.id));
|
|
|
const statuses = ["全部", "未开始", "进行中", "已结束"];
|
|
|
const activeStatus = statuses.includes(state.params.myActivityStatus) ? state.params.myActivityStatus : "全部";
|
|
|
const activities = activeStatus === "全部" ? signed : signed.filter((item) => getActivityStatus(item) === activeStatus);
|
|
|
return `
|
|
|
<div class="filter-bar status-filter my-status-filter">
|
|
|
${statuses.map((item) => `<button class="filter ${item === activeStatus ? "active" : ""}" data-my-activity-status="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="stack">
|
|
|
${activities.length ? activities.map((item) => `
|
|
|
<article class="card" data-detail="activity" data-id="${item.id}">
|
|
|
<div class="course-card-top">
|
|
|
<div>
|
|
|
<div class="item-title">${escapeHtml(item.title)}</div>
|
|
|
</div>
|
|
|
${statusTag(getActivityStatus(item))}
|
|
|
</div>
|
|
|
<div class="summary">${escapeHtml(item.summary)}</div>
|
|
|
<div class="meta">${escapeHtml(item.date)} ${escapeHtml(item.time)} · ${escapeHtml(item.location)}</div>
|
|
|
</article>
|
|
|
`).join("") : `<div class="empty">暂无${activeStatus === "全部" ? "已报名" : activeStatus}活动</div>`}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function activityCard(item) {
|
|
|
const status = getActivityDisplayStatus(item);
|
|
|
const statusTagHtml = status === "报名中" ? tag(status, "brand") : statusTag(status);
|
|
|
return `
|
|
|
<article class="card" data-detail="activity" data-id="${item.id}">
|
|
|
<div class="row between">
|
|
|
<span></span>
|
|
|
${statusTagHtml}
|
|
|
</div>
|
|
|
<div class="title">${escapeHtml(item.title)}</div>
|
|
|
<div class="summary">${escapeHtml(item.summary)}</div>
|
|
|
<div class="meta">${escapeHtml(item.date)} ${escapeHtml(item.time)} · ${escapeHtml(item.location)}</div>
|
|
|
${canShowActivitySignupButton(item) ? `<button class="signup-text-btn activity-signup-link" data-signup-activity="${item.id}" type="button">我要报名</button>` : ""}
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderActivityDetailSignupButton(item) {
|
|
|
if (state.signedActivityIds.includes(item.id)) {
|
|
|
return `<button class="btn detail-signup-btn detail-signed-btn" type="button" disabled>已报名</button>`;
|
|
|
}
|
|
|
if (!isActivitySignupOpen(item)) {
|
|
|
return "";
|
|
|
}
|
|
|
return `<button class="btn btn-primary detail-signup-btn" data-signup-activity="${item.id}" type="button">我要报名</button>`;
|
|
|
}
|
|
|
|
|
|
function renderActivityDetail() {
|
|
|
const item = findById(data.activities, state.params.id);
|
|
|
if (!item) return `<div class="empty">活动不存在</div>`;
|
|
|
const status = getActivityDisplayStatus(item);
|
|
|
const signupButton = renderActivityDetailSignupButton(item);
|
|
|
const sessions = (item.sessions || []).map((session) => {
|
|
|
const full = !hasSessionCapacity(session);
|
|
|
return `
|
|
|
<div class="agenda-item">
|
|
|
<strong>${escapeHtml(session.title)}</strong>
|
|
|
<div>${escapeHtml(session.date)} ${escapeHtml(session.time)} · ${escapeHtml(session.venue)}</div>
|
|
|
<div>${full ? "名额已满" : `剩余 ${Math.max(Number(session.capacity) - Number(session.signed), 0)} / ${session.capacity} 人`}</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).join("");
|
|
|
return `
|
|
|
<article class="card detail-head">
|
|
|
${status === "报名中" ? tag(status, "brand") : statusTag(status)}
|
|
|
<div class="detail-title">${escapeHtml(item.title)}</div>
|
|
|
<div class="summary">${escapeHtml(item.summary)}</div>
|
|
|
</article>
|
|
|
<section class="card section">
|
|
|
${infoRow("日期", item.date)}
|
|
|
${infoRow("时间", item.time)}
|
|
|
${infoRow("地点", item.location)}
|
|
|
${item.signupEndDate ? infoRow("报名截止", item.signupEndDate) : ""}
|
|
|
</section>
|
|
|
${sessions ? `<section class="card section detail-module"><h2 class="section-title">活动场次</h2><div class="agenda-list">${sessions}</div></section>` : ""}
|
|
|
${signupButton}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function newsCard(item) {
|
|
|
return `
|
|
|
<article class="card news-list-card" data-detail="news" data-id="${item.id}">
|
|
|
<div class="news-list-row">
|
|
|
<div class="news-list-main">
|
|
|
<span class="news-list-tag">${escapeHtml(item.tag)}</span>
|
|
|
<span class="news-list-title">${escapeHtml(item.title)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="news-list-date">${escapeHtml(item.date)}</div>
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderDiscover() {
|
|
|
const categories = ["全部", "行业动态", "政策信息", "平台动态"];
|
|
|
const active = state.params.newsCategory || "全部";
|
|
|
const news = data.news.filter((item) => active === "全部" || item.tag === active);
|
|
|
return `
|
|
|
<div class="filter-bar">
|
|
|
${categories.map((item) => `<button class="filter ${item === active ? "active" : ""}" data-news-category="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join("")}
|
|
|
</div>
|
|
|
<div class="stack">${news.map(newsCard).join("")}</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderNewsDetail() {
|
|
|
const item = findById(data.news, state.params.id);
|
|
|
if (!item) return `<div class="empty">资讯不存在</div>`;
|
|
|
return `
|
|
|
<article class="card news-detail-card">
|
|
|
<h1 class="news-detail-title">${escapeHtml(item.title)}</h1>
|
|
|
<div class="news-detail-date">${escapeHtml(item.date)}</div>
|
|
|
<p class="news-detail-content">${escapeHtml(item.content)}</p>
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderCalendar() {
|
|
|
return renderCourseCalendarBlock();
|
|
|
}
|
|
|
|
|
|
function renderActivityCalendar() {
|
|
|
return renderActivityCalendarBlock();
|
|
|
}
|
|
|
|
|
|
function renderDemands() {
|
|
|
return `
|
|
|
<div class="section-head">
|
|
|
<div>
|
|
|
<h2 class="section-title">伙伴需求</h2>
|
|
|
<div class="muted small" style="margin-top:5px">正式伙伴可提交技术、产业、资金等协作需求</div>
|
|
|
</div>
|
|
|
<button class="section-more" data-go="demandCreate" type="button">发布</button>
|
|
|
</div>
|
|
|
${state.demands.length ? `<div class="stack">${state.demands.map(demandCard).join("")}</div>` : `<div class="empty">暂无需求</div>`}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function demandCard(item) {
|
|
|
return `
|
|
|
<article class="card">
|
|
|
<div class="row between">${tag(item.type, "brand")}${tag(item.status)}</div>
|
|
|
<div class="title">${escapeHtml(item.title)}</div>
|
|
|
<div class="summary">${escapeHtml(item.description)}</div>
|
|
|
<div class="muted small" style="margin-top:10px">${escapeHtml(item.date)}</div>
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderDemandCreate() {
|
|
|
return `
|
|
|
<form class="card" id="demandForm">
|
|
|
<div class="field">
|
|
|
<label class="form-label required" for="type">需求类型</label>
|
|
|
<select class="select" id="type" name="type" required>
|
|
|
<option value="">请选择</option>
|
|
|
<option>资金对接</option>
|
|
|
<option>招聘</option>
|
|
|
<option>财法咨询</option>
|
|
|
<option>场地空间</option>
|
|
|
<option>技术合作</option>
|
|
|
<option>其他</option>
|
|
|
</select>
|
|
|
</div>
|
|
|
<div class="field">
|
|
|
<label class="form-label required" for="demandTitle">需求标题</label>
|
|
|
<input class="input" id="demandTitle" name="title" maxlength="40" required>
|
|
|
</div>
|
|
|
<div class="field">
|
|
|
<label class="form-label required" for="description">需求描述</label>
|
|
|
<textarea class="textarea" id="description" name="description" maxlength="300" required></textarea>
|
|
|
</div>
|
|
|
<button class="btn btn-primary" style="margin-top:18px" type="submit">提交需求</button>
|
|
|
</form>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderProfile() {
|
|
|
const user = state.user || {};
|
|
|
const profile = state.profile || {};
|
|
|
const isPartner = state.user ? Boolean(state.user.isPartner) : true;
|
|
|
const displayName = profile.name || user.nickName || "王清萍";
|
|
|
return `
|
|
|
<section class="profile-hero-card">
|
|
|
<div class="profile-hero-main">
|
|
|
<div class="profile-avatar-upload">
|
|
|
<img src="${escapeHtml(profileAvatarSrc())}" alt="王清萍头像">
|
|
|
</div>
|
|
|
<div class="profile-name-group">
|
|
|
<div class="profile-name-line">${escapeHtml(displayName)}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
<section class="profile-menu-panel">
|
|
|
${menuItem("个人资料", "profileEdit", "profile")}
|
|
|
${menuItem("我的课程", "myCourses", "course")}
|
|
|
${menuItem("我的活动", "myActivities", "activity")}
|
|
|
${isPartner ? menuItem("需求发布", "demandCreate", "demand") : ""}
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderProfileEdit() {
|
|
|
const profile = state.profile || {};
|
|
|
return `
|
|
|
<form class="card signup-form" id="profileForm">
|
|
|
<label class="avatar large profile-edit-avatar" for="profileAvatarInput">
|
|
|
<img id="profileAvatarPreview" src="${escapeHtml(profileAvatarSrc())}" alt="王清萍头像">
|
|
|
</label>
|
|
|
<input class="profile-avatar-input" id="profileAvatarInput" type="file" accept="image/*" capture="environment">
|
|
|
<input class="input" name="name" placeholder="姓名" value="${escapeHtml(profile.name || "王清萍")}">
|
|
|
<input class="input" name="company" placeholder="公司" value="${escapeHtml(profile.company || "")}">
|
|
|
<input class="input" name="title" placeholder="职务" value="${escapeHtml(profile.title || "")}">
|
|
|
<textarea class="textarea" name="research" placeholder="研究方向">${escapeHtml(profile.research || "")}</textarea>
|
|
|
<button class="btn btn-primary" type="submit">保存资料</button>
|
|
|
</form>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderAbout() {
|
|
|
return `
|
|
|
<article class="card">
|
|
|
<h2 class="section-title">关于我们</h2>
|
|
|
<p class="summary">高校雷达网用于连接高校科研人才、课程活动与产业资源,帮助政企与高校团队更高效地完成成果转化对接。</p>
|
|
|
${infoRow("联系电话", "0512-0000 0000")}
|
|
|
${infoRow("联系邮箱", "service@example.com")}
|
|
|
${infoRow("用户协议", "演示版本")}
|
|
|
${infoRow("隐私政策", "演示版本")}
|
|
|
</article>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function profileMenuIcon(type) {
|
|
|
const icons = {
|
|
|
profile: "M7 5.5h10v13H7z M9.4 9h5.2 M9.4 12h5.2 M9.4 15h3.2",
|
|
|
course: "M6 6.5c1.8-.9 3.7-.9 5.5 0v11c-1.8-.9-3.7-.9-5.5 0v-11z M12.5 6.5c1.8-.9 3.7-.9 5.5 0v11c-1.8-.9-3.7-.9-5.5 0v-11z",
|
|
|
activity: "M7 5.5h10v13H7z M9 4v3 M15 4v3 M7 9h10 M10 12h4 M10 15h3",
|
|
|
demand: "M12 5.5l1.8 3.7 4.1.6-3 2.9.7 4.1-3.6-1.9-3.6 1.9.7-4.1-3-2.9 4.1-.6L12 5.5z",
|
|
|
star: "M12 5.4l1.9 3.8 4.2.6-3 2.9.7 4.2-3.8-2-3.8 2 .7-4.2-3-2.9 4.2-.6L12 5.4z",
|
|
|
contact: "M7 15.5v-3.2a5 5 0 0 1 10 0v3.2 M7 15.5h2v2H7z M15 15.5h2v2h-2z M9 18.5h6"
|
|
|
};
|
|
|
return `
|
|
|
<span class="menu-icon" aria-hidden="true">
|
|
|
<svg viewBox="0 0 24 24"><path d="${icons[type] || icons.profile}"></path></svg>
|
|
|
</span>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function menuItem(label, route, icon = "profile") {
|
|
|
return `
|
|
|
<button class="menu-item" data-go="${route}">
|
|
|
${profileMenuIcon(icon)}
|
|
|
<span class="menu-copy">${escapeHtml(label)}</span>
|
|
|
<span class="arrow">›</span>
|
|
|
</button>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderLogin() {
|
|
|
return `
|
|
|
<section class="login-page">
|
|
|
<div>
|
|
|
<div class="brand-mark">S</div>
|
|
|
<div class="brand-title">${APP_BRAND_NAME}</div>
|
|
|
<div class="brand-subtitle">连接长三角高校科研人才、课程活动与产业伙伴需求</div>
|
|
|
</div>
|
|
|
<div class="card">
|
|
|
<h2 class="section-title">登录演示</h2>
|
|
|
<div class="muted" style="margin-top:10px;font-size:14px;line-height:1.6">纯 HTML 版本不调用微信授权,点击后写入本地演示身份。</div>
|
|
|
<button class="btn btn-primary" style="margin-top:18px" data-auth="login">一键登录</button>
|
|
|
</div>
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function formatDate(dateText) {
|
|
|
const date = new Date(dateText.replace(/-/g, "/"));
|
|
|
return `${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`;
|
|
|
}
|
|
|
|
|
|
function formatWeekday(dateText) {
|
|
|
return ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][new Date(dateText.replace(/-/g, "/")).getDay()];
|
|
|
}
|
|
|
|
|
|
const titles = {
|
|
|
home: APP_BRAND_NAME,
|
|
|
courses: "课程学习",
|
|
|
courseDetail: "课程详情",
|
|
|
myCourses: "我的课程",
|
|
|
activities: "活动参与",
|
|
|
activityDetail: "活动详情",
|
|
|
myActivities: "我的活动",
|
|
|
activityCalendar: "活动日历",
|
|
|
discover: "发现",
|
|
|
newsDetail: "资讯详情",
|
|
|
calendar: "课程日历",
|
|
|
demands: "伙伴需求",
|
|
|
demandCreate: "发布需求",
|
|
|
profile: "我的",
|
|
|
profileEdit: "个人资料",
|
|
|
about: "关于我们",
|
|
|
login: "登录"
|
|
|
};
|
|
|
|
|
|
const tabRoutes = ["home", "courses", "activities", "discover", "profile"];
|
|
|
const tabActiveRoute = {
|
|
|
courseDetail: "courses",
|
|
|
myCourses: "profile",
|
|
|
calendar: "courses",
|
|
|
activityDetail: "activities",
|
|
|
myActivities: "profile",
|
|
|
activityCalendar: "activities",
|
|
|
newsDetail: "discover",
|
|
|
demands: "profile",
|
|
|
demandCreate: "profile",
|
|
|
profileEdit: "profile",
|
|
|
about: "profile",
|
|
|
login: "profile"
|
|
|
};
|
|
|
|
|
|
function render() {
|
|
|
const route = state.route;
|
|
|
document.body.dataset.route = route;
|
|
|
document.body.classList.toggle("home-route", route === "home");
|
|
|
title.textContent = titles[route] || APP_BRAND_NAME;
|
|
|
backBtn.classList.toggle("visible", !tabRoutes.includes(route));
|
|
|
tabbar.style.display = "flex";
|
|
|
topAction.textContent = state.user ? "已登录" : "登录";
|
|
|
const activeTab = tabActiveRoute[route] || route;
|
|
|
|
|
|
document.querySelectorAll(".tab-item").forEach((item) => {
|
|
|
item.classList.toggle("active", item.dataset.route === activeTab);
|
|
|
});
|
|
|
|
|
|
const views = {
|
|
|
home: renderHome,
|
|
|
courses: renderCourses,
|
|
|
courseDetail: renderCourseDetail,
|
|
|
myCourses: renderMyCourses,
|
|
|
activities: renderActivities,
|
|
|
activityDetail: renderActivityDetail,
|
|
|
myActivities: renderMyActivities,
|
|
|
activityCalendar: renderActivityCalendar,
|
|
|
discover: renderDiscover,
|
|
|
newsDetail: renderNewsDetail,
|
|
|
calendar: renderCalendar,
|
|
|
demands: renderDemands,
|
|
|
demandCreate: renderDemandCreate,
|
|
|
profile: renderProfile,
|
|
|
profileEdit: renderProfileEdit,
|
|
|
about: renderAbout,
|
|
|
login: renderLogin
|
|
|
};
|
|
|
|
|
|
app.innerHTML = (views[route] || renderHome)();
|
|
|
app.scrollTop = 0;
|
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
|
|
}
|
|
|
|
|
|
function parseHash() {
|
|
|
const [route = "home", id] = location.hash.replace(/^#/, "").split("/");
|
|
|
replaceRoute(route || "home", id ? { id } : {});
|
|
|
}
|
|
|
|
|
|
document.addEventListener("click", (event) => {
|
|
|
const goEl = event.target.closest("[data-go]");
|
|
|
if (goEl) {
|
|
|
go(goEl.dataset.go);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const signupCourseEl = event.target.closest("[data-signup-course]");
|
|
|
if (signupCourseEl) {
|
|
|
event.stopPropagation();
|
|
|
const id = Number(signupCourseEl.dataset.signupCourse);
|
|
|
openCourseSignup(id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const sessionEl = event.target.closest("[data-session-id]");
|
|
|
if (sessionEl && !sessionEl.disabled) {
|
|
|
state.signupSessionId = Number(sessionEl.dataset.sessionId);
|
|
|
const activity = findById(data.activities, Number(signupCourseId.value));
|
|
|
if (activity) renderSignupSessionOptions(activity);
|
|
|
const err = document.querySelector("#signupSessionError");
|
|
|
if (err) err.textContent = "";
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (event.target.closest("[data-close-signup]") || event.target === courseSignupModal) {
|
|
|
closeCourseSignup();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (event.target.closest("[data-close-success]") || event.target === signupSuccessModal) {
|
|
|
closeSignupSuccess();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const signupActivityEl = event.target.closest("[data-signup-activity]");
|
|
|
if (signupActivityEl) {
|
|
|
event.stopPropagation();
|
|
|
const id = Number(signupActivityEl.dataset.signupActivity);
|
|
|
openActivitySignup(id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const detailEl = event.target.closest("[data-detail]");
|
|
|
if (detailEl) {
|
|
|
const map = {
|
|
|
course: "courseDetail",
|
|
|
activity: "activityDetail",
|
|
|
news: "newsDetail"
|
|
|
};
|
|
|
go(map[detailEl.dataset.detail], { id: detailEl.dataset.id });
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const categoryEl = event.target.closest("[data-category]");
|
|
|
if (categoryEl) {
|
|
|
state.params.category = categoryEl.dataset.category;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const courseStatusEl = event.target.closest("[data-course-status]");
|
|
|
if (courseStatusEl) {
|
|
|
const status = courseStatusEl.dataset.courseStatus;
|
|
|
state.params.courseStatus = state.params.courseStatus === status ? "" : status;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const myCourseStatusEl = event.target.closest("[data-my-course-status]");
|
|
|
if (myCourseStatusEl) {
|
|
|
state.params.myCourseStatus = myCourseStatusEl.dataset.myCourseStatus;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const activityCategoryEl = event.target.closest("[data-activity-category]");
|
|
|
if (activityCategoryEl) {
|
|
|
state.params.activityCategory = activityCategoryEl.dataset.activityCategory;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const activityStatusEl = event.target.closest("[data-activity-status]");
|
|
|
if (activityStatusEl) {
|
|
|
const status = activityStatusEl.dataset.activityStatus;
|
|
|
state.params.activityStatus = state.params.activityStatus === status ? "" : status;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const myActivityStatusEl = event.target.closest("[data-my-activity-status]");
|
|
|
if (myActivityStatusEl) {
|
|
|
state.params.myActivityStatus = myActivityStatusEl.dataset.myActivityStatus;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const newsCategoryEl = event.target.closest("[data-news-category]");
|
|
|
if (newsCategoryEl) {
|
|
|
state.params.newsCategory = newsCategoryEl.dataset.newsCategory;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const monthEl = event.target.closest("[data-month]");
|
|
|
if (monthEl) {
|
|
|
const [year, month] = state.calendarMonth.split("-").map(Number);
|
|
|
const next = new Date(year, month - 1 + Number(monthEl.dataset.month), 1);
|
|
|
const nextMonth = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
|
|
|
if (nextMonth < minCalendarMonth) return;
|
|
|
state.calendarMonth = nextMonth;
|
|
|
state.selectedDate = `${state.calendarMonth}-01`;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const activityMonthEl = event.target.closest("[data-activity-month]");
|
|
|
if (activityMonthEl) {
|
|
|
const [year, month] = state.activityCalendarMonth.split("-").map(Number);
|
|
|
const next = new Date(year, month - 1 + Number(activityMonthEl.dataset.activityMonth), 1);
|
|
|
const nextMonth = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
|
|
|
if (nextMonth < minCalendarMonth) return;
|
|
|
state.activityCalendarMonth = nextMonth;
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const dateEl = event.target.closest("[data-date]");
|
|
|
if (dateEl) {
|
|
|
state.selectedDate = dateEl.dataset.date;
|
|
|
const dayCourse = data.courses.find((item) => isCourseOnDate(item, state.selectedDate));
|
|
|
if (dayCourse) go("courseDetail", { id: dayCourse.id });
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const activityDateEl = event.target.closest("[data-activity-date]");
|
|
|
if (activityDateEl) {
|
|
|
const dayActivity = data.activities.find((item) => isActivityOnDate(item, activityDateEl.dataset.activityDate));
|
|
|
if (dayActivity) go("activityDetail", { id: dayActivity.id });
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const signupEl = event.target.closest("[data-signup]");
|
|
|
if (signupEl) {
|
|
|
openSignupSuccess(signupEl.dataset.signup === "activity" ? "活动已同步到你的活动日程" : "课程已同步到你的课程日程");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const checkinEl = event.target.closest("[data-checkin]");
|
|
|
if (checkinEl) {
|
|
|
toast("签到成功");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const authEl = event.target.closest("[data-auth]");
|
|
|
if (authEl) {
|
|
|
if (authEl.dataset.auth === "logout") {
|
|
|
state.user = null;
|
|
|
localStorage.removeItem("slakeUser");
|
|
|
toast("已退出登录");
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
state.user = {
|
|
|
nickName: "高校伙伴",
|
|
|
role: "正式伙伴",
|
|
|
isPartner: true,
|
|
|
openId: "mock-openid",
|
|
|
unionId: "mock-unionid",
|
|
|
organization: "S-lake 先进技术发展中心"
|
|
|
};
|
|
|
localStorage.setItem("slakeUser", JSON.stringify(state.user));
|
|
|
toast("登录成功");
|
|
|
go("profile");
|
|
|
}
|
|
|
});
|
|
|
|
|
|
document.addEventListener("input", (event) => {
|
|
|
if (event.target.matches("#courseSignupForm [name='phone']")) {
|
|
|
signupPhoneError.textContent = "";
|
|
|
}
|
|
|
});
|
|
|
|
|
|
document.addEventListener("change", (event) => {
|
|
|
if (!event.target.matches("#profileAvatarInput")) return;
|
|
|
const [file] = event.target.files || [];
|
|
|
if (!file || !file.type.startsWith("image/")) return;
|
|
|
const reader = new FileReader();
|
|
|
reader.addEventListener("load", () => {
|
|
|
state.profile = {
|
|
|
...(state.profile || {}),
|
|
|
avatar: String(reader.result || "")
|
|
|
};
|
|
|
const preview = document.querySelector("#profileAvatarPreview");
|
|
|
if (preview) preview.src = state.profile.avatar;
|
|
|
});
|
|
|
reader.readAsDataURL(file);
|
|
|
});
|
|
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
|
event.preventDefault();
|
|
|
const form = new FormData(event.target);
|
|
|
if (event.target.id === "courseSearchForm") {
|
|
|
state.params.keyword = String(form.get("keyword") || "");
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
if (event.target.id === "activitySearchForm") {
|
|
|
state.params.activityKeyword = String(form.get("keyword") || "");
|
|
|
state.params.activityStatus = state.params.activityStatus || "";
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
if (event.target.id === "courseSignupForm") {
|
|
|
const id = Number(form.get("courseId"));
|
|
|
const type = String(form.get("signupType") || "course");
|
|
|
const phone = String(form.get("phone") || "").trim();
|
|
|
const sessionError = document.querySelector("#signupSessionError");
|
|
|
if (type === "activity") {
|
|
|
const activity = findById(data.activities, id);
|
|
|
if (!activity || !canShowActivitySignupButton(activity)) {
|
|
|
if (sessionError) sessionError.textContent = "当前不可报名";
|
|
|
return;
|
|
|
}
|
|
|
const session = (activity.sessions || []).find((row) => row.id === state.signupSessionId);
|
|
|
if (!session) {
|
|
|
if (sessionError) sessionError.textContent = "请选择场次";
|
|
|
return;
|
|
|
}
|
|
|
if (!hasSessionCapacity(session)) {
|
|
|
if (sessionError) sessionError.textContent = "该场次名额已满";
|
|
|
return;
|
|
|
}
|
|
|
if (sessionError) sessionError.textContent = "";
|
|
|
if (phone && !isValidMobile(phone)) {
|
|
|
signupPhoneError.textContent = "请输入正确的手机号";
|
|
|
event.target.elements.phone.focus();
|
|
|
return;
|
|
|
}
|
|
|
signupPhoneError.textContent = "";
|
|
|
session.signed = Number(session.signed) + 1;
|
|
|
if (!state.signedActivityIds.includes(id)) state.signedActivityIds.push(id);
|
|
|
persistSignup("activity");
|
|
|
closeCourseSignup();
|
|
|
render();
|
|
|
openSignupSuccess("活动已同步到你的活动日程");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const course = findById(data.courses, id);
|
|
|
if (!course || !canShowCourseSignupButton(course)) return;
|
|
|
if (!isValidMobile(phone)) {
|
|
|
signupPhoneError.textContent = "请输入正确的手机号";
|
|
|
event.target.elements.phone.focus();
|
|
|
return;
|
|
|
}
|
|
|
signupPhoneError.textContent = "";
|
|
|
course.signed = Number(course.signed) + 1;
|
|
|
if (!state.signedCourseIds.includes(id)) state.signedCourseIds.push(id);
|
|
|
persistSignup("course");
|
|
|
closeCourseSignup();
|
|
|
render();
|
|
|
openSignupSuccess("课程已同步到你的课程日程");
|
|
|
return;
|
|
|
}
|
|
|
if (event.target.id === "profileForm") {
|
|
|
state.profile = {
|
|
|
...(state.profile || {}),
|
|
|
name: form.get("name"),
|
|
|
company: form.get("company"),
|
|
|
title: form.get("title"),
|
|
|
research: form.get("research")
|
|
|
};
|
|
|
localStorage.setItem("slakeProfile", JSON.stringify(state.profile));
|
|
|
toast("资料已保存");
|
|
|
go("profile");
|
|
|
return;
|
|
|
}
|
|
|
if (event.target.id !== "demandForm") return;
|
|
|
state.demands.unshift({
|
|
|
id: Date.now(),
|
|
|
type: form.get("type"),
|
|
|
title: form.get("title"),
|
|
|
description: form.get("description"),
|
|
|
status: "已提交",
|
|
|
date: "2026-05-25"
|
|
|
});
|
|
|
toast("需求已提交");
|
|
|
go("demands");
|
|
|
});
|
|
|
|
|
|
tabbar.addEventListener("click", (event) => {
|
|
|
const item = event.target.closest(".tab-item");
|
|
|
if (item) go(item.dataset.route);
|
|
|
});
|
|
|
|
|
|
topAction.addEventListener("click", () => {
|
|
|
if (!state.user) go("login");
|
|
|
});
|
|
|
|
|
|
backBtn.addEventListener("click", () => {
|
|
|
history.length > 1 ? history.back() : go("home");
|
|
|
});
|
|
|
|
|
|
window.addEventListener("popstate", (event) => {
|
|
|
if (event.state) {
|
|
|
state.route = event.state.route;
|
|
|
state.params = event.state.params || {};
|
|
|
render();
|
|
|
return;
|
|
|
}
|
|
|
parseHash();
|
|
|
});
|
|
|
|
|
|
parseHash();
|