diff --git a/README.md b/README.md index 2eb9519..3643113 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ - 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化 - 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板 - 剧情服务端存档、嵌套资源效果、结局与幂等选择 -- 入门、标准、进阶三赛道的实时 1v1、今日挑战和单人闯关 +- 入门、标准、进阶三赛道的实时 1v1、今日挑战、单人闯关、24 点和数独 - 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊 - Channels WebSocket 比赛进度通道,Redis Channel Layer - LaTeX 文档与版本、六级零基础课程、练习判定 - 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流 - 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册 -- 多工具工具箱:口算入口、科学计算器、符号查询、函数绘图和 LaTeX Lab +- 多工具工具箱:强计算器、增强函数绘图、数学白板、几何画板、符号查询和 LaTeX Lab - Django Admin、健康检查、请求 ID、限流与统一 API 错误结构 - MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试 @@ -82,6 +82,7 @@ backend/ ├── progression/ # 五维能力、精灵、卡牌、奖励 ├── content/ # 视频、知识卡片、人物内容 ├── engagement/ # 签到与通知 +├── toolbox/ # 受限数学计算内核与工具 API ├── common/ # 健康检查、错误、日志 └── config/ # Django/ASGI 配置 ``` diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index fd44998..b5719ea 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -34,3 +34,24 @@ def test_admin_css_深色系统下仍保持完整浅色主题(): assert "background-color: #ffffff !important" in source assert ".theme-toggle" in source assert "display: none" in source + + +def test_toolbox_and_games_资源入口与移动端触控样式存在(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + toolbox = (STATIC_ROOT / "js" / "toolbox.js").read_text(encoding="utf-8") + games = (STATIC_ROOT / "js" / "games.js").read_text(encoding="utf-8") + styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert "js/toolbox.js" in template + assert "js/games.js" in template + assert 'id="whiteboard-canvas"' in template + assert 'id="geometry-canvas"' in template + assert 'id="math-game-list"' in template + assert "window.HuluToolbox" in toolbox + assert "pointerdown" in toolbox + assert "window.HuluGames" in games + assert "sudoku-board" in games + assert "touch-action: none" in styles + assert ".calculator-controls [hidden]" in styles + assert ".sudoku-board" in styles + assert "@media (max-width: 700px)" in styles diff --git a/backend/static/css/app.css b/backend/static/css/app.css index a4108c7..c729308 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -132,6 +132,21 @@ button { color: inherit; } .track-switch { display: flex; gap: 5px; margin-bottom: 22px; } .track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; } .track-switch button.active { background: var(--ink); color: white; } +.math-games-section { margin-top: 60px; } +.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; } +.game-card::after { content: ""; position: absolute; width: 140px; height: 140px; right: -65px; top: -65px; border-radius: 50%; background: rgba(204,232,91,.18); } +.game-sudoku::after { background: rgba(93,115,232,.13); } +.game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); } +.game-card > b { margin-top: 24px; color: var(--green); font-size: 9px; letter-spacing: .18em; }.game-card h3 { margin: 8px 0; font: 28px Georgia, serif; }.game-card p { margin: 0; color: var(--muted); line-height: 1.7; } +.game-card-controls { display: flex; gap: 10px; margin-top: auto; }.game-card-controls select { min-width: 100px; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }.game-card-controls .primary-button { margin-left: auto; } +.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button { aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; } +.twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; } +.sudoku-board { width: min(100%, 540px); margin: 22px auto; display: grid; grid-template-columns: repeat(9, 1fr); border: 3px solid var(--ink); background: var(--ink); gap: 1px; } +.sudoku-board input { width: 100%; min-width: 0; aspect-ratio: 1; border: 0; border-radius: 0; background: white; color: var(--green); text-align: center; font: 600 21px Georgia, serif; outline: 2px solid transparent; outline-offset: -2px; } +.sudoku-board input:nth-child(3n) { border-right: 2px solid var(--ink); }.sudoku-board input:nth-child(9n) { border-right: 0; }.sudoku-board input:nth-child(n+19):nth-child(-n+27), .sudoku-board input:nth-child(n+46):nth-child(-n+54) { border-bottom: 2px solid var(--ink); } +.sudoku-board input:focus { outline-color: var(--green); }.sudoku-board input.given { background: #ecece5; color: var(--ink); font-weight: 800; }.sudoku-board input.hint { background: #fff2ca; color: #9a5b22; } +.sudoku-actions { display: flex; justify-content: flex-end; gap: 10px; }.game-result-panel { margin-top: 30px; padding: 30px; border-radius: 18px; background: var(--ink); color: white; text-align: center; }.game-result-panel strong { color: var(--lime); font: 48px Georgia, serif; }.game-result-panel p { color: #b9c2bc; } .editor-shell { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; min-height: 430px; } .editor-pane, .preview-pane { padding: 28px; } .editor-pane { background: #1d2721; color: white; } @@ -144,7 +159,8 @@ button { color: inherit; } .metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } .metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; } .metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; } -.tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 22px; } +.profile-subtitle { margin: 30px 0 12px; font: 24px Georgia, serif; }.profile-game-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 9px; }.profile-game-list > div { padding: 13px; border-radius: 11px; background: #eef1eb; }.profile-game-list b, .profile-game-list span { display: block; }.profile-game-list span { margin-top: 5px; color: var(--muted); font-size: 11px; } +.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 22px; } .tool-card { min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px; background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease; @@ -159,19 +175,29 @@ button { color: inherit; } .tool-workspace.active { display: block; animation: rise .3s ease; } .workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; } .workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; } -.calculator-shell { max-width: 800px; margin: auto; } +.calculator-shell { max-width: 980px; margin: auto; } .calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; } .calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; } .formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); } .calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; } .calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; } +.calculator-controls { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }.calculator-controls label, .calculator-expression-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }.calculator-controls select, .calculator-controls input { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; } +.calculator-controls [hidden] { display: none !important; } +.calculator-expression-label textarea { min-height: 92px; margin: 0 0 12px; resize: vertical; font: 17px/1.6 "SFMono-Regular", Consolas, monospace; } +.calculator-result { margin-top: 17px; }.calc-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }.calc-result-grid > div { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: white; }.calc-result-grid span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }.calc-result-grid code { display: block; margin-top: 8px; overflow-wrap: anywhere; color: var(--green); } +.calc-steps { margin: 13px 0 0; padding: 14px 14px 14px 34px; border-radius: 12px; background: #eef1eb; color: var(--muted); font-size: 12px; line-height: 1.8; }.calc-steps:empty { display: none; } .search-input { width: min(370px, 100%); } .symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; } .symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; } .graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; } -.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; } +.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; } +.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; } +.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; } #graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; } +.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; } +.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; } +.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; } .discover-title { padding-bottom: 24px; } .ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; } .map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; } @@ -225,16 +251,17 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; } .nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; } .hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; } - .tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; } + .tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 700px) { .app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; } .brand, .sidebar-foot { display: none; }.nav { margin: 0; grid-template-columns: repeat(6, 1fr); gap: 2px; }.nav-item { padding: 11px 2px; font-size: 0; }.nav-item span { display: block; margin: auto; } .main { padding: 0 18px 90px; }.topbar { height: 66px; }.topbar p { max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; } - .section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell { grid-template-columns: 1fr; } + .section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell, .game-card-grid, .calc-result-grid { grid-template-columns: 1fr; } .content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; } - .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 20px; } + .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; } + .math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; } .map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; } .map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; } } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 18c20a4..09cfbe3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -638,6 +638,26 @@ async function loadProfile() { 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; } @@ -673,10 +693,20 @@ function evaluateExpression(source, variables = {}) { 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 = () => { @@ -880,7 +910,7 @@ function switchTool(tool) { $$(".tool-workspace").forEach((workspace) => { workspace.classList.toggle("active", workspace.id === `tool-${tool}`); }); - if (tool === "graph") window.setTimeout(drawGraph, 30); + window.HuluToolbox?.activate(tool); } async function saveFormula() { @@ -906,24 +936,9 @@ function bindUI() { $$(".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 = @@ -980,9 +995,15 @@ function bindUI() { async function boot() { bindUI(); + window.HuluToolbox?.init(); renderSymbols(); - runCalculator(); - await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]); + await Promise.all([ + loadUser(), + loadStories(), + loadContests(), + loadContent(), + window.HuluGames?.load(), + ]); } boot(); diff --git a/backend/static/js/games.js b/backend/static/js/games.js new file mode 100644 index 0000000..ee153b2 --- /dev/null +++ b/backend/static/js/games.js @@ -0,0 +1,295 @@ +(function () { + const $game = (selector, root = document) => root.querySelector(selector); + + const GAME_META = { + twenty_four: { + title: "24 点", + kicker: "ARITHMETIC PUZZLE", + action: "开始组式", + }, + sudoku: { + title: "数独", + kicker: "LOGIC GRID", + action: "开始推理", + }, + }; + + function difficultySelect() { + const select = document.createElement("select"); + select.className = "game-difficulty"; + [ + ["easy", "入门"], + ["standard", "标准"], + ["hard", "进阶"], + ].forEach(([value, label]) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + if (value === "standard") option.selected = true; + select.append(option); + }); + return select; + } + + function gameCard(game) { + const meta = GAME_META[game.kind]; + const article = document.createElement("article"); + article.className = `game-card game-${game.kind}`; + const top = document.createElement("div"); + top.className = "game-card-top"; + const icon = document.createElement("span"); + icon.textContent = game.kind === "sudoku" ? "9×9" : "24"; + const time = document.createElement("small"); + time.textContent = `约 ${game.estimated_minutes} 分钟`; + top.append(icon, time); + const kicker = document.createElement("b"); + kicker.textContent = meta.kicker; + const title = document.createElement("h3"); + title.textContent = game.title; + const summary = document.createElement("p"); + summary.textContent = game.summary; + const controls = document.createElement("div"); + controls.className = "game-card-controls"; + const difficulty = difficultySelect(); + const start = document.createElement("button"); + start.className = "primary-button"; + start.textContent = meta.action; + start.addEventListener("click", () => startGame(game.kind, difficulty.value)); + controls.append(difficulty, start); + article.append(top, kicker, title, summary, controls); + return article; + } + + async function loadGames() { + const root = $game("#math-game-list"); + try { + const games = await api("contests/games/"); + root.replaceChildren(...games.map(gameCard)); + } catch (error) { + root.textContent = error.message; + } + } + + async function startGame(kind, difficulty) { + if (!requireAuth()) return; + try { + const attempt = await api(`contests/games/${kind}/start/`, { + method: "POST", + body: { difficulty }, + }); + renderGame(attempt); + $game("#experience-dialog").showModal(); + } catch (error) { + showToast(error.message); + } + } + + function gameHeading(attempt) { + const fragment = document.createDocumentFragment(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = + `${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`; + const title = document.createElement("h2"); + title.textContent = GAME_META[attempt.kind].title; + fragment.append(label, title); + return fragment; + } + + function resultPanel(attempt) { + const panel = document.createElement("div"); + panel.className = "game-result-panel"; + const score = document.createElement("strong"); + score.textContent = `${attempt.score} 分`; + const details = document.createElement("p"); + details.textContent = + `用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒 · 提示 ${attempt.hints_used} 次`; + panel.append(score, details); + return panel; + } + + function renderGame(attempt) { + if (attempt.kind === "sudoku") renderSudoku(attempt); + else renderTwentyFour(attempt); + } + + function renderTwentyFour(attempt) { + const root = $game("#experience-content"); + root.replaceChildren(); + root.append(gameHeading(attempt)); + if (attempt.status === "completed") { + root.append(resultPanel(attempt)); + return; + } + const instruction = document.createElement("p"); + instruction.className = "scene"; + instruction.textContent = "四个数字必须各使用一次,只允许 +、−、×、÷ 和括号。"; + const numbers = document.createElement("div"); + numbers.className = "twenty-four-numbers"; + attempt.puzzle.numbers.forEach((number) => { + const tile = document.createElement("button"); + tile.type = "button"; + tile.textContent = number; + tile.addEventListener("click", () => { + input.value += String(number); + input.focus(); + }); + numbers.append(tile); + }); + const form = document.createElement("form"); + form.className = "twenty-four-form"; + const input = document.createElement("input"); + input.className = "formula-input"; + input.placeholder = "例如:6 / (1 - 3 / 4)"; + input.autocomplete = "off"; + input.inputMode = "text"; + const keypad = document.createElement("div"); + keypad.className = "game-keypad"; + ["+", "-", "*", "/", "(", ")"].forEach((operator) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = operator; + button.addEventListener("click", () => { + input.value += operator; + input.focus(); + }); + keypad.append(button); + }); + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "submit"; + submit.textContent = "验证并计分"; + form.append(input, keypad, submit); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: { expression: input.value }, + }, + ); + renderTwentyFour(result); + showToast("得到 24,成绩已记录"); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + root.append(instruction, numbers, form); + window.setTimeout(() => input.focus(), 50); + } + + function sudokuCell(given, value, row, column) { + const input = document.createElement("input"); + input.inputMode = "numeric"; + input.pattern = "[1-9]"; + input.maxLength = 1; + input.dataset.row = row; + input.dataset.column = column; + input.value = value || ""; + input.className = given ? "given" : ""; + input.readOnly = given; + input.setAttribute("aria-label", `第 ${row + 1} 行第 ${column + 1} 列`); + input.addEventListener("input", () => { + input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1); + }); + return input; + } + + function renderSudoku(attempt) { + const root = $game("#experience-content"); + root.replaceChildren(); + root.append(gameHeading(attempt)); + if (attempt.status === "completed") { + root.append(resultPanel(attempt)); + return; + } + const instruction = document.createElement("p"); + instruction.className = "scene"; + instruction.textContent = "每一行、每一列和每个 3×3 九宫格都要包含 1 到 9。"; + const board = document.createElement("div"); + board.className = "sudoku-board"; + const hints = new Map( + (attempt.puzzle.hints || []).map((item) => [`${item.row}-${item.column}`, item.value]) + ); + attempt.puzzle.grid.forEach((rowValues, row) => { + rowValues.forEach((givenValue, column) => { + const hintValue = hints.get(`${row}-${column}`) || 0; + const input = sudokuCell(Boolean(givenValue), givenValue || hintValue, row, column); + if (hintValue) { + input.readOnly = true; + input.classList.add("hint"); + } + board.append(input); + }); + }); + const actions = document.createElement("div"); + actions.className = "sudoku-actions"; + const hint = document.createElement("button"); + hint.type = "button"; + hint.className = "ghost-button"; + hint.textContent = `提示(已用 ${attempt.hints_used}/3)`; + hint.disabled = attempt.hints_used >= 3; + const submit = document.createElement("button"); + submit.type = "button"; + submit.className = "primary-button"; + submit.textContent = "检查并完成"; + hint.addEventListener("click", async () => { + hint.disabled = true; + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/hint/`, + { method: "POST", body: {} }, + ); + const input = $game( + `[data-row="${result.row}"][data-column="${result.column}"]`, + board, + ); + input.value = result.value; + input.readOnly = true; + input.classList.add("hint"); + attempt.hints_used = result.hints_used; + hint.textContent = `提示(已用 ${result.hints_used}/3)`; + hint.disabled = result.hints_used >= 3; + } catch (error) { + showToast(error.message); + hint.disabled = false; + } + }); + submit.addEventListener("click", async () => { + submit.disabled = true; + const grid = Array.from({ length: 9 }, () => Array(9).fill(0)); + $gameAll("input", board).forEach((input) => { + grid[Number(input.dataset.row)][Number(input.dataset.column)] = + Number(input.value || 0); + }); + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: { grid }, + }, + ); + renderSudoku(result); + showToast("数独完成,成绩已记录"); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + actions.append(hint, submit); + root.append(instruction, board, actions); + } + + function $gameAll(selector, root = document) { + return [...root.querySelectorAll(selector)]; + } + + window.HuluGames = { load: loadGames }; +})(); diff --git a/backend/static/js/toolbox.js b/backend/static/js/toolbox.js new file mode 100644 index 0000000..05c511f --- /dev/null +++ b/backend/static/js/toolbox.js @@ -0,0 +1,710 @@ +(function () { + const $tool = (selector, root = document) => root.querySelector(selector); + const $$tool = (selector, root = document) => [...root.querySelectorAll(selector)]; + + const CALC_FIELDS = { + derivative: ["order"], + integral: ["bounds"], + limit: ["point"], + base: ["base"], + unit: ["unit"], + }; + + function formatCalculatorResult(result) { + if (Array.isArray(result)) { + return { + exact: result.map((item) => item.exact).join(", "), + decimal: result.map((item) => item.decimal).join(", "), + latex: result.map((item) => item.latex).join(", "), + }; + } + if (result && typeof result === "object" && "exact" in result) return result; + const entries = Object.entries(result || {}); + return { + exact: entries.map(([key, value]) => `${key}: ${value}`).join(" · "), + decimal: entries.map(([key, value]) => `${key}: ${value}`).join(" · "), + latex: entries.map(([key, value]) => `${key}=${value}`).join(", "), + }; + } + + function updateCalculatorFields() { + const operation = $tool("#calc-operation").value; + const visible = new Set(CALC_FIELDS[operation] || []); + $$tool("[data-calc-field]").forEach((field) => { + field.hidden = !visible.has(field.dataset.calcField); + }); + const input = $tool("#calc-input"); + const placeholders = { + statistics: "12, 15, 18, 21, 24", + base: "FF", + matrix_det: "1,2;3,4", + matrix_inverse: "1,2;3,4", + matrix_rref: "1,2,3;2,4,6", + matrix_transpose: "1,2,3;4,5,6", + solve: "x^2 - 5*x + 6 = 0", + }; + input.placeholder = placeholders[operation] || "例如:sqrt(2) + 1/3"; + } + + async function runCalculator() { + const button = $tool("#calc-run"); + const operation = $tool("#calc-operation").value; + const expression = $tool("#calc-input").value.trim(); + button.disabled = true; + $tool("#calc-history").textContent = "数学内核正在计算…"; + try { + const payload = await api("toolbox/calculate/", { + method: "POST", + body: { + operation, + expression, + variable: $tool("#calc-variable").value, + order: $tool("#calc-order").value, + lower: $tool("#calc-lower").value, + upper: $tool("#calc-upper").value, + point: $tool("#calc-point").value, + from_base: $tool("#calc-from-base").value, + to_base: $tool("#calc-to-base").value, + from_unit: $tool("#calc-from-unit").value, + to_unit: $tool("#calc-to-unit").value, + }, + }); + const result = formatCalculatorResult(payload.result); + $tool("#calc-history").textContent = `${operation} · ${expression}`; + $tool("#calc-output").textContent = result.exact || "完成"; + $tool("#calc-decimal").textContent = result.decimal || result.exact || "—"; + $tool("#calc-latex").textContent = result.latex || result.exact || "—"; + $tool("#calc-steps").replaceChildren( + ...(payload.steps || []).map((step) => { + const item = document.createElement("li"); + item.textContent = step; + return item; + }) + ); + } catch (error) { + $tool("#calc-history").textContent = error.message; + $tool("#calc-output").textContent = "计算失败"; + $tool("#calc-decimal").textContent = "—"; + $tool("#calc-latex").textContent = "—"; + $tool("#calc-steps").replaceChildren(); + } finally { + button.disabled = false; + } + } + + const GRAPH_COLORS = ["#196548", "#d86f45", "#5d73e8", "#8667b5"]; + + function drawFunctionPath(context, expression, variables, range, width, height, color) { + const toY = (value) => height / 2 - (value / range) * (height / 2); + context.strokeStyle = color; + context.lineWidth = 3; + context.beginPath(); + let drawing = false; + const samples = []; + for (let pixel = 0; pixel <= width; pixel += 2) { + const x = (pixel / width) * range * 2 - range; + let y; + try { + y = evaluateExpression(expression, { ...variables, x }); + } catch (error) { + if (pixel === 0) throw error; + drawing = false; + continue; + } + samples.push({ x, y }); + const screenY = toY(y); + if (!Number.isFinite(screenY) || screenY < -height * 2 || screenY > height * 3) { + drawing = false; + continue; + } + if (!drawing) context.moveTo(pixel, screenY); + else context.lineTo(pixel, screenY); + drawing = true; + } + context.stroke(); + return samples; + } + + function graphAnalysis(samples) { + const roots = []; + let extrema = 0; + for (let index = 1; index < samples.length; index += 1) { + const before = samples[index - 1]; + const current = samples[index]; + if (before.y === 0 || before.y * current.y < 0) { + const root = (before.x + current.x) / 2; + if (!roots.length || Math.abs(root - roots.at(-1)) > 0.15) roots.push(root); + } + if (index > 1) { + const previousSlope = before.y - samples[index - 2].y; + const currentSlope = current.y - before.y; + if (previousSlope * currentSlope < 0) extrema += 1; + } + } + return { roots: roots.slice(0, 8), extrema }; + } + + function drawGraph() { + const canvas = $tool("#graph-canvas"); + const context = canvas.getContext("2d"); + const expressions = $tool("#graph-expression").value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 4); + const range = Number($tool("#graph-range").value); + const parameter = Number($tool("#graph-parameter").value); + const width = canvas.width; + const height = canvas.height; + $tool("#graph-range-label").textContent = `−${range} 到 ${range}`; + $tool("#graph-parameter-label").textContent = `a = ${parameter}`; + $tool("#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.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(); + + if (!expressions.length) { + $tool("#graph-error").textContent = "请至少输入一个函数"; + return; + } + try { + const firstSamples = drawFunctionPath( + context, + expressions[0], + { a: parameter }, + range, + width, + height, + GRAPH_COLORS[0], + ); + expressions.slice(1).forEach((expression, index) => { + drawFunctionPath( + context, + expression, + { a: parameter }, + range, + width, + height, + GRAPH_COLORS[index + 1], + ); + }); + + if ($tool("#graph-integral").checked) { + context.fillStyle = "rgba(25, 101, 72, .13)"; + context.beginPath(); + context.moveTo(0, height / 2); + firstSamples.forEach((item) => context.lineTo(toX(item.x), toY(item.y))); + context.lineTo(width, height / 2); + context.closePath(); + context.fill(); + } + if ($tool("#graph-derivative").checked) { + const derivative = firstSamples.slice(1, -1).map((item, index) => { + const before = firstSamples[index]; + const after = firstSamples[index + 2]; + return { x: item.x, y: (after.y - before.y) / (after.x - before.x) }; + }); + context.strokeStyle = "#111827"; + context.lineWidth = 2; + context.setLineDash([9, 7]); + context.beginPath(); + derivative.forEach((item, index) => { + const x = toX(item.x); + const y = toY(item.y); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.setLineDash([]); + } + const analysis = graphAnalysis(firstSamples); + const rootText = analysis.roots.length + ? analysis.roots.map((item) => item.toFixed(2)).join("、") + : "当前范围未发现"; + $tool("#graph-analysis").textContent = + `第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`; + } catch (error) { + $tool("#graph-error").textContent = error.message; + } + } + + function canvasPoint(canvas, event) { + const rect = canvas.getBoundingClientRect(); + return { + x: ((event.clientX - rect.left) / rect.width) * canvas.width, + y: ((event.clientY - rect.top) / rect.height) * canvas.height, + }; + } + + function downloadCanvas(canvas, name) { + const link = document.createElement("a"); + link.download = `${name}-${new Date().toISOString().slice(0, 10)}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + } + + class Whiteboard { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.tool = "pen"; + this.history = []; + this.drawing = false; + this.start = null; + this.preview = null; + this.reset(); + canvas.addEventListener("pointerdown", (event) => this.startDrawing(event)); + canvas.addEventListener("pointermove", (event) => this.move(event)); + canvas.addEventListener("pointerup", (event) => this.finish(event)); + canvas.addEventListener("pointercancel", () => this.cancel()); + } + + reset() { + this.context.fillStyle = "#ffffff"; + this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + snapshot() { + this.history.push(this.canvas.toDataURL("image/png")); + if (this.history.length > 20) this.history.shift(); + } + + startDrawing(event) { + event.preventDefault(); + this.snapshot(); + this.drawing = true; + this.start = canvasPoint(this.canvas, event); + if (this.tool === "text") { + const text = $tool("#whiteboard-text").value.trim(); + if (!text) { + this.history.pop(); + showToast("先输入要放到白板的文字或公式"); + } else { + const size = Math.max(18, Number($tool("#whiteboard-size").value) * 5); + this.context.fillStyle = $tool("#whiteboard-color").value; + this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`; + this.context.fillText(text, this.start.x, this.start.y); + } + this.drawing = false; + return; + } + this.preview = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); + this.canvas.setPointerCapture(event.pointerId); + this.context.lineCap = "round"; + this.context.lineJoin = "round"; + this.context.lineWidth = Number($tool("#whiteboard-size").value); + this.context.strokeStyle = + this.tool === "eraser" ? "#ffffff" : $tool("#whiteboard-color").value; + if (this.tool === "pen" || this.tool === "eraser") { + this.context.beginPath(); + this.context.moveTo(this.start.x, this.start.y); + } + } + + move(event) { + if (!this.drawing) return; + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + if (this.tool === "pen" || this.tool === "eraser") { + this.context.lineTo(point.x, point.y); + this.context.stroke(); + return; + } + this.context.putImageData(this.preview, 0, 0); + this.context.beginPath(); + if (this.tool === "line") { + this.context.moveTo(this.start.x, this.start.y); + this.context.lineTo(point.x, point.y); + } else { + this.context.rect( + this.start.x, + this.start.y, + point.x - this.start.x, + point.y - this.start.y, + ); + } + this.context.stroke(); + } + + finish(event) { + if (!this.drawing) return; + this.move(event); + this.drawing = false; + this.preview = null; + } + + cancel() { + if (this.preview) this.context.putImageData(this.preview, 0, 0); + this.drawing = false; + } + + undo() { + const source = this.history.pop(); + if (!source) return; + const image = new Image(); + image.onload = () => { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(image, 0, 0); + }; + image.src = source; + } + + clear() { + this.snapshot(); + this.reset(); + } + + addGrid() { + this.snapshot(); + const context = this.context; + context.strokeStyle = "#e1e7e1"; + context.lineWidth = 1; + for (let x = 0; x <= this.canvas.width; x += 50) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, this.canvas.height); + context.stroke(); + } + for (let y = 0; y <= this.canvas.height; y += 50) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(this.canvas.width, y); + context.stroke(); + } + context.strokeStyle = "#7a877f"; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(this.canvas.width / 2, 0); + context.lineTo(this.canvas.width / 2, this.canvas.height); + context.moveTo(0, this.canvas.height / 2); + context.lineTo(this.canvas.width, this.canvas.height / 2); + context.stroke(); + } + + addImage(file) { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const image = new Image(); + image.onload = () => { + this.snapshot(); + const scale = Math.min( + 1, + (this.canvas.width * 0.8) / image.width, + (this.canvas.height * 0.8) / image.height, + ); + this.context.drawImage( + image, + 30, + 30, + image.width * scale, + image.height * scale, + ); + }; + image.src = reader.result; + }; + reader.readAsDataURL(file); + } + } + + class GeometryBoard { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.tool = "point"; + this.points = []; + this.segments = []; + this.circles = []; + this.history = []; + this.pending = []; + this.dragging = null; + canvas.addEventListener("pointerdown", (event) => this.pointerDown(event)); + canvas.addEventListener("pointermove", (event) => this.pointerMove(event)); + canvas.addEventListener("pointerup", (event) => this.pointerUp(event)); + canvas.addEventListener("pointercancel", () => { + this.dragging = null; + }); + this.draw(); + } + + save() { + this.history.push( + JSON.stringify({ + points: this.points, + segments: this.segments, + circles: this.circles, + }) + ); + if (this.history.length > 30) this.history.shift(); + } + + findOrCreate(point) { + const nearest = this.findNearest(point); + if (nearest >= 0) return nearest; + this.points.push(point); + return this.points.length - 1; + } + + findNearest(point) { + let nearest = -1; + let distance = 28; + this.points.forEach((item, index) => { + const current = Math.hypot(item.x - point.x, item.y - point.y); + if (current < distance) { + nearest = index; + distance = current; + } + }); + return nearest; + } + + pointerDown(event) { + if (this.tool !== "move") return; + event.preventDefault(); + const nearest = this.findNearest(canvasPoint(this.canvas, event)); + if (nearest < 0 || this.points[nearest].derived) return; + this.save(); + this.dragging = nearest; + this.canvas.setPointerCapture(event.pointerId); + } + + pointerMove(event) { + if (this.dragging === null) return; + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + this.points[this.dragging] = point; + this.draw(); + } + + pointerUp(event) { + if (this.tool === "move") { + this.pointerMove(event); + this.dragging = null; + return; + } + this.handlePoint(event); + } + + handlePoint(event) { + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + this.save(); + if (this.tool === "point") { + this.findOrCreate(point); + } else { + this.pending.push(this.findOrCreate(point)); + if (this.pending.length === 2) { + const [first, second] = this.pending; + if (first !== second && this.tool === "segment") { + this.segments.push({ first, second }); + } else if (first !== second && this.tool === "circle") { + this.circles.push({ center: first, edge: second }); + } else if (first !== second && this.tool === "midpoint") { + const a = this.points[first]; + const b = this.points[second]; + this.points.push({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + derived: true, + parents: [first, second], + }); + this.segments.push({ first, second, guide: true }); + } + this.pending = []; + } + } + this.draw(); + $tool("#geometry-hint").textContent = this.pending.length + ? "再选择一个点完成构造。" + : "可继续创建或切换构造工具。"; + } + + draw() { + const context = this.context; + const width = this.canvas.width; + const height = this.canvas.height; + this.points.forEach((point) => { + if (!point.derived || !point.parents) return; + const first = this.points[point.parents[0]]; + const second = this.points[point.parents[1]]; + point.x = (first.x + second.x) / 2; + point.y = (first.y + second.y) / 2; + }); + context.fillStyle = "#fbfbf7"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "#e7e8e2"; + context.lineWidth = 1; + for (let x = 0; x <= width; x += 50) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, height); + context.stroke(); + } + for (let y = 0; y <= height; y += 50) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(width, y); + context.stroke(); + } + this.segments.forEach((segment) => { + const first = this.points[segment.first]; + const second = this.points[segment.second]; + context.strokeStyle = segment.guide ? "#9ca3af" : "#196548"; + context.setLineDash(segment.guide ? [8, 6] : []); + context.lineWidth = 3; + context.beginPath(); + context.moveTo(first.x, first.y); + context.lineTo(second.x, second.y); + context.stroke(); + context.setLineDash([]); + if ($tool("#geometry-labels").checked) { + context.fillStyle = "#47534c"; + context.font = "18px sans-serif"; + context.fillText( + Math.hypot(first.x - second.x, first.y - second.y).toFixed(1), + (first.x + second.x) / 2 + 8, + (first.y + second.y) / 2 - 8, + ); + } + }); + this.circles.forEach((circle) => { + const center = this.points[circle.center]; + const edge = this.points[circle.edge]; + const radius = Math.hypot(center.x - edge.x, center.y - edge.y); + context.strokeStyle = "#5d73e8"; + context.lineWidth = 3; + context.beginPath(); + context.arc(center.x, center.y, radius, 0, Math.PI * 2); + context.stroke(); + }); + this.points.forEach((point, index) => { + context.fillStyle = point.derived ? "#d86f45" : "#17211b"; + context.beginPath(); + context.arc(point.x, point.y, 7, 0, Math.PI * 2); + context.fill(); + if ($tool("#geometry-labels").checked) { + context.fillStyle = "#17211b"; + context.font = "18px sans-serif"; + context.fillText( + `${String.fromCharCode(65 + (index % 26))} (${Math.round(point.x)}, ${Math.round(point.y)})`, + point.x + 10, + point.y - 10, + ); + } + }); + } + + undo() { + const state = this.history.pop(); + if (!state) return; + const parsed = JSON.parse(state); + this.points = parsed.points; + this.segments = parsed.segments; + this.circles = parsed.circles; + this.pending = []; + this.draw(); + } + + clear() { + this.save(); + this.points = []; + this.segments = []; + this.circles = []; + this.pending = []; + this.draw(); + } + } + + let whiteboard; + let geometry; + + function initDrawingTools() { + whiteboard = new Whiteboard($tool("#whiteboard-canvas")); + geometry = new GeometryBoard($tool("#geometry-canvas")); + $$tool("[data-whiteboard-tool]").forEach((button) => { + button.addEventListener("click", () => { + whiteboard.tool = button.dataset.whiteboardTool; + $$tool("[data-whiteboard-tool]").forEach((item) => + item.classList.toggle("active", item === button) + ); + }); + }); + $tool("#whiteboard-undo").addEventListener("click", () => whiteboard.undo()); + $tool("#whiteboard-clear").addEventListener("click", () => whiteboard.clear()); + $tool("#whiteboard-grid").addEventListener("click", () => whiteboard.addGrid()); + $tool("#whiteboard-image").addEventListener("change", (event) => { + whiteboard.addImage(event.target.files[0]); + event.target.value = ""; + }); + $tool("#whiteboard-export").addEventListener("click", () => + downloadCanvas(whiteboard.canvas, "hulumath-whiteboard") + ); + $$tool("[data-geometry-tool]").forEach((button) => { + button.addEventListener("click", () => { + geometry.tool = button.dataset.geometryTool; + geometry.pending = []; + $$tool("[data-geometry-tool]").forEach((item) => + item.classList.toggle("active", item === button) + ); + }); + }); + $tool("#geometry-labels").addEventListener("change", () => geometry.draw()); + $tool("#geometry-undo").addEventListener("click", () => geometry.undo()); + $tool("#geometry-clear").addEventListener("click", () => geometry.clear()); + $tool("#geometry-export").addEventListener("click", () => + downloadCanvas(geometry.canvas, "hulumath-geometry") + ); + } + + function init() { + $tool("#calc-operation").addEventListener("change", updateCalculatorFields); + $tool("#calc-run").addEventListener("click", runCalculator); + $tool("#calc-input").addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator(); + }); + $$tool("[data-calc-example]").forEach((button) => { + button.addEventListener("click", () => { + $tool("#calc-operation").value = button.dataset.calcOperation; + $tool("#calc-input").value = button.dataset.calcExample; + updateCalculatorFields(); + runCalculator(); + }); + }); + ["#graph-run", "#graph-range", "#graph-parameter", "#graph-derivative", "#graph-integral"] + .forEach((selector) => $tool(selector).addEventListener("input", drawGraph)); + $tool("#graph-expression").addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") drawGraph(); + }); + initDrawingTools(); + updateCalculatorFields(); + drawGraph(); + } + + function activate(tool) { + if (tool === "graph") window.setTimeout(drawGraph, 30); + if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30); + } + + window.HuluToolbox = { init, activate, runCalculator, drawGraph }; +})(); diff --git a/backend/templates/index.html b/backend/templates/index.html index 0f9efa0..d1629bf 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -90,24 +90,82 @@
正在读取比赛…
+
+
+
MATH PLAYGROUND

数学玩法

+

短局推理,成绩由服务端校验并记录。

+
+
+
MATHEMATICAL WORKBENCH

工具箱

计算、查询、绘图与表达,把想法直接变成可以继续工作的对象。

- + - + + +
-
CALCULATOR

科学计算器

支持 + − × ÷、括号、幂、sin、cos、tan、sqrt、log 与常数 π。

-
-
准备计算0
- -
+
COMPUTATION STUDIO

数学计算工作台

精确值、方程、微积分、矩阵、统计与进制转换由受限数学内核计算。

+
+
+ + + + + + + + + + +
+ +
+ + + + + + + +
+
+
准备计算0
+
+
近似值0
+
LaTeX0
+
+
    +
    @@ -117,13 +175,59 @@
    -
    FUNCTION PLOTTER

    函数图形绘制

    变量使用 x,支持与计算器相同的函数。

    +
    FUNCTION PLOTTER

    函数图形绘制

    每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。

    -

    +
    + + + + + + +

    +
    +
    +
    +
    MATH WHITEBOARD

    数学白板

    支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG。

    +
    + + + + + + + + + + + + + +
    +
    +
    + +
    +
    GEOMETRY BOARD

    几何画板

    依次点击构造点、线段和圆;支持中点、长度与坐标测量。

    +
    + + + + + + + + + +
    +
    +

    点击画布创建第一个点。

    +
    +
    LATEX LAB

    数学表达实验室

    源码、实时预览与版本保存。

    @@ -199,6 +303,8 @@
    {% csrf_token %}
    + +