diff --git a/.env.example b/.env.example index 33807b2..761e765 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DATABASE_URL=mysql://hulumath:hulumath@127.0.0.1:3307/hulumath REDIS_URL=redis://localhost:6379/0 CORS_ALLOWED_ORIGINS=http://localhost:3000 +CALCULATOR_RATE=30/minute diff --git a/.env.production.example b/.env.production.example index 5f98d53..08183a6 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,6 +14,7 @@ CORS_ALLOWED_ORIGINS=https://math.example.com CSRF_TRUSTED_ORIGINS=https://math.example.com API_ANON_RATE=120/minute API_USER_RATE=600/minute +CALCULATOR_RATE=30/minute SECURE_HSTS_SECONDS=31536000 # 仅供部署脚本直接访问 127.0.0.1:8000 时设置 Host 头。 diff --git a/Makefile b/Makefile index 10d126d..d8ed05c 100644 --- a/Makefile +++ b/Makefile @@ -18,5 +18,6 @@ test: .venv/bin/python -m pytest -q check: + .venv/bin/ruff check backend scripts cd backend && ../.venv/bin/python manage.py check cd backend && ../.venv/bin/python manage.py makemigrations --check --dry-run diff --git a/README.md b/README.md index 4c49c0b..997acec 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ - 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化 - 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板 - 剧情服务端存档、嵌套资源效果、结局与幂等选择 -- 入门、标准、进阶三赛道的实时 1v1、今日挑战和单人闯关 +- 入门、标准、进阶三赛道的实时 1v1、今日挑战、单人闯关、24 点和数独 - 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊 - Channels WebSocket 比赛进度通道,Redis Channel Layer - LaTeX 文档与版本、六级零基础课程、练习判定 - 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流 - 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册 -- 多工具工具箱:口算入口、科学计算器、符号查询、函数绘图和 LaTeX Lab +- 多工具工具箱:强计算器、增强函数绘图、数学白板、几何画板、符号查询和 LaTeX Lab - Django Admin、健康检查、请求 ID、限流与统一 API 错误结构 - MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试 @@ -88,6 +88,7 @@ backend/ ├── progression/ # 五维能力、精灵、卡牌、奖励 ├── content/ # 视频、知识卡片、人物内容 ├── engagement/ # 签到与通知 +├── toolbox/ # 受限数学计算内核与工具 API ├── common/ # 健康检查、错误、日志 └── config/ # Django/ASGI 配置 ``` diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index fd44998..b5719ea 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -34,3 +34,24 @@ def test_admin_css_深色系统下仍保持完整浅色主题(): assert "background-color: #ffffff !important" in source assert ".theme-toggle" in source assert "display: none" in source + + +def test_toolbox_and_games_资源入口与移动端触控样式存在(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + toolbox = (STATIC_ROOT / "js" / "toolbox.js").read_text(encoding="utf-8") + games = (STATIC_ROOT / "js" / "games.js").read_text(encoding="utf-8") + styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert "js/toolbox.js" in template + assert "js/games.js" in template + assert 'id="whiteboard-canvas"' in template + assert 'id="geometry-canvas"' in template + assert 'id="math-game-list"' in template + assert "window.HuluToolbox" in toolbox + assert "pointerdown" in toolbox + assert "window.HuluGames" in games + assert "sudoku-board" in games + assert "touch-action: none" in styles + assert ".calculator-controls [hidden]" in styles + assert ".sudoku-board" in styles + assert "@media (max-width: 700px)" in styles diff --git a/backend/config/settings.py b/backend/config/settings.py index 509f7b3..725243a 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ "latex_lab", "content", "engagement", + "toolbox", "common", ] @@ -142,6 +143,7 @@ REST_FRAMEWORK = { "DEFAULT_THROTTLE_RATES": { "anon": os.getenv("API_ANON_RATE", "120/minute"), "user": os.getenv("API_USER_RATE", "600/minute"), + "calculator": os.getenv("CALCULATOR_RATE", "30/minute"), }, } diff --git a/backend/config/urls.py b/backend/config/urls.py index 3a6c2e1..e4dccb6 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -17,4 +17,5 @@ urlpatterns = [ path("api/v1/latex/", include("latex_lab.urls")), path("api/v1/content/", include("content.urls")), path("api/v1/progression/", include("progression.urls")), + path("api/v1/toolbox/", include("toolbox.urls")), ] diff --git a/backend/contest/admin.py b/backend/contest/admin.py index 9ba5564..140d575 100644 --- a/backend/contest/admin.py +++ b/backend/contest/admin.py @@ -7,6 +7,7 @@ from .models import ( ContestAttempt, ContestQuestion, LeaderboardSnapshot, + MathGameAttempt, Question, QuestionVersion, RatingHistory, @@ -64,3 +65,21 @@ admin.site.register(ContestAnswer) admin.site.register(RealtimeMatch) admin.site.register(RatingHistory) admin.site.register(LeaderboardSnapshot) + + +@admin.register(MathGameAttempt) +class MathGameAttemptAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "kind", + "difficulty", + "status", + "score", + "hints_used", + "started_at", + ) + list_filter = ("kind", "difficulty", "status") + search_fields = ("user__username", "user__nickname") + readonly_fields = ("puzzle", "solution", "submission", "started_at", "submitted_at") + ordering = ("-started_at",) diff --git a/backend/contest/game_services.py b/backend/contest/game_services.py new file mode 100644 index 0000000..aaa2549 --- /dev/null +++ b/backend/contest/game_services.py @@ -0,0 +1,220 @@ +import ast +import secrets +from collections import Counter +from fractions import Fraction + +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from .models import MathGameAttempt + +SUDOKU_PUZZLES = { + MathGameAttempt.Difficulty.EASY: [ + ( + "530070000600195000098000060800060003400803001700020006060000280000419005000080079", + "534678912672195348198342567859761423426853791713924856961537284287419635345286179", + ), + ], + MathGameAttempt.Difficulty.STANDARD: [ + ( + "000260701680070090190004500820100040004602900050003028009300074040050036703018000", + "435269781682571493197834562826195347374682915951743628519326874248957136763418259", + ), + ], + MathGameAttempt.Difficulty.HARD: [ + ( + "000000010400000000020000000000050407008000300001090000300400200050100000000806000", + "693784512487512936125963874932651487568247391741398625319475268856129743274836159", + ), + ], +} + +TWENTY_FOUR_PUZZLES = { + MathGameAttempt.Difficulty.EASY: [(3, 3, 8, 8), (1, 3, 4, 6), (4, 4, 10, 10)], + MathGameAttempt.Difficulty.STANDARD: [(2, 3, 4, 9), (3, 5, 7, 13), (4, 7, 8, 8)], + MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)], +} + + +def _grid_from_text(value): + return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)] + + +def game_payload(attempt): + return { + "attempt_id": attempt.id, + "kind": attempt.kind, + "difficulty": attempt.difficulty, + "status": attempt.status, + "puzzle": attempt.puzzle, + "score": attempt.score, + "duration_ms": attempt.duration_ms, + "hints_used": attempt.hints_used, + "started_at": attempt.started_at, + } + + +def start_game(user, kind, difficulty): + if kind not in MathGameAttempt.Kind.values: + raise ValidationError({"kind": "不支持的数学玩法"}) + if difficulty not in MathGameAttempt.Difficulty.values: + raise ValidationError({"difficulty": "不支持的难度"}) + if kind == MathGameAttempt.Kind.SUDOKU: + puzzle_text, solution_text = secrets.choice(SUDOKU_PUZZLES[difficulty]) + puzzle = {"grid": _grid_from_text(puzzle_text), "hints": []} + solution = {"grid": _grid_from_text(solution_text)} + else: + numbers = list(secrets.choice(TWENTY_FOUR_PUZZLES[difficulty])) + secrets.SystemRandom().shuffle(numbers) + puzzle = {"numbers": numbers} + solution = {"target": 24} + attempt = MathGameAttempt.objects.create( + user=user, + kind=kind, + difficulty=difficulty, + puzzle=puzzle, + solution=solution, + ) + return game_payload(attempt) + + +def _validate_twenty_four_expression(source, numbers): + source = str(source or "").strip() + if not source or len(source) > 120: + raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"}) + try: + tree = ast.parse(source, mode="eval") + except SyntaxError as exc: + raise ValidationError({"expression": "表达式语法无效"}) from exc + used = [] + + def evaluate(node): + if isinstance(node, ast.Constant) and isinstance(node.value, int): + used.append(node.value) + return Fraction(node.value) + if isinstance(node, ast.BinOp) and isinstance( + node.op, + (ast.Add, ast.Sub, ast.Mult, ast.Div), + ): + left = evaluate(node.left) + right = evaluate(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if right == 0: + raise ValidationError({"expression": "不能除以零"}) + return left / right + raise ValidationError({"expression": "只允许题目数字、括号和 + - * /"}) + + result = evaluate(tree.body) + if Counter(used) != Counter(numbers): + raise ValidationError({"expression": "必须且只能使用题目给出的四个数字各一次"}) + if result != 24: + raise ValidationError({"expression": f"当前结果是 {result},还没有得到 24"}) + return source + + +def _validate_sudoku_grid(raw_grid, puzzle, solution): + if ( + not isinstance(raw_grid, list) + or len(raw_grid) != 9 + or any(not isinstance(row, list) or len(row) != 9 for row in raw_grid) + ): + raise ValidationError({"grid": "数独答案必须是 9×9 网格"}) + try: + grid = [[int(value) for value in row] for row in raw_grid] + except (TypeError, ValueError) as exc: + raise ValidationError({"grid": "每个格子必须填写 1 到 9"}) from exc + if any(value < 1 or value > 9 for row in grid for value in row): + raise ValidationError({"grid": "每个格子必须填写 1 到 9"}) + givens = puzzle["grid"] + for row in range(9): + for column in range(9): + if givens[row][column] and grid[row][column] != givens[row][column]: + raise ValidationError({"grid": "不能修改题目给出的数字"}) + if grid != solution["grid"]: + raise ValidationError({"grid": "答案尚未满足全部行、列和九宫格"}) + return grid + + +@transaction.atomic +def submit_game(user, attempt_id, submission, submission_key): + attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user) + if attempt.status == MathGameAttempt.Status.COMPLETED: + if submission_key and attempt.submission_key == submission_key: + return game_payload(attempt) + raise ValidationError("这局游戏已经完成") + if not submission_key: + raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"}) + + if attempt.kind == MathGameAttempt.Kind.SUDOKU: + normalized = { + "grid": _validate_sudoku_grid( + submission.get("grid"), + attempt.puzzle, + attempt.solution, + ) + } + else: + normalized = { + "expression": _validate_twenty_four_expression( + submission.get("expression"), + attempt.puzzle["numbers"], + ) + } + + now = timezone.now() + duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000)) + base_score = 1800 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000 + time_penalty = duration_ms // (2000 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000) + attempt.submission = normalized + attempt.status = MathGameAttempt.Status.COMPLETED + attempt.duration_ms = duration_ms + attempt.score = max(100, base_score - time_penalty - attempt.hints_used * 150) + attempt.submission_key = submission_key + attempt.submitted_at = now + attempt.save( + update_fields=[ + "submission", + "status", + "duration_ms", + "score", + "submission_key", + "submitted_at", + ] + ) + return game_payload(attempt) + + +@transaction.atomic +def request_sudoku_hint(user, attempt_id): + attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user) + if attempt.kind != MathGameAttempt.Kind.SUDOKU: + raise ValidationError("只有数独支持提示") + if attempt.status != MathGameAttempt.Status.ACTIVE: + raise ValidationError("这局游戏已经结束") + hints = list(attempt.puzzle.get("hints", [])) + if len(hints) >= 3: + raise ValidationError("每局最多使用 3 次提示") + candidates = [ + (row, column) + for row in range(9) + for column in range(9) + if attempt.puzzle["grid"][row][column] == 0 + and not any(item["row"] == row and item["column"] == column for item in hints) + ] + row, column = secrets.choice(candidates) + hint = { + "row": row, + "column": column, + "value": attempt.solution["grid"][row][column], + } + hints.append(hint) + attempt.puzzle = {**attempt.puzzle, "hints": hints} + attempt.hints_used = len(hints) + attempt.save(update_fields=["puzzle", "hints_used"]) + return {**hint, "hints_used": attempt.hints_used} diff --git a/backend/contest/migrations/0003_mathgameattempt_and_more.py b/backend/contest/migrations/0003_mathgameattempt_and_more.py new file mode 100644 index 0000000..ed9b817 --- /dev/null +++ b/backend/contest/migrations/0003_mathgameattempt_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.23 on 2026-08-08 17:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contest', '0002_realtimematch_matchmaking_lookup_idx'), + ] + + operations = [ + migrations.CreateModel( + name='MathGameAttempt', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('sudoku', '数独'), ('twenty_four', '24 点')], max_length=20)), + ('difficulty', models.CharField(choices=[('easy', '入门'), ('standard', '标准'), ('hard', '进阶')], default='standard', max_length=16)), + ('puzzle', models.JSONField(default=dict)), + ('solution', models.JSONField(default=dict)), + ('submission', models.JSONField(blank=True, default=dict)), + ('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('failed', '未通过')], default='active', max_length=16)), + ('score', models.PositiveIntegerField(default=0)), + ('duration_ms', models.PositiveIntegerField(default=0)), + ('hints_used', models.PositiveSmallIntegerField(default=0)), + ('submission_key', models.CharField(blank=True, max_length=80, null=True)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='math_game_attempts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-started_at'], + 'indexes': [models.Index(fields=['kind', 'status', '-score', 'duration_ms'], name='math_game_ranking_idx')], + }, + ), + migrations.AddConstraint( + model_name='mathgameattempt', + constraint=models.UniqueConstraint(fields=('user', 'submission_key'), name='unique_user_math_game_submission'), + ), + ] diff --git a/backend/contest/models.py b/backend/contest/models.py index 670e967..9ac860e 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -192,3 +192,57 @@ class CheatFlag(models.Model): evidence = models.JSONField(default=dict) status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) created_at = models.DateTimeField(auto_now_add=True) + + +class MathGameAttempt(models.Model): + class Kind(models.TextChoices): + SUDOKU = "sudoku", "数独" + TWENTY_FOUR = "twenty_four", "24 点" + + class Difficulty(models.TextChoices): + EASY = "easy", "入门" + STANDARD = "standard", "标准" + HARD = "hard", "进阶" + + class Status(models.TextChoices): + ACTIVE = "active", "进行中" + COMPLETED = "completed", "已完成" + FAILED = "failed", "未通过" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="math_game_attempts", + ) + kind = models.CharField(max_length=20, choices=Kind.choices) + difficulty = models.CharField( + max_length=16, + choices=Difficulty.choices, + default=Difficulty.STANDARD, + ) + puzzle = models.JSONField(default=dict) + solution = models.JSONField(default=dict) + submission = models.JSONField(default=dict, blank=True) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE) + score = models.PositiveIntegerField(default=0) + duration_ms = models.PositiveIntegerField(default=0) + hints_used = models.PositiveSmallIntegerField(default=0) + submission_key = models.CharField(max_length=80, null=True, blank=True) + started_at = models.DateTimeField(auto_now_add=True) + submitted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("user", "submission_key"), + name="unique_user_math_game_submission", + ) + ] + indexes = [ + models.Index( + fields=("kind", "status", "-score", "duration_ms"), + name="math_game_ranking_idx", + ) + ] + ordering = ["-started_at"] diff --git a/backend/contest/test_game_services.py b/backend/contest/test_game_services.py new file mode 100644 index 0000000..588e768 --- /dev/null +++ b/backend/contest/test_game_services.py @@ -0,0 +1,121 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from accounts.models import User +from contest.game_services import ( + SUDOKU_PUZZLES, + request_sudoku_hint, + start_game, + submit_game, +) +from contest.models import MathGameAttempt + + +@pytest.fixture +def game_user(db): + return User.objects.create_user( + username="game_user", + password="StrongPass_2026", + nickname="游戏玩家", + ) + + +@pytest.mark.django_db +def test_twenty_four_服务端校验数字使用与幂等提交(game_user): + attempt = MathGameAttempt.objects.create( + user=game_user, + kind=MathGameAttempt.Kind.TWENTY_FOUR, + puzzle={"numbers": [1, 3, 4, 6]}, + solution={"target": 24}, + ) + + result = submit_game( + game_user, + attempt.id, + {"expression": "6 / (1 - 3 / 4)"}, + "game-submit-1", + ) + replay = submit_game( + game_user, + attempt.id, + {"expression": "1 + 3 + 4 + 6"}, + "game-submit-1", + ) + + assert result["status"] == MathGameAttempt.Status.COMPLETED + assert result["score"] >= 100 + assert replay["score"] == result["score"] + + +@pytest.mark.django_db +def test_twenty_four_拒绝额外数字和非四则表达式(game_user): + attempt = MathGameAttempt.objects.create( + user=game_user, + kind=MathGameAttempt.Kind.TWENTY_FOUR, + puzzle={"numbers": [1, 3, 4, 6]}, + solution={"target": 24}, + ) + + with pytest.raises(ValidationError, match="四个数字"): + submit_game( + game_user, + attempt.id, + {"expression": "6 / (1 - 3 / 4) + 24 - 24"}, + "invalid-extra-number", + ) + with pytest.raises(ValidationError, match="只允许"): + submit_game( + game_user, + attempt.id, + {"expression": "pow(2, 3) * 3"}, + "invalid-function", + ) + + +@pytest.mark.django_db +def test_sudoku_提示受限并由服务端校验完整答案(game_user): + payload = start_game( + game_user, + MathGameAttempt.Kind.SUDOKU, + MathGameAttempt.Difficulty.EASY, + ) + attempt = MathGameAttempt.objects.get(id=payload["attempt_id"]) + hint = request_sudoku_hint(game_user, attempt.id) + solution_text = SUDOKU_PUZZLES[MathGameAttempt.Difficulty.EASY][0][1] + solution = [ + [int(solution_text[row * 9 + column]) for column in range(9)] + for row in range(9) + ] + + result = submit_game( + game_user, + attempt.id, + {"grid": solution}, + "sudoku-submit-1", + ) + + assert hint["value"] == solution[hint["row"]][hint["column"]] + assert result["status"] == MathGameAttempt.Status.COMPLETED + assert result["hints_used"] == 1 + + +@pytest.mark.django_db +def test_math_game_api_目录公开但开局需要登录(client, game_user): + catalog = client.get("/api/v1/contests/games/") + anonymous_start = client.post( + "/api/v1/contests/games/twenty_four/start/", + {"difficulty": "easy"}, + content_type="application/json", + ) + client.force_login(game_user) + authenticated_start = client.post( + "/api/v1/contests/games/twenty_four/start/", + {"difficulty": "easy"}, + content_type="application/json", + ) + + assert catalog.status_code == 200 + assert {item["kind"] for item in catalog.json()} == {"sudoku", "twenty_four"} + assert anonymous_start.status_code in {401, 403} + assert authenticated_start.status_code == 201 + assert "solution" not in authenticated_start.json() diff --git a/backend/contest/urls.py b/backend/contest/urls.py index dcf9ae5..a9f664d 100644 --- a/backend/contest/urls.py +++ b/backend/contest/urls.py @@ -7,6 +7,11 @@ from .views import ( LeaderboardView, MatchmakingView, MatchStateView, + MathGameCatalogView, + MathGameHistoryView, + MathGameStartView, + MathGameSubmitView, + SudokuHintView, ) urlpatterns = [ @@ -16,4 +21,17 @@ urlpatterns = [ path("/leaderboard/", LeaderboardView.as_view(), name="leaderboard"), path("attempts//submit/", AttemptSubmitView.as_view(), name="attempt-submit"), path("matches//", MatchStateView.as_view(), name="match-state"), + path("games/", MathGameCatalogView.as_view(), name="math-game-catalog"), + path("games/history/", MathGameHistoryView.as_view(), name="math-game-history"), + path("games//start/", MathGameStartView.as_view(), name="math-game-start"), + path( + "games/attempts//submit/", + MathGameSubmitView.as_view(), + name="math-game-submit", + ), + path( + "games/attempts//hint/", + SudokuHintView.as_view(), + name="sudoku-hint", + ), ] diff --git a/backend/contest/views.py b/backend/contest/views.py index 04ef6de..f9ab9cb 100644 --- a/backend/contest/views.py +++ b/backend/contest/views.py @@ -3,7 +3,8 @@ from rest_framework import permissions, status from rest_framework.response import Response from rest_framework.views import APIView -from .models import Contest, ContestAttempt, RealtimeMatch +from .game_services import game_payload, request_sudoku_hint, start_game, submit_game +from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch from .services import ( find_match, match_payload, @@ -98,3 +99,59 @@ class LeaderboardView(APIView): for index, attempt in enumerate(attempts, start=1) ] ) + + +class MathGameCatalogView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + return Response( + [ + { + "kind": MathGameAttempt.Kind.TWENTY_FOUR, + "title": "24 点", + "summary": "四个数字各用一次,只用四则运算得到 24。", + "ability": "connection", + "estimated_minutes": 3, + }, + { + "kind": MathGameAttempt.Kind.SUDOKU, + "title": "数独", + "summary": "在行、列和九宫格约束中完成 9×9 数字推理。", + "ability": "detection", + "estimated_minutes": 8, + }, + ] + ) + + +class MathGameStartView(APIView): + def post(self, request, kind): + payload = start_game( + request.user, + kind, + request.data.get("difficulty", MathGameAttempt.Difficulty.STANDARD), + ) + return Response(payload, status=status.HTTP_201_CREATED) + + +class MathGameSubmitView(APIView): + def post(self, request, attempt_id): + payload = submit_game( + request.user, + attempt_id, + request.data, + request.headers.get("Idempotency-Key"), + ) + return Response(payload) + + +class SudokuHintView(APIView): + def post(self, request, attempt_id): + return Response(request_sudoku_hint(request.user, attempt_id)) + + +class MathGameHistoryView(APIView): + def get(self, request): + attempts = MathGameAttempt.objects.filter(user=request.user)[:20] + return Response([game_payload(attempt) for attempt in attempts]) diff --git a/backend/progression/views.py b/backend/progression/views.py index 3d20205..b33b81e 100644 --- a/backend/progression/views.py +++ b/backend/progression/views.py @@ -40,5 +40,17 @@ class ProgressionProfileView(APIView): } for item in request.user.cards.select_related("card") ], + "recent_games": [ + { + "kind": attempt.kind, + "label": attempt.get_kind_display(), + "difficulty": attempt.get_difficulty_display(), + "status": attempt.status, + "score": attempt.score, + "duration_ms": attempt.duration_ms, + "started_at": attempt.started_at, + } + for attempt in request.user.math_game_attempts.all()[:10] + ], } ) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index a4108c7..c729308 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -132,6 +132,21 @@ button { color: inherit; } .track-switch { display: flex; gap: 5px; margin-bottom: 22px; } .track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; } .track-switch button.active { background: var(--ink); color: white; } +.math-games-section { margin-top: 60px; } +.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; } +.game-card::after { content: ""; position: absolute; width: 140px; height: 140px; right: -65px; top: -65px; border-radius: 50%; background: rgba(204,232,91,.18); } +.game-sudoku::after { background: rgba(93,115,232,.13); } +.game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); } +.game-card > b { margin-top: 24px; color: var(--green); font-size: 9px; letter-spacing: .18em; }.game-card h3 { margin: 8px 0; font: 28px Georgia, serif; }.game-card p { margin: 0; color: var(--muted); line-height: 1.7; } +.game-card-controls { display: flex; gap: 10px; margin-top: auto; }.game-card-controls select { min-width: 100px; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }.game-card-controls .primary-button { margin-left: auto; } +.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button { aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; } +.twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; } +.sudoku-board { width: min(100%, 540px); margin: 22px auto; display: grid; grid-template-columns: repeat(9, 1fr); border: 3px solid var(--ink); background: var(--ink); gap: 1px; } +.sudoku-board input { width: 100%; min-width: 0; aspect-ratio: 1; border: 0; border-radius: 0; background: white; color: var(--green); text-align: center; font: 600 21px Georgia, serif; outline: 2px solid transparent; outline-offset: -2px; } +.sudoku-board input:nth-child(3n) { border-right: 2px solid var(--ink); }.sudoku-board input:nth-child(9n) { border-right: 0; }.sudoku-board input:nth-child(n+19):nth-child(-n+27), .sudoku-board input:nth-child(n+46):nth-child(-n+54) { border-bottom: 2px solid var(--ink); } +.sudoku-board input:focus { outline-color: var(--green); }.sudoku-board input.given { background: #ecece5; color: var(--ink); font-weight: 800; }.sudoku-board input.hint { background: #fff2ca; color: #9a5b22; } +.sudoku-actions { display: flex; justify-content: flex-end; gap: 10px; }.game-result-panel { margin-top: 30px; padding: 30px; border-radius: 18px; background: var(--ink); color: white; text-align: center; }.game-result-panel strong { color: var(--lime); font: 48px Georgia, serif; }.game-result-panel p { color: #b9c2bc; } .editor-shell { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; min-height: 430px; } .editor-pane, .preview-pane { padding: 28px; } .editor-pane { background: #1d2721; color: white; } @@ -144,7 +159,8 @@ button { color: inherit; } .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; } -.tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 22px; } +.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; } +.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; background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease; @@ -159,19 +175,29 @@ button { color: inherit; } .tool-workspace.active { display: block; animation: rise .3s ease; } .workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; } .workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; } -.calculator-shell { max-width: 800px; margin: auto; } +.calculator-shell { max-width: 980px; margin: auto; } .calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; } .calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; } .formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); } .calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; } .calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; } +.calculator-controls { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }.calculator-controls label, .calculator-expression-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }.calculator-controls select, .calculator-controls input { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; } +.calculator-controls [hidden] { display: none !important; } +.calculator-expression-label textarea { min-height: 92px; margin: 0 0 12px; resize: vertical; font: 17px/1.6 "SFMono-Regular", Consolas, monospace; } +.calculator-result { margin-top: 17px; }.calc-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }.calc-result-grid > div { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: white; }.calc-result-grid span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }.calc-result-grid code { display: block; margin-top: 8px; overflow-wrap: anywhere; color: var(--green); } +.calc-steps { margin: 13px 0 0; padding: 14px 14px 14px 34px; border-radius: 12px; background: #eef1eb; color: var(--muted); font-size: 12px; line-height: 1.8; }.calc-steps:empty { display: none; } .search-input { width: min(370px, 100%); } .symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; } .symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; } .graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; } -.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; } +.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; } +.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; } +.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; } #graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; } +.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; } +.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; } +.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; } .discover-title { padding-bottom: 24px; } .ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; } .map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; } @@ -225,16 +251,17 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; } .nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; } .hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; } - .tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; } + .tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 700px) { .app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; } .brand, .sidebar-foot { display: none; }.nav { margin: 0; grid-template-columns: repeat(6, 1fr); gap: 2px; }.nav-item { padding: 11px 2px; font-size: 0; }.nav-item span { display: block; margin: auto; } .main { padding: 0 18px 90px; }.topbar { height: 66px; }.topbar p { max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .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 { grid-template-columns: 1fr; } + .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; } - .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 20px; } + .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; } .map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; } .map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; } } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 18c20a4..09cfbe3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -638,6 +638,26 @@ async function loadProfile() { metrics.append(item); }); root.append(heading, pet, metrics); + if (profile.recent_games?.length) { + const gameTitle = document.createElement("h3"); + gameTitle.className = "profile-subtitle"; + gameTitle.textContent = "最近数学玩法"; + const gameList = document.createElement("div"); + gameList.className = "profile-game-list"; + profile.recent_games.forEach((game) => { + const item = document.createElement("div"); + const name = document.createElement("b"); + name.textContent = `${game.label} · ${game.difficulty}`; + const detail = document.createElement("span"); + detail.textContent = + game.status === "completed" + ? `${game.score} 分 · ${(game.duration_ms / 1000).toFixed(1)} 秒` + : "进行中"; + item.append(name, detail); + gameList.append(item); + }); + root.append(gameTitle, gameList); + } } catch (error) { root.textContent = error.message; } @@ -673,10 +693,20 @@ function evaluateExpression(source, variables = {}) { sin: Math.sin, cos: Math.cos, tan: Math.tan, + asin: Math.asin, + acos: Math.acos, + atan: Math.atan, + sinh: Math.sinh, + cosh: Math.cosh, + tanh: Math.tanh, sqrt: Math.sqrt, log: Math.log10, ln: Math.log, + exp: Math.exp, abs: Math.abs, + floor: Math.floor, + ceil: Math.ceil, + round: Math.round, }; const skip = () => { @@ -880,7 +910,7 @@ function switchTool(tool) { $$(".tool-workspace").forEach((workspace) => { workspace.classList.toggle("active", workspace.id === `tool-${tool}`); }); - if (tool === "graph") window.setTimeout(drawGraph, 30); + window.HuluToolbox?.activate(tool); } async function saveFormula() { @@ -906,24 +936,9 @@ function bindUI() { $$(".tool-card").forEach((button) => { button.addEventListener("click", () => switchTool(button.dataset.tool)); }); - $("#calc-run").addEventListener("click", runCalculator); - $("#calc-input").addEventListener("keydown", (event) => { - if (event.key === "Enter") runCalculator(); - }); - $$("[data-calc-example]").forEach((button) => { - button.addEventListener("click", () => { - $("#calc-input").value = button.dataset.calcExample; - runCalculator(); - }); - }); $("#symbol-search").addEventListener("input", (event) => { renderSymbols(event.target.value); }); - $("#graph-run").addEventListener("click", drawGraph); - $("#graph-range").addEventListener("input", drawGraph); - $("#graph-expression").addEventListener("keydown", (event) => { - if (event.key === "Enter") drawGraph(); - }); $$(".ability-node").forEach((button) => { button.addEventListener("click", () => { state.videoAbility = @@ -980,9 +995,15 @@ function bindUI() { async function boot() { bindUI(); + window.HuluToolbox?.init(); renderSymbols(); - runCalculator(); - await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]); + await Promise.all([ + loadUser(), + loadStories(), + loadContests(), + loadContent(), + window.HuluGames?.load(), + ]); } boot(); diff --git a/backend/static/js/games.js b/backend/static/js/games.js new file mode 100644 index 0000000..ee153b2 --- /dev/null +++ b/backend/static/js/games.js @@ -0,0 +1,295 @@ +(function () { + const $game = (selector, root = document) => root.querySelector(selector); + + const GAME_META = { + twenty_four: { + title: "24 点", + kicker: "ARITHMETIC PUZZLE", + action: "开始组式", + }, + sudoku: { + title: "数独", + kicker: "LOGIC GRID", + action: "开始推理", + }, + }; + + function difficultySelect() { + const select = document.createElement("select"); + select.className = "game-difficulty"; + [ + ["easy", "入门"], + ["standard", "标准"], + ["hard", "进阶"], + ].forEach(([value, label]) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + if (value === "standard") option.selected = true; + select.append(option); + }); + return select; + } + + function gameCard(game) { + const meta = GAME_META[game.kind]; + const article = document.createElement("article"); + article.className = `game-card game-${game.kind}`; + const top = document.createElement("div"); + top.className = "game-card-top"; + const icon = document.createElement("span"); + icon.textContent = game.kind === "sudoku" ? "9×9" : "24"; + const time = document.createElement("small"); + time.textContent = `约 ${game.estimated_minutes} 分钟`; + top.append(icon, time); + const kicker = document.createElement("b"); + kicker.textContent = meta.kicker; + const title = document.createElement("h3"); + title.textContent = game.title; + const summary = document.createElement("p"); + summary.textContent = game.summary; + const controls = document.createElement("div"); + controls.className = "game-card-controls"; + const difficulty = difficultySelect(); + const start = document.createElement("button"); + start.className = "primary-button"; + start.textContent = meta.action; + start.addEventListener("click", () => startGame(game.kind, difficulty.value)); + controls.append(difficulty, start); + article.append(top, kicker, title, summary, controls); + return article; + } + + async function loadGames() { + const root = $game("#math-game-list"); + try { + const games = await api("contests/games/"); + root.replaceChildren(...games.map(gameCard)); + } catch (error) { + root.textContent = error.message; + } + } + + async function startGame(kind, difficulty) { + if (!requireAuth()) return; + try { + const attempt = await api(`contests/games/${kind}/start/`, { + method: "POST", + body: { difficulty }, + }); + renderGame(attempt); + $game("#experience-dialog").showModal(); + } catch (error) { + showToast(error.message); + } + } + + function gameHeading(attempt) { + const fragment = document.createDocumentFragment(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = + `${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`; + const title = document.createElement("h2"); + title.textContent = GAME_META[attempt.kind].title; + fragment.append(label, title); + return fragment; + } + + function resultPanel(attempt) { + const panel = document.createElement("div"); + panel.className = "game-result-panel"; + const score = document.createElement("strong"); + score.textContent = `${attempt.score} 分`; + const details = document.createElement("p"); + details.textContent = + `用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒 · 提示 ${attempt.hints_used} 次`; + panel.append(score, details); + return panel; + } + + function renderGame(attempt) { + if (attempt.kind === "sudoku") renderSudoku(attempt); + else renderTwentyFour(attempt); + } + + function renderTwentyFour(attempt) { + const root = $game("#experience-content"); + root.replaceChildren(); + root.append(gameHeading(attempt)); + if (attempt.status === "completed") { + root.append(resultPanel(attempt)); + return; + } + const instruction = document.createElement("p"); + instruction.className = "scene"; + instruction.textContent = "四个数字必须各使用一次,只允许 +、−、×、÷ 和括号。"; + const numbers = document.createElement("div"); + numbers.className = "twenty-four-numbers"; + attempt.puzzle.numbers.forEach((number) => { + const tile = document.createElement("button"); + tile.type = "button"; + tile.textContent = number; + tile.addEventListener("click", () => { + input.value += String(number); + input.focus(); + }); + numbers.append(tile); + }); + const form = document.createElement("form"); + form.className = "twenty-four-form"; + const input = document.createElement("input"); + input.className = "formula-input"; + input.placeholder = "例如:6 / (1 - 3 / 4)"; + input.autocomplete = "off"; + input.inputMode = "text"; + const keypad = document.createElement("div"); + keypad.className = "game-keypad"; + ["+", "-", "*", "/", "(", ")"].forEach((operator) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = operator; + button.addEventListener("click", () => { + input.value += operator; + input.focus(); + }); + keypad.append(button); + }); + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "submit"; + submit.textContent = "验证并计分"; + form.append(input, keypad, submit); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: { expression: input.value }, + }, + ); + renderTwentyFour(result); + showToast("得到 24,成绩已记录"); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + root.append(instruction, numbers, form); + window.setTimeout(() => input.focus(), 50); + } + + function sudokuCell(given, value, row, column) { + const input = document.createElement("input"); + input.inputMode = "numeric"; + input.pattern = "[1-9]"; + input.maxLength = 1; + input.dataset.row = row; + input.dataset.column = column; + input.value = value || ""; + input.className = given ? "given" : ""; + input.readOnly = given; + input.setAttribute("aria-label", `第 ${row + 1} 行第 ${column + 1} 列`); + input.addEventListener("input", () => { + input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1); + }); + return input; + } + + function renderSudoku(attempt) { + const root = $game("#experience-content"); + root.replaceChildren(); + root.append(gameHeading(attempt)); + if (attempt.status === "completed") { + root.append(resultPanel(attempt)); + return; + } + const instruction = document.createElement("p"); + instruction.className = "scene"; + instruction.textContent = "每一行、每一列和每个 3×3 九宫格都要包含 1 到 9。"; + const board = document.createElement("div"); + board.className = "sudoku-board"; + const hints = new Map( + (attempt.puzzle.hints || []).map((item) => [`${item.row}-${item.column}`, item.value]) + ); + attempt.puzzle.grid.forEach((rowValues, row) => { + rowValues.forEach((givenValue, column) => { + const hintValue = hints.get(`${row}-${column}`) || 0; + const input = sudokuCell(Boolean(givenValue), givenValue || hintValue, row, column); + if (hintValue) { + input.readOnly = true; + input.classList.add("hint"); + } + board.append(input); + }); + }); + const actions = document.createElement("div"); + actions.className = "sudoku-actions"; + const hint = document.createElement("button"); + hint.type = "button"; + hint.className = "ghost-button"; + hint.textContent = `提示(已用 ${attempt.hints_used}/3)`; + hint.disabled = attempt.hints_used >= 3; + const submit = document.createElement("button"); + submit.type = "button"; + submit.className = "primary-button"; + submit.textContent = "检查并完成"; + hint.addEventListener("click", async () => { + hint.disabled = true; + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/hint/`, + { method: "POST", body: {} }, + ); + const input = $game( + `[data-row="${result.row}"][data-column="${result.column}"]`, + board, + ); + input.value = result.value; + input.readOnly = true; + input.classList.add("hint"); + attempt.hints_used = result.hints_used; + hint.textContent = `提示(已用 ${result.hints_used}/3)`; + hint.disabled = result.hints_used >= 3; + } catch (error) { + showToast(error.message); + hint.disabled = false; + } + }); + submit.addEventListener("click", async () => { + submit.disabled = true; + const grid = Array.from({ length: 9 }, () => Array(9).fill(0)); + $gameAll("input", board).forEach((input) => { + grid[Number(input.dataset.row)][Number(input.dataset.column)] = + Number(input.value || 0); + }); + try { + const result = await api( + `contests/games/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: { grid }, + }, + ); + renderSudoku(result); + showToast("数独完成,成绩已记录"); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + actions.append(hint, submit); + root.append(instruction, board, actions); + } + + function $gameAll(selector, root = document) { + return [...root.querySelectorAll(selector)]; + } + + window.HuluGames = { load: loadGames }; +})(); diff --git a/backend/static/js/toolbox.js b/backend/static/js/toolbox.js new file mode 100644 index 0000000..05c511f --- /dev/null +++ b/backend/static/js/toolbox.js @@ -0,0 +1,710 @@ +(function () { + const $tool = (selector, root = document) => root.querySelector(selector); + const $$tool = (selector, root = document) => [...root.querySelectorAll(selector)]; + + const CALC_FIELDS = { + derivative: ["order"], + integral: ["bounds"], + limit: ["point"], + base: ["base"], + unit: ["unit"], + }; + + function formatCalculatorResult(result) { + if (Array.isArray(result)) { + return { + exact: result.map((item) => item.exact).join(", "), + decimal: result.map((item) => item.decimal).join(", "), + latex: result.map((item) => item.latex).join(", "), + }; + } + if (result && typeof result === "object" && "exact" in result) return result; + const entries = Object.entries(result || {}); + return { + exact: entries.map(([key, value]) => `${key}: ${value}`).join(" · "), + decimal: entries.map(([key, value]) => `${key}: ${value}`).join(" · "), + latex: entries.map(([key, value]) => `${key}=${value}`).join(", "), + }; + } + + function updateCalculatorFields() { + const operation = $tool("#calc-operation").value; + const visible = new Set(CALC_FIELDS[operation] || []); + $$tool("[data-calc-field]").forEach((field) => { + field.hidden = !visible.has(field.dataset.calcField); + }); + const input = $tool("#calc-input"); + const placeholders = { + statistics: "12, 15, 18, 21, 24", + base: "FF", + matrix_det: "1,2;3,4", + matrix_inverse: "1,2;3,4", + matrix_rref: "1,2,3;2,4,6", + matrix_transpose: "1,2,3;4,5,6", + solve: "x^2 - 5*x + 6 = 0", + }; + input.placeholder = placeholders[operation] || "例如:sqrt(2) + 1/3"; + } + + async function runCalculator() { + const button = $tool("#calc-run"); + const operation = $tool("#calc-operation").value; + const expression = $tool("#calc-input").value.trim(); + button.disabled = true; + $tool("#calc-history").textContent = "数学内核正在计算…"; + try { + const payload = await api("toolbox/calculate/", { + method: "POST", + body: { + operation, + expression, + variable: $tool("#calc-variable").value, + order: $tool("#calc-order").value, + lower: $tool("#calc-lower").value, + upper: $tool("#calc-upper").value, + point: $tool("#calc-point").value, + from_base: $tool("#calc-from-base").value, + to_base: $tool("#calc-to-base").value, + from_unit: $tool("#calc-from-unit").value, + to_unit: $tool("#calc-to-unit").value, + }, + }); + const result = formatCalculatorResult(payload.result); + $tool("#calc-history").textContent = `${operation} · ${expression}`; + $tool("#calc-output").textContent = result.exact || "完成"; + $tool("#calc-decimal").textContent = result.decimal || result.exact || "—"; + $tool("#calc-latex").textContent = result.latex || result.exact || "—"; + $tool("#calc-steps").replaceChildren( + ...(payload.steps || []).map((step) => { + const item = document.createElement("li"); + item.textContent = step; + return item; + }) + ); + } catch (error) { + $tool("#calc-history").textContent = error.message; + $tool("#calc-output").textContent = "计算失败"; + $tool("#calc-decimal").textContent = "—"; + $tool("#calc-latex").textContent = "—"; + $tool("#calc-steps").replaceChildren(); + } finally { + button.disabled = false; + } + } + + const GRAPH_COLORS = ["#196548", "#d86f45", "#5d73e8", "#8667b5"]; + + function drawFunctionPath(context, expression, variables, range, width, height, color) { + const toY = (value) => height / 2 - (value / range) * (height / 2); + context.strokeStyle = color; + context.lineWidth = 3; + context.beginPath(); + let drawing = false; + const samples = []; + for (let pixel = 0; pixel <= width; pixel += 2) { + const x = (pixel / width) * range * 2 - range; + let y; + try { + y = evaluateExpression(expression, { ...variables, x }); + } catch (error) { + if (pixel === 0) throw error; + drawing = false; + continue; + } + samples.push({ x, y }); + const screenY = toY(y); + if (!Number.isFinite(screenY) || screenY < -height * 2 || screenY > height * 3) { + drawing = false; + continue; + } + if (!drawing) context.moveTo(pixel, screenY); + else context.lineTo(pixel, screenY); + drawing = true; + } + context.stroke(); + return samples; + } + + function graphAnalysis(samples) { + const roots = []; + let extrema = 0; + for (let index = 1; index < samples.length; index += 1) { + const before = samples[index - 1]; + const current = samples[index]; + if (before.y === 0 || before.y * current.y < 0) { + const root = (before.x + current.x) / 2; + if (!roots.length || Math.abs(root - roots.at(-1)) > 0.15) roots.push(root); + } + if (index > 1) { + const previousSlope = before.y - samples[index - 2].y; + const currentSlope = current.y - before.y; + if (previousSlope * currentSlope < 0) extrema += 1; + } + } + return { roots: roots.slice(0, 8), extrema }; + } + + function drawGraph() { + const canvas = $tool("#graph-canvas"); + const context = canvas.getContext("2d"); + const expressions = $tool("#graph-expression").value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 4); + const range = Number($tool("#graph-range").value); + const parameter = Number($tool("#graph-parameter").value); + const width = canvas.width; + const height = canvas.height; + $tool("#graph-range-label").textContent = `−${range} 到 ${range}`; + $tool("#graph-parameter-label").textContent = `a = ${parameter}`; + $tool("#graph-error").textContent = ""; + context.clearRect(0, 0, width, height); + context.fillStyle = "#fbfbf7"; + context.fillRect(0, 0, width, height); + const toX = (x) => ((x + range) / (range * 2)) * width; + const toY = (y) => height / 2 - (y / range) * (height / 2); + + context.strokeStyle = "#e4e5df"; + context.lineWidth = 1; + for (let value = -range; value <= range; value += 1) { + context.beginPath(); + context.moveTo(toX(value), 0); + context.lineTo(toX(value), height); + context.moveTo(0, toY(value)); + context.lineTo(width, toY(value)); + context.stroke(); + } + context.strokeStyle = "#718078"; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(0, height / 2); + context.lineTo(width, height / 2); + context.moveTo(width / 2, 0); + context.lineTo(width / 2, height); + context.stroke(); + + if (!expressions.length) { + $tool("#graph-error").textContent = "请至少输入一个函数"; + return; + } + try { + const firstSamples = drawFunctionPath( + context, + expressions[0], + { a: parameter }, + range, + width, + height, + GRAPH_COLORS[0], + ); + expressions.slice(1).forEach((expression, index) => { + drawFunctionPath( + context, + expression, + { a: parameter }, + range, + width, + height, + GRAPH_COLORS[index + 1], + ); + }); + + if ($tool("#graph-integral").checked) { + context.fillStyle = "rgba(25, 101, 72, .13)"; + context.beginPath(); + context.moveTo(0, height / 2); + firstSamples.forEach((item) => context.lineTo(toX(item.x), toY(item.y))); + context.lineTo(width, height / 2); + context.closePath(); + context.fill(); + } + if ($tool("#graph-derivative").checked) { + const derivative = firstSamples.slice(1, -1).map((item, index) => { + const before = firstSamples[index]; + const after = firstSamples[index + 2]; + return { x: item.x, y: (after.y - before.y) / (after.x - before.x) }; + }); + context.strokeStyle = "#111827"; + context.lineWidth = 2; + context.setLineDash([9, 7]); + context.beginPath(); + derivative.forEach((item, index) => { + const x = toX(item.x); + const y = toY(item.y); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.setLineDash([]); + } + const analysis = graphAnalysis(firstSamples); + const rootText = analysis.roots.length + ? analysis.roots.map((item) => item.toFixed(2)).join("、") + : "当前范围未发现"; + $tool("#graph-analysis").textContent = + `第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`; + } catch (error) { + $tool("#graph-error").textContent = error.message; + } + } + + function canvasPoint(canvas, event) { + const rect = canvas.getBoundingClientRect(); + return { + x: ((event.clientX - rect.left) / rect.width) * canvas.width, + y: ((event.clientY - rect.top) / rect.height) * canvas.height, + }; + } + + function downloadCanvas(canvas, name) { + const link = document.createElement("a"); + link.download = `${name}-${new Date().toISOString().slice(0, 10)}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + } + + class Whiteboard { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.tool = "pen"; + this.history = []; + this.drawing = false; + this.start = null; + this.preview = null; + this.reset(); + canvas.addEventListener("pointerdown", (event) => this.startDrawing(event)); + canvas.addEventListener("pointermove", (event) => this.move(event)); + canvas.addEventListener("pointerup", (event) => this.finish(event)); + canvas.addEventListener("pointercancel", () => this.cancel()); + } + + reset() { + this.context.fillStyle = "#ffffff"; + this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + snapshot() { + this.history.push(this.canvas.toDataURL("image/png")); + if (this.history.length > 20) this.history.shift(); + } + + startDrawing(event) { + event.preventDefault(); + this.snapshot(); + this.drawing = true; + this.start = canvasPoint(this.canvas, event); + if (this.tool === "text") { + const text = $tool("#whiteboard-text").value.trim(); + if (!text) { + this.history.pop(); + showToast("先输入要放到白板的文字或公式"); + } else { + const size = Math.max(18, Number($tool("#whiteboard-size").value) * 5); + this.context.fillStyle = $tool("#whiteboard-color").value; + this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`; + this.context.fillText(text, this.start.x, this.start.y); + } + this.drawing = false; + return; + } + this.preview = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); + this.canvas.setPointerCapture(event.pointerId); + this.context.lineCap = "round"; + this.context.lineJoin = "round"; + this.context.lineWidth = Number($tool("#whiteboard-size").value); + this.context.strokeStyle = + this.tool === "eraser" ? "#ffffff" : $tool("#whiteboard-color").value; + if (this.tool === "pen" || this.tool === "eraser") { + this.context.beginPath(); + this.context.moveTo(this.start.x, this.start.y); + } + } + + move(event) { + if (!this.drawing) return; + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + if (this.tool === "pen" || this.tool === "eraser") { + this.context.lineTo(point.x, point.y); + this.context.stroke(); + return; + } + this.context.putImageData(this.preview, 0, 0); + this.context.beginPath(); + if (this.tool === "line") { + this.context.moveTo(this.start.x, this.start.y); + this.context.lineTo(point.x, point.y); + } else { + this.context.rect( + this.start.x, + this.start.y, + point.x - this.start.x, + point.y - this.start.y, + ); + } + this.context.stroke(); + } + + finish(event) { + if (!this.drawing) return; + this.move(event); + this.drawing = false; + this.preview = null; + } + + cancel() { + if (this.preview) this.context.putImageData(this.preview, 0, 0); + this.drawing = false; + } + + undo() { + const source = this.history.pop(); + if (!source) return; + const image = new Image(); + image.onload = () => { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(image, 0, 0); + }; + image.src = source; + } + + clear() { + this.snapshot(); + this.reset(); + } + + addGrid() { + this.snapshot(); + const context = this.context; + context.strokeStyle = "#e1e7e1"; + context.lineWidth = 1; + for (let x = 0; x <= this.canvas.width; x += 50) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, this.canvas.height); + context.stroke(); + } + for (let y = 0; y <= this.canvas.height; y += 50) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(this.canvas.width, y); + context.stroke(); + } + context.strokeStyle = "#7a877f"; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(this.canvas.width / 2, 0); + context.lineTo(this.canvas.width / 2, this.canvas.height); + context.moveTo(0, this.canvas.height / 2); + context.lineTo(this.canvas.width, this.canvas.height / 2); + context.stroke(); + } + + addImage(file) { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const image = new Image(); + image.onload = () => { + this.snapshot(); + const scale = Math.min( + 1, + (this.canvas.width * 0.8) / image.width, + (this.canvas.height * 0.8) / image.height, + ); + this.context.drawImage( + image, + 30, + 30, + image.width * scale, + image.height * scale, + ); + }; + image.src = reader.result; + }; + reader.readAsDataURL(file); + } + } + + class GeometryBoard { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.tool = "point"; + this.points = []; + this.segments = []; + this.circles = []; + this.history = []; + this.pending = []; + this.dragging = null; + canvas.addEventListener("pointerdown", (event) => this.pointerDown(event)); + canvas.addEventListener("pointermove", (event) => this.pointerMove(event)); + canvas.addEventListener("pointerup", (event) => this.pointerUp(event)); + canvas.addEventListener("pointercancel", () => { + this.dragging = null; + }); + this.draw(); + } + + save() { + this.history.push( + JSON.stringify({ + points: this.points, + segments: this.segments, + circles: this.circles, + }) + ); + if (this.history.length > 30) this.history.shift(); + } + + findOrCreate(point) { + const nearest = this.findNearest(point); + if (nearest >= 0) return nearest; + this.points.push(point); + return this.points.length - 1; + } + + findNearest(point) { + let nearest = -1; + let distance = 28; + this.points.forEach((item, index) => { + const current = Math.hypot(item.x - point.x, item.y - point.y); + if (current < distance) { + nearest = index; + distance = current; + } + }); + return nearest; + } + + pointerDown(event) { + if (this.tool !== "move") return; + event.preventDefault(); + const nearest = this.findNearest(canvasPoint(this.canvas, event)); + if (nearest < 0 || this.points[nearest].derived) return; + this.save(); + this.dragging = nearest; + this.canvas.setPointerCapture(event.pointerId); + } + + pointerMove(event) { + if (this.dragging === null) return; + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + this.points[this.dragging] = point; + this.draw(); + } + + pointerUp(event) { + if (this.tool === "move") { + this.pointerMove(event); + this.dragging = null; + return; + } + this.handlePoint(event); + } + + handlePoint(event) { + event.preventDefault(); + const point = canvasPoint(this.canvas, event); + this.save(); + if (this.tool === "point") { + this.findOrCreate(point); + } else { + this.pending.push(this.findOrCreate(point)); + if (this.pending.length === 2) { + const [first, second] = this.pending; + if (first !== second && this.tool === "segment") { + this.segments.push({ first, second }); + } else if (first !== second && this.tool === "circle") { + this.circles.push({ center: first, edge: second }); + } else if (first !== second && this.tool === "midpoint") { + const a = this.points[first]; + const b = this.points[second]; + this.points.push({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + derived: true, + parents: [first, second], + }); + this.segments.push({ first, second, guide: true }); + } + this.pending = []; + } + } + this.draw(); + $tool("#geometry-hint").textContent = this.pending.length + ? "再选择一个点完成构造。" + : "可继续创建或切换构造工具。"; + } + + draw() { + const context = this.context; + const width = this.canvas.width; + const height = this.canvas.height; + this.points.forEach((point) => { + if (!point.derived || !point.parents) return; + const first = this.points[point.parents[0]]; + const second = this.points[point.parents[1]]; + point.x = (first.x + second.x) / 2; + point.y = (first.y + second.y) / 2; + }); + context.fillStyle = "#fbfbf7"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "#e7e8e2"; + context.lineWidth = 1; + for (let x = 0; x <= width; x += 50) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, height); + context.stroke(); + } + for (let y = 0; y <= height; y += 50) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(width, y); + context.stroke(); + } + this.segments.forEach((segment) => { + const first = this.points[segment.first]; + const second = this.points[segment.second]; + context.strokeStyle = segment.guide ? "#9ca3af" : "#196548"; + context.setLineDash(segment.guide ? [8, 6] : []); + context.lineWidth = 3; + context.beginPath(); + context.moveTo(first.x, first.y); + context.lineTo(second.x, second.y); + context.stroke(); + context.setLineDash([]); + if ($tool("#geometry-labels").checked) { + context.fillStyle = "#47534c"; + context.font = "18px sans-serif"; + context.fillText( + Math.hypot(first.x - second.x, first.y - second.y).toFixed(1), + (first.x + second.x) / 2 + 8, + (first.y + second.y) / 2 - 8, + ); + } + }); + this.circles.forEach((circle) => { + const center = this.points[circle.center]; + const edge = this.points[circle.edge]; + const radius = Math.hypot(center.x - edge.x, center.y - edge.y); + context.strokeStyle = "#5d73e8"; + context.lineWidth = 3; + context.beginPath(); + context.arc(center.x, center.y, radius, 0, Math.PI * 2); + context.stroke(); + }); + this.points.forEach((point, index) => { + context.fillStyle = point.derived ? "#d86f45" : "#17211b"; + context.beginPath(); + context.arc(point.x, point.y, 7, 0, Math.PI * 2); + context.fill(); + if ($tool("#geometry-labels").checked) { + context.fillStyle = "#17211b"; + context.font = "18px sans-serif"; + context.fillText( + `${String.fromCharCode(65 + (index % 26))} (${Math.round(point.x)}, ${Math.round(point.y)})`, + point.x + 10, + point.y - 10, + ); + } + }); + } + + undo() { + const state = this.history.pop(); + if (!state) return; + const parsed = JSON.parse(state); + this.points = parsed.points; + this.segments = parsed.segments; + this.circles = parsed.circles; + this.pending = []; + this.draw(); + } + + clear() { + this.save(); + this.points = []; + this.segments = []; + this.circles = []; + this.pending = []; + this.draw(); + } + } + + let whiteboard; + let geometry; + + function initDrawingTools() { + whiteboard = new Whiteboard($tool("#whiteboard-canvas")); + geometry = new GeometryBoard($tool("#geometry-canvas")); + $$tool("[data-whiteboard-tool]").forEach((button) => { + button.addEventListener("click", () => { + whiteboard.tool = button.dataset.whiteboardTool; + $$tool("[data-whiteboard-tool]").forEach((item) => + item.classList.toggle("active", item === button) + ); + }); + }); + $tool("#whiteboard-undo").addEventListener("click", () => whiteboard.undo()); + $tool("#whiteboard-clear").addEventListener("click", () => whiteboard.clear()); + $tool("#whiteboard-grid").addEventListener("click", () => whiteboard.addGrid()); + $tool("#whiteboard-image").addEventListener("change", (event) => { + whiteboard.addImage(event.target.files[0]); + event.target.value = ""; + }); + $tool("#whiteboard-export").addEventListener("click", () => + downloadCanvas(whiteboard.canvas, "hulumath-whiteboard") + ); + $$tool("[data-geometry-tool]").forEach((button) => { + button.addEventListener("click", () => { + geometry.tool = button.dataset.geometryTool; + geometry.pending = []; + $$tool("[data-geometry-tool]").forEach((item) => + item.classList.toggle("active", item === button) + ); + }); + }); + $tool("#geometry-labels").addEventListener("change", () => geometry.draw()); + $tool("#geometry-undo").addEventListener("click", () => geometry.undo()); + $tool("#geometry-clear").addEventListener("click", () => geometry.clear()); + $tool("#geometry-export").addEventListener("click", () => + downloadCanvas(geometry.canvas, "hulumath-geometry") + ); + } + + function init() { + $tool("#calc-operation").addEventListener("change", updateCalculatorFields); + $tool("#calc-run").addEventListener("click", runCalculator); + $tool("#calc-input").addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator(); + }); + $$tool("[data-calc-example]").forEach((button) => { + button.addEventListener("click", () => { + $tool("#calc-operation").value = button.dataset.calcOperation; + $tool("#calc-input").value = button.dataset.calcExample; + updateCalculatorFields(); + runCalculator(); + }); + }); + ["#graph-run", "#graph-range", "#graph-parameter", "#graph-derivative", "#graph-integral"] + .forEach((selector) => $tool(selector).addEventListener("input", drawGraph)); + $tool("#graph-expression").addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") drawGraph(); + }); + initDrawingTools(); + updateCalculatorFields(); + drawGraph(); + } + + function activate(tool) { + if (tool === "graph") window.setTimeout(drawGraph, 30); + if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30); + } + + window.HuluToolbox = { init, activate, runCalculator, drawGraph }; +})(); diff --git a/backend/templates/index.html b/backend/templates/index.html index 0f9efa0..d1629bf 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -90,24 +90,82 @@
正在读取比赛…
+
+
+
MATH PLAYGROUND

数学玩法

+

短局推理,成绩由服务端校验并记录。

+
+
+
MATHEMATICAL WORKBENCH

工具箱

计算、查询、绘图与表达,把想法直接变成可以继续工作的对象。

- + - + + +
-
CALCULATOR

科学计算器

支持 + − × ÷、括号、幂、sin、cos、tan、sqrt、log 与常数 π。

-
-
准备计算0
- -
+
COMPUTATION STUDIO

数学计算工作台

精确值、方程、微积分、矩阵、统计与进制转换由受限数学内核计算。

+
+
+ + + + + + + + + + +
+ +
+ + + + + + + +
+
+
准备计算0
+
+
近似值0
+
LaTeX0
+
+
    +
    @@ -117,13 +175,59 @@
    -
    FUNCTION PLOTTER

    函数图形绘制

    变量使用 x,支持与计算器相同的函数。

    +
    FUNCTION PLOTTER

    函数图形绘制

    每行一个函数;支持参数 a、数值导数、积分区域和曲线分析。

    -

    +
    + + + + + + +

    +
    +
    +
    +
    MATH WHITEBOARD

    数学白板

    支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG。

    +
    + + + + + + + + + + + + + +
    +
    +
    + +
    +
    GEOMETRY BOARD

    几何画板

    依次点击构造点、线段和圆;支持中点、长度与坐标测量。

    +
    + + + + + + + + + +
    +
    +

    点击画布创建第一个点。

    +
    +
    LATEX LAB

    数学表达实验室

    源码、实时预览与版本保存。

    @@ -199,6 +303,8 @@
    {% csrf_token %}
    + + diff --git a/backend/toolbox/__init__.py b/backend/toolbox/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/toolbox/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/toolbox/apps.py b/backend/toolbox/apps.py new file mode 100644 index 0000000..d2fc657 --- /dev/null +++ b/backend/toolbox/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ToolboxConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "toolbox" + verbose_name = "数学工具箱" diff --git a/backend/toolbox/engine.py b/backend/toolbox/engine.py new file mode 100644 index 0000000..0a7b5e2 --- /dev/null +++ b/backend/toolbox/engine.py @@ -0,0 +1,333 @@ +import ast +import math +from statistics import mean, median, pstdev, pvariance + +import sympy as sp +from rest_framework.exceptions import ValidationError + +MAX_EXPRESSION_LENGTH = 500 +MAX_AST_NODES = 120 +MAX_MATRIX_CELLS = 36 +SYMBOLS = {name: sp.Symbol(name, real=True) for name in ("x", "y", "z", "a", "b", "t", "n")} +CONSTANTS = {"pi": sp.pi, "e": sp.E, "E": sp.E, "i": sp.I, "I": sp.I} +FUNCTIONS = { + "sin": sp.sin, + "cos": sp.cos, + "tan": sp.tan, + "asin": sp.asin, + "acos": sp.acos, + "atan": sp.atan, + "sinh": sp.sinh, + "cosh": sp.cosh, + "tanh": sp.tanh, + "sqrt": sp.sqrt, + "exp": sp.exp, + "ln": sp.log, + "log": sp.log, + "abs": sp.Abs, + "factorial": sp.factorial, + "binomial": sp.binomial, + "gcd": sp.gcd, + "lcm": sp.lcm, + "floor": sp.floor, + "ceil": sp.ceiling, +} +FUNCTION_ARITY = { + "factorial": (1, 1), + "binomial": (2, 2), + "gcd": (2, 2), + "lcm": (2, 2), +} +UNIT_FACTORS = { + "mm": ("length", 0.001), + "cm": ("length", 0.01), + "m": ("length", 1.0), + "km": ("length", 1000.0), + "in": ("length", 0.0254), + "ft": ("length", 0.3048), + "g": ("mass", 0.001), + "kg": ("mass", 1.0), + "lb": ("mass", 0.45359237), + "s": ("time", 1.0), + "min": ("time", 60.0), + "h": ("time", 3600.0), + "rad": ("angle", 1.0), + "deg": ("angle", math.pi / 180), +} + + +class SafeExpressionParser: + def __init__(self, source): + source = str(source or "").strip() + if not source: + raise ValidationError({"expression": "请输入数学表达式"}) + if len(source) > MAX_EXPRESSION_LENGTH: + raise ValidationError({"expression": "表达式不能超过 500 个字符"}) + source = ( + source.replace("π", "pi") + .replace("×", "*") + .replace("÷", "/") + .replace("−", "-") + .replace("^", "**") + ) + try: + self.tree = ast.parse(source, mode="eval") + except SyntaxError as exc: + raise ValidationError({"expression": "表达式语法无效"}) from exc + if sum(1 for _ in ast.walk(self.tree)) > MAX_AST_NODES: + raise ValidationError({"expression": "表达式过于复杂"}) + + def parse(self): + return self._convert(self.tree.body) + + def _convert(self, node): + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + raise ValidationError({"expression": "只允许数值常量"}) + if isinstance(node.value, int) and len(str(abs(node.value))) > 50: + raise ValidationError({"expression": "整数位数过多"}) + return sp.Integer(node.value) if isinstance(node.value, int) else sp.Float(node.value) + if isinstance(node, ast.Name): + if node.id in SYMBOLS: + return SYMBOLS[node.id] + if node.id in CONSTANTS: + return CONSTANTS[node.id] + raise ValidationError({"expression": f"不支持变量或常量 {node.id}"}) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = self._convert(node.operand) + return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp): + left = self._convert(node.left) + right = self._convert(node.right) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if isinstance(node.op, ast.Div): + return left / right + if isinstance(node.op, ast.Mod): + return sp.Mod(left, right) + if isinstance(node.op, ast.Pow): + if right.is_number and abs(float(right)) > 100: + raise ValidationError({"expression": "幂指数绝对值不能超过 100"}) + return left**right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + function = FUNCTIONS.get(node.func.id) + if function is None: + raise ValidationError({"expression": f"不支持函数 {node.func.id}"}) + minimum, maximum = FUNCTION_ARITY.get(node.func.id, (1, 2)) + if node.keywords or not minimum <= len(node.args) <= maximum: + raise ValidationError({"expression": f"{node.func.id} 的参数数量无效"}) + try: + return function(*(self._convert(item) for item in node.args)) + except (TypeError, ValueError) as exc: + raise ValidationError({"expression": f"{node.func.id} 的参数无效"}) from exc + raise ValidationError({"expression": "表达式包含不允许的语法"}) + + +def parse_expression(source): + return SafeExpressionParser(source).parse() + + +def parse_equation(source): + source = str(source or "") + if source.count("=") > 1: + raise ValidationError({"expression": "方程只能包含一个等号"}) + if "=" not in source: + return parse_expression(source) + left, right = source.split("=", 1) + return sp.Eq(parse_expression(left), parse_expression(right)) + + +def serialize_math(value): + if isinstance(value, dict): + return { + str(serialize_math(key)["exact"]): serialize_math(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [serialize_math(item) for item in value] + if isinstance(value, sp.MatrixBase): + return { + "exact": str(value.tolist()), + "decimal": str(value.evalf(12).tolist()), + "latex": sp.latex(value), + } + exact = str(value) + try: + decimal = str(sp.N(value, 12)) + except Exception: + decimal = exact + return {"exact": exact, "decimal": decimal, "latex": sp.latex(value)} + + +def parse_matrix(source): + rows = [row.strip() for row in str(source or "").split(";") if row.strip()] + if not rows: + raise ValidationError({"expression": "矩阵格式示例:1,2;3,4"}) + parsed = [[parse_expression(cell.strip()) for cell in row.split(",")] for row in rows] + width = len(parsed[0]) + if width == 0 or any(len(row) != width for row in parsed): + raise ValidationError({"expression": "矩阵每行列数必须一致"}) + if len(parsed) * width > MAX_MATRIX_CELLS: + raise ValidationError({"expression": "矩阵最多支持 36 个元素"}) + return sp.Matrix(parsed) + + +def parse_number_list(source): + try: + values = [float(item.strip()) for item in str(source or "").split(",") if item.strip()] + except ValueError as exc: + raise ValidationError({"expression": "统计数据必须是逗号分隔的数字"}) from exc + if not 1 <= len(values) <= 500: + raise ValidationError({"expression": "统计数据数量必须在 1 到 500 之间"}) + if not all(math.isfinite(item) for item in values): + raise ValidationError({"expression": "统计数据必须是有限数值"}) + return values + + +def calculate(payload): + operation = str(payload.get("operation", "calculate")) + source = payload.get("expression", "") + variable_name = str(payload.get("variable", "x")) + variable = SYMBOLS.get(variable_name) + if variable is None: + raise ValidationError({"variable": "变量仅支持 x、y、z、a、b、t、n"}) + + if operation == "statistics": + values = parse_number_list(source) + result = { + "count": len(values), + "mean": mean(values), + "median": median(values), + "variance": pvariance(values), + "standard_deviation": pstdev(values), + "minimum": min(values), + "maximum": max(values), + } + return { + "operation": operation, + "result": result, + "steps": ["读取数据", "计算集中趋势", "计算离散程度"], + } + + if operation == "base": + try: + from_base = int(payload.get("from_base", 10)) + to_base = int(payload.get("to_base", 2)) + if not 2 <= from_base <= 36 or not 2 <= to_base <= 36: + raise ValueError + number = int(str(source).strip(), from_base) + except ValueError as exc: + raise ValidationError({"expression": "进制必须为 2 到 36,且输入应合法"}) from exc + digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + sign = "-" if number < 0 else "" + remaining = abs(number) + converted = "0" + if remaining: + pieces = [] + while remaining: + remaining, index = divmod(remaining, to_base) + pieces.append(digits[index]) + converted = "".join(reversed(pieces)) + return { + "operation": operation, + "result": {"exact": f"{sign}{converted}", "decimal": str(number), "latex": sign + converted}, + "steps": [f"按 {from_base} 进制读取", f"转换为 {to_base} 进制"], + } + + if operation == "unit": + try: + value = float(str(source).strip()) + from_unit = str(payload.get("from_unit", "m")) + to_unit = str(payload.get("to_unit", "cm")) + source_unit = UNIT_FACTORS[from_unit] + target_unit = UNIT_FACTORS[to_unit] + if source_unit[0] != target_unit[0] or not math.isfinite(value): + raise ValueError + except (KeyError, ValueError) as exc: + raise ValidationError({"expression": "单位不兼容或数值无效"}) from exc + converted = value * source_unit[1] / target_unit[1] + return { + "operation": operation, + "result": { + "exact": f"{converted:.12g} {to_unit}", + "decimal": f"{converted:.12g}", + "latex": f"{converted:.12g}\\,{to_unit}", + }, + "steps": [f"将 {from_unit} 换算为标准单位", f"转换为 {to_unit}"], + } + + if operation.startswith("matrix_"): + matrix = parse_matrix(source) + if operation == "matrix_det": + if not matrix.is_square: + raise ValidationError({"expression": "行列式要求方阵"}) + result = matrix.det() + steps = ["读取矩阵", "按行列式规则计算"] + elif operation == "matrix_inverse": + if not matrix.is_square or matrix.det() == 0: + raise ValidationError({"expression": "矩阵不可逆"}) + result = matrix.inv() + steps = ["读取矩阵", "验证行列式非零", "计算逆矩阵"] + elif operation == "matrix_rref": + result = matrix.rref()[0] + steps = ["读取矩阵", "执行初等行变换", "得到行最简形"] + elif operation == "matrix_transpose": + result = matrix.T + steps = ["读取矩阵", "交换行列"] + else: + raise ValidationError({"operation": "不支持的矩阵操作"}) + return {"operation": operation, "result": serialize_math(result), "steps": steps} + + expression = parse_equation(source) if operation == "solve" else parse_expression(source) + steps = ["解析受限数学表达式"] + if operation == "calculate": + result = sp.simplify(expression) + steps.append("化简并保留精确值") + elif operation == "simplify": + result = sp.trigsimp(sp.cancel(expression)) + steps.append("约分并进行代数/三角化简") + elif operation == "expand": + result = sp.expand(expression) + steps.append("展开乘积与幂") + elif operation == "factor": + result = sp.factor(expression) + steps.append("提取因式并分解") + elif operation == "solve": + result = sp.solve(expression, variable) + if len(result) > 50: + raise ValidationError({"expression": "解的数量过多"}) + steps.extend([f"以 {variable_name} 为未知量", "求解方程"]) + elif operation == "derivative": + order = int(payload.get("order", 1)) + if not 1 <= order <= 5: + raise ValidationError({"order": "导数阶数必须在 1 到 5 之间"}) + result = sp.diff(expression, variable, order) + steps.append(f"对 {variable_name} 求 {order} 阶导数") + elif operation == "integral": + lower = str(payload.get("lower", "")).strip() + upper = str(payload.get("upper", "")).strip() + if lower or upper: + if not lower or not upper: + raise ValidationError({"bounds": "定积分必须同时填写上下限"}) + result = sp.integrate( + expression, + (variable, parse_expression(lower), parse_expression(upper)), + ) + steps.append(f"对 {variable_name} 计算定积分") + else: + result = sp.integrate(expression, variable) + steps.append(f"对 {variable_name} 计算不定积分") + elif operation == "limit": + point = parse_expression(payload.get("point", "0")) + direction = str(payload.get("direction", "+-")) + if direction not in {"+", "-", "+-"}: + raise ValidationError({"direction": "极限方向必须为 +、- 或 +-"}) + result = sp.limit(expression, variable, point, dir=direction) + steps.append(f"令 {variable_name} 趋近 {point}") + else: + raise ValidationError({"operation": "不支持的计算类型"}) + return {"operation": operation, "result": serialize_math(result), "steps": steps} diff --git a/backend/toolbox/test_engine.py b/backend/toolbox/test_engine.py new file mode 100644 index 0000000..b244209 --- /dev/null +++ b/backend/toolbox/test_engine.py @@ -0,0 +1,63 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from toolbox.engine import calculate, parse_expression + + +def test_calculate_精确计算与微积分(): + exact = calculate({"operation": "calculate", "expression": "sqrt(2) + 1/3"}) + derivative = calculate( + {"operation": "derivative", "expression": "sin(x) + x^3", "variable": "x"} + ) + integral = calculate( + { + "operation": "integral", + "expression": "x^2", + "variable": "x", + "lower": "0", + "upper": "3", + } + ) + + assert exact["result"]["exact"] == "1/3 + sqrt(2)" + assert derivative["result"]["exact"] == "3*x**2 + cos(x)" + assert integral["result"]["exact"] == "9" + + +def test_calculate_方程矩阵统计与进制(): + solved = calculate({"operation": "solve", "expression": "x^2 - 5*x + 6 = 0"}) + determinant = calculate({"operation": "matrix_det", "expression": "1,2;3,4"}) + statistics = calculate({"operation": "statistics", "expression": "1,2,3,4"}) + converted = calculate( + {"operation": "base", "expression": "FF", "from_base": 16, "to_base": 2} + ) + units = calculate( + { + "operation": "unit", + "expression": "1.75", + "from_unit": "m", + "to_unit": "cm", + } + ) + combinations = calculate({"operation": "calculate", "expression": "binomial(10, 3)"}) + + assert [item["exact"] for item in solved["result"]] == ["2", "3"] + assert determinant["result"]["exact"] == "-2" + assert statistics["result"]["mean"] == 2.5 + assert converted["result"]["exact"] == "11111111" + assert units["result"]["exact"] == "175 cm" + assert combinations["result"]["exact"] == "120" + + +@pytest.mark.parametrize( + "source", + [ + "__import__('os').system('id')", + "open('/etc/passwd')", + "x.__class__", + "[x for x in range(10)]", + ], +) +def test_parse_expression_拒绝非数学语法(source): + with pytest.raises(ValidationError): + parse_expression(source) diff --git a/backend/toolbox/test_views.py b/backend/toolbox/test_views.py new file mode 100644 index 0000000..bb92f10 --- /dev/null +++ b/backend/toolbox/test_views.py @@ -0,0 +1,25 @@ +import pytest + + +@pytest.mark.django_db +def test_calculator_api_公开访问并返回精确值(client): + response = client.post( + "/api/v1/toolbox/calculate/", + {"operation": "factor", "expression": "x^2 - 1"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["result"]["exact"] == "(x - 1)*(x + 1)" + + +@pytest.mark.django_db +def test_calculator_api_危险表达式返回四百(client): + response = client.post( + "/api/v1/toolbox/calculate/", + {"operation": "calculate", "expression": "__import__('os').system('id')"}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert "error" in response.json() diff --git a/backend/toolbox/urls.py b/backend/toolbox/urls.py new file mode 100644 index 0000000..18f34de --- /dev/null +++ b/backend/toolbox/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import CalculatorView + +urlpatterns = [ + path("calculate/", CalculatorView.as_view(), name="toolbox-calculate"), +] diff --git a/backend/toolbox/views.py b/backend/toolbox/views.py new file mode 100644 index 0000000..38d359c --- /dev/null +++ b/backend/toolbox/views.py @@ -0,0 +1,22 @@ +from rest_framework import permissions +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from .engine import calculate + + +class CalculatorView(APIView): + permission_classes = [permissions.AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "calculator" + + def post(self, request): + try: + payload = calculate(request.data) + except ValidationError: + raise + except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc: + raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc + return Response(payload) diff --git a/requirements.txt b/requirements.txt index 1d6049c..b7492b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ mysqlclient==2.2.7 whitenoise==6.7.0 gunicorn==23.0.0 uvicorn[standard]==0.30.6 +sympy==1.13.3