const ROUTE_MATHEMATICIANS = { believer: [ { name: "高斯", portrait: "/static/img/mathematicians/1000.png", identity: "定理工匠" }, { name: "祖冲之", portrait: "/static/img/mathematicians/1010.png", identity: "穷竭守尺人" }, { name: "欧拉", portrait: "/static/img/mathematicians/1001.png", identity: "公式狂僧" }, { name: "诺特", portrait: "/static/img/mathematicians/1011.png", identity: "抽象代数宗师" }, ], spreader: [ { name: "斐波那契", portrait: "/static/img/mathematicians/1100.png", identity: "商路算郎" }, { name: "华罗庚", portrait: "/static/img/mathematicians/1110.png", identity: "双法布道者" }, { name: "伽罗瓦", portrait: "/static/img/mathematicians/1101.png", identity: "决斗狂热者" }, { name: "Lovelace", portrait: "/static/img/mathematicians/1111.png", identity: "织机先知" }, ], applier: [ { name: "秦九韶", portrait: "/static/img/mathematicians/0100.png", identity: "大衍谋士" }, { name: "牛顿", portrait: "/static/img/mathematicians/0110.png", identity: "宇宙立法者" }, { name: "图灵", portrait: "/static/img/mathematicians/0111.png", identity: "密码破译使" }, { name: "冯·诺依曼", portrait: "/static/img/mathematicians/0101.png", identity: "博弈游侠" }, ], seer: [ { name: "希帕提娅", portrait: "/static/img/mathematicians/0000.png", identity: "几何殉道者" }, { name: "赵爽", portrait: "/static/img/mathematicians/0010.png", identity: "弦图先觉" }, { name: "庞加莱", portrait: "/static/img/mathematicians/0001.png", identity: "拓扑游方" }, { name: "约翰逊", portrait: "/static/img/mathematicians/0011.png", identity: "人脑计算机" }, ], }; const state = { user: null, stories: [], contests: [], matchMode: "quiz", videoCatalog: null, videos: [], videoAbility: "", videoDiscipline: "", }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; function csrfToken() { const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/); return match ? decodeURIComponent(match[1]) : ""; } function createIdempotencyKey() { const cryptoApi = globalThis.crypto; if (typeof cryptoApi?.randomUUID === "function") { return cryptoApi.randomUUID(); } const bytes = new Uint8Array(16); if (typeof cryptoApi?.getRandomValues === "function") { cryptoApi.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")); return [ hex.slice(0, 4).join(""), hex.slice(4, 6).join(""), hex.slice(6, 8).join(""), hex.slice(8, 10).join(""), hex.slice(10).join(""), ].join("-"); } function collectApiErrorMessages(value, messages = []) { if (Array.isArray(value)) { value.forEach((item) => collectApiErrorMessages(item, messages)); } else if (value && typeof value === "object") { Object.values(value).forEach((item) => collectApiErrorMessages(item, messages)); } else if (value !== undefined && value !== null) { const message = String(value).trim(); if (message && !messages.includes(message)) messages.push(message); } return messages; } function logApiError(error) { if (error.status === 401 || error.status === 403) return; console.error("[Hulumath API]", { method: error.method, path: error.path, status: error.status, code: error.code, requestId: error.requestId, details: error.details, }); } async function api(path, options = {}) { const method = (options.method || "GET").toUpperCase(); const requestPath = `/api/v1/${path}`; const headers = { Accept: "application/json", ...(options.headers || {}) }; if (options.body && typeof options.body !== "string") { headers["Content-Type"] = "application/json"; options.body = JSON.stringify(options.body); } if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken(); let response; try { response = await fetch(requestPath, { credentials: "same-origin", ...options, headers }); } catch (cause) { const error = new Error("网络连接失败,请检查连接后重试"); error.status = null; error.code = "network_error"; error.requestId = null; error.details = null; error.path = requestPath; error.method = method; error.cause = cause; logApiError(error); throw error; } if (response.status === 204) return null; const payload = await response.json().catch(() => ({})); if (!response.ok) { const details = payload.error?.details; const detailMessages = collectApiErrorMessages(details); const requestId = payload.error?.request_id || response.headers.get("X-Request-ID"); const baseMessage = detailMessages.length ? detailMessages.join(";") : payload.error?.message || payload.detail || `请求失败(HTTP ${response.status})`; const message = requestId ? `${baseMessage}(请求编号:${requestId})` : baseMessage; const error = new Error(message); error.status = response.status; error.code = payload.error?.code || "request_error"; error.requestId = requestId; error.details = details || null; error.path = requestPath; error.method = method; logApiError(error); throw error; } return payload; } function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.classList.add("show"); window.setTimeout(() => toast.classList.remove("show"), 2600); } async function copyWechatName() { const name = $("#wechat-copy").dataset.wechatName; try { await navigator.clipboard.writeText(name); showToast(`已复制公众号名称:${name}`); } catch { showToast(`请在微信内搜索:${name}`); } } function navigate(view) { $$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view)); $$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`)); window.scrollTo({ top: 0, behavior: "smooth" }); if (view === "profile") loadProfile(); } function openAuth() { $("#auth-error").textContent = ""; $("#auth-dialog").showModal(); } function requireAuth() { if (state.user) return true; openAuth(); showToast("请先登录或使用邀请码注册"); return false; } function updateUserUI() { const chip = $("#user-chip"); chip.replaceChildren(); const avatar = document.createElement("span"); avatar.className = "avatar"; avatar.textContent = state.user ? state.user.nickname.slice(0, 1) : "游"; const name = document.createElement("span"); name.textContent = state.user ? `${state.user.nickname} · ${state.user.rating}` : "游客"; chip.append(avatar, name); $("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册"; $("#admin-entry").hidden = !state.user?.is_staff; if (!state.user) $("#home-match-history").hidden = true; } async function loadUser() { try { state.user = await api("accounts/me/"); } catch (error) { if (error.status !== 401 && error.status !== 403) console.warn(error); state.user = null; } updateUserUI(); await loadHomeMatchHistory(); } function card({ meta, title, body, foot, action, onClick, disabled = false }) { const article = document.createElement("article"); article.className = "content-card"; const metaEl = document.createElement("span"); metaEl.className = "meta"; metaEl.textContent = meta; const titleEl = document.createElement("h3"); titleEl.textContent = title; const bodyEl = document.createElement("p"); bodyEl.textContent = body; const footer = document.createElement("footer"); const footEl = document.createElement("span"); footEl.textContent = foot; const button = document.createElement("button"); button.textContent = action; button.disabled = disabled; if (onClick) button.addEventListener("click", onClick); footer.append(footEl, button); article.append(metaEl, titleEl, bodyEl, footer); return article; } function renderRouteMathematicians() { Object.entries(ROUTE_MATHEMATICIANS).forEach(([clan, mathematicians]) => { const root = $(`[data-mathematicians="${clan}"]`); if (!root) return; root.replaceChildren(...mathematicians.map((person) => { const item = document.createElement("div"); item.className = "route-mathematician"; const img = document.createElement("img"); img.src = person.portrait; img.alt = person.name; img.loading = "lazy"; const name = document.createElement("b"); name.textContent = person.name; const identity = document.createElement("small"); identity.textContent = person.identity; item.append(img, name, identity); return item; })); }); } async function loadStories() { try { state.stories = await api("math-life/stories/"); renderRouteBadges(); renderSkillList(); } catch (error) { showToast(error.message); } } function renderRouteBadges() { const flagship = state.stories.find((story) => story.kind === "flagship" && story.available); const skillCount = state.stories.filter((story) => story.kind === "skill").length; const alumniCount = $("#alumni-count"); alumniCount.textContent = skillCount > 0 ? `当前 ${skillCount} 个校友人生可体验` : "即将上线校友访谈"; if (flagship) { const route = flagship.slug.includes("believer") ? "believer" : "believer"; const card = $(`.route-card[data-route="${route}"]`); if (card) { card.querySelector(".route-status").textContent = `可体验 · 约 ${flagship.estimated_minutes} 分钟`; } } } function renderSkillList() { const root = $("#skill-list"); const skills = state.stories.filter((story) => story.kind === "skill"); if (skills.length === 0) { root.classList.remove("loading"); root.textContent = "校友访谈即将上线,敬请期待。"; return; } root.classList.remove("loading"); root.replaceChildren(...skills.map((story) => { const article = document.createElement("article"); article.className = "skill-card"; const header = document.createElement("div"); header.className = "skill-header"; const meta = document.createElement("span"); meta.className = "skill-meta"; meta.textContent = story.kind === "skill" ? "校友访谈" : "特别篇"; header.append(meta); const body = document.createElement("div"); body.className = "skill-body"; const title = document.createElement("h3"); title.textContent = story.title; const summary = document.createElement("p"); summary.textContent = story.summary || "在不完整信息中判断机会、关系与代价。"; const footer = document.createElement("footer"); const duration = document.createElement("span"); duration.textContent = `约 ${story.estimated_minutes} 分钟`; const button = document.createElement("button"); button.textContent = story.available ? "开始人生 →" : "查看预告"; button.disabled = !story.available; if (story.available) button.addEventListener("click", () => beginStory(story.slug)); footer.append(duration, button); body.append(title, summary, footer); article.append(header, body); return article; })); } function showLifeHub() { $("#life-hub").classList.remove("hidden"); $("#life-skills").classList.add("hidden"); } function showLifeSkills() { $("#life-hub").classList.add("hidden"); $("#life-skills").classList.remove("hidden"); window.scrollTo({ top: 0, behavior: "smooth" }); } async function beginStory(slug) { if (!requireAuth()) return; try { const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} }); renderStoryNode(run); $("#story-experience").classList.remove("hidden"); document.body.classList.add("story-open"); window.scrollTo({ top: 0 }); } catch (error) { showToast(error.message); } } function closeStoryExperience() { $("#story-experience").classList.add("hidden"); document.body.classList.remove("story-open"); navigate("life"); } function renderStoryMarks(run) { const collected = new Set(run.collection.map((mark) => mark.code)); const current = new Set(run.state.marks || []); const labels = run.domain_labels || {}; const counts = $("#story-domain-counts"); counts.replaceChildren( ...Object.entries(labels).map(([domain, label]) => { const item = document.createElement("div"); const value = document.createElement("b"); value.textContent = run.state.mark_counts?.[domain] || 0; const name = document.createElement("span"); name.textContent = label; item.append(value, name); return item; }) ); const list = $("#story-mark-list"); list.replaceChildren( ...run.mark_definitions.map((mark) => { const item = document.createElement("span"); item.className = [ "story-mark", collected.has(mark.code) ? "collected" : "", current.has(mark.code) ? "current-run" : "", ].filter(Boolean).join(" "); item.dataset.domain = mark.domain; item.textContent = mark.name; item.title = `${labels[mark.domain] || mark.domain}印记`; return item; }) ); } function renderStoryNode(run) { const root = $("#story-experience-content"); root.replaceChildren(); $("#story-experience-title").textContent = run.story; const chapterNumber = run.chapter?.number || (run.status === "completed" ? 8 : 1); $("#story-chapter-number").textContent = `第 ${chapterNumber} 章`; $("#story-chapter-title").textContent = run.chapter?.title || "直博方向"; $("#story-chapter-progress").style.width = `${Math.min(100, chapterNumber / 8 * 100)}%`; renderStoryMarks(run); const label = document.createElement("span"); label.className = "kicker"; label.textContent = `CHAPTER ${String(chapterNumber).padStart(2, "0")} · ${run.current_node}`; const title = document.createElement("h2"); title.textContent = run.node.character || "旁白"; const scene = document.createElement("p"); scene.className = "scene"; scene.textContent = run.node.scene; const choices = document.createElement("div"); choices.className = "choice-list"; if (run.status === "completed") { root.classList.add("completed"); const ending = document.createElement("p"); ending.textContent = "统一直博的方向已经确定。这段人生、结局与全部印记已写入你的数学档案。"; const back = document.createElement("button"); back.className = "primary-button"; back.textContent = "返回数学人生大厅"; back.addEventListener("click", closeStoryExperience); choices.append(ending, back); } else { root.classList.remove("completed"); run.node.choices.forEach((choice) => { const button = document.createElement("button"); button.textContent = choice.text; if (choice.special) button.classList.add("story-special-choice"); button.addEventListener("click", async () => { button.disabled = true; try { const next = await api(`math-life/runs/${run.run_id}/choice/`, { method: "POST", headers: { "Idempotency-Key": createIdempotencyKey() }, body: { choice_index: choice.index }, }); renderStoryNode(next); } catch (error) { showToast(error.message); button.disabled = false; } }); choices.append(button); }); } root.append(label, title, scene, choices); } async function startMathBTI() { try { const assessment = await api("math-life/mathbti/"); const questions = assessment.definition.questions; const answers = []; let index = 0; const root = $("#experience-content"); const render = () => { const question = questions[index]; root.replaceChildren(); const label = document.createElement("span"); label.className = "kicker"; label.textContent = `MATHBTI · ${index + 1} / ${questions.length}`; const progress = document.createElement("div"); progress.className = "quiz-progress"; const bar = document.createElement("i"); bar.style.width = `${((index + 1) / questions.length) * 100}%`; progress.append(bar); const title = document.createElement("h2"); title.textContent = question.question; const choices = document.createElement("div"); choices.className = "choice-list"; question.options.forEach((option, optionIndex) => { const button = document.createElement("button"); button.textContent = option.text; button.addEventListener("click", async () => { answers.push(optionIndex); index += 1; if (index < questions.length) { render(); return; } try { const result = await api("math-life/mathbti/submit/", { method: "POST", body: { version: assessment.version, answers }, }); renderMathBTIResult(result); } catch (error) { showToast(error.message); } }); choices.append(button); }); root.append(label, progress, title, choices); }; render(); $("#experience-dialog").showModal(); } catch (error) { showToast(error.message); } } function renderMathBTIResult(result) { const root = $("#experience-content"); root.replaceChildren(); const label = document.createElement("span"); label.className = "kicker"; label.textContent = `${result.identity.clan} · ${result.identity.code}`; const title = document.createElement("h2"); title.textContent = `${result.identity.name} · ${result.identity.mathematician}`; const description = document.createElement("p"); description.className = "scene"; description.textContent = result.identity.description; const button = document.createElement("button"); button.className = "primary-button"; button.textContent = state.user ? "进入推荐人生" : "注册并保存身份"; button.addEventListener("click", () => { $("#experience-dialog").close(); if (state.user) navigate("life"); else openAuth(); }); root.append(label, title, description, button); } async function loadContests() { const root = $("#contest-list"); try { state.contests = await api("contests/"); renderContests(); } catch (error) { root.textContent = error.message; } } function renderContests() { const root = $("#contest-list"); const contests = ["realtime", "daily", "practice"] .map((kind) => { const candidates = state.contests.filter((contest) => contest.kind === kind); return ( candidates.find((contest) => contest.track === "open") || candidates.find((contest) => contest.track === "standard") || candidates[0] ); }) .filter(Boolean); root.classList.remove("loading"); root.replaceChildren(...contests.map((contest) => card({ meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习", title: contest.title.replace(/^(入门|标准|进阶)/, ""), body: contest.kind === "realtime" ? "统一玩家池,支持口算、数独与 24 点,服务端计时和 Rating 结算。" : "每次随机抽取题目,正式答案只在提交后显示。", foot: `${contest.duration_seconds} 秒`, action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →", onClick: () => beginContest(contest), }))); } async function beginContest(contest) { if (!requireAuth()) return; try { if (contest.kind === "realtime") { await window.HuluRealtime.startRandom(contest); return; } else { renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} })); } $("#experience-dialog").showModal(); } catch (error) { showToast(error.message); } } function renderAttempt(attempt) { const root = $("#experience-content"); root.replaceChildren(); const label = document.createElement("span"); label.className = "kicker"; label.textContent = `${attempt.kind} · ${attempt.duration_seconds} 秒`; const title = document.createElement("h2"); title.textContent = attempt.contest; if (attempt.status !== "active") { const summary = document.createElement("p"); summary.className = "scene"; summary.textContent = `得分 ${attempt.score},答对 ${attempt.correct_count} 题,用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒。`; root.append(label, title, summary); return; } const form = document.createElement("form"); form.className = "choice-list"; attempt.questions.forEach((question) => { const field = document.createElement("label"); field.textContent = `${question.order}. ${question.prompt}`; const input = document.createElement("input"); input.name = String(question.order); input.inputMode = "decimal"; input.autocomplete = "off"; input.style.cssText = "margin-top:7px;width:100%;padding:12px;border:1px solid #d6d8d1;border-radius:9px"; field.append(input); form.append(field); }); const submit = document.createElement("button"); submit.className = "primary-button"; submit.type = "submit"; submit.textContent = "提交并结算"; form.append(submit); form.addEventListener("submit", async (event) => { event.preventDefault(); submit.disabled = true; const data = new FormData(form); const answers = attempt.questions.map((question) => ({ order: question.order, answer: data.get(String(question.order)) || "", })); try { const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, { method: "POST", headers: { "Idempotency-Key": createIdempotencyKey() }, body: { answers }, }); renderAttempt(result); } catch (error) { showToast(error.message); submit.disabled = false; } }); root.append(label, title, form); } function filterOption(label, value, type, active) { const button = document.createElement("button"); button.className = `filter-option${active ? " active" : ""}`; button.textContent = label; button.dataset[type] = value; button.setAttribute("role", "option"); button.addEventListener("click", () => { if (type === "ability") state.videoAbility = value; if (type === "discipline") state.videoDiscipline = value; renderVideoFilters(); renderVideos(); closeFilterDropdowns(); }); return button; } function closeFilterDropdowns() { $$(".filter-dropdown-panel").forEach((panel) => panel.classList.add("hidden")); $$(".filter-dropdown-toggle").forEach((toggle) => toggle.setAttribute("aria-expanded", "false")); } function toggleFilterDropdown(type) { const panel = $(`#${type}-panel`); const toggle = $(`#${type}-toggle`); const isOpen = !panel.classList.contains("hidden"); closeFilterDropdowns(); if (!isOpen) { panel.classList.remove("hidden"); toggle.setAttribute("aria-expanded", "true"); } } function renderVideoFilters() { if (!state.videoCatalog) return; const abilityPanel = $("#ability-panel"); abilityPanel.replaceChildren( filterOption(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility), ...state.videoCatalog.abilities.map((ability) => filterOption( `${ability.icon} ${ability.label} ${ability.count}`, ability.id, "ability", state.videoAbility === ability.id, ) ), ); const abilityLabel = state.videoCatalog.abilities.find((a) => a.id === state.videoAbility); $("#ability-label").textContent = abilityLabel ? `${abilityLabel.icon} ${abilityLabel.label}` : "全部维度"; const disciplinePanel = $("#discipline-panel"); disciplinePanel.replaceChildren( filterOption("全部专业", "", "discipline", !state.videoDiscipline), ...state.videoCatalog.disciplines.map((discipline) => filterOption( `${discipline.icon || "∑"} ${discipline.name}`, discipline.name, "discipline", state.videoDiscipline === discipline.name, ) ), ); const disciplineLabel = state.videoCatalog.disciplines.find((d) => d.name === state.videoDiscipline); $("#discipline-label").textContent = disciplineLabel ? `${disciplineLabel.icon || "∑"} ${disciplineLabel.name}` : "全部专业"; $("#video-stream-title").textContent = abilityLabel ? `${abilityLabel.label}视频流` : "全部视频"; $$(".ability-node").forEach((node) => { node.classList.toggle("active", node.dataset.ability === state.videoAbility); }); } function formatCount(value) { if (value >= 10000) return `${(value / 10000).toFixed(1)}万`; return String(value); } function formatDuration(seconds) { const minutes = Math.floor(seconds / 60); const remaining = seconds % 60; return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; } function videoCard(item, index) { const article = document.createElement("article"); article.className = "video-card"; article.setAttribute("role", "button"); article.setAttribute("aria-label", `打开视频:${item.title}`); article.tabIndex = 0; const ability = item.ability || { label: "数学探索", icon: "∑", color: "#657068" }; article.style.setProperty("--badge-color", ability.color); article.style.setProperty("--cover-a", `${ability.color}22`); article.style.setProperty("--cover-b", `${ability.color}55`); const cover = document.createElement("div"); cover.className = "video-cover"; const duration = document.createElement("b"); duration.className = "video-duration"; duration.textContent = formatDuration(item.duration_seconds); const icon = document.createElement("span"); icon.textContent = item.discipline_icon || ability.icon; cover.append(duration, icon); if (item.progress?.completed) { const complete = document.createElement("i"); complete.className = "video-complete"; complete.textContent = "已完成"; cover.append(complete); } const body = document.createElement("div"); body.className = "video-body"; const badge = document.createElement("span"); badge.className = "ability-badge"; badge.textContent = `${ability.icon} ${ability.label}`; const title = document.createElement("h3"); title.textContent = item.title; const metrics = document.createElement("div"); metrics.className = "video-metrics"; const views = document.createElement("span"); views.textContent = `◉ ${formatCount(item.view_count)}`; const comments = document.createElement("span"); comments.textContent = `◌ ${formatCount(item.comment_count)}`; metrics.append(views, comments); const author = document.createElement("div"); author.className = "video-author"; const avatar = document.createElement("i"); avatar.textContent = item.discipline?.slice(0, 1) || "数"; const authorName = document.createElement("span"); authorName.textContent = item.author || "葫芦数学志愿者"; author.append(avatar, authorName); body.append(badge, title, metrics, author); article.append(cover, body); article.addEventListener("click", () => openVideo(item)); article.addEventListener("keydown", (event) => { if (event.key === "Enter") openVideo(item); }); return article; } function renderVideos() { const root = $("#video-list"); const videos = state.videos.filter( (item) => (!state.videoAbility || item.ability_dimension === state.videoAbility) && (!state.videoDiscipline || item.discipline === state.videoDiscipline) ); root.classList.remove("loading"); root.replaceChildren(...videos.map(videoCard)); $("#video-result-count").textContent = `${videos.length} 条内容`; } function openVideo(item) { const root = $("#video-dialog-content"); root.replaceChildren(); const player = document.createElement("div"); player.className = "video-player"; const icon = document.createElement("span"); icon.textContent = item.discipline_icon || "▶"; const status = document.createElement("h3"); status.textContent = item.media_url ? "志愿者视频外部播放" : "视频素材待上传"; const note = document.createElement("p"); note.textContent = item.media_url ? "点击下方按钮打开原始视频来源。观看后可回到这里记录完成。" : "内容卡、分类和成长链路已经就绪,运营上传视频地址后即可播放。"; player.append(icon, status, note); const title = document.createElement("h2"); title.textContent = item.title; const meta = document.createElement("div"); meta.className = "video-detail-meta"; [ `${item.discipline_icon || "∑"} ${item.discipline}`, formatDuration(item.duration_seconds), item.author, item.ability?.label || "数学探索", ].forEach((value) => { const span = document.createElement("span"); span.textContent = value; meta.append(span); }); const description = document.createElement("p"); description.className = "video-description"; description.textContent = item.summary; const actions = document.createElement("div"); actions.className = "video-dialog-actions"; if (item.media_url) { const link = document.createElement("a"); link.className = "ghost-button"; link.href = item.media_url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = "打开视频来源"; actions.append(link); } else { const unavailable = document.createElement("span"); unavailable.textContent = "暂时没有可播放地址"; actions.append(unavailable); } const complete = document.createElement("button"); complete.className = "primary-button"; complete.textContent = item.progress?.completed ? "已完成观看" : "标记完成 · +1 能力碎片"; complete.disabled = Boolean(item.progress?.completed); complete.addEventListener("click", async () => { if (!requireAuth()) return; complete.disabled = true; try { const result = await api(`content/${item.slug}/complete/`, { method: "POST", body: {}, }); item.progress = { completed: true, reward_granted: result.reward_granted }; complete.textContent = "已完成观看"; renderVideos(); showToast(result.reward ? "观看完成,获得 1 枚能力碎片" : "观看记录已保存"); } catch (error) { complete.disabled = false; showToast(error.message); } }); actions.append(complete); root.append(player, title, meta, description, actions); $("#video-dialog").showModal(); } async function loadContent() { const root = $("#video-list"); try { const [catalog, videos] = await Promise.all([ api("content/videos/catalog/"), api("content/?kind=video"), ]); state.videoCatalog = catalog; state.videos = videos; const abilityRoot = $("#ability-legend"); abilityRoot.replaceChildren( ...catalog.abilities.map((ability) => { const item = document.createElement("span"); const dot = document.createElement("i"); dot.style.background = ability.color; item.append(dot, document.createTextNode(`${ability.label} · ${ability.count}`)); return item; }) ); catalog.abilities.forEach((ability) => { const node = $(`.ability-node[data-ability="${ability.id}"]`); if (node) node.querySelector("small").textContent = ability.count; }); renderVideoFilters(); renderVideos(); } catch (error) { root.textContent = error.message; } } async function loadProfile() { const root = $("#profile-panel"); if (!state.user) { root.innerHTML = '

登录后查看完整档案。

'; $("[data-open-auth]", root).addEventListener("click", openAuth); return; } try { const profile = await api("progression/me/"); root.replaceChildren(); const heading = document.createElement("h2"); heading.textContent = `${state.user.nickname} · Rating ${state.user.rating}`; const pet = document.createElement("p"); pet.textContent = `数学精灵:${profile.pet.name},等级 ${profile.pet.level}`; const metrics = document.createElement("div"); metrics.className = "metric-grid"; profile.abilities.forEach((ability) => { const item = document.createElement("div"); item.className = "metric"; const value = document.createElement("b"); value.textContent = ability.level; const label = document.createElement("span"); label.textContent = `${ability.label} · ${ability.fragments} 碎片`; item.append(value, label); metrics.append(item); }); root.append(heading, pet, metrics); if (profile.story_marks?.length) { const markTitle = document.createElement("h3"); markTitle.className = "profile-subtitle"; markTitle.textContent = `数学人生印记 · ${profile.story_marks.length} / 20`; const markList = document.createElement("div"); markList.className = "profile-mark-list"; profile.story_marks.forEach((mark) => { const item = document.createElement("span"); item.textContent = mark.name; item.dataset.domain = mark.domain; markList.append(item); }); root.append(markTitle, markList); } if (profile.recent_matches?.length) { const matchTitle = document.createElement("h3"); matchTitle.className = "profile-subtitle"; matchTitle.textContent = "最近实时对局"; const matchList = document.createElement("div"); matchList.className = "match-history-list"; renderMatchHistory(matchList, profile.recent_matches); root.append(matchTitle, matchList); } if (profile.recent_games?.length) { const gameTitle = document.createElement("h3"); gameTitle.className = "profile-subtitle"; gameTitle.textContent = "最近数学玩法"; const gameList = document.createElement("div"); gameList.className = "profile-game-list"; profile.recent_games.forEach((game) => { const item = document.createElement("div"); const name = document.createElement("b"); name.textContent = game.label; const detail = document.createElement("span"); detail.textContent = game.status === "completed" ? `${game.score} 分 · ${(game.duration_ms / 1000).toFixed(1)} 秒` : "进行中"; item.append(name, detail); gameList.append(item); }); root.append(gameTitle, gameList); } } catch (error) { root.textContent = error.message; } } function renderMatchHistory(root, matches) { root.replaceChildren( ...matches.map((match) => { const item = document.createElement("article"); item.className = `match-history-item result-${match.result}`; const summary = document.createElement("div"); const title = document.createElement("b"); title.textContent = `${match.contest} · 对手 ${match.opponent}`; const time = document.createElement("span"); time.textContent = new Date(match.completed_at).toLocaleString(); summary.append(title, time); const result = document.createElement("div"); const resultLabel = document.createElement("strong"); resultLabel.textContent = match.result === "win" ? "胜利" : match.result === "loss" ? "惜败" : "平局"; const delta = document.createElement("span"); const sign = match.rating_delta > 0 ? "+" : ""; delta.textContent = `Rating ${sign}${match.rating_delta} → ${match.rating_after}`; result.append(resultLabel, delta); item.append(summary, result); return item; }) ); } async function loadHomeMatchHistory() { const section = $("#home-match-history"); if (!state.user) { section.hidden = true; return; } try { const profile = await api("progression/me/"); if (!profile.recent_matches?.length) { section.hidden = true; return; } renderMatchHistory($("#home-match-list"), profile.recent_matches); section.hidden = false; } catch (error) { section.hidden = true; if (![401, 403].includes(error.status)) console.warn(error); } } const MATH_SYMBOLS = [ ["∑", "求和", "一组数或表达式的总和", "\\sum"], ["∏", "连乘", "一组数或表达式的乘积", "\\prod"], ["∫", "积分", "连续累积与面积", "\\int"], ["∂", "偏导数", "多元函数对一个变量的变化率", "\\partial"], ["∞", "无穷", "没有有限边界", "\\infty"], ["√", "平方根", "平方后得到原数的值", "\\sqrt{x}"], ["≈", "约等于", "数值近似相等", "\\approx"], ["≠", "不等于", "两个量不相等", "\\ne"], ["≤", "小于等于", "不大于右侧的量", "\\le"], ["≥", "大于等于", "不小于右侧的量", "\\ge"], ["∈", "属于", "元素属于某个集合", "\\in"], ["∉", "不属于", "元素不在某个集合中", "\\notin"], ["⊂", "真子集", "集合被另一个集合包含", "\\subset"], ["∪", "并集", "属于至少一个集合的元素", "\\cup"], ["∩", "交集", "同时属于多个集合的元素", "\\cap"], ["∀", "任意", "对所有对象都成立", "\\forall"], ["∃", "存在", "至少存在一个对象", "\\exists"], ["⇒", "推出", "前件能够推出后件", "\\Rightarrow"], ["π", "圆周率", "圆周长与直径之比", "\\pi"], ["θ", "角变量", "常用于表示角度", "\\theta"], ]; function evaluateExpression(source, variables = {}) { const input = String(source).replaceAll("π", "pi").replaceAll("×", "*").replaceAll("÷", "/"); let position = 0; const functions = { sin: Math.sin, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos, atan: Math.atan, sinh: Math.sinh, cosh: Math.cosh, tanh: Math.tanh, sqrt: Math.sqrt, log: Math.log10, ln: Math.log, exp: Math.exp, abs: Math.abs, floor: Math.floor, ceil: Math.ceil, round: Math.round, }; const skip = () => { while (/\s/.test(input[position] || "")) position += 1; }; const parseExpression = () => { let value = parseTerm(); while (true) { skip(); const operator = input[position]; if (operator !== "+" && operator !== "-") break; position += 1; const right = parseTerm(); value = operator === "+" ? value + right : value - right; } return value; }; const parseTerm = () => { let value = parsePower(); while (true) { skip(); const operator = input[position]; if (operator !== "*" && operator !== "/") break; position += 1; const right = parsePower(); value = operator === "*" ? value * right : value / right; } return value; }; const parsePower = () => { let value = parseUnary(); skip(); if (input[position] === "^") { position += 1; value = value ** parsePower(); } return value; }; const parseUnary = () => { skip(); if (input[position] === "+") { position += 1; return parseUnary(); } if (input[position] === "-") { position += 1; return -parseUnary(); } return parsePrimary(); }; const parsePrimary = () => { skip(); if (input[position] === "(") { position += 1; const value = parseExpression(); skip(); if (input[position] !== ")") throw new Error("缺少右括号"); position += 1; return value; } const numberMatch = input.slice(position).match(/^(?:\d+\.?\d*|\.\d+)/); if (numberMatch) { position += numberMatch[0].length; return Number(numberMatch[0]); } const identifierMatch = input.slice(position).match(/^[A-Za-z]+/); if (!identifierMatch) throw new Error(`无法识别位置 ${position + 1} 的内容`); const identifier = identifierMatch[0].toLowerCase(); position += identifierMatch[0].length; if (identifier === "pi") return Math.PI; if (identifier === "e") return Math.E; if (Object.hasOwn(variables, identifier)) return Number(variables[identifier]); if (!functions[identifier]) throw new Error(`不支持函数 ${identifier}`); skip(); if (input[position] !== "(") throw new Error(`${identifier} 后需要括号`); position += 1; const argument = parseExpression(); skip(); if (input[position] !== ")") throw new Error("函数缺少右括号"); position += 1; return functions[identifier](argument); }; const value = parseExpression(); skip(); if (position !== input.length) throw new Error(`无法识别位置 ${position + 1} 的内容`); if (!Number.isFinite(value)) throw new Error("结果不是有限数值"); return value; } function renderSymbols(query = "") { const keyword = query.trim().toLowerCase(); const matches = MATH_SYMBOLS.filter((symbol) => symbol.join(" ").toLowerCase().includes(keyword) ); $("#symbol-list").replaceChildren( ...matches.map(([glyph, name, description, latex]) => { const button = document.createElement("button"); button.className = "symbol-card"; const symbol = document.createElement("strong"); symbol.textContent = glyph; const title = document.createElement("b"); title.textContent = name; const detail = document.createElement("small"); detail.textContent = description; const code = document.createElement("code"); code.textContent = latex; button.append(symbol, title, detail, code); button.addEventListener("click", async () => { try { await navigator.clipboard.writeText(latex); showToast(`已复制 ${latex}`); } catch { showToast(`LaTeX:${latex}`); } }); return button; }) ); } function switchTool(tool) { $$(".tool-card").forEach((card) => { card.classList.toggle("active", card.dataset.tool === tool); }); $$(".tool-workspace").forEach((workspace) => { workspace.classList.toggle("active", workspace.id === `tool-${tool}`); }); window.HuluToolbox?.activate(tool); } function normalizeLatexSource(source) { let normalized = String(source || "").trim(); if (normalized.startsWith("$$") && normalized.endsWith("$$")) { normalized = normalized.slice(2, -2).trim(); } else if (normalized.startsWith("\\[") && normalized.endsWith("\\]")) { normalized = normalized.slice(2, -2).trim(); } else if (normalized.startsWith("$") && normalized.endsWith("$")) { normalized = normalized.slice(1, -1).trim(); } return normalized.replace(/\\\\(?=[A-Za-z,;:!])/g, "\\"); } function renderLatexPreview(source) { const target = $("#latex-preview"); target.replaceChildren(); target.classList.remove("latex-preview-error"); const normalized = normalizeLatexSource(source); if (!normalized) return; if (typeof globalThis.katex?.render !== "function") { target.classList.add("latex-preview-error"); target.textContent = "公式渲染组件加载失败,请刷新页面后重试。"; return; } try { globalThis.katex.render(normalized, target, { displayMode: true, throwOnError: true, trust: false, maxExpand: 500, maxSize: 20, }); } catch (error) { target.classList.add("latex-preview-error"); target.textContent = `公式语法错误:${error.message}`; } } async function saveFormula() { if (!requireAuth()) return; try { await api("latex/documents/", { method: "POST", body: { title: `公式 ${new Date().toLocaleString()}`, source: normalizeLatexSource($("#latex-source").value), }, }); showToast("公式及首个版本已保存"); } catch (error) { showToast(error.message); } } function bindUI() { $$(".nav-item").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.view))); $$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump))); $$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth)); $("#start-mathbti").addEventListener("click", startMathBTI); $("#wechat-copy").addEventListener("click", copyWechatName); $("#story-experience-close").addEventListener("click", closeStoryExperience); $("#save-formula").addEventListener("click", saveFormula); $("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); }); $$(".tool-card").forEach((button) => { button.addEventListener("click", () => switchTool(button.dataset.tool)); }); $("#symbol-search").addEventListener("input", (event) => { renderSymbols(event.target.value); }); $("#enter-alumni").addEventListener("click", showLifeSkills); $("#back-to-hub").addEventListener("click", showLifeHub); $("#ability-toggle").addEventListener("click", () => toggleFilterDropdown("ability")); $("#discipline-toggle").addEventListener("click", () => toggleFilterDropdown("discipline")); document.addEventListener("click", (event) => { if (!event.target.closest(".filter-dropdown")) closeFilterDropdowns(); }); $$(".route-card").forEach((card) => { card.addEventListener("click", (event) => { if (event.target.closest(".route-enter")) return; const route = card.dataset.route; const flagship = state.stories.find( (story) => story.kind === "flagship" && story.available && story.slug.includes(route), ); if (flagship) beginStory(flagship.slug); else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`); }); card.querySelector(".route-enter").addEventListener("click", () => { const route = card.dataset.route; const flagship = state.stories.find( (story) => story.kind === "flagship" && story.available && story.slug.includes(route), ); if (flagship) beginStory(flagship.slug); else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`); }); }); $$(".ability-node").forEach((button) => { button.addEventListener("click", () => { state.videoAbility = state.videoAbility === button.dataset.ability ? "" : button.dataset.ability; renderVideoFilters(); renderVideos(); $("#video-list").scrollIntoView({ behavior: "smooth", block: "start" }); }); }); $("#auth-button").addEventListener("click", async () => { if (!state.user) return openAuth(); await api("accounts/logout/", { method: "POST", body: {} }); window.HuluRealtime?.reset(); state.user = null; updateUserUI(); if ($("#view-profile").classList.contains("active")) await loadProfile(); showToast("已退出登录"); }); $$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); $$("[data-auth-tab]").forEach((button) => button.addEventListener("click", () => { $$("[data-auth-tab]").forEach((item) => item.classList.toggle("active", item === button)); $("#login-form").classList.toggle("hidden", button.dataset.authTab !== "login"); $("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register"); $("#auth-error").textContent = ""; })); $$("#match-mode-switch button").forEach((button) => button.addEventListener("click", () => { state.matchMode = button.dataset.matchMode; $$("#match-mode-switch button").forEach((item) => { item.classList.toggle("active", item === button); }); const label = button.querySelector("b").textContent; $("#challenge-create").textContent = `创建${label}约战`; })); $("#login-form").addEventListener("submit", async (event) => { event.preventDefault(); try { state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); $("#auth-dialog").close(); updateUserUI(); await Promise.all([ loadContent(), loadHomeMatchHistory(), $("#view-profile").classList.contains("active") ? loadProfile() : null, ]); showToast("登录成功"); } catch (error) { $("#auth-error").textContent = error.message; } }); $("#register-form").addEventListener("submit", async (event) => { event.preventDefault(); try { state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); $("#auth-dialog").close(); updateUserUI(); await Promise.all([ loadContent(), loadHomeMatchHistory(), $("#view-profile").classList.contains("active") ? loadProfile() : null, ]); showToast("账号和数学档案已建立"); } catch (error) { $("#auth-error").textContent = error.message; } }); } async function boot() { bindUI(); renderRouteMathematicians(); window.HuluToolbox?.init(); window.HuluRealtime?.init(); renderSymbols(); await Promise.all([ loadUser(), loadStories(), loadContests(), loadContent(), window.HuluGames?.load(), ]); renderLatexPreview($("#latex-source").value); } boot();