v0.2 Preview: Construction Plan.
CI / test (push) Failing after 1m31s

This commit is contained in:
2026-08-08 21:25:27 +08:00
parent b88d15698a
commit 1f98f7152d
822 changed files with 96959 additions and 8 deletions
+961
View File
@@ -0,0 +1,961 @@
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]) : "";
}
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 ? "退出登录" : "登录 / 注册";
}
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": crypto.randomUUID() },
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": crypto.randomUUID() },
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);
} 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,
sqrt: Math.sqrt,
log: Math.log10,
ln: Math.log,
abs: Math.abs,
};
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}`);
});
if (tool === "graph") window.setTimeout(drawGraph, 30);
}
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));
});
$("#calc-run").addEventListener("click", runCalculator);
$("#calc-input").addEventListener("keydown", (event) => {
if (event.key === "Enter") runCalculator();
});
$$("[data-calc-example]").forEach((button) => {
button.addEventListener("click", () => {
$("#calc-input").value = button.dataset.calcExample;
runCalculator();
});
});
$("#symbol-search").addEventListener("input", (event) => {
renderSymbols(event.target.value);
});
$("#graph-run").addEventListener("click", drawGraph);
$("#graph-range").addEventListener("input", drawGraph);
$("#graph-expression").addEventListener("keydown", (event) => {
if (event.key === "Enter") drawGraph();
});
$$(".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();
renderSymbols();
runCalculator();
await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]);
}
boot();