Fix/api error details #14
+52
-4
@@ -1,17 +1,65 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from rest_framework.views import exception_handler as drf_exception_handler
|
from rest_framework.views import exception_handler as drf_exception_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_messages(details):
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
def collect(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for item in value.values():
|
||||||
|
collect(item)
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for item in value:
|
||||||
|
collect(item)
|
||||||
|
elif value is not None:
|
||||||
|
message = str(value).strip()
|
||||||
|
if message and message not in messages:
|
||||||
|
messages.append(message)
|
||||||
|
|
||||||
|
collect(details)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _error_message(details):
|
||||||
|
messages = _error_messages(details)
|
||||||
|
if not messages:
|
||||||
|
return "请求未能完成"
|
||||||
|
return ";".join(messages)[:300]
|
||||||
|
|
||||||
|
|
||||||
def exception_handler(exc, context):
|
def exception_handler(exc, context):
|
||||||
response = drf_exception_handler(exc, context)
|
response = drf_exception_handler(exc, context)
|
||||||
|
request = context.get("request")
|
||||||
if response is None:
|
if response is None:
|
||||||
|
logger.exception(
|
||||||
|
"api_unhandled_error method=%s path=%s",
|
||||||
|
getattr(request, "method", "-"),
|
||||||
|
getattr(request, "path", "-"),
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
request = context.get("request")
|
details = response.data
|
||||||
|
code = getattr(exc, "default_code", "request_error")
|
||||||
|
message = _error_message(details)
|
||||||
|
fields = ",".join(details.keys()) if isinstance(details, dict) else "-"
|
||||||
|
logger.info(
|
||||||
|
"api_request_error method=%s path=%s status=%s code=%s fields=%s message=%s",
|
||||||
|
getattr(request, "method", "-"),
|
||||||
|
getattr(request, "path", "-"),
|
||||||
|
response.status_code,
|
||||||
|
code,
|
||||||
|
fields,
|
||||||
|
message,
|
||||||
|
)
|
||||||
response.data = {
|
response.data = {
|
||||||
"error": {
|
"error": {
|
||||||
"code": getattr(exc, "default_code", "request_error"),
|
"code": code,
|
||||||
"message": "请求未能完成",
|
"message": message,
|
||||||
"details": response.data,
|
"details": details,
|
||||||
"request_id": getattr(request, "request_id", None),
|
"request_id": getattr(request, "request_id", None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ def test_app_js_幂等键兼容非安全上下文():
|
|||||||
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
|
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_api_错误优先显示详情并输出安全诊断日志():
|
||||||
|
source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function collectApiErrorMessages(" in source
|
||||||
|
assert "const baseMessage = detailMessages.length" in source
|
||||||
|
assert 'console.error("[Hulumath API]", {' in source
|
||||||
|
assert "requestId: error.requestId" in source
|
||||||
|
assert "error.details = details || null" in source
|
||||||
|
assert "payload.error?.message || payload.detail ||" in source
|
||||||
|
assert "payload.error?.message || payload.detail ||\n (details ?" not in source
|
||||||
|
|
||||||
|
|
||||||
def test_app_css_答题提交按钮可见且长弹窗可滚动():
|
def test_app_css_答题提交按钮可见且长弹窗可滚动():
|
||||||
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -51,6 +63,8 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
|
|||||||
assert "pointerdown" in toolbox
|
assert "pointerdown" 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.textContent = error.message" in games
|
||||||
assert "touch-action: none" in styles
|
assert "touch-action: none" in styles
|
||||||
assert ".calculator-controls [hidden]" in styles
|
assert ".calculator-controls [hidden]" in styles
|
||||||
assert ".sudoku-board" in styles
|
assert ".sudoku-board" in styles
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ast
|
import ast
|
||||||
import secrets
|
import secrets
|
||||||
|
import unicodedata
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
|
|
||||||
@@ -36,6 +37,18 @@ TWENTY_FOUR_PUZZLES = {
|
|||||||
MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)],
|
MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TWENTY_FOUR_SYMBOLS = str.maketrans(
|
||||||
|
{
|
||||||
|
"×": "*",
|
||||||
|
"·": "*",
|
||||||
|
"∙": "*",
|
||||||
|
"÷": "/",
|
||||||
|
"−": "-",
|
||||||
|
"–": "-",
|
||||||
|
"—": "-",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _grid_from_text(value):
|
def _grid_from_text(value):
|
||||||
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)]
|
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)]
|
||||||
@@ -80,7 +93,10 @@ def start_game(user, kind, difficulty):
|
|||||||
|
|
||||||
|
|
||||||
def _validate_twenty_four_expression(source, numbers):
|
def _validate_twenty_four_expression(source, numbers):
|
||||||
source = str(source or "").strip()
|
source = unicodedata.normalize("NFKC", str(source or "")).translate(
|
||||||
|
TWENTY_FOUR_SYMBOLS
|
||||||
|
)
|
||||||
|
source = source.strip()
|
||||||
if not source or len(source) > 120:
|
if not source or len(source) > 120:
|
||||||
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
|
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
|
||||||
try:
|
try:
|
||||||
@@ -90,7 +106,11 @@ def _validate_twenty_four_expression(source, numbers):
|
|||||||
used = []
|
used = []
|
||||||
|
|
||||||
def evaluate(node):
|
def evaluate(node):
|
||||||
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
if (
|
||||||
|
isinstance(node, ast.Constant)
|
||||||
|
and isinstance(node.value, int)
|
||||||
|
and not isinstance(node.value, bool)
|
||||||
|
):
|
||||||
used.append(node.value)
|
used.append(node.value)
|
||||||
return Fraction(node.value)
|
return Fraction(node.value)
|
||||||
if isinstance(node, ast.BinOp) and isinstance(
|
if isinstance(node, ast.BinOp) and isinstance(
|
||||||
|
|||||||
@@ -47,6 +47,55 @@ def test_twenty_four_服务端校验数字使用与幂等提交(game_user):
|
|||||||
assert replay["score"] == result["score"]
|
assert replay["score"] == result["score"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_twenty_four_api_兼容常见数学符号并完成计分(client, game_user):
|
||||||
|
attempt = MathGameAttempt.objects.create(
|
||||||
|
user=game_user,
|
||||||
|
kind=MathGameAttempt.Kind.TWENTY_FOUR,
|
||||||
|
puzzle={"numbers": [1, 3, 4, 6]},
|
||||||
|
solution={"target": 24},
|
||||||
|
)
|
||||||
|
client.force_login(game_user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
|
||||||
|
{"expression": "6÷(1−3÷4)"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_IDEMPOTENCY_KEY="unicode-game-submit",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == MathGameAttempt.Status.COMPLETED
|
||||||
|
assert response.json()["score"] >= 100
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
assert attempt.submission == {"expression": "6/(1-3/4)"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_twenty_four_api_答案错误时返回具体原因和请求编号(client, game_user):
|
||||||
|
attempt = MathGameAttempt.objects.create(
|
||||||
|
user=game_user,
|
||||||
|
kind=MathGameAttempt.Kind.TWENTY_FOUR,
|
||||||
|
puzzle={"numbers": [1, 3, 4, 6]},
|
||||||
|
solution={"target": 24},
|
||||||
|
)
|
||||||
|
client.force_login(game_user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
|
||||||
|
{"expression": "1 + 3 + 4 + 6"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_IDEMPOTENCY_KEY="incorrect-game-submit",
|
||||||
|
HTTP_X_REQUEST_ID="twenty-four-invalid-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["error"]["message"] == "当前结果是 14,还没有得到 24"
|
||||||
|
assert response.json()["error"]["request_id"] == "twenty-four-invalid-test"
|
||||||
|
attempt.refresh_from_db()
|
||||||
|
assert attempt.status == MathGameAttempt.Status.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
|
def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
|
||||||
attempt = MathGameAttempt.objects.create(
|
attempt = MathGameAttempt.objects.create(
|
||||||
@@ -70,6 +119,13 @@ def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
|
|||||||
{"expression": "pow(2, 3) * 3"},
|
{"expression": "pow(2, 3) * 3"},
|
||||||
"invalid-function",
|
"invalid-function",
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValidationError, match="只允许"):
|
||||||
|
submit_game(
|
||||||
|
game_user,
|
||||||
|
attempt.id,
|
||||||
|
{"expression": "6 / (True - 3 / 4)"},
|
||||||
|
"invalid-boolean",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
from contest.models import (
|
||||||
|
Contest,
|
||||||
|
ContestQuestion,
|
||||||
|
Question,
|
||||||
|
QuestionVersion,
|
||||||
|
RealtimeMatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -94,3 +103,103 @@ def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_s
|
|||||||
final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
|
final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||||
assert final_state["result"]["winner"] == "self"
|
assert final_state["result"]["winner"] == "self"
|
||||||
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42"
|
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_challenge_api_无效联机码返回具体原因_request_id_和诊断日志(
|
||||||
|
realtime_api_setup,
|
||||||
|
caplog,
|
||||||
|
):
|
||||||
|
_, _, second_client = realtime_api_setup
|
||||||
|
caplog.set_level("INFO", logger="common.api")
|
||||||
|
|
||||||
|
response = second_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": "ABC234"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_X_REQUEST_ID="challenge-invalid-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response["X-Request-ID"] == "challenge-invalid-test"
|
||||||
|
assert response.json()["error"] == {
|
||||||
|
"code": "invalid",
|
||||||
|
"message": "联机码不存在",
|
||||||
|
"details": {"challenge_code": "联机码不存在"},
|
||||||
|
"request_id": "challenge-invalid-test",
|
||||||
|
}
|
||||||
|
assert "api_request_error method=POST" in caplog.text
|
||||||
|
assert "path=/api/v1/contests/challenges/join/" in caplog.text
|
||||||
|
assert "fields=challenge_code message=联机码不存在" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_challenge_api_创建者不能加入自己的联机码(realtime_api_setup):
|
||||||
|
contest, first_client, _ = realtime_api_setup
|
||||||
|
created = first_client.post(
|
||||||
|
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||||
|
{},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = first_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": created.json()["challenge_code"]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["error"]["message"] == "不能加入自己创建的约战"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_challenge_api_过期联机码返回具体原因(realtime_api_setup):
|
||||||
|
contest, first_client, second_client = realtime_api_setup
|
||||||
|
created = first_client.post(
|
||||||
|
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||||
|
{},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
RealtimeMatch.objects.filter(id=created.json()["match_id"]).update(
|
||||||
|
expires_at=timezone.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = second_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": created.json()["challenge_code"]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
|
||||||
|
contest, first_client, second_client = realtime_api_setup
|
||||||
|
created = first_client.post(
|
||||||
|
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||||
|
{},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
second_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": created.json()["challenge_code"]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
third = User.objects.create_user(
|
||||||
|
username="api_player_three",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="API 玩家三",
|
||||||
|
)
|
||||||
|
third_client = Client()
|
||||||
|
third_client.force_login(third)
|
||||||
|
|
||||||
|
response = third_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": created.json()["challenge_code"]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
||||||
|
|||||||
@@ -70,22 +70,71 @@ function createIdempotencyKey() {
|
|||||||
].join("-");
|
].join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectApiErrorMessages(value, messages = []) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => collectApiErrorMessages(item, messages));
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
Object.values(value).forEach((item) => collectApiErrorMessages(item, messages));
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
const message = String(value).trim();
|
||||||
|
if (message && !messages.includes(message)) messages.push(message);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logApiError(error) {
|
||||||
|
console.error("[Hulumath API]", {
|
||||||
|
method: error.method,
|
||||||
|
path: error.path,
|
||||||
|
status: error.status,
|
||||||
|
code: error.code,
|
||||||
|
requestId: error.requestId,
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
|
const method = (options.method || "GET").toUpperCase();
|
||||||
|
const requestPath = `/api/v1/${path}`;
|
||||||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||||||
if (options.body && typeof options.body !== "string") {
|
if (options.body && typeof options.body !== "string") {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
options.body = JSON.stringify(options.body);
|
options.body = JSON.stringify(options.body);
|
||||||
}
|
}
|
||||||
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
|
if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken();
|
||||||
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(requestPath, { credentials: "same-origin", ...options, headers });
|
||||||
|
} catch (cause) {
|
||||||
|
const error = new Error("网络连接失败,请检查连接后重试");
|
||||||
|
error.status = null;
|
||||||
|
error.code = "network_error";
|
||||||
|
error.requestId = null;
|
||||||
|
error.details = null;
|
||||||
|
error.path = requestPath;
|
||||||
|
error.method = method;
|
||||||
|
error.cause = cause;
|
||||||
|
logApiError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (response.status === 204) return null;
|
if (response.status === 204) return null;
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const details = payload.error?.details;
|
const details = payload.error?.details;
|
||||||
const message = payload.error?.message || payload.detail ||
|
const detailMessages = collectApiErrorMessages(details);
|
||||||
(details ? Object.values(details).flat().join(" ") : "请求失败");
|
const requestId = payload.error?.request_id || response.headers.get("X-Request-ID");
|
||||||
|
const baseMessage = detailMessages.length
|
||||||
|
? detailMessages.join(";")
|
||||||
|
: payload.error?.message || payload.detail || `请求失败(HTTP ${response.status})`;
|
||||||
|
const message = requestId ? `${baseMessage}(请求编号:${requestId})` : baseMessage;
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
|
error.code = payload.error?.code || "request_error";
|
||||||
|
error.requestId = requestId;
|
||||||
|
error.details = details || null;
|
||||||
|
error.path = requestPath;
|
||||||
|
error.method = method;
|
||||||
|
logApiError(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -143,6 +143,13 @@
|
|||||||
input.placeholder = "例如:6 / (1 - 3 / 4)";
|
input.placeholder = "例如:6 / (1 - 3 / 4)";
|
||||||
input.autocomplete = "off";
|
input.autocomplete = "off";
|
||||||
input.inputMode = "text";
|
input.inputMode = "text";
|
||||||
|
input.spellcheck = false;
|
||||||
|
const errorMessage = document.createElement("p");
|
||||||
|
errorMessage.className = "form-error game-form-error";
|
||||||
|
errorMessage.setAttribute("role", "alert");
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
errorMessage.textContent = "";
|
||||||
|
});
|
||||||
const keypad = document.createElement("div");
|
const keypad = document.createElement("div");
|
||||||
keypad.className = "game-keypad";
|
keypad.className = "game-keypad";
|
||||||
["+", "-", "*", "/", "(", ")"].forEach((operator) => {
|
["+", "-", "*", "/", "(", ")"].forEach((operator) => {
|
||||||
@@ -159,10 +166,11 @@
|
|||||||
submit.className = "primary-button";
|
submit.className = "primary-button";
|
||||||
submit.type = "submit";
|
submit.type = "submit";
|
||||||
submit.textContent = "验证并计分";
|
submit.textContent = "验证并计分";
|
||||||
form.append(input, keypad, submit);
|
form.append(input, keypad, errorMessage, submit);
|
||||||
form.addEventListener("submit", async (event) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
|
errorMessage.textContent = "";
|
||||||
try {
|
try {
|
||||||
const result = await api(
|
const result = await api(
|
||||||
`contests/games/attempts/${attempt.attempt_id}/submit/`,
|
`contests/games/attempts/${attempt.attempt_id}/submit/`,
|
||||||
@@ -175,6 +183,7 @@
|
|||||||
renderTwentyFour(result);
|
renderTwentyFour(result);
|
||||||
showToast("得到 24,成绩已记录");
|
showToast("得到 24,成绩已记录");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
errorMessage.textContent = error.message;
|
||||||
showToast(error.message);
|
showToast(error.message);
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user