6 Commits
Author SHA1 Message Date
Jacky 6bcff55c4f Merge pull request 'fix: keep admin theme contrast consistent' (#5) from fix/admin-light-theme-contrast into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/5
2026-08-09 00:37:20 +08:00
Jacky c365454395 fix: keep admin theme contrast consistent
CI / test (pull_request) Canceled after 34s
PR合并自动部署 / release-check (pull_request) Successful in 1m35s
PR合并自动部署 / deploy (pull_request) Successful in 10s
2026-08-09 00:34:31 +08:00
Jacky ce07f6b3b7 Merge pull request 'ci: send production host in runner health check' (#4) from fix/runner-health-host into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/4
2026-08-09 00:29:14 +08:00
Jacky 54684fac7c ci: send production host in runner health check
PR合并自动部署 / release-check (pull_request) Canceled after 0s
PR合并自动部署 / deploy (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 14s
2026-08-09 00:28:05 +08:00
Jacky 160d58b2d2 Merge pull request 'Ci/quality and smoke gates' (#3) from ci/quality-and-smoke-gates into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/3
2026-08-09 00:23:50 +08:00
Jacky 8aad781581 fix: support idempotent actions over HTTP
CI / test (pull_request) Canceled after 1m25s
PR合并自动部署 / release-check (pull_request) Successful in 1m44s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 00:20:48 +08:00
5 changed files with 145 additions and 3 deletions
+2
View File
@@ -150,9 +150,11 @@ jobs:
- name: 从 Runner 验证 Nginx 健康状态
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PUBLIC_HOST: 117.72.28.96
run: |
curl --fail --silent --show-error \
--retry 5 \
--retry-delay 2 \
--header "Host: $DEPLOY_PUBLIC_HOST" \
"http://$DEPLOY_HOST:4321/health/" \
| grep -q '"status": "ok"'
+36
View File
@@ -0,0 +1,36 @@
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
def test_admin_css_深色系统下仍保持完整浅色主题():
source = (STATIC_ROOT / "admin" / "css" / "hulumath_admin.css").read_text(
encoding="utf-8"
)
assert "color-scheme: light" in source
assert "--body-fg: #17211b" in source
assert "--body-bg: #f3f1e9" in source
assert "--darkened-bg: #ebeae3" in source
assert "background-color: #ffffff !important" in source
assert ".theme-toggle" in source
assert "display: none" in source
@@ -1,4 +1,5 @@
:root {
color-scheme: light;
--hulu-ink: #17211b;
--hulu-green: #196548;
--hulu-green-dark: #124d37;
@@ -8,18 +9,44 @@
--hulu-line: #dfe2da;
--hulu-muted: #6b756e;
--primary: #196548;
--primary-fg: #ffffff;
--secondary: #17211b;
--accent: #cce85b;
--body-fg: #17211b;
--body-bg: #f3f1e9;
--body-quiet-color: #6b756e;
--body-loud-color: #0d1510;
--header-bg: #17211b;
--header-color: #ffffff;
--header-branding-color: #ffffff;
--header-link-color: #ffffff;
--breadcrumbs-fg: #f3f1e9;
--breadcrumbs-bg: #235f47;
--breadcrumbs-link-fg: #f3f1e9;
--link-fg: #196548;
--link-hover-color: #124d37;
--link-selected-fg: #124d37;
--hairline-color: #e4e6df;
--border-color: #ccd2ca;
--error-fg: #b84136;
--message-success-bg: #dcefe5;
--message-warning-bg: #fff1c7;
--message-error-bg: #f8deda;
--darkened-bg: #ebeae3;
--selected-bg: #e2e9e2;
--selected-row: #dce9df;
--button-fg: #ffffff;
--button-bg: #196548;
--button-hover-bg: #124d37;
--default-button-bg: #17211b;
--default-button-hover-bg: #2c3931;
--close-button-bg: #667169;
--close-button-hover-bg: #3e4942;
--delete-button-bg: #b84136;
--delete-button-hover-bg: #913128;
--object-tools-fg: #ffffff;
--object-tools-bg: #196548;
--object-tools-hover-bg: #124d37;
}
body {
@@ -27,6 +54,10 @@ body {
color: var(--hulu-ink);
}
.theme-toggle {
display: none;
}
#header {
min-height: 70px;
padding: 0 28px;
@@ -125,6 +156,19 @@ div.breadcrumbs {
background: #ebeae3;
}
#nav-sidebar .module,
#nav-sidebar .module table,
#nav-sidebar .module th,
#nav-sidebar .module td {
background: var(--hulu-panel);
color: var(--hulu-ink);
}
#nav-sidebar .module td,
#nav-sidebar .module th {
border-color: var(--hulu-line);
}
#nav-sidebar .current-app .section,
#nav-sidebar .current-model {
background: rgba(25, 101, 72, .1);
@@ -164,6 +208,14 @@ select,
border-radius: 8px !important;
}
input:not([type=submit]):not([type=button]):not([type=checkbox]):not([type=radio]),
textarea,
select,
.vTextField {
background-color: #ffffff !important;
color: var(--hulu-ink) !important;
}
input:focus,
textarea:focus,
select:focus {
@@ -172,6 +224,29 @@ select:focus {
outline: none;
}
.form-row,
.submit-row {
border-color: var(--hulu-line);
background: var(--hulu-panel);
}
.form-row label,
.form-row p,
.form-row .help,
.form-row div.help,
.form-row div.help li {
color: var(--hulu-muted);
}
.form-row label {
color: #334039;
font-weight: 600;
}
.submit-row {
border: 1px solid var(--hulu-line);
}
.button,
input[type=submit],
input[type=button],
+4 -1
View File
@@ -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; }
+28 -2
View File
@@ -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);