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直觉者
当形式尚未成形,你敢不敢追随结构感?
想象 · 洞察 · 创造
+
+
+
RECENT MATCHES
最近对局
+
+
+
+