feat: add collaborative math board and draw guess mode

This commit is contained in:
2026-08-10 00:47:03 +08:00
parent 40e9462842
commit b8916180a9
16 changed files with 890 additions and 36 deletions
+7
View File
@@ -61,9 +61,16 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
assert "js/games.js" in template assert "js/games.js" in template
assert 'id="whiteboard-canvas"' in template assert 'id="whiteboard-canvas"' in template
assert 'id="geometry-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 'id="math-game-list"' in template
assert "window.HuluToolbox" in toolbox assert "window.HuluToolbox" in toolbox
assert "pointerdown" 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 "window.HuluGames" in games
assert "sudoku-board" in games assert "sudoku-board" in games
assert 'errorMessage.className = "form-error game-form-error"' in games assert 'errorMessage.className = "form-error game-form-error"' in games
+2
View File
@@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application()
from common.consumers import HealthConsumer from common.consumers import HealthConsumer
from contest.routing import websocket_urlpatterns from contest.routing import websocket_urlpatterns
from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
@@ -20,6 +21,7 @@ application = ProtocolTypeRouter(
[ [
path("ws/health/", HealthConsumer.as_asgi()), path("ws/health/", HealthConsumer.as_asgi()),
*websocket_urlpatterns, *websocket_urlpatterns,
*toolbox_websocket_urlpatterns,
] ]
) )
), ),
+5
View File
@@ -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; } .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-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; } .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; } .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-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; } #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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; }
+253 -1
View File
@@ -273,6 +273,7 @@
this.drawing = false; this.drawing = false;
this.start = null; this.start = null;
this.preview = null; this.preview = null;
this.onChange = null;
this.reset(); this.reset();
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event)); canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
canvas.addEventListener("pointermove", (event) => this.move(event)); canvas.addEventListener("pointermove", (event) => this.move(event));
@@ -290,6 +291,23 @@
if (this.history.length > 20) this.history.shift(); 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) { startDrawing(event) {
event.preventDefault(); event.preventDefault();
this.snapshot(); this.snapshot();
@@ -305,6 +323,7 @@
this.context.fillStyle = $tool("#whiteboard-color").value; this.context.fillStyle = $tool("#whiteboard-color").value;
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`; this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
this.context.fillText(text, this.start.x, this.start.y); this.context.fillText(text, this.start.x, this.start.y);
this.emitSnapshot();
} }
this.drawing = false; this.drawing = false;
return; return;
@@ -352,6 +371,7 @@
this.move(event); this.move(event);
this.drawing = false; this.drawing = false;
this.preview = null; this.preview = null;
this.emitSnapshot();
} }
cancel() { cancel() {
@@ -366,6 +386,7 @@
image.onload = () => { image.onload = () => {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(image, 0, 0); this.context.drawImage(image, 0, 0);
this.emitSnapshot();
}; };
image.src = source; image.src = source;
} }
@@ -373,6 +394,7 @@
clear() { clear() {
this.snapshot(); this.snapshot();
this.reset(); this.reset();
this.emitSnapshot();
} }
addGrid() { addGrid() {
@@ -400,6 +422,7 @@
context.moveTo(0, this.canvas.height / 2); context.moveTo(0, this.canvas.height / 2);
context.lineTo(this.canvas.width, this.canvas.height / 2); context.lineTo(this.canvas.width, this.canvas.height / 2);
context.stroke(); context.stroke();
this.emitSnapshot();
} }
addImage(file) { addImage(file) {
@@ -421,6 +444,7 @@
image.width * scale, image.width * scale,
image.height * scale, image.height * scale,
); );
this.emitSnapshot();
}; };
image.src = reader.result; image.src = reader.result;
}; };
@@ -439,6 +463,7 @@
this.history = []; this.history = [];
this.pending = []; this.pending = [];
this.dragging = null; this.dragging = null;
this.onChange = null;
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event)); canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
canvas.addEventListener("pointermove", (event) => this.pointerMove(event)); canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
canvas.addEventListener("pointerup", (event) => this.pointerUp(event)); canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
@@ -448,6 +473,34 @@
this.draw(); 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() { save() {
this.history.push( this.history.push(
JSON.stringify({ JSON.stringify({
@@ -501,6 +554,7 @@
if (this.tool === "move") { if (this.tool === "move") {
this.pointerMove(event); this.pointerMove(event);
this.dragging = null; this.dragging = null;
this.emitState();
return; return;
} }
this.handlePoint(event); this.handlePoint(event);
@@ -535,6 +589,7 @@
} }
} }
this.draw(); this.draw();
this.emitState();
$tool("#geometry-hint").textContent = this.pending.length $tool("#geometry-hint").textContent = this.pending.length
? "再选择一个点完成构造。" ? "再选择一个点完成构造。"
: "可继续创建或切换构造工具。"; : "可继续创建或切换构造工具。";
@@ -624,6 +679,7 @@
this.circles = parsed.circles; this.circles = parsed.circles;
this.pending = []; this.pending = [];
this.draw(); this.draw();
this.emitState();
} }
clear() { clear() {
@@ -633,15 +689,196 @@
this.circles = []; this.circles = [];
this.pending = []; this.pending = [];
this.draw(); 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 whiteboard;
let geometry; let geometry;
let boardRealtime;
function initDrawingTools() { function initDrawingTools() {
whiteboard = new Whiteboard($tool("#whiteboard-canvas")); whiteboard = new Whiteboard($tool("#whiteboard-canvas"));
geometry = new GeometryBoard($tool("#geometry-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) => { $$tool("[data-whiteboard-tool]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
whiteboard.tool = button.dataset.whiteboardTool; whiteboard.tool = button.dataset.whiteboardTool;
@@ -675,6 +912,21 @@
$tool("#geometry-export").addEventListener("click", () => $tool("#geometry-export").addEventListener("click", () =>
downloadCanvas(geometry.canvas, "hulumath-geometry") 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() { function init() {
@@ -703,7 +955,7 @@
function activate(tool) { function activate(tool) {
if (tool === "graph") window.setTimeout(drawGraph, 30); 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 }; window.HuluToolbox = { init, activate, runCalculator, drawGraph };
+32 -6
View File
@@ -212,8 +212,7 @@
<button class="tool-card active" data-tool="calculator"><span>123</span><b>数学计算工作台</b><small>代数、微积分、矩阵与统计</small></button> <button class="tool-card active" data-tool="calculator"><span>123</span><b>数学计算工作台</b><small>代数、微积分、矩阵与统计</small></button>
<button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button> <button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button>
<button class="tool-card" data-tool="graph"><span></span><b>函数图形绘制</b><small>多函数、参数、导数与分析</small></button> <button class="tool-card" data-tool="graph"><span></span><b>函数图形绘制</b><small>多函数、参数、导数与分析</small></button>
<button class="tool-card" data-tool="whiteboard"><span></span><b>数学</b><small>书写、图形、撤销与导出</small></button> <button class="tool-card" data-tool="whiteboard"><span></span><b>数学</b><small>白板、几何、联机展示与你画我猜</small></button>
<button class="tool-card" data-tool="geometry"><span></span><b>几何画板</b><small>点线圆、中点与动态测量</small></button>
<button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、预览、课程与公式库</small></button> <button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、预览、课程与公式库</small></button>
</div> </div>
@@ -284,6 +283,10 @@
<div class="workspace-heading"><div><span class="kicker">FUNCTION PLOTTER</span><h2>函数图形绘制</h2></div><p>每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。</p></div> <div class="workspace-heading"><div><span class="kicker">FUNCTION PLOTTER</span><h2>函数图形绘制</h2></div><p>每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。</p></div>
<div class="graph-shell"> <div class="graph-shell">
<div class="graph-controls"> <div class="graph-controls">
<details class="function-syntax-help" open>
<summary>函数书写规则</summary>
<p>每行一个表达式,直接写右侧:<code>sin(x)</code><code>x^2 - 2*x + 1</code><code>sqrt(abs(x))</code>。乘法必须写 <code>*</code>,幂可写 <code>^</code>,参数仅支持 <code>a</code></p>
</details>
<label>函数列表<textarea id="graph-expression" class="formula-input">sin(x) + a*x/3&#10;0.08*x^2 - 2</textarea></label> <label>函数列表<textarea id="graph-expression" class="formula-input">sin(x) + a*x/3&#10;0.08*x^2 - 2</textarea></label>
<label>X 范围<input id="graph-range" type="range" min="5" max="30" value="10"><span id="graph-range-label">10 到 10</span></label> <label>X 范围<input id="graph-range" type="range" min="5" max="30" value="10"><span id="graph-range-label">10 到 10</span></label>
<label>参数 a<input id="graph-parameter" type="range" min="-5" max="5" step="0.1" value="1"><span id="graph-parameter-label">a = 1</span></label> <label>参数 a<input id="graph-parameter" type="range" min="-5" max="5" step="0.1" value="1"><span id="graph-parameter-label">a = 1</span></label>
@@ -298,7 +301,31 @@
</div> </div>
<div class="tool-workspace" id="tool-whiteboard"> <div class="tool-workspace" id="tool-whiteboard">
<div class="workspace-heading"><div><span class="kicker">MATH WHITEBOARD</span><h2>数学</h2></div><p>支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG</p></div> <div class="workspace-heading"><div><span class="kicker">MATH BOARD</span><h2>统一数学</h2></div><p>在白板与几何构造之间切换,可用联机码同步展示或发起数学你画我猜</p></div>
<div class="board-mode-switch">
<button class="active" data-board-pane-toggle="whiteboard">自由白板</button>
<button data-board-pane-toggle="geometry">几何构造</button>
</div>
<section class="board-online-panel">
<div>
<select id="board-session-mode" aria-label="画板联机模式">
<option value="collaborate">协作展示</option>
<option value="draw_guess">数学你画我猜</option>
</select>
<button id="board-create" class="primary-button">创建联机码</button>
</div>
<form id="board-join-form">
<input id="board-code-input" maxlength="6" autocomplete="off" placeholder="输入 6 位联机码" aria-label="画板联机码">
<button type="submit">加入画板</button>
</form>
<p id="board-session-status">当前为本地画板,创建或加入后开始实时同步。</p>
<strong id="board-target" hidden></strong>
<form id="board-guess-form" hidden>
<input id="board-guess-input" maxlength="40" placeholder="猜一个数学对象">
<button type="submit">提交猜测</button>
</form>
</section>
<div class="board-pane active" data-board-pane="whiteboard">
<div class="drawing-toolbar" id="whiteboard-toolbar"> <div class="drawing-toolbar" id="whiteboard-toolbar">
<button class="active" data-whiteboard-tool="pen">画笔</button> <button class="active" data-whiteboard-tool="pen">画笔</button>
<button data-whiteboard-tool="line">直线</button> <button data-whiteboard-tool="line">直线</button>
@@ -316,9 +343,7 @@
</div> </div>
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div> <div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
</div> </div>
<div class="board-pane" data-board-pane="geometry">
<div class="tool-workspace" id="tool-geometry">
<div class="workspace-heading"><div><span class="kicker">GEOMETRY BOARD</span><h2>几何画板</h2></div><p>依次点击构造点、线段和圆;支持中点、长度与坐标测量。</p></div>
<div class="drawing-toolbar" id="geometry-toolbar"> <div class="drawing-toolbar" id="geometry-toolbar">
<button class="active" data-geometry-tool="point"></button> <button class="active" data-geometry-tool="point"></button>
<button data-geometry-tool="segment">线段</button> <button data-geometry-tool="segment">线段</button>
@@ -333,6 +358,7 @@
<div class="canvas-stage geometry-stage"><canvas id="geometry-canvas" width="1200" height="700" aria-label="几何画板"></canvas></div> <div class="canvas-stage geometry-stage"><canvas id="geometry-canvas" width="1200" height="700" aria-label="几何画板"></canvas></div>
<p id="geometry-hint" class="canvas-hint">点击画布创建第一个点。</p> <p id="geometry-hint" class="canvas-hint">点击画布创建第一个点。</p>
</div> </div>
</div>
<div class="tool-workspace" id="tool-latex"> <div class="tool-workspace" id="tool-latex">
<div class="workspace-heading"><div><span class="kicker">LATEX LAB</span><h2>数学表达实验室</h2></div><p>源码、实时预览与版本保存。</p></div> <div class="workspace-heading"><div><span class="kicker">LATEX LAB</span><h2>数学表达实验室</h2></div><p>源码、实时预览与版本保存。</p></div>
+20
View File
@@ -0,0 +1,20 @@
from django.contrib import admin
from .models import BoardSession
@admin.register(BoardSession)
class BoardSessionAdmin(admin.ModelAdmin):
list_display = (
"code",
"mode",
"host",
"guest",
"status",
"guest_score",
"created_at",
)
list_filter = ("mode", "status")
search_fields = ("code", "host__username", "guest__username")
readonly_fields = ("code", "target", "created_at", "completed_at")
ordering = ("-created_at",)
+147
View File
@@ -0,0 +1,147 @@
import secrets
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import BoardSession
BOARD_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
BOARD_TTL = timedelta(minutes=30)
DRAW_GUESS_TARGETS = (
"抛物线",
"三角形",
"勾股定理",
"质数",
"",
"正弦函数",
"分数",
"坐标系",
)
def _new_board_code():
for _ in range(20):
code = "".join(secrets.choice(BOARD_CODE_ALPHABET) for _ in range(6))
if not BoardSession.objects.filter(code=code).exists():
return code
raise ValidationError("暂时无法生成画板联机码,请稍后重试")
def broadcast_board(session_id, reason):
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
f"board_{session_id}",
{
"type": "board.state",
"reason": reason,
},
)
def board_payload(session, user):
is_host = session.host_id == user.id
reveal_target = (
session.mode == BoardSession.Mode.DRAW_GUESS
and (is_host or session.status == BoardSession.Status.COMPLETED)
)
return {
"session_id": session.id,
"code": session.code,
"mode": session.mode,
"mode_label": session.get_mode_display(),
"status": session.status,
"role": "host" if is_host else "guest",
"target": session.target if reveal_target else None,
"host": session.host.nickname,
"guest": session.guest.nickname if session.guest else None,
"host_score": session.host_score,
"guest_score": session.guest_score,
"expires_at": session.expires_at,
"websocket_path": f"/ws/v1/toolbox/boards/{session.id}/",
}
@transaction.atomic
def create_board(user, mode):
if mode not in BoardSession.Mode.values:
raise ValidationError({"mode": "不支持的画板联机模式"})
BoardSession.objects.filter(
host=user,
status=BoardSession.Status.WAITING,
).update(status=BoardSession.Status.CANCELLED)
return BoardSession.objects.create(
code=_new_board_code(),
mode=mode,
host=user,
target=(
secrets.choice(DRAW_GUESS_TARGETS)
if mode == BoardSession.Mode.DRAW_GUESS
else ""
),
expires_at=timezone.now() + BOARD_TTL,
)
@transaction.atomic
def join_board(user, code):
normalized = str(code or "").strip().upper()
if len(normalized) != 6 or any(
character not in BOARD_CODE_ALPHABET for character in normalized
):
raise ValidationError({"code": "画板联机码应为 6 位大写字母或数字"})
try:
session = (
BoardSession.objects.select_for_update()
.select_related("host", "guest")
.get(code=normalized)
)
except BoardSession.DoesNotExist as exc:
raise ValidationError({"code": "画板联机码不存在"}) from exc
if session.host_id == user.id:
raise ValidationError({"code": "不能加入自己创建的画板"})
if session.status != BoardSession.Status.WAITING:
raise ValidationError({"code": "画板联机码已失效或已被使用"})
if session.expires_at <= timezone.now():
session.status = BoardSession.Status.CANCELLED
session.save(update_fields=["status"])
raise ValidationError({"code": "画板联机码已经过期"})
session.guest = user
session.status = BoardSession.Status.ACTIVE
session.save(update_fields=["guest", "status"])
transaction.on_commit(lambda: broadcast_board(session.id, "joined"))
return session
@transaction.atomic
def submit_guess(user, session_id, raw_guess):
session = (
BoardSession.objects.select_for_update()
.select_related("host", "guest")
.get(id=session_id)
)
if session.guest_id != user.id:
raise ValidationError("只有猜题方可以提交答案")
if (
session.mode != BoardSession.Mode.DRAW_GUESS
or session.status != BoardSession.Status.ACTIVE
):
raise ValidationError("当前画板不接受猜题")
guess = str(raw_guess or "").strip()
if not guess or len(guess) > 40:
raise ValidationError({"guess": "请输入不超过 40 个字符的数学对象"})
correct = guess.replace(" ", "").lower() == session.target.replace(" ", "").lower()
if correct:
session.guest_score += 1
session.status = BoardSession.Status.COMPLETED
session.completed_at = timezone.now()
session.save(
update_fields=["guest_score", "status", "completed_at"]
)
transaction.on_commit(lambda: broadcast_board(session.id, "completed"))
return session, correct
+100
View File
@@ -0,0 +1,100 @@
import json
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from .models import BoardSession
class BoardConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.session_id = self.scope["url_route"]["kwargs"]["session_id"]
self.group_name = f"board_{self.session_id}"
user = self.scope["user"]
if not user.is_authenticated or not await self._is_participant(user.id):
await self.close(code=4403)
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
await self.send_json(
{
"type": "connected",
"session_id": str(self.session_id),
}
)
async def disconnect(self, close_code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(
self.group_name,
self.channel_name,
)
async def receive_json(self, content, **kwargs):
event_type = content.get("type")
if event_type == "ping":
await self.send_json({"type": "pong"})
return
if event_type not in {"canvas", "geometry"}:
await self.send_json({"type": "error", "message": "不支持的画板消息"})
return
if not await self._is_active():
await self.send_json({"type": "error", "message": "画板尚未开始或已经结束"})
return
payload = content.get("payload")
if event_type == "canvas":
valid = (
isinstance(payload, str)
and payload.startswith("data:image/")
and len(payload) <= 700_000
)
else:
valid = isinstance(payload, dict) and len(
json.dumps(payload, ensure_ascii=False)
) <= 100_000
if not valid:
await self.send_json({"type": "error", "message": "画板消息无效或过大"})
return
await self.channel_layer.group_send(
self.group_name,
{
"type": "board.update",
"event_type": event_type,
"payload": payload,
"user_id": str(self.scope["user"].id),
},
)
async def board_update(self, event):
await self.send_json(
{
"type": event["event_type"],
"payload": event["payload"],
"user_id": event["user_id"],
}
)
async def board_state(self, event):
await self.send_json(
{
"type": "state",
"reason": event["reason"],
"session_id": str(self.session_id),
}
)
@database_sync_to_async
def _is_participant(self, user_id):
return BoardSession.objects.filter(id=self.session_id).filter(
host_id=user_id
).exists() or BoardSession.objects.filter(
id=self.session_id,
guest_id=user_id,
).exists()
@database_sync_to_async
def _is_active(self):
return BoardSession.objects.filter(
id=self.session_id,
status=BoardSession.Status.ACTIVE,
).exists()
@@ -0,0 +1,38 @@
# Generated by Django 4.2.23 on 2026-08-09 16:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardSession',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=6, unique=True)),
('mode', models.CharField(choices=[('collaborate', '协作展示'), ('draw_guess', '数学你画我猜')], default='collaborate', max_length=16)),
('target', models.CharField(blank=True, max_length=40)),
('host_score', models.PositiveSmallIntegerField(default=0)),
('guest_score', models.PositiveSmallIntegerField(default=0)),
('status', models.CharField(choices=[('waiting', '等待加入'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)),
('expires_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('guest', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='joined_board_sessions', to=settings.AUTH_USER_MODEL)),
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hosted_board_sessions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+53
View File
@@ -0,0 +1,53 @@
import uuid
from django.conf import settings
from django.db import models
class BoardSession(models.Model):
class Mode(models.TextChoices):
COLLABORATE = "collaborate", "协作展示"
DRAW_GUESS = "draw_guess", "数学你画我猜"
class Status(models.TextChoices):
WAITING = "waiting", "等待加入"
ACTIVE = "active", "进行中"
COMPLETED = "completed", "已完成"
CANCELLED = "cancelled", "已取消"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=6, unique=True)
mode = models.CharField(
max_length=16,
choices=Mode.choices,
default=Mode.COLLABORATE,
)
host = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="hosted_board_sessions",
)
guest = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="joined_board_sessions",
)
target = models.CharField(max_length=40, blank=True)
host_score = models.PositiveSmallIntegerField(default=0)
guest_score = models.PositiveSmallIntegerField(default=0)
status = models.CharField(
max_length=16,
choices=Status.choices,
default=Status.WAITING,
)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.code} · {self.get_mode_display()}"
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .consumers import BoardConsumer
websocket_urlpatterns = [
path(
"ws/v1/toolbox/boards/<uuid:session_id>/",
BoardConsumer.as_asgi(),
),
]
+78
View File
@@ -0,0 +1,78 @@
from datetime import timedelta
import pytest
from asgiref.sync import async_to_sync
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.urls import path
from django.utils import timezone
from accounts.models import User
from toolbox.consumers import BoardConsumer
from toolbox.models import BoardSession
@pytest.mark.django_db(transaction=True)
def test_board_consumer_参与者同步画布并拒绝局外人():
host = User.objects.create_user(
username="board_socket_host",
password="StrongPass_2026",
nickname="WS 房主",
)
guest = User.objects.create_user(
username="board_socket_guest",
password="StrongPass_2026",
nickname="WS 访客",
)
outsider = User.objects.create_user(
username="board_socket_outsider",
password="StrongPass_2026",
nickname="WS 局外人",
)
session = BoardSession.objects.create(
code="ABC234",
host=host,
guest=guest,
status=BoardSession.Status.ACTIVE,
expires_at=timezone.now() + timedelta(minutes=30),
)
application = URLRouter(
[
path(
"ws/test/<uuid:session_id>/",
BoardConsumer.as_asgi(),
)
]
)
async def scenario():
outsider_socket = WebsocketCommunicator(
application,
f"/ws/test/{session.id}/",
)
outsider_socket.scope["user"] = outsider
connected, close_code = await outsider_socket.connect()
assert not connected
assert close_code == 4403
host_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/")
guest_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/")
host_socket.scope["user"] = host
guest_socket.scope["user"] = guest
assert (await host_socket.connect())[0]
assert (await guest_socket.connect())[0]
assert (await host_socket.receive_json_from())["type"] == "connected"
assert (await guest_socket.receive_json_from())["type"] == "connected"
payload = "data:image/png;base64,AAAA"
await host_socket.send_json_to({"type": "canvas", "payload": payload})
host_event = await host_socket.receive_json_from()
guest_event = await guest_socket.receive_json_from()
assert host_event["payload"] == payload
assert guest_event["payload"] == payload
assert guest_event["user_id"] == str(host.id)
await host_socket.disconnect()
await guest_socket.disconnect()
async_to_sync(scenario)()
+54
View File
@@ -1,5 +1,8 @@
import pytest import pytest
from accounts.models import User
from toolbox.models import BoardSession
@pytest.mark.django_db @pytest.mark.django_db
def test_calculator_api_公开访问并返回精确值(client): def test_calculator_api_公开访问并返回精确值(client):
@@ -23,3 +26,54 @@ def test_calculator_api_危险表达式返回四百(client):
assert response.status_code == 400 assert response.status_code == 400
assert "error" in response.json() assert "error" in response.json()
@pytest.mark.django_db
def test_board_api_联机码加入你画我猜并服务端计分(client):
host = User.objects.create_user(
username="board_host",
password="StrongPass_2026",
nickname="画板房主",
)
guest = User.objects.create_user(
username="board_guest",
password="StrongPass_2026",
nickname="猜题玩家",
)
client.force_login(host)
created = client.post(
"/api/v1/toolbox/boards/",
{"mode": "draw_guess"},
content_type="application/json",
)
target = created.json()["target"]
code = created.json()["code"]
session_id = created.json()["session_id"]
client.force_login(guest)
joined = client.post(
"/api/v1/toolbox/boards/join/",
{"code": code.lower()},
content_type="application/json",
)
wrong = client.post(
f"/api/v1/toolbox/boards/{session_id}/guess/",
{"guess": "不是答案"},
content_type="application/json",
)
correct = client.post(
f"/api/v1/toolbox/boards/{session_id}/guess/",
{"guess": target},
content_type="application/json",
)
assert created.status_code == 201
assert created.json()["role"] == "host"
assert target
assert joined.status_code == 200
assert joined.json()["target"] is None
assert joined.json()["status"] == BoardSession.Status.ACTIVE
assert wrong.json()["correct"] is False
assert correct.json()["correct"] is True
assert correct.json()["guest_score"] == 1
assert correct.json()["status"] == BoardSession.Status.COMPLETED
+15 -1
View File
@@ -1,7 +1,21 @@
from django.urls import path from django.urls import path
from .views import CalculatorView from .views import (
BoardCreateView,
BoardGuessView,
BoardJoinView,
BoardStateView,
CalculatorView,
)
urlpatterns = [ urlpatterns = [
path("calculate/", CalculatorView.as_view(), name="toolbox-calculate"), path("calculate/", CalculatorView.as_view(), name="toolbox-calculate"),
path("boards/", BoardCreateView.as_view(), name="board-create"),
path("boards/join/", BoardJoinView.as_view(), name="board-join"),
path("boards/<uuid:session_id>/", BoardStateView.as_view(), name="board-state"),
path(
"boards/<uuid:session_id>/guess/",
BoardGuessView.as_view(),
name="board-guess",
),
] ]
+48
View File
@@ -1,10 +1,19 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import permissions from rest_framework import permissions
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .board_services import (
board_payload,
create_board,
join_board,
submit_guess,
)
from .engine import calculate from .engine import calculate
from .models import BoardSession
class CalculatorView(APIView): class CalculatorView(APIView):
@@ -20,3 +29,42 @@ class CalculatorView(APIView):
except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc: except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc:
raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc
return Response(payload) return Response(payload)
class BoardCreateView(APIView):
def post(self, request):
session = create_board(
request.user,
request.data.get("mode", BoardSession.Mode.COLLABORATE),
)
return Response(board_payload(session, request.user), status=201)
class BoardJoinView(APIView):
def post(self, request):
session = join_board(request.user, request.data.get("code"))
return Response(board_payload(session, request.user))
class BoardStateView(APIView):
def get(self, request, session_id):
session = get_object_or_404(
BoardSession.objects.select_related("host", "guest").filter(
Q(host=request.user) | Q(guest=request.user)
),
id=session_id,
)
return Response(board_payload(session, request.user))
class BoardGuessView(APIView):
def post(self, request, session_id):
session, correct = submit_guess(
request.user,
session_id,
request.data.get("guess"),
)
payload = board_payload(session, request.user)
payload["correct"] = correct
payload["message"] = "猜对了,得 1 分" if correct else "还不对,再观察一下画板"
return Response(payload)