fix: complete v1.2 account and profile P0 fixes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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/"
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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 题。"""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
<article class="spirit intuitive"><b>04</b><h3>直觉者</h3><p>当形式尚未成形,你敢不敢追随结构感?</p><span>想象 · 洞察 · 创造</span></article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section home-match-history" id="home-match-history" hidden>
|
||||
<div class="section-heading">
|
||||
<div><span class="kicker">RECENT MATCHES</span><h2>最近对局</h2></div>
|
||||
<button class="text-button" data-jump="profile">查看完整数学档案</button>
|
||||
</div>
|
||||
<div id="home-match-list" class="match-history-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-life">
|
||||
|
||||
Reference in New Issue
Block a user