diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py new file mode 100644 index 0000000..6e185e7 --- /dev/null +++ b/backend/common/test_frontend_assets.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from django.conf import settings + +STATIC_ROOT = Path(settings.BASE_DIR) / "static" + + +def test_app_js_幂等键兼容非安全上下文(): + source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + + assert "function createIdempotencyKey()" in source + assert "crypto.randomUUID()" not in source + assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2 + + +def test_app_css_答题提交按钮可见且长弹窗可滚动(): + source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert ".choice-list .primary-button" in source + assert "background: var(--green); color: white; text-align: center" in source + assert "max-height: calc(100dvh - 30px)" in source + assert "overflow-y: auto" in source diff --git a/backend/static/css/app.css b/backend/static/css/app.css index abbbd6a..a4108c7 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -198,7 +198,7 @@ button { color: inherit; } .video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; } .video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; } .video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; } -dialog { width: min(460px, calc(100% - 30px)); border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); } +dialog { width: min(460px, calc(100% - 30px)); max-height: calc(100vh - 30px); max-height: calc(100dvh - 30px); overflow-y: auto; border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); } dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; } .dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; } @@ -213,6 +213,9 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .choice-list { display: grid; gap: 9px; margin-top: 25px; } .choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; } .choice-list button:hover { border-color: var(--green); } +.choice-list .primary-button { width: 100%; border-color: var(--green); background: var(--green); color: white; text-align: center; font-weight: 650; } +.choice-list .primary-button:hover { border-color: var(--green); background: var(--green); } +.choice-list button:disabled { cursor: wait; opacity: .65; } .quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; } .toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; } .toast.show { opacity: 1; transform: none; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d14f6c1..18c20a4 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -17,6 +17,32 @@ function csrfToken() { return match ? decodeURIComponent(match[1]) : ""; } +function createIdempotencyKey() { + const cryptoApi = globalThis.crypto; + if (typeof cryptoApi?.randomUUID === "function") { + return cryptoApi.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (typeof cryptoApi?.getRandomValues === "function") { + cryptoApi.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10).join(""), + ].join("-"); +} + async function api(path, options = {}) { const headers = { Accept: "application/json", ...(options.headers || {}) }; if (options.body && typeof options.body !== "string") { @@ -165,7 +191,7 @@ function renderStoryNode(run) { try { const next = await api(`math-life/runs/${run.run_id}/choice/`, { method: "POST", - headers: { "Idempotency-Key": crypto.randomUUID() }, + headers: { "Idempotency-Key": createIdempotencyKey() }, body: { choice_index: choice.index }, }); renderStoryNode(next); @@ -341,7 +367,7 @@ function renderAttempt(attempt) { try { const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, { method: "POST", - headers: { "Idempotency-Key": crypto.randomUUID() }, + headers: { "Idempotency-Key": createIdempotencyKey() }, body: { answers }, }); renderAttempt(result);