diff --git a/.env.production.example b/.env.production.example index 08183a6..b384f0a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -2,6 +2,7 @@ # 包含 #、空格等特殊字符的值请用单引号包裹。 DJANGO_SETTINGS_MODULE=config.settings +APP_VERSION=1.2.0 DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters' DJANGO_DEBUG=false DJANGO_USE_HTTPS=true diff --git a/README.md b/README.md index 2e13fbd..042753c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ 面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。 +当前发布版本:**v1.2.0**。变更与迁移说明见 +[v1.2.0 Release Notes](docs/RELEASE_NOTES_V1.2.0.md)。 + ## 参与贡献 提交代码、内容或文档前,请先完整阅读 [代码贡献指南](CONTRIBUTING.md)。 @@ -14,13 +17,14 @@ - 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化 - 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板 - 剧情服务端存档、嵌套资源效果、结局与幂等选择 -- 入门、标准、进阶三赛道的实时 1v1、今日挑战、单人闯关、24 点和数独 +- 统一玩家池的口算、数独 Timerun、24 点实时 1v1,以及随机今日挑战和单人闯关 - 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊 - Channels WebSocket 比赛进度通道,Redis Channel Layer - LaTeX 文档与版本、六级零基础课程、练习判定 - 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流 - 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册 -- 多工具工具箱:强计算器、增强函数绘图、数学白板、几何画板、符号查询和 LaTeX Lab +- 多工具工具箱:强计算器、增强函数绘图、统一联机数学画板、符号查询和 LaTeX Lab +- 信仰者人生 20 印记、4 个组合彩蛋、4 个直博方向与独立全屏交互 - Django Admin、健康检查、请求 ID、限流与统一 API 错误结构 - MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试 diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index 87edb75..613215f 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -180,3 +180,13 @@ def test_math_life_使用独立界面印记面板且清理交叉点(): assert "function renderStoryMarks(run)" in app assert "choice.special" in app assert ".story-experience { position: fixed; inset: 0" in styles + + +def test_home_公众号入口可复制并跳转微信(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + + assert 'id="wechat-copy"' in template + assert 'href="weixin://"' in template + assert "function copyWechatName()" in app + assert 'navigator.clipboard.writeText(name)' in app diff --git a/backend/common/test_release_surface.py b/backend/common/test_release_surface.py new file mode 100644 index 0000000..15945a1 --- /dev/null +++ b/backend/common/test_release_surface.py @@ -0,0 +1,25 @@ +import pytest +from django.conf import settings + + +@pytest.mark.django_db +def test_health_包含当前发布版本(client): + response = client.get("/health/") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "database": "ok", + "version": settings.APP_VERSION, + } + + +def test_home_展示公众号入口与版本(client): + response = client.get("/") + content = response.content.decode() + + assert response.status_code == 200 + assert "公众号" in content + assert "葫芦数学" in content + assert f"Hulumath v{settings.APP_VERSION}" in content + assert 'href="weixin://"' in content diff --git a/backend/common/views.py b/backend/common/views.py index bc787c7..f5997d6 100644 --- a/backend/common/views.py +++ b/backend/common/views.py @@ -1,14 +1,21 @@ +from django.conf import settings from django.db import connection from django.http import JsonResponse from django.shortcuts import render def home(request): - return render(request, "index.html") + return render(request, "index.html", {"app_version": settings.APP_VERSION}) def health(request): with connection.cursor() as cursor: cursor.execute("SELECT 1") cursor.fetchone() - return JsonResponse({"status": "ok", "database": "ok"}) + return JsonResponse( + { + "status": "ok", + "database": "ok", + "version": settings.APP_VERSION, + } + ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 725243a..cd9d18d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured BASE_DIR = Path(__file__).resolve().parent.parent PROJECT_ROOT = BASE_DIR.parent +APP_VERSION = os.getenv("APP_VERSION", "1.2.0") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-before-production") DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" diff --git a/backend/contest/management/commands/seed_contests.py b/backend/contest/management/commands/seed_contests.py index f00476e..d639496 100644 --- a/backend/contest/management/commands/seed_contests.py +++ b/backend/contest/management/commands/seed_contests.py @@ -117,6 +117,21 @@ def _generate_advanced(): result = n * (n + 1) // 2 questions.append((f"a-auto-sum-{idx:04d}", f"1 到 {n} 的整数和", str(result))) idx += 1 + # 一元一次方程,补足稳定的 200 题池 + for solution in range(2, 60): + if idx >= 200: + break + coefficient = solution % 7 + 2 + offset = solution % 11 + 1 + total = coefficient * solution + offset + questions.append( + ( + f"a-auto-linear-{idx:04d}", + f"{coefficient}x + {offset} = {total},求 x", + str(solution), + ) + ) + idx += 1 return questions[:200] diff --git a/backend/contest/migrations/0008_fill_advanced_question_pool.py b/backend/contest/migrations/0008_fill_advanced_question_pool.py new file mode 100644 index 0000000..7eecb4a --- /dev/null +++ b/backend/contest/migrations/0008_fill_advanced_question_pool.py @@ -0,0 +1,74 @@ +from django.db import migrations + + +def fill_advanced_pool(apps, schema_editor): + Contest = apps.get_model("contest", "Contest") + ContestQuestion = apps.get_model("contest", "ContestQuestion") + Question = apps.get_model("contest", "Question") + QuestionVersion = apps.get_model("contest", "QuestionVersion") + + versions = [] + for offset, solution in enumerate(range(2, 22), start=180): + coefficient = solution % 7 + 2 + constant = solution % 11 + 1 + total = coefficient * solution + constant + question, _ = Question.objects.update_or_create( + slug=f"a-auto-linear-{offset:04d}", + defaults={ + "track": "advanced", + "tags": ["口算"], + "is_active": True, + }, + ) + version, _ = QuestionVersion.objects.update_or_create( + question=question, + version=1, + defaults={ + "prompt": f"{coefficient}x + {constant} = {total},求 x", + "answer": str(solution), + "explanation": f"答案为 {solution}", + }, + ) + versions.append(version) + + for contest in Contest.objects.filter(track="advanced").iterator(): + existing_ids = set( + ContestQuestion.objects.filter(contest=contest).values_list( + "question_version_id", + flat=True, + ) + ) + next_order = ( + ContestQuestion.objects.filter(contest=contest) + .order_by("-order") + .values_list("order", flat=True) + .first() + or 0 + ) + additions = [] + for version in versions: + if version.id in existing_ids: + continue + next_order += 1 + additions.append( + ContestQuestion( + contest_id=contest.id, + question_version_id=version.id, + order=next_order, + points=100, + ) + ) + ContestQuestion.objects.bulk_create(additions) + + +class Migration(migrations.Migration): + dependencies = [ + ("contest", "0007_remove_realtimematch_matchmaking_lookup_idx_and_more"), + ] + + operations = [ + migrations.RunPython( + fill_advanced_pool, + migrations.RunPython.noop, + ), + ] diff --git a/backend/contest/test_seed_contests.py b/backend/contest/test_seed_contests.py new file mode 100644 index 0000000..aaa53f3 --- /dev/null +++ b/backend/contest/test_seed_contests.py @@ -0,0 +1,8 @@ +from contest.management.commands.seed_contests import QUESTIONS +from contest.models import Question + + +def test_seed_question_pool_三个历史赛道题量充足(): + assert len(QUESTIONS[Question.Track.BEGINNER]) == 400 + assert len(QUESTIONS[Question.Track.STANDARD]) == 400 + assert len(QUESTIONS[Question.Track.ADVANCED]) == 200 diff --git a/backend/static/css/app.css b/backend/static/css/app.css index d00107c..59867e6 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -50,6 +50,7 @@ button { color: inherit; } .sidebar-foot { margin-top: auto; display: grid; gap: 15px; } .system-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; } .system-status i { width: 7px; height: 7px; margin-right: 7px; display: inline-block; border-radius: 50%; background: #5bb67c; box-shadow: 0 0 0 4px rgba(91,182,124,.13); } +.app-version { color: var(--muted); font-size: 9px; letter-spacing: .1em; } .ghost-button, .text-button { background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; cursor: pointer; } @@ -79,6 +80,7 @@ button { color: inherit; } content: ""; position: absolute; width: 330px; height: 330px; right: -100px; bottom: -150px; border: 55px solid rgba(25,101,72,.09); border-radius: 50%; } +.wechat-entry { display: flex; align-items: center; justify-content: space-between; gap: 28px; margin-top: 18px; padding: 28px 32px; border: 1px solid rgba(25,101,72,.16); border-radius: 20px; background: linear-gradient(120deg, rgba(25,101,72,.08), rgba(204,232,91,.18)); }.wechat-entry h2 { margin: 8px 0; font: 28px Georgia, serif; }.wechat-entry p { margin: 0; color: var(--muted); line-height: 1.7; }.wechat-entry-actions { display: flex; gap: 9px; flex-shrink: 0; }.wechat-entry-actions a { display: inline-flex; align-items: center; text-decoration: none; } .kicker { color: var(--green); font-size: 10px; font-weight: 700; letter-spacing: .24em; } .hero h1, .page-title h1 { margin: 20px 0 18px; font: 500 clamp(42px, 5vw, 76px)/1.03 Georgia, "Songti SC", serif; letter-spacing: -.04em; } .hero h1 em { color: var(--green); font-weight: inherit; } @@ -307,6 +309,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .match-mode-switch { grid-template-columns: 1fr; } .board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; } .story-experience { padding: 14px 14px 40px; }.story-experience-header { align-items: flex-start; }.story-experience-header .ghost-button { width: auto; padding: 9px 11px; font-size: 10px; }.story-chapter-bar, .story-experience-layout { grid-template-columns: 1fr; }.story-scene-panel { min-height: 0; padding: 25px 20px; }.story-scene-panel .scene { min-height: 120px; }.story-mark-panel { position: static; }.story-domain-counts { grid-template-columns: repeat(2, 1fr); } + .wechat-entry { align-items: flex-start; flex-direction: column; padding: 24px; }.wechat-entry-actions { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }.wechat-entry-actions > * { justify-content: center; text-align: center; } .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 4ce7814..2e623a6 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -148,6 +148,16 @@ function showToast(message) { window.setTimeout(() => toast.classList.remove("show"), 2600); } +async function copyWechatName() { + const name = $("#wechat-copy").dataset.wechatName; + try { + await navigator.clipboard.writeText(name); + showToast(`已复制公众号名称:${name}`); + } catch { + showToast(`请在微信内搜索:${name}`); + } +} + function navigate(view) { $$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view)); $$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`)); @@ -1273,6 +1283,7 @@ function bindUI() { $$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump))); $$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth)); $("#start-mathbti").addEventListener("click", startMathBTI); + $("#wechat-copy").addEventListener("click", copyWechatName); $("#story-experience-close").addEventListener("click", closeStoryExperience); $("#save-formula").addEventListener("click", saveFormula); $("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); }); diff --git a/backend/templates/index.html b/backend/templates/index.html index 376692b..69b2f1a 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -28,6 +28,7 @@
@@ -66,6 +67,18 @@ +微信内搜索公众号「葫芦数学」,获取新剧情、比赛活动与版本公告。
+