You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1649 lines
63 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();