Files
Hulumath-Web/backend/static/js/app.js
T

1395 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
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 = '<p>登录后查看完整档案。</p><button class="primary-button" data-open-auth>登录 / 注册</button>';
$("[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 runCalculator() {
const input = $("#calc-input").value;
try {
const result = evaluateExpression(input);
$("#calc-history").textContent = input;
$("#calc-output").textContent = Number(result.toPrecision(12)).toString();
} catch (error) {
$("#calc-history").textContent = error.message;
$("#calc-output").textContent = "错误";
}
}
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 drawGraph() {
const canvas = $("#graph-canvas");
const context = canvas.getContext("2d");
const expression = $("#graph-expression").value;
const range = Number($("#graph-range").value);
const width = canvas.width;
const height = canvas.height;
$("#graph-range-label").textContent = `${range}${range}`;
$("#graph-error").textContent = "";
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
const toX = (x) => ((x + range) / (range * 2)) * width;
const toY = (y) => height / 2 - (y / range) * (height / 2);
context.strokeStyle = "#e4e5df";
context.lineWidth = 1;
for (let value = -range; value <= range; value += 1) {
context.beginPath();
context.moveTo(toX(value), 0);
context.lineTo(toX(value), height);
context.stroke();
context.beginPath();
context.moveTo(0, toY(value));
context.lineTo(width, toY(value));
context.stroke();
}
context.strokeStyle = "#718078";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width, height / 2);
context.moveTo(width / 2, 0);
context.lineTo(width / 2, height);
context.stroke();
context.strokeStyle = "#196548";
context.lineWidth = 3;
context.beginPath();
let drawing = false;
try {
for (let pixel = 0; pixel <= width; pixel += 2) {
const x = (pixel / width) * range * 2 - range;
const y = evaluateExpression(expression, { x });
const screenY = toY(y);
if (screenY < -height * 2 || screenY > height * 3) {
drawing = false;
continue;
}
if (!drawing) context.moveTo(pixel, screenY);
else context.lineTo(pixel, screenY);
drawing = true;
}
context.stroke();
} catch (error) {
$("#graph-error").textContent = error.message;
}
}
function switchTool(tool) {
if (tool === "mental") {
navigate("contest");
return;
}
$$(".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);
$("#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();