feat: add collaborative math board and draw guess mode
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 0.08*x^2 - 2</textarea></label>
|
<label>函数列表<textarea id="graph-expression" class="formula-input">sin(x) + a*x/3 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,40 +301,63 @@
|
|||||||
</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="drawing-toolbar" id="whiteboard-toolbar">
|
<div class="board-mode-switch">
|
||||||
<button class="active" data-whiteboard-tool="pen">画笔</button>
|
<button class="active" data-board-pane-toggle="whiteboard">自由白板</button>
|
||||||
<button data-whiteboard-tool="line">直线</button>
|
<button data-board-pane-toggle="geometry">几何构造</button>
|
||||||
<button data-whiteboard-tool="rect">矩形</button>
|
|
||||||
<button data-whiteboard-tool="text">文字 / 公式</button>
|
|
||||||
<button data-whiteboard-tool="eraser">橡皮</button>
|
|
||||||
<input id="whiteboard-text" class="toolbar-text-input" placeholder="输入文字或公式">
|
|
||||||
<label>颜色<input id="whiteboard-color" type="color" value="#17211b"></label>
|
|
||||||
<label>粗细<input id="whiteboard-size" type="range" min="2" max="24" value="4"></label>
|
|
||||||
<button id="whiteboard-grid">添加坐标纸</button>
|
|
||||||
<label class="file-tool">导入图片<input id="whiteboard-image" type="file" accept="image/*"></label>
|
|
||||||
<button id="whiteboard-undo">撤销</button>
|
|
||||||
<button id="whiteboard-clear">清空</button>
|
|
||||||
<button id="whiteboard-export" class="primary-button">导出 PNG</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
|
<section class="board-online-panel">
|
||||||
</div>
|
<div>
|
||||||
|
<select id="board-session-mode" aria-label="画板联机模式">
|
||||||
<div class="tool-workspace" id="tool-geometry">
|
<option value="collaborate">协作展示</option>
|
||||||
<div class="workspace-heading"><div><span class="kicker">GEOMETRY BOARD</span><h2>几何画板</h2></div><p>依次点击构造点、线段和圆;支持中点、长度与坐标测量。</p></div>
|
<option value="draw_guess">数学你画我猜</option>
|
||||||
<div class="drawing-toolbar" id="geometry-toolbar">
|
</select>
|
||||||
<button class="active" data-geometry-tool="point">点</button>
|
<button id="board-create" class="primary-button">创建联机码</button>
|
||||||
<button data-geometry-tool="segment">线段</button>
|
</div>
|
||||||
<button data-geometry-tool="circle">圆</button>
|
<form id="board-join-form">
|
||||||
<button data-geometry-tool="midpoint">中点</button>
|
<input id="board-code-input" maxlength="6" autocomplete="off" placeholder="输入 6 位联机码" aria-label="画板联机码">
|
||||||
<button data-geometry-tool="move">拖动</button>
|
<button type="submit">加入画板</button>
|
||||||
<label class="check-row"><input id="geometry-labels" type="checkbox" checked> 坐标与测量</label>
|
</form>
|
||||||
<button id="geometry-undo">撤销</button>
|
<p id="board-session-status">当前为本地画板,创建或加入后开始实时同步。</p>
|
||||||
<button id="geometry-clear">清空</button>
|
<strong id="board-target" hidden></strong>
|
||||||
<button id="geometry-export" class="primary-button">导出 PNG</button>
|
<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">
|
||||||
|
<button class="active" data-whiteboard-tool="pen">画笔</button>
|
||||||
|
<button data-whiteboard-tool="line">直线</button>
|
||||||
|
<button data-whiteboard-tool="rect">矩形</button>
|
||||||
|
<button data-whiteboard-tool="text">文字 / 公式</button>
|
||||||
|
<button data-whiteboard-tool="eraser">橡皮</button>
|
||||||
|
<input id="whiteboard-text" class="toolbar-text-input" placeholder="输入文字或公式">
|
||||||
|
<label>颜色<input id="whiteboard-color" type="color" value="#17211b"></label>
|
||||||
|
<label>粗细<input id="whiteboard-size" type="range" min="2" max="24" value="4"></label>
|
||||||
|
<button id="whiteboard-grid">添加坐标纸</button>
|
||||||
|
<label class="file-tool">导入图片<input id="whiteboard-image" type="file" accept="image/*"></label>
|
||||||
|
<button id="whiteboard-undo">撤销</button>
|
||||||
|
<button id="whiteboard-clear">清空</button>
|
||||||
|
<button id="whiteboard-export" class="primary-button">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="board-pane" data-board-pane="geometry">
|
||||||
|
<div class="drawing-toolbar" id="geometry-toolbar">
|
||||||
|
<button class="active" data-geometry-tool="point">点</button>
|
||||||
|
<button data-geometry-tool="segment">线段</button>
|
||||||
|
<button data-geometry-tool="circle">圆</button>
|
||||||
|
<button data-geometry-tool="midpoint">中点</button>
|
||||||
|
<button data-geometry-tool="move">拖动</button>
|
||||||
|
<label class="check-row"><input id="geometry-labels" type="checkbox" checked> 坐标与测量</label>
|
||||||
|
<button id="geometry-undo">撤销</button>
|
||||||
|
<button id="geometry-clear">清空</button>
|
||||||
|
<button id="geometry-export" class="primary-button">导出 PNG</button>
|
||||||
|
</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>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-workspace" id="tool-latex">
|
<div class="tool-workspace" id="tool-latex">
|
||||||
|
|||||||
@@ -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",)
|
||||||
@@ -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
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()}"
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)()
|
||||||
@@ -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
@@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user