1326 lines
48 KiB
JavaScript
1326 lines
48 KiB
JavaScript
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);
|
||
$("#experience-dialog").showModal();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
function renderStoryNode(run) {
|
||
const root = $("#experience-content");
|
||
root.replaceChildren();
|
||
const label = document.createElement("span");
|
||
label.className = "kicker";
|
||
label.textContent = `${run.story} · ${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") {
|
||
const ending = document.createElement("p");
|
||
ending.textContent = "这段人生已经抵达结局,路径已写入你的数学档案。";
|
||
choices.append(ending);
|
||
} else {
|
||
run.node.choices.forEach((choice) => {
|
||
const button = document.createElement("button");
|
||
button.textContent = choice.text;
|
||
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.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);
|
||
$("#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();
|