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 'id="whiteboard-canvas"' in template
|
||||
assert 'id="geometry-canvas"' in template
|
||||
assert template.count('data-tool="whiteboard"') == 1
|
||||
assert 'data-tool="geometry"' not in template
|
||||
assert 'id="board-join-form"' in template
|
||||
assert "函数书写规则" in template
|
||||
assert 'id="math-game-list"' in template
|
||||
assert "window.HuluToolbox" in toolbox
|
||||
assert "pointerdown" in toolbox
|
||||
assert "class BoardRealtime" in toolbox
|
||||
assert 'boardRealtime.send("canvas", payload)' in toolbox
|
||||
assert 'boardRealtime.send("geometry", payload)' in toolbox
|
||||
assert "window.HuluGames" in games
|
||||
assert "sudoku-board" in games
|
||||
assert 'errorMessage.className = "form-error game-form-error"' in games
|
||||
|
||||
@@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application()
|
||||
|
||||
from common.consumers import HealthConsumer
|
||||
from contest.routing import websocket_urlpatterns
|
||||
from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
@@ -20,6 +21,7 @@ application = ProtocolTypeRouter(
|
||||
[
|
||||
path("ws/health/", HealthConsumer.as_asgi()),
|
||||
*websocket_urlpatterns,
|
||||
*toolbox_websocket_urlpatterns,
|
||||
]
|
||||
)
|
||||
),
|
||||
|
||||
@@ -214,12 +214,16 @@ button { color: inherit; }
|
||||
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
|
||||
.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; }
|
||||
.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; }
|
||||
.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); }
|
||||
.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; }
|
||||
.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
|
||||
.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; }
|
||||
.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; }
|
||||
.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.board-mode-switch { display: flex; gap: 8px; margin-bottom: 12px; }.board-mode-switch button { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; background: white; cursor: pointer; }.board-mode-switch button.active { border-color: var(--ink); background: var(--ink); color: white; }
|
||||
.board-online-panel { display: grid; grid-template-columns: auto minmax(250px, 1fr); gap: 10px 16px; align-items: center; margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(120deg, rgba(25,101,72,.06), rgba(204,232,91,.1)); }.board-online-panel > div, .board-online-panel form { display: flex; gap: 8px; }.board-online-panel select, .board-online-panel input { min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.board-online-panel p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 11px; }.board-online-panel > strong { grid-column: 1 / -1; color: var(--green); }.board-online-panel [hidden] { display: none !important; }
|
||||
.board-pane { display: none; }.board-pane.active { display: block; }
|
||||
.discover-title { padding-bottom: 24px; }
|
||||
.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; }
|
||||
.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
@@ -290,6 +294,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
||||
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
|
||||
.preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; }
|
||||
.match-mode-switch { grid-template-columns: 1fr; }
|
||||
.board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; }
|
||||
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
|
||||
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
|
||||
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
@@ -273,6 +273,7 @@
|
||||
this.drawing = false;
|
||||
this.start = null;
|
||||
this.preview = null;
|
||||
this.onChange = null;
|
||||
this.reset();
|
||||
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
|
||||
canvas.addEventListener("pointermove", (event) => this.move(event));
|
||||
@@ -290,6 +291,23 @@
|
||||
if (this.history.length > 20) this.history.shift();
|
||||
}
|
||||
|
||||
emitSnapshot() {
|
||||
if (!this.onChange) return;
|
||||
const data = this.canvas.toDataURL("image/png");
|
||||
if (data.length <= 700000) this.onChange(data);
|
||||
else showToast("当前画布内容较大,已保留本地编辑但暂停联机同步");
|
||||
}
|
||||
|
||||
applySnapshot(data) {
|
||||
if (typeof data !== "string" || !data.startsWith("data:image/")) return;
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
|
||||
};
|
||||
image.src = data;
|
||||
}
|
||||
|
||||
startDrawing(event) {
|
||||
event.preventDefault();
|
||||
this.snapshot();
|
||||
@@ -305,6 +323,7 @@
|
||||
this.context.fillStyle = $tool("#whiteboard-color").value;
|
||||
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
|
||||
this.context.fillText(text, this.start.x, this.start.y);
|
||||
this.emitSnapshot();
|
||||
}
|
||||
this.drawing = false;
|
||||
return;
|
||||
@@ -352,6 +371,7 @@
|
||||
this.move(event);
|
||||
this.drawing = false;
|
||||
this.preview = null;
|
||||
this.emitSnapshot();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
@@ -366,6 +386,7 @@
|
||||
image.onload = () => {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(image, 0, 0);
|
||||
this.emitSnapshot();
|
||||
};
|
||||
image.src = source;
|
||||
}
|
||||
@@ -373,6 +394,7 @@
|
||||
clear() {
|
||||
this.snapshot();
|
||||
this.reset();
|
||||
this.emitSnapshot();
|
||||
}
|
||||
|
||||
addGrid() {
|
||||
@@ -400,6 +422,7 @@
|
||||
context.moveTo(0, this.canvas.height / 2);
|
||||
context.lineTo(this.canvas.width, this.canvas.height / 2);
|
||||
context.stroke();
|
||||
this.emitSnapshot();
|
||||
}
|
||||
|
||||
addImage(file) {
|
||||
@@ -421,6 +444,7 @@
|
||||
image.width * scale,
|
||||
image.height * scale,
|
||||
);
|
||||
this.emitSnapshot();
|
||||
};
|
||||
image.src = reader.result;
|
||||
};
|
||||
@@ -439,6 +463,7 @@
|
||||
this.history = [];
|
||||
this.pending = [];
|
||||
this.dragging = null;
|
||||
this.onChange = null;
|
||||
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
|
||||
canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
|
||||
canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
|
||||
@@ -448,6 +473,34 @@
|
||||
this.draw();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
points: this.points,
|
||||
segments: this.segments,
|
||||
circles: this.circles,
|
||||
};
|
||||
}
|
||||
|
||||
emitState() {
|
||||
if (this.onChange) this.onChange(this.serialize());
|
||||
}
|
||||
|
||||
applyState(payload) {
|
||||
if (
|
||||
!payload ||
|
||||
!Array.isArray(payload.points) ||
|
||||
!Array.isArray(payload.segments) ||
|
||||
!Array.isArray(payload.circles)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.points = payload.points.slice(0, 300);
|
||||
this.segments = payload.segments.slice(0, 500);
|
||||
this.circles = payload.circles.slice(0, 300);
|
||||
this.pending = [];
|
||||
this.draw();
|
||||
}
|
||||
|
||||
save() {
|
||||
this.history.push(
|
||||
JSON.stringify({
|
||||
@@ -501,6 +554,7 @@
|
||||
if (this.tool === "move") {
|
||||
this.pointerMove(event);
|
||||
this.dragging = null;
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.handlePoint(event);
|
||||
@@ -535,6 +589,7 @@
|
||||
}
|
||||
}
|
||||
this.draw();
|
||||
this.emitState();
|
||||
$tool("#geometry-hint").textContent = this.pending.length
|
||||
? "再选择一个点完成构造。"
|
||||
: "可继续创建或切换构造工具。";
|
||||
@@ -624,6 +679,7 @@
|
||||
this.circles = parsed.circles;
|
||||
this.pending = [];
|
||||
this.draw();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
clear() {
|
||||
@@ -633,15 +689,196 @@
|
||||
this.circles = [];
|
||||
this.pending = [];
|
||||
this.draw();
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
|
||||
class BoardRealtime {
|
||||
constructor() {
|
||||
this.session = null;
|
||||
this.socket = null;
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
websocketUrl(path) {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.pollTimer) window.clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
if (this.socket) {
|
||||
this.socket.onclose = null;
|
||||
this.socket.close();
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.session?.session_id) return;
|
||||
try {
|
||||
this.session = await api(`toolbox/boards/${this.session.session_id}/`);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
if (error.status === 404) this.close();
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.close();
|
||||
if (!this.session?.websocket_path) return;
|
||||
const socket = new WebSocket(this.websocketUrl(this.session.websocket_path));
|
||||
this.socket = socket;
|
||||
socket.addEventListener("open", () => {
|
||||
this.render("实时连接已建立");
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
});
|
||||
socket.addEventListener("message", async (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "canvas" &&
|
||||
message.user_id !== String(state.user?.id)
|
||||
) {
|
||||
whiteboard.applySnapshot(message.payload);
|
||||
} else if (
|
||||
message.type === "geometry" &&
|
||||
message.user_id !== String(state.user?.id)
|
||||
) {
|
||||
geometry.applyState(message.payload);
|
||||
} else if (message.type === "state") {
|
||||
await this.refresh();
|
||||
if (message.reason === "joined" && this.session?.role === "host") {
|
||||
this.send("canvas", whiteboard.canvas.toDataURL("image/png"));
|
||||
this.send("geometry", geometry.serialize());
|
||||
}
|
||||
} else if (message.type === "error") {
|
||||
showToast(message.message);
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
this.render("实时连接已断开,正在轮询房间状态");
|
||||
});
|
||||
this.pollTimer = window.setInterval(() => this.refresh(), 3000);
|
||||
}
|
||||
|
||||
send(type, payload) {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
|
||||
async create() {
|
||||
if (!requireAuth()) return;
|
||||
const mode = $tool("#board-session-mode").value;
|
||||
try {
|
||||
this.session = await api("toolbox/boards/", {
|
||||
method: "POST",
|
||||
body: { mode },
|
||||
});
|
||||
this.render();
|
||||
this.connect();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async join(event) {
|
||||
event.preventDefault();
|
||||
if (!requireAuth()) return;
|
||||
const input = $tool("#board-code-input");
|
||||
try {
|
||||
this.session = await api("toolbox/boards/join/", {
|
||||
method: "POST",
|
||||
body: { code: input.value },
|
||||
});
|
||||
input.value = "";
|
||||
this.render();
|
||||
this.connect();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async guess(event) {
|
||||
event.preventDefault();
|
||||
if (!this.session) return;
|
||||
const input = $tool("#board-guess-input");
|
||||
try {
|
||||
const result = await api(
|
||||
`toolbox/boards/${this.session.session_id}/guess/`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { guess: input.value },
|
||||
},
|
||||
);
|
||||
this.session = result;
|
||||
showToast(result.message);
|
||||
if (result.correct) input.value = "";
|
||||
this.render();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
render(connectionMessage = "") {
|
||||
const status = $tool("#board-session-status");
|
||||
const target = $tool("#board-target");
|
||||
const guessForm = $tool("#board-guess-form");
|
||||
if (!this.session) {
|
||||
status.textContent = "当前为本地画板,创建或加入后开始实时同步。";
|
||||
target.hidden = true;
|
||||
guessForm.hidden = true;
|
||||
return;
|
||||
}
|
||||
const participants = this.session.guest
|
||||
? `${this.session.host} 与 ${this.session.guest}`
|
||||
: `${this.session.host} 正在等待另一位用户`;
|
||||
status.textContent =
|
||||
`${this.session.mode_label} · 联机码 ${this.session.code} · ${participants}` +
|
||||
(connectionMessage ? ` · ${connectionMessage}` : "");
|
||||
target.hidden = !this.session.target;
|
||||
target.textContent = this.session.target
|
||||
? `本轮数学对象:${this.session.target}`
|
||||
: "";
|
||||
guessForm.hidden = !(
|
||||
this.session.mode === "draw_guess" &&
|
||||
this.session.role === "guest" &&
|
||||
this.session.status === "active"
|
||||
);
|
||||
if (this.session.status === "completed") {
|
||||
status.textContent +=
|
||||
` · 本轮结束,比分 ${this.session.host_score}:${this.session.guest_score}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let whiteboard;
|
||||
let geometry;
|
||||
let boardRealtime;
|
||||
|
||||
function initDrawingTools() {
|
||||
whiteboard = new Whiteboard($tool("#whiteboard-canvas"));
|
||||
geometry = new GeometryBoard($tool("#geometry-canvas"));
|
||||
boardRealtime = new BoardRealtime();
|
||||
whiteboard.onChange = (payload) => boardRealtime.send("canvas", payload);
|
||||
geometry.onChange = (payload) => boardRealtime.send("geometry", payload);
|
||||
$$tool("[data-board-pane-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const pane = button.dataset.boardPaneToggle;
|
||||
$$tool("[data-board-pane-toggle]").forEach((item) => {
|
||||
item.classList.toggle("active", item === button);
|
||||
});
|
||||
$$tool("[data-board-pane]").forEach((item) => {
|
||||
item.classList.toggle("active", item.dataset.boardPane === pane);
|
||||
});
|
||||
if (pane === "geometry") window.setTimeout(() => geometry.draw(), 30);
|
||||
});
|
||||
});
|
||||
$$tool("[data-whiteboard-tool]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
whiteboard.tool = button.dataset.whiteboardTool;
|
||||
@@ -675,6 +912,21 @@
|
||||
$tool("#geometry-export").addEventListener("click", () =>
|
||||
downloadCanvas(geometry.canvas, "hulumath-geometry")
|
||||
);
|
||||
$tool("#board-create").addEventListener("click", () => boardRealtime.create());
|
||||
$tool("#board-join-form").addEventListener(
|
||||
"submit",
|
||||
(event) => boardRealtime.join(event),
|
||||
);
|
||||
$tool("#board-guess-form").addEventListener(
|
||||
"submit",
|
||||
(event) => boardRealtime.guess(event),
|
||||
);
|
||||
$tool("#board-code-input").addEventListener("input", (event) => {
|
||||
event.target.value = event.target.value
|
||||
.toUpperCase()
|
||||
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
|
||||
.slice(0, 6);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
@@ -703,7 +955,7 @@
|
||||
|
||||
function activate(tool) {
|
||||
if (tool === "graph") window.setTimeout(drawGraph, 30);
|
||||
if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30);
|
||||
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
|
||||
}
|
||||
|
||||
window.HuluToolbox = { init, activate, runCalculator, drawGraph };
|
||||
|
||||
@@ -212,8 +212,7 @@
|
||||
<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="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="geometry"><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="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、预览、课程与公式库</small></button>
|
||||
</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="graph-shell">
|
||||
<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>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>
|
||||
@@ -298,40 +301,63 @@
|
||||
</div>
|
||||
|
||||
<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="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 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>
|
||||
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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 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 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
|
||||
|
||||
from accounts.models import User
|
||||
from toolbox.models import BoardSession
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_calculator_api_公开访问并返回精确值(client):
|
||||
@@ -23,3 +26,54 @@ def test_calculator_api_危险表达式返回四百(client):
|
||||
|
||||
assert response.status_code == 400
|
||||
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 .views import CalculatorView
|
||||
from .views import (
|
||||
BoardCreateView,
|
||||
BoardGuessView,
|
||||
BoardJoinView,
|
||||
BoardStateView,
|
||||
CalculatorView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
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.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .board_services import (
|
||||
board_payload,
|
||||
create_board,
|
||||
join_board,
|
||||
submit_guess,
|
||||
)
|
||||
from .engine import calculate
|
||||
from .models import BoardSession
|
||||
|
||||
|
||||
class CalculatorView(APIView):
|
||||
@@ -20,3 +29,42 @@ class CalculatorView(APIView):
|
||||
except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc:
|
||||
raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc
|
||||
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