Files
Hulumath-Web/backend/static/js/app.js
T
Jacky 6cd155ef05
CI / test (pull_request) Canceled after 13s
feat: expand toolbox and add math games
2026-08-09 01:52:35 +08:00

1010 lines
35 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 state = {
user: null,
stories: [],
contests: [],
track: "standard",
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("-");
}
async function api(path, options = {}) {
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(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const details = payload.error?.details;
const message = payload.error?.message || payload.detail ||
(details ? Object.values(details).flat().join(" ") : "请求失败");
const error = new Error(message);
error.status = response.status;
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;
}
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();
}
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;
}
async function loadStories() {
const root = $("#story-list");
try {
state.stories = await api("math-life/stories/");
root.classList.remove("loading");
root.replaceChildren(...state.stories.map((story) => card({
meta: story.kind === "flagship" ? "旗舰人生" : story.kind === "skill" ? "人物 Skill" : "特别篇",
title: story.title,
body: story.summary || "在不完整信息中判断机会、关系与代价。",
foot: `约 ${story.estimated_minutes} 分钟`,
action: story.available ? "开始人生 →" : "查看预告",
disabled: !story.available,
onClick: () => beginStory(story.slug),
})));
} catch (error) {
root.textContent = error.message;
}
}
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 = state.contests.filter((contest) => contest.track === state.track);
root.classList.remove("loading");
root.replaceChildren(...contests.map((contest) => card({
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
title: contest.title,
body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。",
foot: `${contest.duration_seconds} 秒`,
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
onClick: () => beginContest(contest),
})));
}
async function beginContest(contest) {
if (!requireAuth()) return;
try {
if (contest.kind === "realtime") {
const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} });
if (match.status === "waiting") {
showToast("已进入匹配队列,等待同赛道对手");
return;
}
renderAttempt(match.attempt);
} 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 filterChip(label, value, type, active) {
const button = document.createElement("button");
button.className = `filter-chip${active ? " active" : ""}`;
button.textContent = label;
button.dataset[type] = value;
button.addEventListener("click", () => {
if (type === "ability") state.videoAbility = value;
if (type === "discipline") state.videoDiscipline = value;
renderVideoFilters();
renderVideos();
});
return button;
}
function renderVideoFilters() {
if (!state.videoCatalog) return;
const abilityRoot = $("#ability-filters");
abilityRoot.replaceChildren(
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
...state.videoCatalog.abilities.map((ability) =>
filterChip(
`${ability.icon} ${ability.label} ${ability.count}`,
ability.id,
"ability",
state.videoAbility === ability.id,
)
),
);
const disciplineRoot = $("#discipline-filters");
disciplineRoot.replaceChildren(
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
...state.videoCatalog.disciplines.map((discipline) =>
filterChip(
`${discipline.icon || "∑"} ${discipline.name}`,
discipline.name,
"discipline",
state.videoDiscipline === discipline.name,
)
),
);
const selected = state.videoCatalog.abilities.find(
(item) => item.id === state.videoAbility
);
$("#video-stream-title").textContent = selected ? `${selected.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_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} · ${game.difficulty}`;
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;
}
}
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);
}
async function saveFormula() {
if (!requireAuth()) return;
try {
await api("latex/documents/", {
method: "POST",
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#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) => { $("#latex-preview").textContent = event.target.value; });
$$(".tool-card").forEach((button) => {
button.addEventListener("click", () => switchTool(button.dataset.tool));
});
$("#symbol-search").addEventListener("input", (event) => {
renderSymbols(event.target.value);
});
$$(".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: {} });
state.user = null;
updateUserUI();
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 = "";
}));
$$("#track-switch button").forEach((button) => button.addEventListener("click", () => {
state.track = button.dataset.track;
$$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button));
renderContests();
}));
$("#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 loadContent();
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 loadContent();
showToast("账号和数学档案已建立");
} catch (error) {
$("#auth-error").textContent = error.message;
}
});
}
async function boot() {
bindUI();
window.HuluToolbox?.init();
renderSymbols();
await Promise.all([
loadUser(),
loadStories(),
loadContests(),
loadContent(),
window.HuluGames?.load(),
]);
}
boot();