diff --git a/Makefile b/Makefile index 19541b9..7f43389 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install migrate seed run run-asgi test check +.PHONY: install migrate seed local-admin run run-asgi test check install: python3 -m venv .venv @@ -11,6 +11,9 @@ seed: cd backend && ../.venv/bin/python manage.py seed_initial_content cd backend && ../.venv/bin/python manage.py seed_contests +local-admin: + cd backend && ../.venv/bin/python manage.py init_local_admin + run: cd backend && ../.venv/bin/python manage.py runserver diff --git a/README.md b/README.md index 56bc89c..2e13fbd 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,19 @@ make run 管理后台位于 `http://127.0.0.1:8000/admin/`。创建管理员: +```bash +make local-admin +``` + +默认本地测试账号为 `local_admin` / `LocalAdmin2026!`。该命令仅允许在 +`DJANGO_DEBUG=true` 时运行,不会接触或修改生产管理员。需要自定义时: + ```bash cd backend -../.venv/bin/python manage.py createsuperuser +../.venv/bin/python manage.py init_local_admin \ + --username jacky_local \ + --password '仅用于本机的测试密码' \ + --reset-password ``` ## 测试 diff --git a/backend/accounts/management/__init__.py b/backend/accounts/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/accounts/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/accounts/management/commands/__init__.py b/backend/accounts/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/accounts/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/accounts/management/commands/init_local_admin.py b/backend/accounts/management/commands/init_local_admin.py new file mode 100644 index 0000000..b63b805 --- /dev/null +++ b/backend/accounts/management/commands/init_local_admin.py @@ -0,0 +1,69 @@ +import os + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from accounts.models import User + + +class Command(BaseCommand): + help = "仅在 DEBUG 环境创建或修复本地测试管理员" + + def add_arguments(self, parser): + parser.add_argument( + "--username", + default=os.getenv("LOCAL_ADMIN_USERNAME", "local_admin"), + ) + parser.add_argument( + "--password", + default=os.getenv("LOCAL_ADMIN_PASSWORD", "LocalAdmin2026!"), + ) + parser.add_argument( + "--email", + default=os.getenv("LOCAL_ADMIN_EMAIL", "local-admin@example.test"), + ) + parser.add_argument( + "--reset-password", + action="store_true", + help="已存在账号时也重置密码", + ) + + def handle(self, *args, **options): + if not settings.DEBUG: + raise CommandError("init_local_admin 仅允许在 DJANGO_DEBUG=true 时运行") + + username = options["username"].strip() + password = options["password"] + if not username or len(password) < 8: + raise CommandError("用户名不能为空,密码至少需要 8 个字符") + + user, created = User.objects.get_or_create( + username=username, + defaults={ + "nickname": "本地管理员", + "email": options["email"], + "is_staff": True, + "is_superuser": True, + "is_active": True, + }, + ) + changed_fields = [] + for field in ("is_staff", "is_superuser", "is_active"): + if not getattr(user, field): + setattr(user, field, True) + changed_fields.append(field) + if not user.nickname: + user.nickname = "本地管理员" + changed_fields.append("nickname") + if created or options["reset_password"] or not user.has_usable_password(): + user.set_password(password) + changed_fields.append("password") + if changed_fields: + user.save(update_fields=changed_fields) + + action = "已创建" if created else "已确认" + self.stdout.write( + self.style.SUCCESS( + f"{action}本地管理员 {username};后台地址 http://127.0.0.1:8000/admin/" + ) + ) diff --git a/backend/accounts/test_management_commands.py b/backend/accounts/test_management_commands.py new file mode 100644 index 0000000..6173e17 --- /dev/null +++ b/backend/accounts/test_management_commands.py @@ -0,0 +1,37 @@ +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import override_settings + +from accounts.models import User + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_init_local_admin_创建可登录管理员(client): + call_command( + "init_local_admin", + username="test_local_admin", + password="LocalAdmin2026!", + ) + + user = User.objects.get(username="test_local_admin") + assert user.is_staff is True + assert user.is_superuser is True + assert client.login( + username="test_local_admin", + password="LocalAdmin2026!", + ) + + +@pytest.mark.django_db +@override_settings(DEBUG=False) +def test_init_local_admin_生产环境拒绝执行(): + with pytest.raises(CommandError, match="仅允许"): + call_command( + "init_local_admin", + username="forbidden_admin", + password="LocalAdmin2026!", + ) + + assert not User.objects.filter(username="forbidden_admin").exists() diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index fd92b75..345c9a9 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -94,6 +94,27 @@ def test_latex_默认源码可渲染且_katex_静态资源完整(): assert (katex_root / "LICENSE.txt").is_file() +def test_latex_长公式不会挤走保存按钮(): + styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert "grid-template-rows: auto minmax(0, 1fr) auto" in styles + assert "#latex-preview { align-self: center; width: 100%; min-width: 0" in styles + assert ".preview-pane button { align-self: end; justify-self: end" in styles + + +def test_rating_历史和登录后档案立即刷新(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8") + + assert 'id="home-match-history"' in template + assert "function renderMatchHistory(root, matches)" in app + assert "function loadHomeMatchHistory()" in app + assert '$("#view-profile").classList.contains("active") ? loadProfile() : null' in app + assert "state.user.rating = match.result.rating_after" in realtime + assert "updateUserUI();" in realtime + + def test_production_manifest_可处理全部静态资源(tmp_path): storage = "whitenoise.storage.CompressedManifestStaticFilesStorage" with override_settings( diff --git a/backend/contest/management/commands/seed_contests.py b/backend/contest/management/commands/seed_contests.py index c564dd0..6ce4898 100644 --- a/backend/contest/management/commands/seed_contests.py +++ b/backend/contest/management/commands/seed_contests.py @@ -1,9 +1,9 @@ +import random + from django.core.management.base import BaseCommand from contest.models import Contest, ContestQuestion, Question, QuestionVersion -import random - def _generate_beginner(): """入门:100以内加减乘除,约 400 题。""" diff --git a/backend/progression/tests.py b/backend/progression/tests.py index 4929020..2fe2581 100644 --- a/backend/progression/tests.py +++ b/backend/progression/tests.py @@ -1,2 +1,60 @@ -# Create your tests here. +import pytest +from django.utils import timezone + +from accounts.models import User +from contest.models import Contest, Question, RatingHistory, RealtimeMatch + + +@pytest.mark.django_db +def test_profile_返回实时比赛历史及_rating_变化(client): + user = User.objects.create_user( + username="profile_player", + password="StrongPass_2026", + nickname="档案玩家", + rating=1016, + ) + opponent = User.objects.create_user( + username="profile_opponent", + password="StrongPass_2026", + nickname="对局对手", + rating=984, + ) + contest = Contest.objects.create( + slug="profile-realtime", + title="档案实时赛", + kind=Contest.Kind.REALTIME, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + ) + match = RealtimeMatch.objects.create( + contest=contest, + player_one=user, + player_two=opponent, + player_one_rating=1000, + player_two_rating=1000, + winner=user, + status=RealtimeMatch.Status.COMPLETED, + completed_at=timezone.now(), + ) + RatingHistory.objects.create( + user=user, + match=match, + rating_before=1000, + rating_after=1016, + delta=16, + ) + client.force_login(user) + + response = client.get("/api/v1/progression/me/") + + assert response.status_code == 200 + history = response.json()["recent_matches"] + assert len(history) == 1 + assert history[0]["match_id"] == str(match.id) + assert history[0]["contest"] == "档案实时赛" + assert history[0]["opponent"] == "对局对手" + assert history[0]["result"] == "win" + assert history[0]["rating_delta"] == 16 + assert history[0]["rating_after"] == 1016 + assert history[0]["completed_at"] diff --git a/backend/progression/views.py b/backend/progression/views.py index b33b81e..241e80b 100644 --- a/backend/progression/views.py +++ b/backend/progression/views.py @@ -1,6 +1,9 @@ +from django.db.models import Prefetch, Q from rest_framework.response import Response from rest_framework.views import APIView +from contest.models import RatingHistory, RealtimeMatch + from .models import UserAbility, UserPet @@ -21,6 +24,54 @@ class ProgressionProfileView(APIView): "fragments": ability.fragments, } ) + matches = ( + RealtimeMatch.objects.filter( + Q(player_one=request.user) | Q(player_two=request.user), + status=RealtimeMatch.Status.COMPLETED, + ) + .select_related("contest", "player_one", "player_two", "winner") + .prefetch_related( + Prefetch( + "rating_changes", + queryset=RatingHistory.objects.filter(user=request.user), + to_attr="viewer_rating_changes", + ) + ) + .order_by("-completed_at")[:10] + ) + recent_matches = [] + for match in matches: + opponent = ( + match.player_two + if match.player_one_id == request.user.id + else match.player_one + ) + rating_change = ( + match.viewer_rating_changes[0] + if match.viewer_rating_changes + else None + ) + recent_matches.append( + { + "match_id": match.id, + "contest": match.contest.title, + "opponent": opponent.nickname if opponent else "未知对手", + "result": ( + "draw" + if match.winner_id is None + else "win" + if match.winner_id == request.user.id + else "loss" + ), + "rating_delta": rating_change.delta if rating_change else 0, + "rating_after": ( + rating_change.rating_after + if rating_change + else request.user.rating + ), + "completed_at": match.completed_at, + } + ) return Response( { "pet": { @@ -52,5 +103,6 @@ class ProgressionProfileView(APIView): } for attempt in request.user.math_game_attempts.all()[:10] ], + "recent_matches": recent_matches, } ) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index ceb3d21..9bf9f6c 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -162,16 +162,22 @@ button { color: inherit; } .editor-pane { background: #1d2721; color: white; } .editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; } .editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; } -.preview-pane { display: flex; flex-direction: column; } -#latex-preview { margin: auto; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; } +.preview-pane { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; } +#latex-preview { align-self: center; width: 100%; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; } #latex-preview .katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; } #latex-preview.latex-preview-error { color: #b84136; font: 14px/1.7 system-ui, sans-serif; white-space: normal; } -.preview-pane button { align-self: flex-end; } +.preview-pane button { align-self: end; justify-self: end; margin-top: 24px; } .profile-panel { padding: 35px; } .metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } .metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; } .metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; } .profile-subtitle { margin: 30px 0 12px; font: 24px Georgia, serif; }.profile-game-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 9px; }.profile-game-list > div { padding: 13px; border-radius: 11px; background: #eef1eb; }.profile-game-list b, .profile-game-list span { display: block; }.profile-game-list span { margin-top: 5px; color: var(--muted); font-size: 11px; } +.home-match-history[hidden] { display: none; } +.match-history-list { display: grid; gap: 10px; } +.match-history-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 13px; background: rgba(255,255,252,.8); } +.match-history-item.result-loss { border-left-color: #c05245; }.match-history-item.result-draw { border-left-color: #9a8455; } +.match-history-item > div, .match-history-item b, .match-history-item span, .match-history-item strong { display: block; } +.match-history-item > div:last-child { text-align: right; }.match-history-item span { margin-top: 5px; color: var(--muted); font-size: 11px; }.match-history-item strong { color: var(--green); } .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 22px; } .tool-card { min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px; @@ -278,6 +284,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; } .section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell, .game-card-grid, .calc-result-grid { grid-template-columns: 1fr; } .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; } .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; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cb1a43a..c895dbf 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -178,6 +178,7 @@ function updateUserUI() { chip.append(avatar, name); $("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册"; $("#admin-entry").hidden = !state.user?.is_staff; + if (!state.user) $("#home-match-history").hidden = true; } async function loadUser() { @@ -188,6 +189,7 @@ async function loadUser() { state.user = null; } updateUserUI(); + await loadHomeMatchHistory(); } function card({ meta, title, body, foot, action, onClick, disabled = false }) { @@ -807,6 +809,15 @@ async function loadProfile() { metrics.append(item); }); root.append(heading, pet, metrics); + if (profile.recent_matches?.length) { + const matchTitle = document.createElement("h3"); + matchTitle.className = "profile-subtitle"; + matchTitle.textContent = "最近实时对局"; + const matchList = document.createElement("div"); + matchList.className = "match-history-list"; + renderMatchHistory(matchList, profile.recent_matches); + root.append(matchTitle, matchList); + } if (profile.recent_games?.length) { const gameTitle = document.createElement("h3"); gameTitle.className = "profile-subtitle"; @@ -832,6 +843,51 @@ async function loadProfile() { } } +function renderMatchHistory(root, matches) { + root.replaceChildren( + ...matches.map((match) => { + const item = document.createElement("article"); + item.className = `match-history-item result-${match.result}`; + const summary = document.createElement("div"); + const title = document.createElement("b"); + title.textContent = `${match.contest} · 对手 ${match.opponent}`; + const time = document.createElement("span"); + time.textContent = new Date(match.completed_at).toLocaleString(); + summary.append(title, time); + const result = document.createElement("div"); + const resultLabel = document.createElement("strong"); + resultLabel.textContent = + match.result === "win" ? "胜利" : match.result === "loss" ? "惜败" : "平局"; + const delta = document.createElement("span"); + const sign = match.rating_delta > 0 ? "+" : ""; + delta.textContent = `Rating ${sign}${match.rating_delta} → ${match.rating_after}`; + result.append(resultLabel, delta); + item.append(summary, result); + return item; + }) + ); +} + +async function loadHomeMatchHistory() { + const section = $("#home-match-history"); + if (!state.user) { + section.hidden = true; + return; + } + try { + const profile = await api("progression/me/"); + if (!profile.recent_matches?.length) { + section.hidden = true; + return; + } + renderMatchHistory($("#home-match-list"), profile.recent_matches); + section.hidden = false; + } catch (error) { + section.hidden = true; + if (![401, 403].includes(error.status)) console.warn(error); + } +} + const MATH_SYMBOLS = [ ["∑", "求和", "一组数或表达式的总和", "\\sum"], ["∏", "连乘", "一组数或表达式的乘积", "\\prod"], @@ -1189,6 +1245,7 @@ function bindUI() { window.HuluRealtime?.reset(); state.user = null; updateUserUI(); + if ($("#view-profile").classList.contains("active")) await loadProfile(); showToast("已退出登录"); }); $$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); @@ -1209,7 +1266,11 @@ function bindUI() { state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); $("#auth-dialog").close(); updateUserUI(); - await loadContent(); + await Promise.all([ + loadContent(), + loadHomeMatchHistory(), + $("#view-profile").classList.contains("active") ? loadProfile() : null, + ]); showToast("登录成功"); } catch (error) { $("#auth-error").textContent = error.message; @@ -1221,7 +1282,11 @@ function bindUI() { state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); $("#auth-dialog").close(); updateUserUI(); - await loadContent(); + await Promise.all([ + loadContent(), + loadHomeMatchHistory(), + $("#view-profile").classList.contains("active") ? loadProfile() : null, + ]); showToast("账号和数学档案已建立"); } catch (error) { $("#auth-error").textContent = error.message; diff --git a/backend/static/js/realtime.js b/backend/static/js/realtime.js index c0a5d94..230dff2 100644 --- a/backend/static/js/realtime.js +++ b/backend/static/js/realtime.js @@ -350,8 +350,11 @@ result.append(outcome, score, timeLine, rating); root.append(result); - // 刷新右上角用户 Rating 显示 - if (typeof loadUser === "function") loadUser(); + if (state.user) { + state.user.rating = match.result.rating_after; + updateUserUI(); + } + loadUser(); const review = document.createElement("div"); review.className = "realtime-review"; diff --git a/backend/templates/index.html b/backend/templates/index.html index 1f9df21..0711d38 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -78,6 +78,13 @@
04

直觉者

当形式尚未成形,你敢不敢追随结构感?

想象 · 洞察 · 创造
+