diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index b419477..bdde153 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -61,9 +61,16 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在(): assert "js/games.js" in template assert 'id="whiteboard-canvas"' in template assert 'id="geometry-canvas"' in template + assert template.count('data-tool="whiteboard"') == 1 + assert 'data-tool="geometry"' not in template + assert 'id="board-join-form"' in template + assert "函数书写规则" in template assert 'id="math-game-list"' in template assert "window.HuluToolbox" in toolbox assert "pointerdown" in toolbox + assert "class BoardRealtime" in toolbox + assert 'boardRealtime.send("canvas", payload)' in toolbox + assert 'boardRealtime.send("geometry", payload)' in toolbox assert "window.HuluGames" in games assert "sudoku-board" in games assert 'errorMessage.className = "form-error game-form-error"' in games diff --git a/backend/config/asgi.py b/backend/config/asgi.py index f1784c0..0497c3d 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application() from common.consumers import HealthConsumer from contest.routing import websocket_urlpatterns +from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns application = ProtocolTypeRouter( { @@ -20,6 +21,7 @@ application = ProtocolTypeRouter( [ path("ws/health/", HealthConsumer.as_asgi()), *websocket_urlpatterns, + *toolbox_websocket_urlpatterns, ] ) ), diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 4e056c9..1474464 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -214,12 +214,16 @@ button { color: inherit; } .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 textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; } +.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); } .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; } +.board-mode-switch { display: flex; gap: 8px; margin-bottom: 12px; }.board-mode-switch button { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; background: white; cursor: pointer; }.board-mode-switch button.active { border-color: var(--ink); background: var(--ink); color: white; } +.board-online-panel { display: grid; grid-template-columns: auto minmax(250px, 1fr); gap: 10px 16px; align-items: center; margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(120deg, rgba(25,101,72,.06), rgba(204,232,91,.1)); }.board-online-panel > div, .board-online-panel form { display: flex; gap: 8px; }.board-online-panel select, .board-online-panel input { min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.board-online-panel p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 11px; }.board-online-panel > strong { grid-column: 1 / -1; color: var(--green); }.board-online-panel [hidden] { display: none !important; } +.board-pane { display: none; }.board-pane.active { display: block; } .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; } @@ -290,6 +294,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; } .preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; } .match-mode-switch { grid-template-columns: 1fr; } + .board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; } .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; } .challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; } diff --git a/backend/static/js/toolbox.js b/backend/static/js/toolbox.js index 05c511f..2924917 100644 --- a/backend/static/js/toolbox.js +++ b/backend/static/js/toolbox.js @@ -273,6 +273,7 @@ this.drawing = false; this.start = null; this.preview = null; + this.onChange = null; this.reset(); canvas.addEventListener("pointerdown", (event) => this.startDrawing(event)); canvas.addEventListener("pointermove", (event) => this.move(event)); @@ -290,6 +291,23 @@ if (this.history.length > 20) this.history.shift(); } + emitSnapshot() { + if (!this.onChange) return; + const data = this.canvas.toDataURL("image/png"); + if (data.length <= 700000) this.onChange(data); + else showToast("当前画布内容较大,已保留本地编辑但暂停联机同步"); + } + + applySnapshot(data) { + if (typeof data !== "string" || !data.startsWith("data:image/")) return; + const image = new Image(); + image.onload = () => { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height); + }; + image.src = data; + } + startDrawing(event) { event.preventDefault(); this.snapshot(); @@ -305,6 +323,7 @@ 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.emitSnapshot(); } this.drawing = false; return; @@ -352,6 +371,7 @@ this.move(event); this.drawing = false; this.preview = null; + this.emitSnapshot(); } cancel() { @@ -366,6 +386,7 @@ image.onload = () => { this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.drawImage(image, 0, 0); + this.emitSnapshot(); }; image.src = source; } @@ -373,6 +394,7 @@ clear() { this.snapshot(); this.reset(); + this.emitSnapshot(); } addGrid() { @@ -400,6 +422,7 @@ context.moveTo(0, this.canvas.height / 2); context.lineTo(this.canvas.width, this.canvas.height / 2); context.stroke(); + this.emitSnapshot(); } addImage(file) { @@ -421,6 +444,7 @@ image.width * scale, image.height * scale, ); + this.emitSnapshot(); }; image.src = reader.result; }; @@ -439,6 +463,7 @@ this.history = []; this.pending = []; this.dragging = null; + this.onChange = null; canvas.addEventListener("pointerdown", (event) => this.pointerDown(event)); canvas.addEventListener("pointermove", (event) => this.pointerMove(event)); canvas.addEventListener("pointerup", (event) => this.pointerUp(event)); @@ -448,6 +473,34 @@ this.draw(); } + serialize() { + return { + points: this.points, + segments: this.segments, + circles: this.circles, + }; + } + + emitState() { + if (this.onChange) this.onChange(this.serialize()); + } + + applyState(payload) { + if ( + !payload || + !Array.isArray(payload.points) || + !Array.isArray(payload.segments) || + !Array.isArray(payload.circles) + ) { + return; + } + this.points = payload.points.slice(0, 300); + this.segments = payload.segments.slice(0, 500); + this.circles = payload.circles.slice(0, 300); + this.pending = []; + this.draw(); + } + save() { this.history.push( JSON.stringify({ @@ -501,6 +554,7 @@ if (this.tool === "move") { this.pointerMove(event); this.dragging = null; + this.emitState(); return; } this.handlePoint(event); @@ -535,6 +589,7 @@ } } this.draw(); + this.emitState(); $tool("#geometry-hint").textContent = this.pending.length ? "再选择一个点完成构造。" : "可继续创建或切换构造工具。"; @@ -624,6 +679,7 @@ this.circles = parsed.circles; this.pending = []; this.draw(); + this.emitState(); } clear() { @@ -633,15 +689,196 @@ this.circles = []; this.pending = []; this.draw(); + this.emitState(); + } + } + + class BoardRealtime { + constructor() { + this.session = null; + this.socket = null; + this.pollTimer = null; + } + + websocketUrl(path) { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${path}`; + } + + close() { + if (this.pollTimer) window.clearInterval(this.pollTimer); + this.pollTimer = null; + if (this.socket) { + this.socket.onclose = null; + this.socket.close(); + } + this.socket = null; + } + + async refresh() { + if (!this.session?.session_id) return; + try { + this.session = await api(`toolbox/boards/${this.session.session_id}/`); + this.render(); + } catch (error) { + if (error.status === 404) this.close(); + } + } + + connect() { + this.close(); + if (!this.session?.websocket_path) return; + const socket = new WebSocket(this.websocketUrl(this.session.websocket_path)); + this.socket = socket; + socket.addEventListener("open", () => { + this.render("实时连接已建立"); + socket.send(JSON.stringify({ type: "ping" })); + }); + socket.addEventListener("message", async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if ( + message.type === "canvas" && + message.user_id !== String(state.user?.id) + ) { + whiteboard.applySnapshot(message.payload); + } else if ( + message.type === "geometry" && + message.user_id !== String(state.user?.id) + ) { + geometry.applyState(message.payload); + } else if (message.type === "state") { + await this.refresh(); + if (message.reason === "joined" && this.session?.role === "host") { + this.send("canvas", whiteboard.canvas.toDataURL("image/png")); + this.send("geometry", geometry.serialize()); + } + } else if (message.type === "error") { + showToast(message.message); + } + }); + socket.addEventListener("close", () => { + this.render("实时连接已断开,正在轮询房间状态"); + }); + this.pollTimer = window.setInterval(() => this.refresh(), 3000); + } + + send(type, payload) { + if (this.socket?.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ type, payload })); + } + + async create() { + if (!requireAuth()) return; + const mode = $tool("#board-session-mode").value; + try { + this.session = await api("toolbox/boards/", { + method: "POST", + body: { mode }, + }); + this.render(); + this.connect(); + } catch (error) { + showToast(error.message); + } + } + + async join(event) { + event.preventDefault(); + if (!requireAuth()) return; + const input = $tool("#board-code-input"); + try { + this.session = await api("toolbox/boards/join/", { + method: "POST", + body: { code: input.value }, + }); + input.value = ""; + this.render(); + this.connect(); + } catch (error) { + showToast(error.message); + } + } + + async guess(event) { + event.preventDefault(); + if (!this.session) return; + const input = $tool("#board-guess-input"); + try { + const result = await api( + `toolbox/boards/${this.session.session_id}/guess/`, + { + method: "POST", + body: { guess: input.value }, + }, + ); + this.session = result; + showToast(result.message); + if (result.correct) input.value = ""; + this.render(); + } catch (error) { + showToast(error.message); + } + } + + render(connectionMessage = "") { + const status = $tool("#board-session-status"); + const target = $tool("#board-target"); + const guessForm = $tool("#board-guess-form"); + if (!this.session) { + status.textContent = "当前为本地画板,创建或加入后开始实时同步。"; + target.hidden = true; + guessForm.hidden = true; + return; + } + const participants = this.session.guest + ? `${this.session.host} 与 ${this.session.guest}` + : `${this.session.host} 正在等待另一位用户`; + status.textContent = + `${this.session.mode_label} · 联机码 ${this.session.code} · ${participants}` + + (connectionMessage ? ` · ${connectionMessage}` : ""); + target.hidden = !this.session.target; + target.textContent = this.session.target + ? `本轮数学对象:${this.session.target}` + : ""; + guessForm.hidden = !( + this.session.mode === "draw_guess" && + this.session.role === "guest" && + this.session.status === "active" + ); + if (this.session.status === "completed") { + status.textContent += + ` · 本轮结束,比分 ${this.session.host_score}:${this.session.guest_score}`; + } } } let whiteboard; let geometry; + let boardRealtime; function initDrawingTools() { whiteboard = new Whiteboard($tool("#whiteboard-canvas")); geometry = new GeometryBoard($tool("#geometry-canvas")); + boardRealtime = new BoardRealtime(); + whiteboard.onChange = (payload) => boardRealtime.send("canvas", payload); + geometry.onChange = (payload) => boardRealtime.send("geometry", payload); + $$tool("[data-board-pane-toggle]").forEach((button) => { + button.addEventListener("click", () => { + const pane = button.dataset.boardPaneToggle; + $$tool("[data-board-pane-toggle]").forEach((item) => { + item.classList.toggle("active", item === button); + }); + $$tool("[data-board-pane]").forEach((item) => { + item.classList.toggle("active", item.dataset.boardPane === pane); + }); + if (pane === "geometry") window.setTimeout(() => geometry.draw(), 30); + }); + }); $$tool("[data-whiteboard-tool]").forEach((button) => { button.addEventListener("click", () => { whiteboard.tool = button.dataset.whiteboardTool; @@ -675,6 +912,21 @@ $tool("#geometry-export").addEventListener("click", () => downloadCanvas(geometry.canvas, "hulumath-geometry") ); + $tool("#board-create").addEventListener("click", () => boardRealtime.create()); + $tool("#board-join-form").addEventListener( + "submit", + (event) => boardRealtime.join(event), + ); + $tool("#board-guess-form").addEventListener( + "submit", + (event) => boardRealtime.guess(event), + ); + $tool("#board-code-input").addEventListener("input", (event) => { + event.target.value = event.target.value + .toUpperCase() + .replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "") + .slice(0, 6); + }); } function init() { @@ -703,7 +955,7 @@ function activate(tool) { if (tool === "graph") window.setTimeout(drawGraph, 30); - if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30); + if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30); } window.HuluToolbox = { init, activate, runCalculator, drawGraph }; diff --git a/backend/templates/index.html b/backend/templates/index.html index 7dafd08..de7491e 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -212,8 +212,7 @@ - - + @@ -284,6 +283,10 @@
每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。
每行一个表达式,直接写右侧:sin(x)、x^2 - 2*x + 1、sqrt(abs(x))。乘法必须写 *,幂可写 ^,参数仅支持 a。
支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG。
依次点击构造点、线段和圆;支持中点、长度与坐标测量。