commit 0841b1a1030d864db73190c8ea0999918931c9ea
Author: Zhang Jing Xuan <17660634225@163.com>
Date: Wed Jul 29 16:29:10 2026 +0800
智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0acdd24
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,53 @@
+# Dependencies
+node_modules/
+frontend/node_modules/
+
+# Build output
+dist/
+build/
+frontend/dist/
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+*.egg-info/
+.venv/
+venv/
+
+# Secrets & env (contains API keys)
+.env
+*.env.local
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# Logs
+*.log
+backend_stdout.log
+backend_stderr.log
+
+# Temp / debug / scratch files
+_*.png
+_*.txt
+_*.json
+_shots/
+.claude/
+debug_json_fail.txt
+raw_ai.txt
+_gen_dump.json
+_cw_id.txt
+test_frontend_api.py
+test_output.json
+*.bak
+
+# IDE
+.idea/
+.vscode/
+*.swp
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..39be815
--- /dev/null
+++ b/README.md
@@ -0,0 +1,118 @@
+# 智教助手
+
+面向教师 AI 教学创作场景设计的智能体系统,帮助教师将教学想法快速转化为互动课件、教学动画、课堂工具和教案成果。
+
+## 核心功能
+
+| 模块 | 功能 | 说明 |
+|------|------|------|
+| AI课件生成 | 一句话生成互动课件 | 输入教学想法,AI自动生成交互式课件 |
+| 教学动画 | 交互式教学动画生成 | 数学公式可视化、物理实验模拟等 |
+| 互动练习 | 游戏化练习生成 | 贪吃蛇、消消乐等趣味单词/知识点练习 |
+| 教案生成 | 智能教案辅助 | AI根据课程内容生成结构化教案 |
+| AI命题 | 智能出题 | 根据知识点自动生成各类题型 |
+| 作文批改 | AI作文评分与批注 | 多维度作文分析与改进建议 |
+| 课堂工具 | 实时课堂互动 | 白板、倒计时、答题器、连线等 |
+| 资源库 | 教学资源共享 | 课件、动画、教案等资源管理 |
+| 教师社区 | 同行交流 | 资源分享、经验交流社区 |
+
+## 技术栈
+
+### 后端
+- **框架**: Python 3.11 + FastAPI
+- **数据库**: SQLite (开发) / PostgreSQL (生产)
+- **ORM**: SQLAlchemy 2.0 + Alembic
+- **缓存**: Redis
+- **AI**: OpenAI API 兼容接口
+- **任务队列**: Celery + Redis
+
+### 前端
+- **框架**: Vue 3 + TypeScript + Vite
+- **UI库**: Element Plus
+- **状态管理**: Pinia
+- **图表**: ECharts
+- **动画**: Lottie + Canvas
+- **编辑器**: TinyMCE / WangEditor
+
+### 部署
+- **容器化**: Docker + Docker Compose
+- **Web服务器**: Nginx
+- **CI/CD**: GitHub Actions
+
+## 项目结构
+
+```
+jiaoyu/
+├── README.md
+├── docs/ # 项目文档
+│ ├── architecture.md # 架构设计
+│ └── api.md # API文档
+├── backend/ # 后端服务
+│ ├── requirements.txt
+│ ├── main.py # 应用入口
+│ ├── config.py # 配置管理
+│ ├── database.py # 数据库连接
+│ ├── models/ # SQLAlchemy模型
+│ ├── schemas/ # Pydantic模型
+│ ├── routers/ # API路由
+│ ├── services/ # 业务逻辑
+│ └── utils/ # 工具函数
+├── frontend/ # 前端应用
+│ ├── package.json
+│ ├── vite.config.ts
+│ ├── index.html
+│ └── src/
+│ ├── main.ts
+│ ├── App.vue
+│ ├── router/
+│ ├── stores/
+│ ├── api/
+│ ├── views/
+│ ├── components/
+│ ├── styles/
+│ └── utils/
+└── docker-compose.yml
+```
+
+## 快速开始
+
+### 后端启动
+
+```bash
+cd backend
+python -m venv venv
+source venv/bin/activate # Windows: venv\Scripts\activate
+pip install -r requirements.txt
+cp .env.example .env # 编辑配置
+python main.py
+```
+
+后端运行在 http://localhost:8000,API文档 http://localhost:8000/docs
+
+### 前端启动
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+前端运行在 http://localhost:5173
+
+### Docker 部署
+
+```bash
+docker-compose up -d
+```
+
+## 环境变量
+
+| 变量名 | 说明 | 默认值 |
+|--------|------|--------|
+| DATABASE_URL | 数据库连接字符串 | sqlite:///./jiaoyu.db |
+| REDIS_URL | Redis连接 | redis://localhost:6379 |
+| AI_API_KEY | AI服务API密钥 | - |
+| AI_API_BASE | AI服务基础URL | https://api.openai.com/v1 |
+| AI_MODEL | 默认AI模型 | gpt-4o |
+| SECRET_KEY | JWT密钥 | - |
+| CORS_ORIGINS | 允许的跨域来源 | http://localhost:5173 |
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..86a38e3
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,10 @@
+DATABASE_URL=sqlite:///./jiaoyu.db
+REDIS_URL=redis://localhost:6379/0
+
+AI_API_KEY=your-api-key-here
+AI_API_BASE=https://api.openai.com/v1
+AI_MODEL=gpt-4o
+
+# 必填:JWT 签名密钥,生产环境请用 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 生成
+SECRET_KEY=please-generate-a-random-secret-key-of-at-least-32-chars
+CORS_ORIGINS=["http://localhost:5173"]
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..a00a059
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,14 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN mkdir -p uploads
+
+EXPOSE 8000
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..89effd8
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,40 @@
+# Alembic migration configuration
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+sqlalchemy.url = sqlite:///./jiaoyu.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..ca94963
--- /dev/null
+++ b/backend/alembic/env.py
@@ -0,0 +1,54 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from config import get_settings
+from database import Base
+import models # noqa: F401 ensures all models are loaded
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+settings = get_settings()
+config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ compare_type=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ compare_type=True,
+ render_as_batch=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako
new file mode 100644
index 0000000..590f5b3
--- /dev/null
+++ b/backend/alembic/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/alembic/versions/97e0aa47c003_initial_schema.py b/backend/alembic/versions/97e0aa47c003_initial_schema.py
new file mode 100644
index 0000000..612e4a5
--- /dev/null
+++ b/backend/alembic/versions/97e0aa47c003_initial_schema.py
@@ -0,0 +1,28 @@
+"""initial schema
+
+Revision ID: 97e0aa47c003
+Revises:
+Create Date: 2026-07-21 11:38:09.510081
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '97e0aa47c003'
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_audit_logs_table.py b/backend/alembic/versions/a1b2c3d4e5f6_add_audit_logs_table.py
new file mode 100644
index 0000000..ac4d456
--- /dev/null
+++ b/backend/alembic/versions/a1b2c3d4e5f6_add_audit_logs_table.py
@@ -0,0 +1,44 @@
+"""add audit logs table
+
+Revision ID: a1b2c3d4e5f6
+Revises: 97e0aa47c003
+Create Date: 2026-07-21 12:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'a1b2c3d4e5f6'
+down_revision: Union[str, None] = '97e0aa47c003'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'audit_logs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('username', sa.String(length=100), nullable=False, server_default=''),
+ sa.Column('action', sa.String(length=64), nullable=False),
+ sa.Column('target_type', sa.String(length=32), nullable=False, server_default=''),
+ sa.Column('target_id', sa.String(length=64), nullable=False, server_default=''),
+ sa.Column('detail', sa.Text(), nullable=False, server_default=''),
+ sa.Column('ip', sa.String(length=64), nullable=False, server_default=''),
+ sa.Column('user_agent', sa.String(length=255), nullable=False, server_default=''),
+ sa.Column('status', sa.String(length=16), nullable=False, server_default='success'),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index('ix_audit_logs_id', 'audit_logs', ['id'])
+ op.create_index('ix_audit_logs_user_id', 'audit_logs', ['user_id'])
+ op.create_index('ix_audit_logs_action', 'audit_logs', ['action'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_audit_logs_action', table_name='audit_logs')
+ op.drop_index('ix_audit_logs_user_id', table_name='audit_logs')
+ op.drop_index('ix_audit_logs_id', table_name='audit_logs')
+ op.drop_table('audit_logs')
diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_mind_maps_table.py b/backend/alembic/versions/b2c3d4e5f6a7_add_mind_maps_table.py
new file mode 100644
index 0000000..19a2dd8
--- /dev/null
+++ b/backend/alembic/versions/b2c3d4e5f6a7_add_mind_maps_table.py
@@ -0,0 +1,39 @@
+"""add mind maps table
+
+Revision ID: b2c3d4e5f6a7
+Revises: a1b2c3d4e5f6
+Create Date: 2026-07-22 09:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'b2c3d4e5f6a7'
+down_revision: Union[str, None] = 'a1b2c3d4e5f6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'mind_maps',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('title', sa.String(length=200), nullable=False),
+ sa.Column('subject', sa.String(length=50), nullable=False, server_default='综合'),
+ sa.Column('nodes', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id']),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index('ix_mind_maps_id', 'mind_maps', ['id'])
+ op.create_index('ix_mind_maps_user_id', 'mind_maps', ['user_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_mind_maps_user_id', table_name='mind_maps')
+ op.drop_index('ix_mind_maps_id', table_name='mind_maps')
+ op.drop_table('mind_maps')
diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_reset_codes_table.py b/backend/alembic/versions/c3d4e5f6a7b8_add_reset_codes_table.py
new file mode 100644
index 0000000..99a7805
--- /dev/null
+++ b/backend/alembic/versions/c3d4e5f6a7b8_add_reset_codes_table.py
@@ -0,0 +1,37 @@
+"""add reset codes table
+
+Revision ID: c3d4e5f6a7b8
+Revises: b2c3d4e5f6a7
+Create Date: 2026-07-22 10:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'c3d4e5f6a7b8'
+down_revision: Union[str, None] = 'b2c3d4e5f6a7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'reset_codes',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('phone', sa.String(length=20), nullable=False),
+ sa.Column('code', sa.String(length=6), nullable=False),
+ sa.Column('consumed', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('expires_at', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index('ix_reset_codes_id', 'reset_codes', ['id'])
+ op.create_index('ix_reset_codes_phone', 'reset_codes', ['phone'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_reset_codes_phone', table_name='reset_codes')
+ op.drop_index('ix_reset_codes_id', table_name='reset_codes')
+ op.drop_table('reset_codes')
diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_notifications_table.py b/backend/alembic/versions/d4e5f6a7b8c9_add_notifications_table.py
new file mode 100644
index 0000000..3407632
--- /dev/null
+++ b/backend/alembic/versions/d4e5f6a7b8c9_add_notifications_table.py
@@ -0,0 +1,41 @@
+"""add notifications table
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-07-22 11:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'd4e5f6a7b8c9'
+down_revision: Union[str, None] = 'c3d4e5f6a7b8'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'notifications',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('actor_id', sa.Integer(), nullable=True),
+ sa.Column('ntype', sa.String(length=32), nullable=False, server_default='comment'),
+ sa.Column('title', sa.String(length=200), nullable=False),
+ sa.Column('content', sa.String(length=500), nullable=False, server_default=''),
+ sa.Column('link', sa.String(length=255), nullable=False, server_default=''),
+ sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id']),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index('ix_notifications_id', 'notifications', ['id'])
+ op.create_index('ix_notifications_user_id', 'notifications', ['user_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_notifications_user_id', table_name='notifications')
+ op.drop_index('ix_notifications_id', table_name='notifications')
+ op.drop_table('notifications')
diff --git a/backend/config.py b/backend/config.py
new file mode 100644
index 0000000..9d40d8c
--- /dev/null
+++ b/backend/config.py
@@ -0,0 +1,86 @@
+import os
+import logging
+import secrets as _secrets
+from pydantic_settings import BaseSettings
+from pydantic import field_validator
+from functools import lru_cache
+
+
+logger = logging.getLogger(__name__)
+
+# 已知的弱/默认密钥,实例化时会拒绝(防止源码泄露后被伪造 JWT)
+_INSECURE_DEFAULTS = {
+ "",
+ "change-me-in-production",
+ "changeme",
+ "secret",
+ "your-secret-key",
+}
+
+
+class Settings(BaseSettings):
+ APP_NAME: str = "智教助手"
+ APP_VERSION: str = "1.0.0"
+ DEBUG: bool = True
+
+ DATABASE_URL: str = "sqlite:///./jiaoyu.db"
+ REDIS_URL: str = "redis://localhost:6379/0"
+
+ SECRET_KEY: str = "change-me-in-production"
+ ALGORITHM: str = "HS256"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
+
+ AI_API_KEY: str = ""
+ AI_API_BASE: str = "https://api.openai.com/v1"
+ AI_MODEL: str = "gpt-4o"
+
+ CORS_ORIGINS: list[str] = ["http://localhost:5173"]
+
+ UPLOAD_DIR: str = "uploads"
+ MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024
+ MAX_REQUEST_BODY_SIZE: int = 60 * 1024 * 1024 # global request body limit
+
+ @field_validator("SECRET_KEY")
+ @classmethod
+ def _validate_secret_key(cls, v: str) -> str:
+ """在 .env 加载后执行:弱默认值在开发模式生成临时密钥,生产模式拒绝启动。"""
+ raw = (v or "").strip()
+ debug_env = str(os.getenv("DEBUG", "")).lower()
+ # DEBUG 字段可能已被 pydantic 解析;优先看环境变量原始值,回退看已解析的 DEBUG
+ is_debug = debug_env in ("", "1", "true", "yes", "on") if debug_env else True
+ # 若环境变量显式设了 DEBUG,用它;否则用 pydantic 解析后的值(默认 True)
+ if debug_env:
+ is_debug = debug_env in ("1", "true", "yes", "on")
+
+ if raw in _INSECURE_DEFAULTS:
+ if is_debug:
+ generated = _secrets.token_urlsafe(48)
+ logger.warning(
+ "SECRET_KEY 未配置或为弱默认值,已生成临时开发密钥(仅本次运行有效)。"
+ "生产环境请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机字符串。"
+ )
+ return generated
+ raise RuntimeError(
+ "SECRET_KEY 未配置或为弱默认值,生产环境拒绝启动。"
+ "请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机字符串。"
+ )
+ if len(raw) < 32:
+ if is_debug:
+ logger.warning(
+ "SECRET_KEY 长度不足 32 字符(当前 %d),建议使用更长的随机字符串。", len(raw)
+ )
+ return raw
+ raise RuntimeError(
+ f"SECRET_KEY 长度不足 32 字符(当前 {len(raw)}),生产环境拒绝启动。"
+ )
+ return raw
+
+ class Config:
+ env_file = ".env"
+ extra = "ignore"
+
+
+@lru_cache()
+def get_settings() -> Settings:
+ return Settings()
diff --git a/backend/database.py b/backend/database.py
new file mode 100644
index 0000000..580223a
--- /dev/null
+++ b/backend/database.py
@@ -0,0 +1,40 @@
+from sqlalchemy import create_engine, event
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+from config import get_settings
+
+settings = get_settings()
+
+_is_sqlite = "sqlite" in settings.DATABASE_URL
+
+engine = create_engine(
+ settings.DATABASE_URL,
+ connect_args={"check_same_thread": False} if _is_sqlite else {},
+ echo=settings.DEBUG,
+)
+
+# SQLite concurrency hardening: enable WAL mode + busy_timeout on every connection.
+# WAL allows concurrent readers alongside a single writer, eliminating most
+# "database is locked" errors under FastAPI's threaded request handling.
+if _is_sqlite:
+ @event.listens_for(engine, "connect")
+ def _set_sqlite_pragma(dbapi_conn, conn_record):
+ cur = dbapi_conn.cursor()
+ cur.execute("PRAGMA journal_mode=WAL")
+ cur.execute("PRAGMA synchronous=NORMAL")
+ cur.execute("PRAGMA busy_timeout=10000")
+ cur.execute("PRAGMA foreign_keys=ON")
+ cur.close()
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/jiaoyu.db-shm b/backend/jiaoyu.db-shm
new file mode 100644
index 0000000..269cd3f
Binary files /dev/null and b/backend/jiaoyu.db-shm differ
diff --git a/backend/jiaoyu.db-wal b/backend/jiaoyu.db-wal
new file mode 100644
index 0000000..dffc61f
Binary files /dev/null and b/backend/jiaoyu.db-wal differ
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..756beee
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,91 @@
+import os
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request as StarletteRequest
+from starlette.responses import JSONResponse
+from slowapi import _rate_limit_exceeded_handler
+from slowapi.errors import RateLimitExceeded
+from services.limiter import limiter
+from fastapi.staticfiles import StaticFiles
+from config import get_settings
+from database import engine, Base
+import models # noqa: F401
+from routers import auth, courseware, animation, exercise, lesson_plan, essay, ai, resource, community, exam, classroom, material, search, admin, mindmap, notification, chat
+
+settings = get_settings()
+
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ Base.metadata.create_all(bind=engine)
+ os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
+ yield
+
+
+app = FastAPI(
+ title=settings.APP_NAME,
+ version=settings.APP_VERSION,
+ lifespan=lifespan,
+)
+
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+class BodySizeLimitMiddleware(BaseHTTPMiddleware):
+ """Reject request bodies exceeding MAX_REQUEST_BODY_SIZE (default 60MB)."""
+
+ async def dispatch(self, request: StarletteRequest, call_next):
+ max_size = getattr(settings, "MAX_REQUEST_BODY_SIZE", 60 * 1024 * 1024)
+ cl = request.headers.get("content-length")
+ if cl and int(cl) > max_size:
+ return JSONResponse(
+ status_code=413,
+ content={"detail": f"请求体过大,最大允许 {max_size // (1024*1024)}MB"},
+ )
+ return await call_next(request)
+
+
+app.add_middleware(BodySizeLimitMiddleware)
+
+app.include_router(auth.router)
+app.include_router(courseware.router)
+app.include_router(animation.router)
+app.include_router(exercise.router)
+app.include_router(lesson_plan.router)
+app.include_router(essay.router)
+app.include_router(exam.router)
+app.include_router(ai.router)
+app.include_router(resource.router)
+app.include_router(material.router)
+app.include_router(community.router)
+app.include_router(classroom.router)
+app.include_router(search.router)
+app.include_router(admin.router)
+app.include_router(mindmap.router)
+app.include_router(notification.router)
+app.include_router(chat.router)
+
+if os.path.exists(settings.UPLOAD_DIR):
+ app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
+
+
+@app.get("/api/health")
+def health_check():
+ return {"status": "ok", "version": settings.APP_VERSION}
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG)
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
new file mode 100644
index 0000000..702ab8e
--- /dev/null
+++ b/backend/models/__init__.py
@@ -0,0 +1,28 @@
+from .user import User
+from .courseware import Courseware, CoursewareShare
+from .animation import Animation
+from .exercise import Exercise, ExerciseAttempt
+from .lesson_plan import LessonPlan
+from .essay import EssayGrade
+from .exam import Exam
+from .resource import Resource, ResourceFavorite
+from .material import Material
+from .community import Post, Comment, PostFavorite
+from .classroom import ClassroomActivity
+from .credit import CreditAccount, CreditTransaction
+from .audit import AuditLog
+from .mindmap import MindMap
+from .reset_code import ResetCode
+from .notification import Notification
+from .chat import ChatConversation, ChatMessage
+
+__all__ = [
+ "User", "Courseware", "CoursewareShare", "Animation", "Exercise", "ExerciseAttempt",
+ "LessonPlan", "EssayGrade", "Exam", "Resource", "ResourceFavorite", "Material", "Post", "Comment", "PostFavorite", "ClassroomActivity",
+ "CreditAccount", "CreditTransaction",
+ "AuditLog",
+ "MindMap",
+ "ResetCode",
+ "Notification",
+ "ChatConversation", "ChatMessage",
+]
diff --git a/backend/models/animation.py b/backend/models/animation.py
new file mode 100644
index 0000000..09b1418
--- /dev/null
+++ b/backend/models/animation.py
@@ -0,0 +1,24 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Animation(Base):
+ __tablename__ = "animations"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ anim_type = Column(
+ SAEnum("math", "physics", "chemistry", "biology", "geography", "general", name="anim_type"),
+ default="general",
+ )
+ description = Column(Text, default="")
+ config = Column(JSON, default=dict)
+ thumbnail = Column(String(500), default="")
+ status = Column(SAEnum("draft", "published", name="anim_status"), default="draft")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="animations")
diff --git a/backend/models/audit.py b/backend/models/audit.py
new file mode 100644
index 0000000..364e546
--- /dev/null
+++ b/backend/models/audit.py
@@ -0,0 +1,21 @@
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.sql import func
+
+from database import Base
+
+
+class AuditLog(Base):
+ """安全审计日志:记录登录、权限变更、内容发布、敏感操作等。"""
+ __tablename__ = "audit_logs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, nullable=True, index=True)
+ username = Column(String(100), default="")
+ action = Column(String(64), nullable=False, index=True)
+ target_type = Column(String(32), default="")
+ target_id = Column(String(64), default="")
+ detail = Column(Text, default="")
+ ip = Column(String(64), default="")
+ user_agent = Column(String(255), default="")
+ status = Column(String(16), default="success") # success / failed / denied
+ created_at = Column(DateTime, server_default=func.now())
diff --git a/backend/models/chat.py b/backend/models/chat.py
new file mode 100644
index 0000000..f95873c
--- /dev/null
+++ b/backend/models/chat.py
@@ -0,0 +1,39 @@
+from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Text, Boolean
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class ChatConversation(Base):
+ __tablename__ = "chat_conversations"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ title = Column(String(200), nullable=False, default="新对话")
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ pinned = Column(Boolean, default=False)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="chat_conversations")
+ messages = relationship(
+ "ChatMessage",
+ back_populates="conversation",
+ order_by="ChatMessage.id",
+ lazy="selectin",
+ cascade="all, delete-orphan",
+ )
+
+
+class ChatMessage(Base):
+ __tablename__ = "chat_messages"
+
+ id = Column(Integer, primary_key=True, index=True)
+ conversation_id = Column(Integer, ForeignKey("chat_conversations.id"), nullable=False, index=True)
+ role = Column(String(20), nullable=False) # user / assistant / system
+ content = Column(Text, nullable=False)
+ tokens = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+
+ conversation = relationship("ChatConversation", back_populates="messages")
diff --git a/backend/models/classroom.py b/backend/models/classroom.py
new file mode 100644
index 0000000..84e6546
--- /dev/null
+++ b/backend/models/classroom.py
@@ -0,0 +1,20 @@
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, JSON, String, Text, func
+from sqlalchemy.orm import relationship
+from database import Base
+
+
+class ClassroomActivity(Base):
+ __tablename__ = "classroom_activities"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ activity_type = Column(String(50), default="poll")
+ description = Column(Text, default="")
+ config = Column(JSON, default=dict)
+ responses = Column(JSON, default=list)
+ status = Column(String(20), default="draft")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User")
diff --git a/backend/models/community.py b/backend/models/community.py
new file mode 100644
index 0000000..858ad1b
--- /dev/null
+++ b/backend/models/community.py
@@ -0,0 +1,54 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Post(Base):
+ __tablename__ = "posts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ content = Column(Text, default="")
+ post_type = Column(String(50), default="discussion")
+ tags = Column(JSON, default=list)
+ attachments = Column(JSON, default=list)
+ views = Column(Integer, default=0)
+ likes = Column(Integer, default=0)
+ comments_count = Column(Integer, default=0)
+ is_pinned = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="posts")
+ comments = relationship("Comment", back_populates="post", lazy="selectin", cascade="all, delete-orphan")
+ favorites = relationship("PostFavorite", back_populates="post", lazy="selectin", cascade="all, delete-orphan")
+
+
+class Comment(Base):
+ __tablename__ = "comments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ content = Column(Text, nullable=False)
+ parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True)
+ likes = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+
+ post = relationship("Post", back_populates="comments")
+ author = relationship("User")
+
+
+class PostFavorite(Base):
+ __tablename__ = "post_favorites"
+ __table_args__ = (UniqueConstraint("user_id", "post_id", name="uq_post_favorite_user_post"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True)
+ created_at = Column(DateTime, server_default=func.now())
+
+ user = relationship("User")
+ post = relationship("Post", back_populates="favorites")
diff --git a/backend/models/courseware.py b/backend/models/courseware.py
new file mode 100644
index 0000000..60dc167
--- /dev/null
+++ b/backend/models/courseware.py
@@ -0,0 +1,46 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Courseware(Base):
+ __tablename__ = "coursewares"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ description = Column(Text, default="")
+ content = Column(JSON, default=list)
+ cover_image = Column(String(500), default="")
+ status = Column(SAEnum("draft", "published", "archived", name="cw_status"), default="draft")
+ version = Column(Integer, default=1)
+ tags = Column(JSON, default=list)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="coursewares")
+
+
+class CoursewareShare(Base):
+ __tablename__ = "courseware_shares"
+
+ id = Column(Integer, primary_key=True, index=True)
+ courseware_id = Column(Integer, ForeignKey("coursewares.id"), nullable=False, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ token = Column(String(64), unique=True, nullable=False, index=True)
+ title = Column(String(200), nullable=False)
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ description = Column(Text, default="")
+ content = Column(JSON, default=list)
+ tags = Column(JSON, default=list)
+ views = Column(Integer, default=0)
+ status = Column(String(20), default="active")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ courseware = relationship("Courseware")
+ author = relationship("User")
diff --git a/backend/models/credit.py b/backend/models/credit.py
new file mode 100644
index 0000000..3492749
--- /dev/null
+++ b/backend/models/credit.py
@@ -0,0 +1,33 @@
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+
+from database import Base
+
+
+class CreditAccount(Base):
+ __tablename__ = "credit_accounts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False, index=True)
+ balance = Column(Integer, default=130)
+ total_granted = Column(Integer, default=130)
+ total_used = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ user = relationship("User", back_populates="credit_account")
+
+
+class CreditTransaction(Base):
+ __tablename__ = "credit_transactions"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ amount = Column(Integer, nullable=False)
+ action = Column(String(50), default="")
+ description = Column(Text, default="")
+ balance_after = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+
+ user = relationship("User", back_populates="credit_transactions")
diff --git a/backend/models/essay.py b/backend/models/essay.py
new file mode 100644
index 0000000..7db0f03
--- /dev/null
+++ b/backend/models/essay.py
@@ -0,0 +1,21 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class EssayGrade(Base):
+ __tablename__ = "essay_grades"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ title = Column(String(200), nullable=False)
+ essay_text = Column(Text, default="")
+ grade_level = Column(String(50), default="初中")
+ essay_type = Column(String(50), default="记叙文")
+ total_score = Column(Integer, default=50)
+ result = Column(JSON, default=dict)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="essay_grades")
diff --git a/backend/models/exam.py b/backend/models/exam.py
new file mode 100644
index 0000000..62ece23
--- /dev/null
+++ b/backend/models/exam.py
@@ -0,0 +1,22 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Exam(Base):
+ __tablename__ = "exams"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ questions = Column(JSON, default=list)
+ answers = Column(JSON, default=list)
+ duration = Column(Integer, default=90)
+ total_score = Column(Integer, default=100)
+ difficulty = Column(String(20), default="medium")
+ knowledge_points = Column(JSON, default=list)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/models/exercise.py b/backend/models/exercise.py
new file mode 100644
index 0000000..0640dd6
--- /dev/null
+++ b/backend/models/exercise.py
@@ -0,0 +1,44 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Exercise(Base):
+ __tablename__ = "exercises"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ exercise_type = Column(
+ SAEnum(
+ "game_snake", "game_match", "game_adventure",
+ "choice", "fill_blank", "true_false", "drag_sort", "matching",
+ name="exercise_type",
+ ),
+ default="choice",
+ )
+ subject = Column(String(50), default="")
+ knowledge_points = Column(JSON, default=list)
+ questions = Column(JSON, default=list)
+ settings = Column(JSON, default=dict)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="exercises")
+ attempts = relationship("ExerciseAttempt", back_populates="exercise", lazy="selectin")
+
+
+class ExerciseAttempt(Base):
+ __tablename__ = "exercise_attempts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ exercise_id = Column(Integer, ForeignKey("exercises.id"), nullable=False)
+ student_name = Column(String(50), default="")
+ answers = Column(JSON, default=list)
+ score = Column(Integer, default=0)
+ total = Column(Integer, default=0)
+ duration = Column(Integer, default=0)
+ created_at = Column(DateTime, server_default=func.now())
+
+ exercise = relationship("Exercise", back_populates="attempts")
diff --git a/backend/models/lesson_plan.py b/backend/models/lesson_plan.py
new file mode 100644
index 0000000..1a26b1b
--- /dev/null
+++ b/backend/models/lesson_plan.py
@@ -0,0 +1,25 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class LessonPlan(Base):
+ __tablename__ = "lesson_plans"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ title = Column(String(200), nullable=False)
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ objectives = Column(JSON, default=list)
+ key_points = Column(JSON, default=list)
+ difficulties = Column(JSON, default=list)
+ content = Column(JSON, default=dict)
+ duration = Column(Integer, default=45)
+ materials = Column(JSON, default=list)
+ homework = Column(JSON, default=list)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="lesson_plans")
diff --git a/backend/models/material.py b/backend/models/material.py
new file mode 100644
index 0000000..c9fc780
--- /dev/null
+++ b/backend/models/material.py
@@ -0,0 +1,26 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Material(Base):
+ __tablename__ = "materials"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ filename = Column(String(255), nullable=False)
+ title = Column(String(200), nullable=False)
+ material_type = Column(String(50), default="text")
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ tags = Column(JSON, default=list)
+ summary = Column(Text, default="")
+ char_count = Column(Integer, default=0)
+ size = Column(Integer, default=0)
+ source = Column(String(50), default="upload")
+ status = Column(SAEnum("active", "archived", name="material_status"), default="active")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="materials")
diff --git a/backend/models/mindmap.py b/backend/models/mindmap.py
new file mode 100644
index 0000000..3140517
--- /dev/null
+++ b/backend/models/mindmap.py
@@ -0,0 +1,18 @@
+from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class MindMap(Base):
+ __tablename__ = "mind_maps"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ title = Column(String(200), nullable=False)
+ subject = Column(String(50), default="综合")
+ nodes = Column(JSON, default=list)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="mind_maps")
diff --git a/backend/models/notification.py b/backend/models/notification.py
new file mode 100644
index 0000000..2e82a27
--- /dev/null
+++ b/backend/models/notification.py
@@ -0,0 +1,21 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Notification(Base):
+ """站内通知:评论/收藏/系统消息。"""
+ __tablename__ = "notifications"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ actor_id = Column(Integer, nullable=True)
+ ntype = Column(String(32), nullable=False, default="comment")
+ title = Column(String(200), nullable=False)
+ content = Column(String(500), default="")
+ link = Column(String(255), default="")
+ is_read = Column(Boolean, default=False)
+ created_at = Column(DateTime, server_default=func.now())
+
+ user = relationship("User", back_populates="notifications")
diff --git a/backend/models/reset_code.py b/backend/models/reset_code.py
new file mode 100644
index 0000000..5374f86
--- /dev/null
+++ b/backend/models/reset_code.py
@@ -0,0 +1,16 @@
+import datetime
+from sqlalchemy import Column, Integer, String, DateTime
+from sqlalchemy.sql import func
+from database import Base
+
+
+class ResetCode(Base):
+ """密码重置验证码:绑定手机号,5 分钟过期。"""
+ __tablename__ = "reset_codes"
+
+ id = Column(Integer, primary_key=True, index=True)
+ phone = Column(String(20), nullable=False, index=True)
+ code = Column(String(6), nullable=False)
+ consumed = Column(Integer, default=0)
+ expires_at = Column(DateTime, nullable=False)
+ created_at = Column(DateTime, server_default=func.now())
diff --git a/backend/models/resource.py b/backend/models/resource.py
new file mode 100644
index 0000000..bddce6b
--- /dev/null
+++ b/backend/models/resource.py
@@ -0,0 +1,46 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Enum as SAEnum, UniqueConstraint
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class Resource(Base):
+ __tablename__ = "resources"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ resource_type = Column(
+ SAEnum("courseware", "animation", "exercise", "lesson_plan", "exam", "other", name="res_type"),
+ default="other",
+ )
+ title = Column(String(200), nullable=False)
+ description = Column(Text, default="")
+ content_ref = Column(String(500), default="")
+ file_url = Column(String(500), default="")
+ cover_image = Column(String(500), default="")
+ tags = Column(JSON, default=list)
+ subject = Column(String(50), default="")
+ grade = Column(String(50), default="")
+ downloads = Column(Integer, default=0)
+ likes = Column(Integer, default=0)
+ views = Column(Integer, default=0)
+ is_public = Column(Integer, default=1)
+ status = Column(SAEnum("active", "archived", name="res_status"), default="active")
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ author = relationship("User", back_populates="resources")
+ favorites = relationship("ResourceFavorite", back_populates="resource", cascade="all, delete-orphan")
+
+
+class ResourceFavorite(Base):
+ __tablename__ = "resource_favorites"
+ __table_args__ = (UniqueConstraint("user_id", "resource_id", name="uq_resource_favorite_user_resource"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+ resource_id = Column(Integer, ForeignKey("resources.id"), nullable=False, index=True)
+ created_at = Column(DateTime, server_default=func.now())
+
+ user = relationship("User", back_populates="resource_favorites")
+ resource = relationship("Resource", back_populates="favorites")
diff --git a/backend/models/user.py b/backend/models/user.py
new file mode 100644
index 0000000..4cfe5a8
--- /dev/null
+++ b/backend/models/user.py
@@ -0,0 +1,36 @@
+from sqlalchemy import Column, Integer, String, DateTime, Enum as SAEnum, Boolean
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from database import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ phone = Column(String(20), unique=True, index=True, nullable=False)
+ password_hash = Column(String(255), nullable=False)
+ name = Column(String(50), nullable=False)
+ avatar = Column(String(500), default="")
+ subject = Column(String(50), default="")
+ school = Column(String(100), default="")
+ grade = Column(String(50), default="")
+ role = Column(SAEnum("teacher", "admin", name="user_role"), default="teacher")
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, server_default=func.now())
+ updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+
+ coursewares = relationship("Courseware", back_populates="author", lazy="selectin")
+ animations = relationship("Animation", back_populates="author", lazy="selectin")
+ exercises = relationship("Exercise", back_populates="author", lazy="selectin")
+ lesson_plans = relationship("LessonPlan", back_populates="author", lazy="selectin")
+ essay_grades = relationship("EssayGrade", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
+ resources = relationship("Resource", back_populates="author", lazy="selectin")
+ materials = relationship("Material", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
+ resource_favorites = relationship("ResourceFavorite", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
+ posts = relationship("Post", back_populates="author", lazy="selectin")
+ credit_account = relationship("CreditAccount", back_populates="user", uselist=False, lazy="selectin", cascade="all, delete-orphan")
+ credit_transactions = relationship("CreditTransaction", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
+ mind_maps = relationship("MindMap", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
+ notifications = relationship("Notification", back_populates="user", lazy="selectin", cascade="all, delete-orphan")
+ chat_conversations = relationship("ChatConversation", back_populates="author", lazy="selectin", cascade="all, delete-orphan")
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..1ac34e5
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,22 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+sqlalchemy==2.0.36
+alembic==1.14.0
+pydantic==2.10.4
+pydantic-settings==2.7.1
+python-jose[cryptography]==3.3.0
+bcrypt==4.0.1
+python-multipart==0.0.20
+httpx==0.28.1
+redis==5.2.1
+celery==5.4.0
+openai==1.58.1
+python-docx==1.1.2
+pillow==11.1.0
+python-pptx==1.0.2
+aiofiles==24.1.0
+python-dotenv==1.0.1
+typing_extensions>=4.13.0
+pypdf==5.1.0
+openpyxl==3.1.5
+slowapi==0.1.9
diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/routers/admin.py b/backend/routers/admin.py
new file mode 100644
index 0000000..a374e3d
--- /dev/null
+++ b/backend/routers/admin.py
@@ -0,0 +1,236 @@
+from datetime import datetime, time, timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models.animation import Animation
+from models.courseware import Courseware
+from models.credit import CreditAccount, CreditTransaction
+from models.exercise import Exercise
+from models.lesson_plan import LessonPlan
+from models.material import Material
+from models.resource import Resource, ResourceFavorite
+from models.user import User
+from models.audit import AuditLog
+from schemas.admin import AdminDashboardOut, AdminGrantCredits, AdminUserOut, AdminUserUpdate
+from schemas.resource import ResourceOut, ResourceAuthorOut
+from services.auth import get_admin_user
+from services.audit import log_action
+from services.credits import grant_credits
+
+router = APIRouter(prefix="/api/admin", tags=["管理后台"])
+
+
+def _user_with_credits(user: User, db: Session) -> dict:
+ account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
+ data = AdminUserOut.model_validate(user).model_dump()
+ data["credits"] = account.balance if account else 0
+ return data
+
+
+@router.get("/dashboard", response_model=AdminDashboardOut)
+def dashboard(current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)):
+ today = datetime.now().date()
+ today_start = datetime.combine(today, time.min)
+
+ total_users = db.query(func.count(User.id)).scalar() or 0
+ active_users = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
+ admin_users = db.query(func.count(User.id)).filter(User.role == "admin").scalar() or 0
+ new_users_today = db.query(func.count(User.id)).filter(User.created_at >= today_start).scalar() or 0
+
+ total_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active").scalar() or 0
+ public_resources = db.query(func.count(Resource.id)).filter(Resource.status == "active", Resource.is_public == 1).scalar() or 0
+
+ total_coursewares = db.query(func.count(Courseware.id)).filter(Courseware.status != "archived").scalar() or 0
+ total_animations = db.query(func.count(Animation.id)).filter(Animation.status != "archived").scalar() or 0
+ total_exercises = db.query(func.count(Exercise.id)).scalar() or 0
+ total_lesson_plans = db.query(func.count(LessonPlan.id)).scalar() or 0
+
+ total_views = db.query(func.coalesce(func.sum(Resource.views), 0)).scalar() or 0
+ total_downloads = db.query(func.coalesce(func.sum(Resource.downloads), 0)).scalar() or 0
+ total_favorites = db.query(func.count(ResourceFavorite.id)).scalar() or 0
+ total_credits_used = db.query(func.coalesce(func.sum(CreditAccount.total_used), 0)).scalar() or 0
+
+ exam_count = 0
+ try:
+ from models.exam import Exam
+ exam_count = db.query(func.count(Exam.id)).scalar() or 0
+ except Exception:
+ pass
+
+ return AdminDashboardOut(
+ total_users=total_users,
+ active_users=active_users,
+ admin_users=admin_users,
+ new_users_today=new_users_today,
+ total_resources=total_resources,
+ public_resources=public_resources,
+ total_coursewares=total_coursewares,
+ total_animations=total_animations,
+ total_exercises=total_exercises,
+ total_lesson_plans=total_lesson_plans,
+ total_exams=exam_count,
+ total_views=total_views,
+ total_downloads=total_downloads,
+ total_favorites=total_favorites,
+ total_credits_used=total_credits_used,
+ )
+
+
+@router.get("/users", response_model=list[AdminUserOut])
+def list_users(
+ keyword: str = Query("", description="搜索手机号或姓名"),
+ role: str = Query("", description="按角色筛选"),
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(User)
+ if keyword:
+ like = f"%{keyword}%"
+ query = query.filter((User.phone.contains(like)) | (User.name.contains(like)))
+ if role:
+ query = query.filter(User.role == role)
+ users = query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()
+ return [_user_with_credits(u, db) for u in users]
+
+
+@router.put("/users/{user_id}", response_model=AdminUserOut)
+def update_user(
+ user_id: int,
+ data: AdminUserUpdate,
+ request: Request,
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+ changes = []
+ if data.role is not None:
+ if data.role not in {"teacher", "admin"}:
+ raise HTTPException(status_code=400, detail="角色无效")
+ changes.append(f"role:{user.role}->{data.role}")
+ user.role = data.role
+ if data.is_active is not None:
+ changes.append(f"active:{user.is_active}->{data.is_active}")
+ user.is_active = data.is_active
+ if data.name is not None:
+ changes.append(f"name:{user.name}->{data.name}")
+ user.name = data.name
+ db.commit()
+ db.refresh(user)
+ log_action(db, action="admin_update_user", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"修改用户 {user.name or user.phone or user.id}:{', '.join(changes) or '无变更'}")
+ return _user_with_credits(user, db)
+
+
+@router.post("/users/{user_id}/grant-credits", response_model=AdminUserOut)
+def grant_user_credits(
+ user_id: int,
+ data: AdminGrantCredits,
+ request: Request,
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+ grant_credits(db, user, data.amount, action="admin_grant", description=f"管理员 {current_user.name} 发放 {data.amount} 积分")
+ db.commit()
+ db.refresh(user)
+ log_action(db, action="admin_grant_credits", user=current_user, request=request, target_type="user", target_id=user.id, detail=f"向 {user.name or user.phone or user.id} 发放 {data.amount} 积分")
+ return _user_with_credits(user, db)
+
+
+@router.get("/resources", response_model=list[ResourceOut])
+def list_all_resources(
+ keyword: str = Query(""),
+ resource_type: str = Query(""),
+ status: str = Query("active"),
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ from sqlalchemy import or_
+
+ query = db.query(Resource)
+ if status:
+ query = query.filter(Resource.status == status)
+ if resource_type:
+ query = query.filter(Resource.resource_type == resource_type)
+ if keyword:
+ like = f"%{keyword}%"
+ query = query.filter(or_(Resource.title.contains(like), Resource.description.contains(like)))
+ resources = query.order_by(Resource.created_at.desc()).offset(skip).limit(limit).all()
+ result = []
+ for res in resources:
+ out = ResourceOut.model_validate(res)
+ out.is_favorited = False
+ result.append(out)
+ return result
+
+
+@router.put("/resources/{resource_id}/moderate", response_model=dict)
+def moderate_resource(
+ resource_id: int,
+ request: Request,
+ status: str = Query(..., description="active 或 archived"),
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ if status not in {"active", "archived"}:
+ raise HTTPException(status_code=400, detail="状态无效")
+ res = db.query(Resource).filter(Resource.id == resource_id).first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ prev = res.status
+ res.status = status
+ db.commit()
+ log_action(db, action="admin_moderate_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"资源《{res.title}》状态 {prev} -> {status}")
+ return {"success": True, "status": res.status}
+
+
+@router.get("/audit-logs", response_model=list[dict])
+def list_audit_logs(
+ action: str | None = None,
+ status: str | None = None,
+ user_id: int | None = None,
+ keyword: str | None = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ current_user: User = Depends(get_admin_user),
+ db: Session = Depends(get_db),
+):
+ """查询安全审计日志(仅管理员)。"""
+ from sqlalchemy import or_
+ q = db.query(AuditLog)
+ if action:
+ q = q.filter(AuditLog.action == action)
+ if status:
+ q = q.filter(AuditLog.status == status)
+ if user_id:
+ q = q.filter(AuditLog.user_id == user_id)
+ if keyword:
+ like = f"%{keyword}%"
+ q = q.filter(or_(AuditLog.username.contains(like), AuditLog.detail.contains(like), AuditLog.ip.contains(like)))
+ rows = q.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
+ return [
+ {
+ "id": r.id,
+ "user_id": r.user_id,
+ "username": r.username,
+ "action": r.action,
+ "target_type": r.target_type,
+ "target_id": r.target_id,
+ "detail": r.detail,
+ "ip": r.ip,
+ "user_agent": r.user_agent,
+ "status": r.status,
+ "created_at": r.created_at.isoformat() if r.created_at else None,
+ }
+ for r in rows
+ ]
diff --git a/backend/routers/ai.py b/backend/routers/ai.py
new file mode 100644
index 0000000..8e88504
--- /dev/null
+++ b/backend/routers/ai.py
@@ -0,0 +1,297 @@
+import re
+from io import BytesIO
+from urllib.parse import quote
+
+from docx import Document
+from pptx import Presentation
+from PIL import Image
+from pypdf import PdfReader
+from openpyxl import load_workbook
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
+from fastapi.responses import StreamingResponse
+from models.material import Material
+from models.user import User
+from schemas.ai import EssayGradeRequest, ExamExportRequest, ExamGenerateRequest, HtmlExportRequest
+from services.limiter import limiter
+from services.auth import get_current_user
+from services.audit import log_action
+from services.upload_security import validate_upload, sanitize_filename
+from services.ai_service import AIService
+from services.credits import credits_payload, spend_credits
+from database import get_db
+from sqlalchemy.orm import Session
+
+router = APIRouter(prefix="/api/ai", tags=["AI能力"])
+ai_service = AIService()
+
+
+@router.post("/essay-grade", response_model=dict)
+@limiter.limit("10/minute")
+async def grade_essay(
+ request: Request,
+ data: EssayGradeRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "essay_grade", "作文批改")
+ db.commit()
+ result = await ai_service.grade_essay(
+ essay_text=data.essay_text, grade_level=data.grade_level,
+ essay_type=data.essay_type, total_score=data.total_score,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "essay_grade")}
+
+
+@router.post("/exam-generate", response_model=dict)
+@limiter.limit("10/minute")
+async def generate_exam(
+ request: Request,
+ data: ExamGenerateRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "exam_generate", f"智能命题:{data.subject}")
+ db.commit()
+ result = await ai_service.generate_exam(
+ subject=data.subject, grade=data.grade,
+ knowledge_points=data.knowledge_points, difficulty=data.difficulty,
+ question_types=data.question_types, count=data.count, total_score=data.total_score,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "exam_generate")}
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "export"
+ return f"{stem[:80]}{suffix}"
+
+
+def _summarize_text(text: str, limit: int = 4000) -> str:
+ normalized = re.sub(r"[ \t]+", " ", text.replace("\r\n", "\n")).strip()
+ normalized = re.sub(r"\n{3,}", "\n\n", normalized)
+ return normalized[:limit]
+
+
+def _title_from_filename(filename: str) -> str:
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
+ title = re.sub(r"[_-]+", " ", stem).strip() or "教学素材"
+ return title[:200]
+
+
+def _decode_text(content: bytes) -> str:
+ for encoding in ("utf-8-sig", "utf-8", "gb18030", "gbk"):
+ try:
+ return content.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return content.decode("utf-8", errors="ignore")
+
+
+def _extract_docx(content: bytes) -> str:
+ document = Document(BytesIO(content))
+ paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
+ table_lines = []
+ for table in document.tables:
+ for row in table.rows:
+ cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
+ if cells:
+ table_lines.append(" | ".join(cells))
+ return "\n".join([*paragraphs, *table_lines])
+
+
+def _extract_pptx(content: bytes) -> str:
+ presentation = Presentation(BytesIO(content))
+ lines = []
+ for index, slide in enumerate(presentation.slides, start=1):
+ slide_lines = []
+ for shape in slide.shapes:
+ text = getattr(shape, "text", "").strip()
+ if text:
+ slide_lines.append(text)
+ if slide_lines:
+ lines.append(f"第 {index} 页:\n" + "\n".join(slide_lines))
+ return "\n\n".join(lines)
+
+
+def _extract_pdf(content: bytes) -> str:
+ reader = PdfReader(BytesIO(content))
+ pages = []
+ for index, page in enumerate(reader.pages, start=1):
+ text = (page.extract_text() or "").strip()
+ if text:
+ pages.append(f"第 {index} 页:\n{text}")
+ return "\n\n".join(pages)
+
+
+def _extract_xlsx(content: bytes) -> str:
+ workbook = load_workbook(BytesIO(content), data_only=True, read_only=True)
+ lines = []
+ for sheet in workbook.worksheets:
+ row_lines = []
+ for row in sheet.iter_rows(values_only=True):
+ cells = [str(cell).strip() for cell in row if cell is not None and str(cell).strip()]
+ if cells:
+ row_lines.append(" | ".join(cells))
+ if row_lines:
+ lines.append(f"工作表 {sheet.title}:\n" + "\n".join(row_lines[:200]))
+ return "\n\n".join(lines)
+
+
+async def _describe_image(content: bytes, filename: str) -> str:
+ try:
+ with Image.open(BytesIO(content)) as image:
+ width, height = image.size
+ mode = image.mode
+ except Exception:
+ width, height, mode = 0, 0, ""
+ meta = f"图片文件:{filename}"
+ if width and height:
+ meta += f";尺寸:{width}×{height}px"
+ if mode:
+ meta += f";色彩模式:{mode}"
+
+ # Try AI vision OCR first; fall back to metadata-only description
+ try:
+ ocr_text = await ai_service.ocr_image(content, filename)
+ if ocr_text and "[无法识别文字内容]" not in ocr_text:
+ return f"{meta}\n\n【图片文字识别结果】\n{ocr_text}"
+ except Exception as exc:
+ import logging
+ logging.getLogger(__name__).warning("图片OCR失败,使用元数据描述: %s", exc)
+
+ return (
+ f"{meta}。\n"
+ "这是一份拍照/图片教学素材,可能包含教材页、题目、板书、图表、实验现象或课堂场景。"
+ "当前系统已保存图片素材类型,可将其带入课件、动画、练习、教案或命题流程;"
+ "生成时请教师在提示词中补充图片中的关键文字、题干或知识点,以便产出更准确。"
+ )
+
+
+@router.post("/materials/parse", response_model=dict)
+@limiter.limit("20/minute")
+async def parse_material(
+ request: Request,
+ file: UploadFile = File(...),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ content = await file.read()
+ check = validate_upload(file, content=content, max_size=50 * 1024 * 1024)
+ filename = check.filename
+ suffix = check.suffix
+ media_type = check.media_type
+
+ try:
+ if media_type.startswith("image/"):
+ text = await _describe_image(content, filename)
+ material_type = "image"
+ elif suffix == "docx":
+ text = _extract_docx(content)
+ material_type = "docx"
+ elif suffix == "pptx":
+ text = _extract_pptx(content)
+ material_type = "pptx"
+ elif suffix == "pdf":
+ text = _extract_pdf(content)
+ material_type = "pdf"
+ elif suffix in {"xlsx", "xls"}:
+ text = _extract_xlsx(content)
+ material_type = "xlsx"
+ elif suffix in {"txt", "md", "csv", "json"} or media_type.startswith("text/"):
+ text = _decode_text(content)
+ material_type = suffix or "text"
+ else:
+ raise HTTPException(status_code=415, detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片")
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail=f"材料解析失败:{exc}") from exc
+
+ summary = _summarize_text(text)
+ account = spend_credits(db, current_user, "material_parse", f"解析材料:{filename}")
+ material = Material(
+ user_id=current_user.id,
+ filename=filename,
+ title=_title_from_filename(filename),
+ material_type=material_type,
+ summary=summary,
+ char_count=len(text),
+ size=len(content),
+ source="upload",
+ )
+ db.add(material)
+ db.commit()
+ db.refresh(material)
+ log_action(db, action="material_parse", user=current_user, request=request, target_type="material", target_id=material.id, detail=f"解析 {filename}({material_type}, {len(content)}B)")
+ return {
+ "success": True,
+ "credits": credits_payload(account, "material_parse"),
+ "data": {
+ "material_id": material.id,
+ "filename": filename,
+ "title": material.title,
+ "material_type": material_type,
+ "size": len(content),
+ "summary": summary,
+ "char_count": len(text),
+ },
+ }
+
+
+@router.post("/export/html")
+def export_html(data: HtmlExportRequest, current_user: User = Depends(get_current_user)):
+ content = f"""
+
+
+
+
+ {data.title}
+
+
+{data.html}
+
+
+"""
+ filename = _safe_filename(data.title, ".html")
+ return StreamingResponse(
+ BytesIO(content.encode("utf-8")),
+ media_type="text/html; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.post("/export/exam-docx")
+def export_exam_docx(data: ExamExportRequest, current_user: User = Depends(get_current_user)):
+ document = Document()
+ document.add_heading(data.title or "试卷", level=1)
+ meta = " / ".join(part for part in [data.subject, data.grade] if part)
+ if meta:
+ document.add_paragraph(meta)
+
+ for idx, question in enumerate(data.questions, start=1):
+ q_type = question.get("type") or question.get("question_type") or "题目"
+ score = question.get("score")
+ heading = f"{idx}. [{q_type}]"
+ if score:
+ heading += f"({score}分)"
+ document.add_paragraph(heading)
+ document.add_paragraph(str(question.get("content") or question.get("question") or ""))
+ options = question.get("options") or []
+ if isinstance(options, list):
+ for option in options:
+ document.add_paragraph(str(option), style=None)
+
+ if data.answers:
+ document.add_page_break()
+ document.add_heading("参考答案", level=1)
+ for idx, answer in enumerate(data.answers, start=1):
+ document.add_paragraph(f"{idx}. {answer}")
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(data.title, ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
diff --git a/backend/routers/animation.py b/backend/routers/animation.py
new file mode 100644
index 0000000..12eaa8d
--- /dev/null
+++ b/backend/routers/animation.py
@@ -0,0 +1,188 @@
+import re
+from html import escape
+from io import BytesIO
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+from typing import Optional
+from database import get_db
+from models.animation import Animation
+from models.resource import Resource
+from models.user import User
+from schemas.animation import AnimationGenerate, AnimationCreate, AnimationUpdate, AnimationOut
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user, get_optional_current_user
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/animations", tags=["教学动画"])
+ai_service = AIService()
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教学动画"
+ return f"{stem[:80]}{suffix}"
+
+
+def _animation_or_404(anim_id: int, current_user: User | None, db: Session) -> Animation:
+ anim = db.query(Animation).filter(Animation.id == anim_id).first()
+ if not anim:
+ raise HTTPException(status_code=404, detail="动画不存在")
+ if (not current_user or anim.user_id != current_user.id) and not has_public_resource(db, anim_id):
+ raise HTTPException(status_code=403, detail="无权访问该动画")
+ return anim
+
+
+def _build_animation_html(anim: Animation) -> str:
+ config = anim.config or {}
+ body = config.get("html") or config.get("content") or ""
+ if not isinstance(body, str) or not body.strip():
+ raise HTTPException(status_code=400, detail="当前动画没有可导出的 HTML")
+ title = escape(anim.title or config.get("title") or "教学动画")
+ return f"""
+
+
+
+
+ {title}
+
+
+
+
+ {body}
+
+
+
+"""
+
+
+def has_public_resource(db: Session, anim_id: int) -> bool:
+ return db.query(Resource).filter(
+ Resource.content_ref == f"animation:{anim_id}",
+ Resource.status == "active",
+ Resource.is_public == 1,
+ ).first() is not None
+
+
+@limiter.limit("10/minute")
+@router.post("/ai-generate", response_model=dict)
+async def ai_generate_animation(
+ request: Request,
+ data: AnimationGenerate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "animation_generate", f"生成教学动画:{data.prompt[:80]}")
+ db.commit()
+ result = await ai_service.generate_animation(prompt=data.prompt, anim_type=data.anim_type)
+ return {"success": True, "data": result, "credits": credits_payload(account, "animation_generate")}
+
+
+@router.post("/", response_model=AnimationOut, status_code=201)
+def create_animation(data: AnimationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ anim = Animation(user_id=current_user.id, **data.model_dump())
+ db.add(anim)
+ db.commit()
+ db.refresh(anim)
+ return anim
+
+
+@router.get("/", response_model=list[AnimationOut])
+def list_animations(
+ anim_type: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Animation).filter(Animation.user_id == current_user.id)
+ if anim_type:
+ query = query.filter(Animation.anim_type == anim_type)
+ return query.order_by(Animation.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{anim_id}", response_model=AnimationOut)
+def get_animation(
+ anim_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ return _animation_or_404(anim_id, current_user, db)
+
+
+@router.get("/{anim_id}/export-html")
+def export_animation_html(
+ anim_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ anim = _animation_or_404(anim_id, current_user, db)
+ content = _build_animation_html(anim)
+ filename = _safe_filename(f"{anim.title or '教学动画'}-动画", ".html")
+ return StreamingResponse(
+ BytesIO(content.encode("utf-8")),
+ media_type="text/html; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.put("/{anim_id}", response_model=AnimationOut)
+def update_animation(
+ anim_id: int, data: AnimationUpdate,
+ current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
+):
+ anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
+ if not anim:
+ raise HTTPException(status_code=404, detail="动画不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(anim, key, value)
+ db.commit()
+ db.refresh(anim)
+ return anim
+
+
+@router.post("/{anim_id}/remix", response_model=AnimationOut, status_code=201)
+def remix_animation(
+ anim_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ source = db.query(Animation).filter(Animation.id == anim_id).first()
+ if not source:
+ raise HTTPException(status_code=404, detail="动画不存在")
+ if source.user_id != current_user.id and not has_public_resource(db, anim_id):
+ raise HTTPException(status_code=403, detail="无权改编该动画")
+
+ clone = Animation(
+ user_id=current_user.id,
+ title=f"{source.title}(改编)",
+ anim_type=source.anim_type or "general",
+ description=source.description or "",
+ config=source.config or {},
+ thumbnail=source.thumbnail or "",
+ status="draft",
+ )
+ db.add(clone)
+ db.commit()
+ db.refresh(clone)
+ return clone
+
+
+@router.delete("/{anim_id}", status_code=204)
+def delete_animation(anim_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
+ if not anim:
+ raise HTTPException(status_code=404, detail="动画不存在")
+ db.query(Resource).filter(
+ Resource.user_id == current_user.id,
+ Resource.content_ref == f"animation:{anim_id}",
+ Resource.status == "active",
+ ).update({"status": "archived"}, synchronize_session=False)
+ db.delete(anim)
+ db.commit()
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
new file mode 100644
index 0000000..486e5d8
--- /dev/null
+++ b/backend/routers/auth.py
@@ -0,0 +1,435 @@
+from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+import os
+import secrets
+from config import get_settings
+from database import get_db
+from services.upload_security import validate_upload
+from services.password_policy import validate_password_strength
+from models.animation import Animation
+from models.classroom import ClassroomActivity
+from models.community import Comment, Post, PostFavorite
+from models.courseware import Courseware
+from models.credit import CreditTransaction
+from models.exam import Exam
+from models.essay import EssayGrade
+from models.exercise import Exercise
+from models.lesson_plan import LessonPlan
+from models.mindmap import MindMap
+from models.reset_code import ResetCode
+from models.resource import Resource, ResourceFavorite
+from models.user import User
+from schemas.user import CreditBenefitOut, UserLogin, UserRegister, UserUpdate, UserOut, Token, ProfileStatsOut, PasswordChange, SendCodeRequest, ResetPasswordRequest
+from services.credits import DAILY_CHECKIN_CREDITS, grant_daily_checkin, has_credit_transaction_today, ensure_credit_account
+from services.limiter import limiter
+from services.auth import (
+ get_password_hash, verify_password,
+ create_access_token, create_refresh_token,
+ get_current_user,
+)
+from services.audit import log_action
+
+router = APIRouter(prefix="/api/auth", tags=["认证"])
+
+
+def serialize_user(user: User, db: Session) -> dict:
+ account = ensure_credit_account(db, user)
+ data = UserOut.model_validate(user).model_dump()
+ data.update({
+ "credits": account.balance,
+ "total_credits_granted": account.total_granted,
+ "total_credits_used": account.total_used,
+ })
+ return data
+
+
+@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
+@limiter.limit("5/minute")
+def register(request: Request, data: UserRegister, db: Session = Depends(get_db)):
+ if db.query(User).filter(User.phone == data.phone).first():
+ raise HTTPException(status_code=400, detail="该手机号已注册")
+ pwd_err = validate_password_strength(data.password)
+ if pwd_err:
+ raise HTTPException(status_code=400, detail=pwd_err)
+ user = User(
+ phone=data.phone,
+ password_hash=get_password_hash(data.password),
+ name=data.name,
+ subject=data.subject,
+ school=data.school,
+ grade=data.grade,
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ log_action(db, action="register", user=user, request=request, target_type="user", target_id=user.id, detail=f"注册手机号 {user.phone}")
+ return Token(
+ access_token=create_access_token(user.id),
+ refresh_token=create_refresh_token(user.id),
+ )
+
+
+@router.post("/login", response_model=Token)
+@limiter.limit("10/minute")
+def login(request: Request, data: UserLogin, db: Session = Depends(get_db)):
+ user = db.query(User).filter(User.phone == data.phone).first()
+ if not user or not verify_password(data.password, user.password_hash):
+ log_action(db, action="login", request=request, target_type="user", detail=f"登录失败:{data.phone}", status="failed")
+ raise HTTPException(status_code=401, detail="手机号或密码错误")
+ if not user.is_active:
+ log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="账号已禁用", status="denied")
+ raise HTTPException(status_code=403, detail="账号已被禁用")
+ log_action(db, action="login", user=user, request=request, target_type="user", target_id=user.id, detail="登录成功")
+ return Token(
+ access_token=create_access_token(user.id),
+ refresh_token=create_refresh_token(user.id),
+ )
+
+
+@router.post("/refresh", response_model=Token)
+@limiter.limit("30/minute")
+def refresh_token(request: Request, refresh_token: str, db: Session = Depends(get_db)):
+ """用 refresh_token 换取新的 access_token(与新的 refresh_token)。"""
+ from jose import jwt, JWTError
+ from config import get_settings as _gs
+ _s = _gs()
+ try:
+ payload = jwt.decode(refresh_token, _s.SECRET_KEY, algorithms=[_s.ALGORITHM])
+ if payload.get("type") != "refresh":
+ raise HTTPException(status_code=401, detail="无效的刷新凭证")
+ user_id = int(payload.get("sub"))
+ except (JWTError, TypeError, ValueError):
+ log_action(db, action="refresh_token", request=request, detail="刷新令牌无效或过期", status="failed")
+ raise HTTPException(status_code=401, detail="刷新凭证无效或已过期,请重新登录")
+
+ user = db.query(User).filter(User.id == user_id).first()
+ if user is None:
+ raise HTTPException(status_code=401, detail="用户不存在")
+ if not user.is_active:
+ raise HTTPException(status_code=403, detail="账号已被禁用")
+
+ log_action(db, action="refresh_token", user=user, request=request, target_type="user", target_id=user.id, detail="刷新访问令牌")
+ return Token(
+ access_token=create_access_token(user.id),
+ refresh_token=create_refresh_token(user.id),
+ )
+
+
+@router.get("/me", response_model=UserOut)
+def get_profile(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ db.commit()
+ return serialize_user(current_user, db)
+
+
+@router.put("/me", response_model=UserOut)
+def update_profile(
+ data: UserUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ update_data = data.model_dump(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(current_user, key, value)
+ db.commit()
+ db.refresh(current_user)
+ return serialize_user(current_user, db)
+
+
+@router.post("/change-password", response_model=dict)
+def change_password(
+ data: PasswordChange,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ if not verify_password(data.old_password, current_user.password_hash):
+ log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="旧密码错误", status="failed")
+ raise HTTPException(status_code=400, detail="当前密码错误")
+ if data.old_password == data.new_password:
+ raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
+ new_pwd_err = validate_password_strength(data.new_password)
+ if new_pwd_err:
+ raise HTTPException(status_code=400, detail=new_pwd_err)
+ current_user.password_hash = get_password_hash(data.new_password)
+ db.commit()
+ log_action(db, action="change_password", user=current_user, request=request, target_type="user", target_id=current_user.id, detail="修改密码成功")
+ return {"success": True}
+
+
+@router.post("/avatar", response_model=dict)
+async def upload_avatar(
+ request: Request,
+ file: UploadFile = File(...),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """上传用户头像,返回可访问的 URL。"""
+ content = await file.read()
+ settings = get_settings()
+ check = validate_upload(file, content=content, max_size=5 * 1024 * 1024)
+ # 仅允许图片
+ if check.suffix not in {"png", "jpg", "jpeg", "gif", "webp", "bmp"}:
+ raise HTTPException(status_code=415, detail="头像仅支持 png/jpg/jpeg/gif/webp/bmp 格式")
+ # 保存到 UPLOAD_DIR/avatars/
+ avatars_dir = os.path.join(settings.UPLOAD_DIR, "avatars")
+ os.makedirs(avatars_dir, exist_ok=True)
+ filename = f"user_{current_user.id}_{int(__import__('time').time())}.{check.suffix}"
+ save_path = os.path.join(avatars_dir, filename)
+ with open(save_path, "wb") as f:
+ f.write(content)
+ avatar_url = f"/uploads/avatars/{filename}"
+ current_user.avatar = avatar_url
+ db.commit()
+ log_action(db, action="upload_avatar", user=current_user, request=request, target_type="user", target_id=current_user.id, detail=f"上传头像 {filename}")
+ return {"success": True, "avatar": avatar_url}
+
+
+def credit_benefit_payload(user: User, db: Session) -> dict:
+ account = ensure_credit_account(db, user)
+ daily_claimed = has_credit_transaction_today(db, user, "daily_checkin")
+ recent_checkins = (
+ db.query(CreditTransaction)
+ .filter(CreditTransaction.user_id == user.id, CreditTransaction.action == "daily_checkin")
+ .order_by(CreditTransaction.created_at.desc())
+ .limit(7)
+ .all()
+ )
+ return {
+ "credits": account.balance,
+ "daily_amount": DAILY_CHECKIN_CREDITS,
+ "daily_claimed": daily_claimed,
+ "next_claim_text": "明日可继续领取" if daily_claimed else "今日可领取",
+ "recent_checkins": recent_checkins,
+ }
+
+
+@router.get("/credits/benefits", response_model=CreditBenefitOut)
+def get_credit_benefits(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return credit_benefit_payload(current_user, db)
+
+
+@router.post("/credits/daily-checkin", response_model=CreditBenefitOut)
+def claim_daily_checkin(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ grant_daily_checkin(db, current_user)
+ db.commit()
+ db.refresh(current_user)
+ return credit_benefit_payload(current_user, db)
+
+
+@router.get("/stats", response_model=ProfileStatsOut)
+def get_profile_stats(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ user_id = current_user.id
+ account = ensure_credit_account(db, current_user)
+
+ coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
+ animations = db.query(Animation).filter(Animation.user_id == user_id).all()
+ exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
+ lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
+ essay_grades = db.query(EssayGrade).filter(EssayGrade.user_id == user_id).all()
+ exams = db.query(Exam).filter(Exam.user_id == user_id).all()
+ mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
+ resources = db.query(Resource).filter(Resource.user_id == user_id, Resource.status == "active").all()
+ classroom_activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == user_id).all()
+ community_posts = db.query(Post).filter(Post.user_id == user_id).all()
+
+ works_by_type = {
+ "courseware": len(coursewares),
+ "animation": len(animations),
+ "exercise": len(exercises),
+ "lesson_plan": len(lesson_plans),
+ "essay_grade": len(essay_grades),
+ "exam": len(exams),
+ "mindmap": len(mind_maps),
+ }
+ resources_by_type = {key: 0 for key in ["courseware", "animation", "exercise", "lesson_plan", "exam", "other"]}
+ for resource in resources:
+ resources_by_type[resource.resource_type or "other"] = resources_by_type.get(resource.resource_type or "other", 0) + 1
+
+ published_refs = {item.content_ref for item in resources if item.content_ref}
+ published_refs.update(f"courseware:{item.id}" for item in coursewares if item.status == "published")
+ published_refs.update(f"animation:{item.id}" for item in animations if item.status == "published")
+ published_works = len(published_refs)
+ favorite_count = db.query(ResourceFavorite).filter(ResourceFavorite.user_id == user_id).count()
+ community_favorite_count = db.query(PostFavorite).filter(PostFavorite.user_id == user_id).count()
+ community_comment_count = db.query(Comment).filter(Comment.user_id == user_id).count()
+
+ def source_status(item, item_type: str, fallback: str = "") -> str:
+ if f"{item_type}:{item.id}" in published_refs:
+ return "published"
+ return fallback
+
+ def recent_work(item, item_type: str, subtitle: str = "", status: str = "") -> dict:
+ return {
+ "id": item.id,
+ "item_type": item_type,
+ "title": item.title,
+ "subtitle": subtitle,
+ "status": status,
+ "updated_at": item.updated_at,
+ }
+
+ classroom_type_labels = {
+ "poll": "课堂投票",
+ "qa": "即时问答",
+ "quiz": "随堂测验",
+ "checkin": "课堂签到",
+ "feedback": "学习反馈",
+ }
+ community_type_labels = {
+ "discussion": "案例",
+ "share": "模板",
+ "help": "需求",
+ }
+
+ recent_works = [
+ *[recent_work(item, "courseware", "课件", source_status(item, "courseware", item.status or "draft")) for item in coursewares],
+ *[recent_work(item, "animation", "动画", source_status(item, "animation", item.status or "draft")) for item in animations],
+ *[recent_work(item, "exercise", "互动练习", source_status(item, "exercise", "draft")) for item in exercises],
+ *[recent_work(item, "lesson_plan", "教案", source_status(item, "lesson_plan", "draft")) for item in lesson_plans],
+ *[recent_work(item, "essay_grade", "作文批改", "已批改") for item in essay_grades],
+ *[recent_work(item, "exam", "试卷", source_status(item, "exam", "draft")) for item in exams],
+ *[recent_work(item, "mindmap", "思维导图", "draft") for item in mind_maps],
+ ]
+ recent_works.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
+
+ recent_resources = [
+ {
+ "id": item.id,
+ "item_type": item.resource_type or "other",
+ "title": item.title,
+ "subtitle": "公开" if item.is_public else "私密",
+ "status": item.status or "active",
+ "updated_at": item.updated_at,
+ }
+ for item in sorted(resources, key=lambda resource: resource.updated_at or datetime.min, reverse=True)[:5]
+ ]
+
+ recent_interactions = [
+ *[
+ {
+ "id": item.id,
+ "item_type": "classroom_activity",
+ "title": item.title,
+ "subtitle": f"{classroom_type_labels.get(item.activity_type or '', '课堂活动')} · {len(item.responses or [])} 条提交",
+ "status": item.status or "draft",
+ "updated_at": item.updated_at,
+ }
+ for item in classroom_activities
+ ],
+ *[
+ {
+ "id": item.id,
+ "item_type": "community_post",
+ "title": item.title,
+ "subtitle": f"{community_type_labels.get(item.post_type or '', '社区内容')} · {item.likes or 0} 收藏 · {item.comments_count or 0} 评论",
+ "status": "published",
+ "updated_at": item.updated_at,
+ }
+ for item in community_posts
+ ],
+ ]
+ recent_interactions.sort(key=lambda item: item["updated_at"] or datetime.min, reverse=True)
+
+ total_favorites = db.query(func.count(ResourceFavorite.id)).join(Resource).filter(
+ Resource.user_id == user_id,
+ Resource.status == "active",
+ ).scalar() or 0
+ recent_credit_transactions = sorted(
+ list(current_user.credit_transactions or []),
+ key=lambda item: item.created_at or datetime.min,
+ reverse=True,
+ )[:8]
+
+ return {
+ "credits": account.balance,
+ "total_credits_granted": account.total_granted,
+ "total_credits_used": account.total_used,
+ "total_works": sum(works_by_type.values()),
+ "draft_works": max(sum(works_by_type.values()) - published_works, 0),
+ "published_works": published_works,
+ "published_resources": len(resources),
+ "public_resources": sum(1 for item in resources if item.is_public),
+ "private_resources": sum(1 for item in resources if not item.is_public),
+ "favorite_resources": favorite_count,
+ "total_views": sum(item.views or 0 for item in resources),
+ "total_downloads": sum(item.downloads or 0 for item in resources),
+ "total_favorites": total_favorites,
+ "classroom_activities": len(classroom_activities),
+ "active_classroom_activities": sum(1 for item in classroom_activities if item.status == "active"),
+ "classroom_responses": sum(len(item.responses or []) for item in classroom_activities),
+ "community_posts": len(community_posts),
+ "community_favorites": community_favorite_count,
+ "community_comments": community_comment_count,
+ "works_by_type": works_by_type,
+ "resources_by_type": resources_by_type,
+ "recent_works": recent_works[:5],
+ "recent_resources": recent_resources,
+ "recent_interactions": recent_interactions[:6],
+ "recent_credit_transactions": recent_credit_transactions,
+ }
+
+
+@limiter.limit("3/minute")
+@router.post("/send-code", response_model=dict)
+def send_reset_code(
+ request: Request,
+ data: SendCodeRequest,
+ db: Session = Depends(get_db),
+):
+ """发送密码重置验证码。生产环境对接短信网关;开发环境返回 code 便于调试。"""
+ user = db.query(User).filter(User.phone == data.phone).first()
+ if not user:
+ # 不暴露手机号是否注册,统一返回成功
+ return {"success": True, "message": "验证码已发送"}
+ code = f"{secrets.randbelow(1000000):06d}"
+ expires = datetime.now() + timedelta(minutes=5)
+ # 使该手机号之前未消费的验证码失效
+ db.query(ResetCode).filter(
+ ResetCode.phone == data.phone, ResetCode.consumed == 0
+ ).update({"consumed": 1}, synchronize_session=False)
+ db.add(ResetCode(phone=data.phone, code=code, expires_at=expires, consumed=0))
+ db.commit()
+ # 开发环境返回验证码;生产环境去掉 dev_code 字段
+ return {"success": True, "message": "验证码已发送", "dev_code": code}
+
+
+@router.post("/reset-password", response_model=dict)
+def reset_password(
+ request: Request,
+ data: ResetPasswordRequest,
+ db: Session = Depends(get_db),
+):
+ """通过验证码重置密码。"""
+ pwd_err = validate_password_strength(data.new_password)
+ if pwd_err:
+ raise HTTPException(status_code=400, detail=pwd_err)
+ record = db.query(ResetCode).filter(
+ ResetCode.phone == data.phone,
+ ResetCode.code == data.code,
+ ResetCode.consumed == 0,
+ ).order_by(ResetCode.created_at.desc()).first()
+ if not record:
+ raise HTTPException(status_code=400, detail="验证码无效或已过期")
+ if record.expires_at < datetime.now():
+ record.consumed = 1
+ db.commit()
+ raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
+ user = db.query(User).filter(User.phone == data.phone).first()
+ if not user:
+ raise HTTPException(status_code=400, detail="账号不存在")
+ user.password_hash = get_password_hash(data.new_password)
+ record.consumed = 1
+ db.commit()
+ log_action(db, action="reset_password", user=user, request=request, target_type="user", target_id=user.id, detail="通过验证码重置密码")
+ return {"success": True, "message": "密码重置成功"}
diff --git a/backend/routers/chat.py b/backend/routers/chat.py
new file mode 100644
index 0000000..1441254
--- /dev/null
+++ b/backend/routers/chat.py
@@ -0,0 +1,184 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from sqlalchemy.orm import Session
+from database import get_db
+from models.chat import ChatConversation, ChatMessage
+from models.user import User
+from schemas.chat import ConversationCreate, ConversationRename, ChatSend, MessageOut, ConversationOut
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user
+from services.audit import log_action
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/chat", tags=["AI教学助手"])
+ai_service = AIService()
+
+# 历史上下文窗口:最近 N 轮(user+assistant 计为 2 条),避免 token 超限
+MAX_HISTORY_TURNS = 12
+MAX_TITLE_LEN = 60
+
+
+def _ensure_owned(db: Session, conversation_id: int, user: User) -> ChatConversation:
+ item = db.query(ChatConversation).filter(
+ ChatConversation.id == conversation_id,
+ ChatConversation.user_id == user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="对话不存在")
+ return item
+
+
+def _derive_title(text: str) -> str:
+ cleaned = " ".join(text.split())
+ return (cleaned[:MAX_TITLE_LEN] + "…") if len(cleaned) > MAX_TITLE_LEN else (cleaned or "新对话")
+
+
+@router.get("/conversations", response_model=list[ConversationOut])
+def list_conversations(
+ keyword: str | None = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(30, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(ChatConversation).filter(ChatConversation.user_id == current_user.id)
+ if keyword:
+ query = query.filter(ChatConversation.title.contains(keyword))
+ return (
+ query.order_by(ChatConversation.pinned.desc(), ChatConversation.updated_at.desc())
+ .offset(skip).limit(limit).all()
+ )
+
+
+@router.post("/conversations", response_model=ConversationOut, status_code=201)
+def create_conversation(
+ data: ConversationCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = ChatConversation(
+ user_id=current_user.id,
+ title=(data.title or "新对话").strip() or "新对话",
+ subject=data.subject,
+ grade=data.grade,
+ )
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+ return item
+
+
+@router.get("/conversations/{conversation_id}", response_model=ConversationOut)
+def get_conversation(
+ conversation_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return _ensure_owned(db, conversation_id, current_user)
+
+
+@router.put("/conversations/{conversation_id}", response_model=ConversationOut)
+def rename_conversation(
+ conversation_id: int,
+ data: ConversationRename,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = _ensure_owned(db, conversation_id, current_user)
+ item.title = data.title.strip() or item.title
+ if data.pinned is not None:
+ item.pinned = data.pinned
+ db.commit()
+ db.refresh(item)
+ return item
+
+
+@router.delete("/conversations/{conversation_id}", status_code=204)
+def delete_conversation(
+ conversation_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = _ensure_owned(db, conversation_id, current_user)
+ db.delete(item)
+ db.commit()
+ return None
+
+
+@router.post("/conversations/{conversation_id}/messages", response_model=dict)
+@limiter.limit("20/minute")
+async def send_message(
+ request: Request,
+ conversation_id: int,
+ data: ChatSend,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ conversation = _ensure_owned(db, conversation_id, current_user)
+ account = spend_credits(db, current_user, "chat_generate", "AI教学助手对话")
+ db.add(ChatMessage(
+ conversation_id=conversation.id,
+ role="user",
+ content=data.content,
+ ))
+ db.flush()
+
+ history = (
+ db.query(ChatMessage)
+ .filter(ChatMessage.conversation_id == conversation.id)
+ .order_by(ChatMessage.id.desc())
+ .limit(MAX_HISTORY_TURNS)
+ .all()
+ )
+ history = list(reversed(history))
+ history_payload = [{"role": m.role, "content": m.content} for m in history[:-1]] if history else []
+
+ try:
+ reply = await ai_service.chat_teaching(
+ history=history_payload,
+ user_message=data.content,
+ subject=data.subject or conversation.subject,
+ grade=data.grade or conversation.grade,
+ )
+ except RuntimeError as exc:
+ msg = str(exc)
+ if "CONNECT" in msg or "TIMEOUT" in msg:
+ reply = (
+ "无法连接到 AI 服务,请检查网络或稍后重试。\n"
+ "你的问题我已记录,服务恢复后重新发送即可继续对话。"
+ )
+ elif "EMPTY" in msg:
+ reply = (
+ "AI 模型本次未返回内容(推理模型可能因思考超时导致空回复),请稍后重试或换一种问法。"
+ )
+ else:
+ reply = (
+ "当前 AI 服务暂时不可用,请稍后重试。我已经记录了你的问题,"
+ "服务恢复后再次发送即可继续对话。"
+ )
+
+ assistant_msg = ChatMessage(
+ conversation_id=conversation.id,
+ role="assistant",
+ content=reply,
+ )
+ db.add(assistant_msg)
+
+ first_message = len(conversation.messages) <= 2
+ if conversation.title == "新对话" or first_message:
+ conversation.title = _derive_title(data.content)
+ db.commit()
+ db.refresh(assistant_msg)
+
+ log_action(
+ db, action="chat_send", user=current_user, request=request,
+ target_type="chat_conversation", target_id=conversation.id,
+ detail=f"AI助手对话({len(data.content)}字)",
+ )
+
+ return {
+ "success": True,
+ "data": MessageOut.model_validate(assistant_msg).model_dump(),
+ "title": conversation.title,
+ "credits": credits_payload(account, "chat_generate"),
+ }
diff --git a/backend/routers/classroom.py b/backend/routers/classroom.py
new file mode 100644
index 0000000..22744af
--- /dev/null
+++ b/backend/routers/classroom.py
@@ -0,0 +1,513 @@
+import re
+from collections import Counter
+from datetime import datetime, timezone
+from io import BytesIO
+from html import escape
+from urllib.parse import quote
+
+from docx import Document
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models.classroom import ClassroomActivity
+from models.user import User
+from schemas.classroom import (
+ ClassroomActivityCreate,
+ ClassroomAnalysisOut,
+ ClassroomActivityOut,
+ ClassroomActivityUpdate,
+ ClassroomResponseCreate,
+)
+from services.auth import get_current_user
+
+router = APIRouter(prefix="/api/classroom", tags=["课堂工具"])
+
+
+STOP_WORDS = {
+ "这个", "我们", "老师", "学生", "因为", "所以", "可以", "需要", "没有", "已经",
+ "the", "and", "that", "with", "this", "from", "have", "will",
+}
+
+
+def _format_answer(answer) -> str:
+ if isinstance(answer, str):
+ return answer.strip()
+ if answer is None:
+ return ""
+ return str(answer)
+
+
+def _percent(count: int, total: int) -> int:
+ return round((count / total) * 100) if total else 0
+
+
+def _extract_keywords(text: str) -> list[dict]:
+ words = re.findall(r"[\u4e00-\u9fa5]{2,}|[a-zA-Z0-9]{2,}", text)
+ counter = Counter(word for word in words if word.lower() not in STOP_WORDS)
+ return [{"keyword": word, "count": count} for word, count in counter.most_common(10)]
+
+
+def _infer_correct_answer(activity: ClassroomActivity) -> str:
+ config = activity.config or {}
+ for key in ("answer", "correct_answer", "correctAnswer"):
+ value = config.get(key)
+ if value:
+ return str(value).strip()
+ return ""
+
+
+def _build_mastery_level(activity_type: str, response_count: int, accuracy: int | None, option_stats: list[dict]) -> str:
+ if response_count <= 0:
+ return "暂无数据"
+ if accuracy is not None:
+ if accuracy >= 85:
+ return "整体掌握较好"
+ if accuracy >= 60:
+ return "基础掌握,仍需巩固"
+ return "薄弱点明显,建议重点讲评"
+ if option_stats:
+ top = max(option_stats, key=lambda item: item["count"])
+ if top["percent"] >= 70:
+ return f"倾向集中:{top['option']}"
+ if top["percent"] >= 45:
+ return f"主要倾向:{top['option']}"
+ return "分布分散,需要追问原因"
+ if activity_type in {"qa", "feedback"}:
+ return "已收集开放反馈"
+ return "已收集课堂数据"
+
+
+def _build_diagnosis(
+ activity: ClassroomActivity,
+ response_count: int,
+ participant_count: int,
+ option_stats: list[dict],
+ keywords: list[dict],
+ accuracy: int | None,
+) -> list[str]:
+ if response_count <= 0:
+ return ["尚未收到学生提交,建议先发布活动并提醒学生扫码参与。"]
+ diagnosis = [f"本次共收到 {response_count} 条提交,覆盖 {participant_count} 名学生。"]
+ if accuracy is not None:
+ diagnosis.append(f"按正确答案统计,当前正确率为 {accuracy}%。")
+ if option_stats:
+ top = max(option_stats, key=lambda item: item["count"])
+ diagnosis.append(f"选择最多的是“{top['option']}”,占比 {top['percent']}%。")
+ weak_options = [item for item in option_stats if item["count"] and item["percent"] >= 20 and item is not top]
+ if weak_options:
+ names = "、".join(item["option"] for item in weak_options[:3])
+ diagnosis.append(f"仍有较多学生选择“{names}”,建议追问选择原因。")
+ if keywords:
+ diagnosis.append("开放回答中高频词包括:" + "、".join(item["keyword"] for item in keywords[:5]) + "。")
+ if activity.activity_type == "checkin":
+ diagnosis.append("该活动更适合作为参与记录,可结合后续测验或反馈判断掌握情况。")
+ return diagnosis
+
+
+def _build_teaching_suggestions(
+ activity: ClassroomActivity,
+ response_count: int,
+ option_stats: list[dict],
+ accuracy: int | None,
+ keywords: list[dict],
+) -> list[str]:
+ if response_count <= 0:
+ return ["先用投放链接收集至少 5 条反馈,再生成更可靠的讲评建议。"]
+ suggestions = []
+ if accuracy is not None and accuracy < 60:
+ suggestions.append("用 5 分钟回到概念源头,先讲清关键定义或公式来源,再做同类例题。")
+ suggestions.append("安排 3 道低门槛变式题,确认学生能独立完成基本步骤。")
+ elif accuracy is not None and accuracy < 85:
+ suggestions.append("挑选一个典型错选项进行对比讲评,让学生说出错误思路。")
+ suggestions.append("追加 2 到 3 道变式练习,覆盖易错条件和表达规范。")
+ elif accuracy is not None:
+ suggestions.append("整体掌握较好,可进入拓展任务或让学生互相解释解题依据。")
+ elif option_stats:
+ top = max(option_stats, key=lambda item: item["count"])
+ suggestions.append(f"围绕“{top['option']}”这个主流反馈追问原因,确认学生是理解到位还是凭感觉选择。")
+ suggestions.append("让不同选择的学生各举一个理由,形成板书对比后再归纳共识。")
+ else:
+ suggestions.append("把高频回答聚类成 2 到 3 类,在下一轮讲评中分别回应。")
+ if keywords:
+ suggestions.append(f"优先处理“{keywords[0]['keyword']}”相关问题,并设计一个即时追问。")
+ suggestions.append("课末再投放一次简短反馈,比较讲评前后的变化。")
+ return suggestions[:5]
+
+
+def _build_followup_prompt(activity: ClassroomActivity, analysis: dict) -> str:
+ question = analysis.get("question") or activity.title
+ diagnosis = ";".join(analysis.get("diagnosis") or [])
+ material_context = str((activity.config or {}).get("material_context") or "").strip()
+ material_part = f"参考素材:{material_context}。" if material_context else ""
+ return (
+ f"基于课堂数据回收《{activity.title}》生成一套二次巩固练习。"
+ f"回收题目:{question}。"
+ f"{material_part}"
+ f"学情诊断:{diagnosis}。"
+ "要求题目有梯度,覆盖高频错误点,附答案和解析。"
+ )
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂诊断报告"
+ return f"{stem[:80]}{suffix}"
+
+
+def _excel_cell(value, cell_type: str = "String") -> str:
+ if value is None:
+ value = ""
+ if isinstance(value, datetime):
+ value = value.strftime("%Y-%m-%d %H:%M:%S")
+ text = escape(str(value), quote=False)
+ return f"{text} | "
+
+
+def _excel_row(values: list) -> str:
+ return "" + "".join(_excel_cell(value) for value in values) + "
"
+
+
+def _excel_sheet(name: str, rows: list[list]) -> str:
+ safe_name = escape(name[:31] or "Sheet", quote=True)
+ body = "\n".join(_excel_row(row) for row in rows)
+ return f" "
+
+
+def _build_activity_workbook(activity: ClassroomActivity, analysis: dict) -> bytes:
+ responses = list(activity.responses or [])
+ detail_rows = [
+ ["活动ID", "活动标题", "活动类型", "学生姓名", "回答", "提交时间", "来源"],
+ *[
+ [
+ activity.id,
+ activity.title,
+ activity.activity_type,
+ res.get("student_name") or "匿名",
+ _format_answer(res.get("answer")),
+ res.get("submitted_at") or "",
+ (res.get("meta") or {}).get("source", ""),
+ ]
+ for res in responses
+ ],
+ ]
+ stats_rows = [
+ ["指标", "数值"],
+ ["提交数", analysis["response_count"]],
+ ["参与学生数", analysis["participant_count"]],
+ ["掌握水平", analysis["mastery_level"]],
+ ["正确率", "" if analysis["accuracy"] is None else f"{analysis['accuracy']}%"],
+ ["回收题目", analysis["question"]],
+ ]
+ if analysis["option_stats"]:
+ stats_rows.extend([[], ["选项", "人数", "占比"]])
+ stats_rows.extend([[item["option"], item["count"], f"{item['percent']}%"] for item in analysis["option_stats"]])
+ if analysis["keywords"]:
+ stats_rows.extend([[], ["关键词", "次数"]])
+ stats_rows.extend([[item["keyword"], item["count"]] for item in analysis["keywords"]])
+
+ diagnosis_rows = [
+ ["诊断结论"],
+ *[[item] for item in analysis["diagnosis"]],
+ [],
+ ["讲评建议"],
+ *[[item] for item in analysis["teaching_suggestions"]],
+ [],
+ ["二次练习提示词"],
+ [analysis["followup_prompt"]],
+ ]
+ workbook = f"""
+
+
+{_excel_sheet("提交明细", detail_rows)}
+{_excel_sheet("统计汇总", stats_rows)}
+{_excel_sheet("诊断建议", diagnosis_rows)}
+
+"""
+ return workbook.encode("utf-8")
+
+
+def _analysis_payload(activity: ClassroomActivity) -> dict:
+ responses = list(activity.responses or [])
+ response_count = len(responses)
+ participants = {
+ str(res.get("student_name") or "匿名").strip() or "匿名"
+ for res in responses
+ }
+ participant_count = len(participants)
+ answers = [_format_answer(res.get("answer")) for res in responses]
+ answer_text = " ".join(answers)
+ config = activity.config or {}
+ options = [str(option).strip() for option in config.get("options", []) if str(option).strip()]
+ option_stats = []
+ if options:
+ counter = Counter(answers)
+ option_stats = [
+ {"option": option, "count": counter.get(option, 0), "percent": _percent(counter.get(option, 0), response_count)}
+ for option in options
+ ]
+
+ correct_answer = _infer_correct_answer(activity)
+ accuracy = None
+ if correct_answer and response_count:
+ accuracy = _percent(sum(1 for answer in answers if answer == correct_answer), response_count)
+
+ common_answers = [answer for answer, _ in Counter(answer for answer in answers if answer).most_common(6)]
+ keywords = _extract_keywords(answer_text)
+ mastery_level = _build_mastery_level(activity.activity_type, response_count, accuracy, option_stats)
+ diagnosis = _build_diagnosis(activity, response_count, participant_count, option_stats, keywords, accuracy)
+ teaching_suggestions = _build_teaching_suggestions(activity, response_count, option_stats, accuracy, keywords)
+ payload = {
+ "activity_id": activity.id,
+ "title": activity.title,
+ "activity_type": activity.activity_type,
+ "question": config.get("question") or activity.description or activity.title,
+ "response_count": response_count,
+ "participant_count": participant_count,
+ "option_stats": option_stats,
+ "keywords": keywords,
+ "common_answers": common_answers,
+ "mastery_level": mastery_level,
+ "accuracy": accuracy,
+ "diagnosis": diagnosis,
+ "teaching_suggestions": teaching_suggestions,
+ "generated_at": datetime.now(timezone.utc),
+ }
+ payload["followup_prompt"] = _build_followup_prompt(activity, payload)
+ return payload
+
+
+def _public_activity_payload(activity: ClassroomActivity) -> ClassroomActivity:
+ config = dict(activity.config or {})
+ for key in ("answer", "correct_answer", "correctAnswer", "material_context"):
+ config.pop(key, None)
+ setattr(activity, "config", config)
+ return activity
+
+
+def _add_bullets(document: Document, items: list[str]) -> None:
+ for item in items:
+ document.add_paragraph(str(item), style="List Bullet")
+
+
+@router.post("/activities", response_model=ClassroomActivityOut, status_code=201)
+def create_activity(
+ data: ClassroomActivityCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = ClassroomActivity(user_id=current_user.id, **data.model_dump())
+ db.add(activity)
+ db.commit()
+ db.refresh(activity)
+ return activity
+
+
+@router.get("/activities", response_model=list[ClassroomActivityOut])
+def list_activities(
+ activity_type: str = "",
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id)
+ if activity_type:
+ query = query.filter(ClassroomActivity.activity_type == activity_type)
+ return query.order_by(ClassroomActivity.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/activities/{activity_id}", response_model=ClassroomActivityOut)
+def get_activity(
+ activity_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ return activity
+
+
+@router.get("/activities/{activity_id}/analysis", response_model=ClassroomAnalysisOut)
+def analyze_activity(
+ activity_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ return _analysis_payload(activity)
+
+
+@router.get("/activities/{activity_id}/analysis/export")
+def export_activity_analysis(
+ activity_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+
+ analysis = _analysis_payload(activity)
+ document = Document()
+ document.add_heading(f"课堂学情诊断报告:{activity.title}", level=1)
+ document.add_paragraph(f"活动类型:{activity.activity_type}")
+ document.add_paragraph(f"回收题目:{analysis['question']}")
+ document.add_paragraph(f"生成时间:{analysis['generated_at'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
+
+ document.add_heading("核心指标", level=2)
+ document.add_paragraph(f"提交数:{analysis['response_count']}")
+ document.add_paragraph(f"参与学生数:{analysis['participant_count']}")
+ document.add_paragraph(f"掌握水平:{analysis['mastery_level']}")
+ if analysis["accuracy"] is not None:
+ document.add_paragraph(f"正确率:{analysis['accuracy']}%")
+
+ if analysis["option_stats"]:
+ document.add_heading("选项分布", level=2)
+ table = document.add_table(rows=1, cols=3)
+ table.style = "Table Grid"
+ hdr = table.rows[0].cells
+ hdr[0].text = "选项"
+ hdr[1].text = "人数"
+ hdr[2].text = "占比"
+ for item in analysis["option_stats"]:
+ row = table.add_row().cells
+ row[0].text = item["option"]
+ row[1].text = str(item["count"])
+ row[2].text = f"{item['percent']}%"
+
+ if analysis["keywords"]:
+ document.add_heading("高频关键词", level=2)
+ document.add_paragraph("、".join(f"{item['keyword']}({item['count']})" for item in analysis["keywords"]))
+
+ if analysis["common_answers"]:
+ document.add_heading("常见回答", level=2)
+ _add_bullets(document, analysis["common_answers"])
+
+ document.add_heading("诊断结论", level=2)
+ _add_bullets(document, analysis["diagnosis"])
+
+ document.add_heading("讲评建议", level=2)
+ _add_bullets(document, analysis["teaching_suggestions"])
+
+ document.add_heading("二次练习提示词", level=2)
+ document.add_paragraph(analysis["followup_prompt"])
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(f"{activity.title}-学情诊断报告", ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.get("/activities/{activity_id}/responses/export")
+def export_activity_responses(
+ activity_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+
+ analysis = _analysis_payload(activity)
+ buffer = BytesIO(_build_activity_workbook(activity, analysis))
+ filename = _safe_filename(f"{activity.title}-课堂数据回收", ".xls")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.ms-excel",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.get("/public/activities/{activity_id}", response_model=ClassroomActivityOut)
+def get_public_activity(
+ activity_id: int,
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
+ if not activity or activity.status not in {"active", "closed"}:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ return _public_activity_payload(activity)
+
+
+@router.put("/activities/{activity_id}", response_model=ClassroomActivityOut)
+def update_activity(
+ activity_id: int,
+ data: ClassroomActivityUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(activity, key, value)
+ db.commit()
+ db.refresh(activity)
+ return activity
+
+
+@router.post("/activities/{activity_id}/responses", response_model=dict, status_code=201)
+def submit_response(
+ activity_id: int,
+ data: ClassroomResponseCreate,
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(ClassroomActivity.id == activity_id).first()
+ if not activity or activity.status == "archived":
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ if activity.status != "active":
+ raise HTTPException(status_code=409, detail="课堂活动未发布或已结束")
+
+ responses = list(activity.responses or [])
+ responses.append({
+ "student_name": data.student_name,
+ "answer": data.answer,
+ "meta": data.meta,
+ "submitted_at": datetime.now(timezone.utc).isoformat(),
+ })
+ activity.responses = responses
+ db.commit()
+ return {"success": True, "count": len(responses)}
+
+
+@router.delete("/activities/{activity_id}", status_code=204)
+def delete_activity(
+ activity_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ activity = db.query(ClassroomActivity).filter(
+ ClassroomActivity.id == activity_id,
+ ClassroomActivity.user_id == current_user.id,
+ ).first()
+ if not activity:
+ raise HTTPException(status_code=404, detail="课堂活动不存在")
+ db.delete(activity)
+ db.commit()
diff --git a/backend/routers/community.py b/backend/routers/community.py
new file mode 100644
index 0000000..845100d
--- /dev/null
+++ b/backend/routers/community.py
@@ -0,0 +1,210 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+from typing import Optional
+from database import get_db
+from models.community import Post, Comment, PostFavorite
+from models.user import User
+from schemas.community import PostCreate, PostUpdate, PostOut, CommentCreate, CommentOut
+from services.auth import get_current_user, get_optional_current_user
+from routers.notification import push_notification
+
+router = APIRouter(prefix="/api/community", tags=["教师社区"])
+
+
+def _sync_post_favorite_count(post: Post, db: Session) -> None:
+ post.likes = db.query(PostFavorite).filter(PostFavorite.post_id == post.id).count()
+
+
+def _serialize_post(post: Post, current_user: User | None, db: Session) -> Post:
+ is_favorited = False
+ if current_user:
+ is_favorited = db.query(PostFavorite).filter(
+ PostFavorite.user_id == current_user.id,
+ PostFavorite.post_id == post.id,
+ ).first() is not None
+ setattr(post, "is_favorited", is_favorited)
+ setattr(post, "author", post.author)
+ return post
+
+
+@router.post("/posts", response_model=PostOut, status_code=201)
+def create_post(data: PostCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ post = Post(user_id=current_user.id, **data.model_dump())
+ db.add(post)
+ db.commit()
+ db.refresh(post)
+ return _serialize_post(post, current_user, db)
+
+
+@router.get("/posts", response_model=list[PostOut])
+def list_posts(
+ post_type: Optional[str] = None,
+ keyword: Optional[str] = None,
+ scope: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Post)
+ if scope in {"mine", "favorite"} and not current_user:
+ raise HTTPException(status_code=401, detail="请先登录")
+ if scope == "mine":
+ query = query.filter(Post.user_id == current_user.id)
+ elif scope == "favorite":
+ query = query.join(PostFavorite, PostFavorite.post_id == Post.id).filter(
+ PostFavorite.user_id == current_user.id,
+ )
+ if post_type:
+ query = query.filter(Post.post_type == post_type)
+ if keyword:
+ query = query.filter(or_(
+ Post.title.contains(keyword),
+ Post.content.contains(keyword),
+ ))
+ posts = query.order_by(Post.is_pinned.desc(), Post.created_at.desc()).offset(skip).limit(limit).all()
+ return [_serialize_post(post, current_user, db) for post in posts]
+
+
+@router.get("/posts/ranking", response_model=list[PostOut])
+def ranking_posts(
+ limit: int = Query(5, ge=1, le=20),
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ score = Post.views + Post.likes * 10 + Post.comments_count * 3
+ posts = (
+ db.query(Post)
+ .order_by(Post.is_pinned.desc(), score.desc(), Post.created_at.desc())
+ .limit(limit)
+ .all()
+ )
+ return [_serialize_post(post, current_user, db) for post in posts]
+
+
+@router.get("/posts/{post_id}", response_model=PostOut)
+def get_post(
+ post_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ post.views = (post.views or 0) + 1
+ db.commit()
+ db.refresh(post)
+ return _serialize_post(post, current_user, db)
+
+
+@router.put("/posts/{post_id}", response_model=PostOut)
+def update_post(
+ post_id: int,
+ data: PostUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ update_data = data.model_dump(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(post, key, value)
+ db.commit()
+ db.refresh(post)
+ return _serialize_post(post, current_user, db)
+
+
+@router.post("/posts/{post_id}/like", response_model=dict)
+def like_post(
+ post_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ favorite = db.query(PostFavorite).filter(
+ PostFavorite.user_id == current_user.id,
+ PostFavorite.post_id == post_id,
+ ).first()
+ if not favorite:
+ db.add(PostFavorite(user_id=current_user.id, post_id=post_id))
+ db.flush()
+ push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
+ _sync_post_favorite_count(post, db)
+ db.commit()
+ return {"success": True, "likes": post.likes, "is_favorited": True}
+
+
+@router.delete("/posts/{post_id}/like", response_model=dict)
+def unlike_post(
+ post_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ favorite = db.query(PostFavorite).filter(
+ PostFavorite.user_id == current_user.id,
+ PostFavorite.post_id == post_id,
+ ).first()
+ if favorite:
+ db.delete(favorite)
+ db.flush()
+ _sync_post_favorite_count(post, db)
+ db.commit()
+ return {"success": True, "likes": post.likes, "is_favorited": False}
+
+
+@router.get("/posts/{post_id}/comments", response_model=list[CommentOut])
+def list_comments(post_id: int, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db)):
+ return db.query(Comment).filter(Comment.post_id == post_id).order_by(Comment.created_at).offset(skip).limit(limit).all()
+
+
+@router.post("/posts/{post_id}/comments", response_model=CommentOut, status_code=201)
+def create_comment(
+ post_id: int, data: CommentCreate,
+ current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ comment = Comment(post_id=post_id, user_id=current_user.id, **data.model_dump())
+ db.add(comment)
+ post.comments_count = (post.comments_count or 0) + 1
+ push_notification(db, user_id=post.user_id, actor_id=current_user.id, ntype="comment", title=f"{current_user.name} 评论了你的帖子", content=post.title[:60], link=f"/community/{post_id}")
+ db.commit()
+ db.refresh(comment)
+ return comment
+
+
+@router.delete("/posts/{post_id}/comments/{comment_id}", status_code=204)
+def delete_comment(
+ post_id: int,
+ comment_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ post = db.query(Post).filter(Post.id == post_id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ comment = db.query(Comment).filter(Comment.id == comment_id, Comment.post_id == post_id).first()
+ if not comment:
+ raise HTTPException(status_code=404, detail="评论不存在")
+ if comment.user_id != current_user.id and post.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权删除该评论")
+ db.delete(comment)
+ post.comments_count = max((post.comments_count or 0) - 1, 0)
+ db.commit()
+
+
+@router.delete("/posts/{post_id}", status_code=204)
+def delete_post(post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
+ if not post:
+ raise HTTPException(status_code=404, detail="帖子不存在")
+ db.delete(post)
+ db.commit()
diff --git a/backend/routers/courseware.py b/backend/routers/courseware.py
new file mode 100644
index 0000000..f6826e3
--- /dev/null
+++ b/backend/routers/courseware.py
@@ -0,0 +1,317 @@
+import html
+import re
+import secrets
+from io import BytesIO
+from urllib.parse import quote
+
+from docx import Document
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+from typing import Optional
+from database import get_db
+from models.courseware import Courseware, CoursewareShare
+from models.resource import Resource
+from models.user import User
+from schemas.courseware import CoursewareCreate, CoursewareAIGenerate, CoursewareShareOut, CoursewareUpdate, CoursewareOut
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user, get_optional_current_user
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/coursewares", tags=["课件管理"])
+ai_service = AIService()
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课件"
+ return f"{stem[:80]}{suffix}"
+
+
+def _plain_text_from_html(raw_html: str) -> str:
+ text = str(raw_html or "")
+ text = re.sub(r"<(script|style)[\s\S]*?\1>", " ", text, flags=re.IGNORECASE)
+ text = re.sub(r" ", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"(p|div|section|article|li|h[1-6]|tr)>", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = html.unescape(text)
+ text = re.sub(r"[ \t\u00a0]+", " ", text)
+ text = re.sub(r"\n\s+", "\n", text)
+ text = re.sub(r"\n{3,}", "\n\n", text)
+ return text.strip()
+
+
+def _courseware_or_404(
+ courseware_id: int,
+ current_user: User | None,
+ db: Session,
+) -> Courseware:
+ cw = db.query(Courseware).filter(Courseware.id == courseware_id).first()
+ if not cw or cw.status == "archived":
+ raise HTTPException(status_code=404, detail="课件不存在")
+ if cw.status != "published" and (not current_user or cw.user_id != current_user.id):
+ raise HTTPException(status_code=403, detail="无权访问该课件")
+ return cw
+
+
+def _add_if_present(document: Document, label: str, value: str | None) -> None:
+ if value:
+ document.add_paragraph(f"{label}:{value}")
+
+
+def _share_payload(share: CoursewareShare, request: Request | None = None) -> dict:
+ data = CoursewareShareOut.model_validate(share).model_dump()
+ data["share_url"] = f"/preview/share/{share.token}"
+ return data
+
+
+@limiter.limit("10/minute")
+@router.post("/ai-generate", response_model=dict)
+async def ai_generate_courseware(
+ request: Request,
+ data: CoursewareAIGenerate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "courseware_generate", f"生成互动课件:{data.prompt[:80]}")
+ db.commit()
+ result = await ai_service.generate_courseware(
+ prompt=data.prompt, subject=data.subject, grade=data.grade, page_count=data.page_count,
+ aspect_ratio=data.aspect_ratio,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "courseware_generate")}
+
+
+@router.post("/", response_model=CoursewareOut, status_code=201)
+def create_courseware(data: CoursewareCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ cw = Courseware(user_id=current_user.id, **data.model_dump())
+ db.add(cw)
+ db.commit()
+ db.refresh(cw)
+ return cw
+
+
+@router.get("/", response_model=list[CoursewareOut])
+def list_coursewares(
+ subject: Optional[str] = None,
+ grade: Optional[str] = None,
+ status: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Courseware).filter(Courseware.user_id == current_user.id)
+ if subject:
+ query = query.filter(Courseware.subject == subject)
+ if grade:
+ query = query.filter(Courseware.grade == grade)
+ if status:
+ query = query.filter(Courseware.status == status)
+ else:
+ query = query.filter(Courseware.status != "archived")
+ return query.order_by(Courseware.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/shares/mine/list", response_model=list[CoursewareShareOut])
+def list_my_courseware_shares(
+ request: Request,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ shares = (
+ db.query(CoursewareShare)
+ .filter(CoursewareShare.user_id == current_user.id, CoursewareShare.status == "active")
+ .order_by(CoursewareShare.updated_at.desc())
+ .offset(skip)
+ .limit(limit)
+ .all()
+ )
+ return [_share_payload(share, request) for share in shares]
+
+
+@router.delete("/shares/mine/{share_id}", status_code=204)
+def revoke_courseware_share(
+ share_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ share = db.query(CoursewareShare).filter(
+ CoursewareShare.id == share_id,
+ CoursewareShare.user_id == current_user.id,
+ CoursewareShare.status == "active",
+ ).first()
+ if not share:
+ raise HTTPException(status_code=404, detail="分享不存在")
+ share.status = "revoked"
+ db.commit()
+
+
+@router.get("/shares/{token}", response_model=CoursewareShareOut)
+def get_courseware_share(
+ token: str,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ share = db.query(CoursewareShare).filter(
+ CoursewareShare.token == token,
+ CoursewareShare.status == "active",
+ ).first()
+ if not share:
+ raise HTTPException(status_code=404, detail="分享不存在或已失效")
+ share.views = (share.views or 0) + 1
+ db.commit()
+ db.refresh(share)
+ return _share_payload(share, request)
+
+
+@router.get("/{courseware_id}", response_model=CoursewareOut)
+def get_courseware(
+ courseware_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ return _courseware_or_404(courseware_id, current_user, db)
+
+
+@router.post("/{courseware_id}/share", response_model=CoursewareShareOut, status_code=201)
+def create_courseware_share(
+ courseware_id: int,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ cw = db.query(Courseware).filter(
+ Courseware.id == courseware_id,
+ Courseware.user_id == current_user.id,
+ Courseware.status != "archived",
+ ).first()
+ if not cw:
+ raise HTTPException(status_code=404, detail="课件不存在")
+ share = db.query(CoursewareShare).filter(
+ CoursewareShare.courseware_id == courseware_id,
+ CoursewareShare.user_id == current_user.id,
+ CoursewareShare.status == "active",
+ ).first()
+ if not share:
+ share = CoursewareShare(
+ courseware_id=courseware_id,
+ user_id=current_user.id,
+ token=secrets.token_urlsafe(18),
+ )
+ db.add(share)
+ share.title = cw.title
+ share.subject = cw.subject or ""
+ share.grade = cw.grade or ""
+ share.description = cw.description or ""
+ share.content = cw.content or []
+ share.tags = list(cw.tags or [])
+ share.status = "active"
+ db.commit()
+ db.refresh(share)
+ return _share_payload(share, request)
+
+
+@router.get("/{courseware_id}/export-docx")
+def export_courseware_docx(
+ courseware_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ cw = _courseware_or_404(courseware_id, current_user, db)
+ document = Document()
+ document.add_heading(cw.title or "互动课件讲义", level=1)
+
+ meta_parts = [part for part in [cw.subject, cw.grade] if part]
+ if meta_parts:
+ document.add_paragraph(" / ".join(meta_parts))
+ _add_if_present(document, "课件说明", cw.description)
+ if cw.tags:
+ document.add_paragraph("标签:" + "、".join(str(tag) for tag in cw.tags if str(tag).strip()))
+
+ pages = cw.content or []
+ document.add_paragraph(f"页数:{len(pages)}")
+
+ for index, page in enumerate(pages, start=1):
+ if not isinstance(page, dict):
+ continue
+ page_title = str(page.get("title") or f"第 {index} 页").strip()
+ document.add_heading(f"第 {index} 页:{page_title}", level=2)
+ page_type = page.get("type")
+ if page_type:
+ document.add_paragraph(f"页面类型:{page_type}")
+ text = _plain_text_from_html(page.get("content") or "")
+ document.add_paragraph(text or "该页暂无可提取文本。")
+ notes = str(page.get("notes") or "").strip()
+ if notes:
+ document.add_paragraph(f"教师备注:{notes}")
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(f"{cw.title or '课件'}-讲义", ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.put("/{courseware_id}", response_model=CoursewareOut)
+def update_courseware(
+ courseware_id: int, data: CoursewareUpdate,
+ current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
+):
+ cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
+ if not cw:
+ raise HTTPException(status_code=404, detail="课件不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(cw, key, value)
+ db.commit()
+ db.refresh(cw)
+ return cw
+
+
+@router.post("/{courseware_id}/remix", response_model=CoursewareOut, status_code=201)
+def remix_courseware(
+ courseware_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ source = db.query(Courseware).filter(Courseware.id == courseware_id).first()
+ if not source or source.status == "archived":
+ raise HTTPException(status_code=404, detail="课件不存在")
+ if source.status != "published" and source.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权改编该课件")
+
+ clone = Courseware(
+ user_id=current_user.id,
+ title=f"{source.title}(改编)",
+ subject=source.subject or "",
+ grade=source.grade or "",
+ description=source.description or "",
+ content=source.content or [],
+ cover_image=source.cover_image or "",
+ tags=list(source.tags or []),
+ status="draft",
+ )
+ db.add(clone)
+ db.commit()
+ db.refresh(clone)
+ return clone
+
+
+@router.delete("/{courseware_id}", status_code=204)
+def delete_courseware(courseware_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
+ if not cw:
+ raise HTTPException(status_code=404, detail="课件不存在")
+ cw.status = "archived"
+ db.query(Resource).filter(
+ Resource.user_id == current_user.id,
+ Resource.content_ref == f"courseware:{courseware_id}",
+ Resource.status == "active",
+ ).update({"status": "archived"}, synchronize_session=False)
+ db.commit()
diff --git a/backend/routers/essay.py b/backend/routers/essay.py
new file mode 100644
index 0000000..81762b9
--- /dev/null
+++ b/backend/routers/essay.py
@@ -0,0 +1,83 @@
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+from database import get_db
+from models.essay import EssayGrade
+from models.user import User
+from schemas.essay import EssayGradeCreate, EssayGradeOut
+from services.auth import get_current_user
+from services.ai_service import AIService
+from services.credits import spend_credits
+
+router = APIRouter(prefix="/api/essay-grades", tags=["作文批改"])
+ai_service = AIService()
+
+
+@router.post("/", response_model=EssayGradeOut, status_code=201)
+async def create_essay_grade(
+ data: EssayGradeCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """Submit an essay for AI grading; the result is persisted automatically."""
+ account = spend_credits(db, current_user, "essay_grade", f"作文批改:{data.title[:40]}")
+ db.commit()
+
+ result = await ai_service.grade_essay(
+ essay_text=data.essay_text, grade_level=data.grade_level,
+ essay_type=data.essay_type, total_score=data.total_score,
+ )
+
+ item = EssayGrade(
+ user_id=current_user.id,
+ title=data.title,
+ essay_text=data.essay_text,
+ grade_level=data.grade_level,
+ essay_type=data.essay_type,
+ total_score=data.total_score,
+ result=result,
+ )
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+ return item
+
+
+@router.get("/", response_model=list[EssayGradeOut])
+def list_essay_grades(
+ keyword: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(EssayGrade).filter(EssayGrade.user_id == current_user.id)
+ if keyword:
+ query = query.filter(EssayGrade.title.contains(keyword))
+ return query.order_by(EssayGrade.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{grade_id}", response_model=EssayGradeOut)
+def get_essay_grade(
+ grade_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="批改记录不存在")
+ return item
+
+
+@router.delete("/{grade_id}", status_code=204)
+def delete_essay_grade(
+ grade_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(EssayGrade).filter(EssayGrade.id == grade_id, EssayGrade.user_id == current_user.id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="批改记录不存在")
+ db.delete(item)
+ db.commit()
+ return None
diff --git a/backend/routers/exam.py b/backend/routers/exam.py
new file mode 100644
index 0000000..bbecd82
--- /dev/null
+++ b/backend/routers/exam.py
@@ -0,0 +1,198 @@
+import re
+from io import BytesIO
+from urllib.parse import quote
+
+from docx import Document
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models.exam import Exam
+from models.resource import Resource
+from models.user import User
+from schemas.exam import ExamCreate, ExamOut, ExamUpdate
+from services.auth import get_current_user, get_optional_current_user
+
+router = APIRouter(prefix="/api/exams", tags=["智能命题"])
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "试卷"
+ return f"{stem[:80]}{suffix}"
+
+
+def _as_list(value) -> list:
+ if isinstance(value, list):
+ return value
+ if value in (None, ""):
+ return []
+ return [value]
+
+
+def _exam_or_404(exam_id: int, current_user: User | None, db: Session) -> Exam:
+ exam = db.query(Exam).filter(Exam.id == exam_id).first()
+ if not exam:
+ raise HTTPException(status_code=404, detail="试卷不存在")
+ if (not current_user or exam.user_id != current_user.id) and not has_public_resource(db, exam_id):
+ raise HTTPException(status_code=403, detail="无权访问该试卷")
+ return exam
+
+
+def has_public_resource(db: Session, exam_id: int) -> bool:
+ return db.query(Resource).filter(
+ Resource.content_ref == f"exam:{exam_id}",
+ Resource.status == "active",
+ Resource.is_public == 1,
+ ).first() is not None
+
+
+@router.post("/", response_model=ExamOut, status_code=201)
+def create_exam(data: ExamCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ exam = Exam(user_id=current_user.id, **data.model_dump())
+ db.add(exam)
+ db.commit()
+ db.refresh(exam)
+ return exam
+
+
+@router.get("/", response_model=list[ExamOut])
+def list_exams(
+ subject: str = "",
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Exam).filter(Exam.user_id == current_user.id)
+ if subject:
+ query = query.filter(Exam.subject == subject)
+ return query.order_by(Exam.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{exam_id}", response_model=ExamOut)
+def get_exam(
+ exam_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ return _exam_or_404(exam_id, current_user, db)
+
+
+@router.get("/{exam_id}/export-docx")
+def export_exam_docx(
+ exam_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ exam = _exam_or_404(exam_id, current_user, db)
+ document = Document()
+ document.add_heading(exam.title or "智能试卷", level=1)
+ meta = [
+ exam.subject,
+ exam.grade,
+ f"{exam.duration or 90}分钟",
+ f"总分{exam.total_score or 100}分",
+ f"难度:{exam.difficulty or 'medium'}",
+ ]
+ if exam.knowledge_points:
+ meta.append("知识点:" + "、".join(str(item) for item in exam.knowledge_points if str(item).strip()))
+ document.add_paragraph(" / ".join(str(part) for part in meta if part))
+
+ for index, question in enumerate(exam.questions or [], start=1):
+ if not isinstance(question, dict):
+ document.add_paragraph(f"{index}. {question}")
+ continue
+ q_type = question.get("type") or question.get("question_type") or "题目"
+ score = question.get("score")
+ heading = f"{index}. [{q_type}]"
+ if score:
+ heading += f"({score}分)"
+ document.add_paragraph(heading)
+ document.add_paragraph(str(question.get("content") or question.get("question") or question.get("stem") or ""))
+ options = _as_list(question.get("options"))
+ for option_index, option in enumerate(options):
+ prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
+ document.add_paragraph(f"{prefix}. {option}")
+
+ answers = exam.answers or [question.get("answer") for question in exam.questions or [] if isinstance(question, dict)]
+ if answers:
+ document.add_page_break()
+ document.add_heading("参考答案与解析", level=1)
+ for index, question in enumerate(exam.questions or [], start=1):
+ answer = answers[index - 1] if index - 1 < len(answers) else ""
+ analysis = question.get("analysis") or question.get("explanation") if isinstance(question, dict) else ""
+ document.add_paragraph(f"{index}. 答案:{answer}")
+ if analysis:
+ document.add_paragraph(f"解析:{analysis}")
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(f"{exam.title or '智能试卷'}-试卷", ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.put("/{exam_id}", response_model=ExamOut)
+def update_exam(
+ exam_id: int,
+ data: ExamUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
+ if not exam:
+ raise HTTPException(status_code=404, detail="试卷不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(exam, key, value)
+ db.commit()
+ db.refresh(exam)
+ return exam
+
+
+@router.post("/{exam_id}/remix", response_model=ExamOut, status_code=201)
+def remix_exam(
+ exam_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ source = db.query(Exam).filter(Exam.id == exam_id).first()
+ if not source:
+ raise HTTPException(status_code=404, detail="试卷不存在")
+ if source.user_id != current_user.id and not has_public_resource(db, exam_id):
+ raise HTTPException(status_code=403, detail="无权改编该试卷")
+
+ clone = Exam(
+ user_id=current_user.id,
+ title=f"{source.title}(改编)",
+ subject=source.subject or "",
+ grade=source.grade or "",
+ questions=list(source.questions or []),
+ answers=list(source.answers or []),
+ duration=source.duration or 90,
+ total_score=source.total_score or 100,
+ difficulty=source.difficulty or "medium",
+ knowledge_points=list(source.knowledge_points or []),
+ )
+ db.add(clone)
+ db.commit()
+ db.refresh(clone)
+ return clone
+
+
+@router.delete("/{exam_id}", status_code=204)
+def delete_exam(exam_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ exam = db.query(Exam).filter(Exam.id == exam_id, Exam.user_id == current_user.id).first()
+ if not exam:
+ raise HTTPException(status_code=404, detail="试卷不存在")
+ db.query(Resource).filter(
+ Resource.user_id == current_user.id,
+ Resource.content_ref == f"exam:{exam_id}",
+ Resource.status == "active",
+ ).update({"status": "archived"}, synchronize_session=False)
+ db.delete(exam)
+ db.commit()
diff --git a/backend/routers/exercise.py b/backend/routers/exercise.py
new file mode 100644
index 0000000..d5fafd9
--- /dev/null
+++ b/backend/routers/exercise.py
@@ -0,0 +1,248 @@
+import re
+from io import BytesIO
+from urllib.parse import quote
+
+from docx import Document
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+from typing import Optional
+from database import get_db
+from models.exercise import Exercise, ExerciseAttempt
+from models.resource import Resource
+from models.user import User
+from schemas.exercise import ExerciseCreate, ExerciseAIGenerate, ExerciseAttemptCreate, ExerciseAttemptOut, ExerciseOut, ExerciseUpdate
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user, get_optional_current_user
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/exercises", tags=["互动练习"])
+ai_service = AIService()
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂练习"
+ return f"{stem[:80]}{suffix}"
+
+
+def _as_list(value) -> list:
+ if isinstance(value, list):
+ return value
+ if value in (None, ""):
+ return []
+ return [value]
+
+
+def _exercise_or_404(exercise_id: int, current_user: User | None, db: Session) -> Exercise:
+ ex = db.query(Exercise).filter(Exercise.id == exercise_id).first()
+ if not ex:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ if (not current_user or ex.user_id != current_user.id) and not has_public_resource(db, exercise_id):
+ raise HTTPException(status_code=403, detail="无权访问该练习")
+ return ex
+
+
+def has_public_resource(db: Session, exercise_id: int) -> bool:
+ return db.query(Resource).filter(
+ Resource.content_ref == f"exercise:{exercise_id}",
+ Resource.status == "active",
+ Resource.is_public == 1,
+ ).first() is not None
+
+
+@limiter.limit("10/minute")
+@router.post("/ai-generate", response_model=dict)
+async def ai_generate_exercise(
+ request: Request,
+ data: ExerciseAIGenerate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "exercise_generate", f"生成互动练习:{data.prompt[:80]}")
+ db.commit()
+ result = await ai_service.generate_exercise(
+ prompt=data.prompt, exercise_type=data.exercise_type,
+ subject=data.subject, knowledge_points=data.knowledge_points, count=data.count,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "exercise_generate")}
+
+
+@router.post("/", response_model=ExerciseOut, status_code=201)
+def create_exercise(data: ExerciseCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ ex = Exercise(user_id=current_user.id, **data.model_dump())
+ db.add(ex)
+ db.commit()
+ db.refresh(ex)
+ return ex
+
+
+@router.get("/", response_model=list[ExerciseOut])
+def list_exercises(
+ exercise_type: Optional[str] = None,
+ subject: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Exercise).filter(Exercise.user_id == current_user.id)
+ if exercise_type:
+ query = query.filter(Exercise.exercise_type == exercise_type)
+ if subject:
+ query = query.filter(Exercise.subject == subject)
+ return query.order_by(Exercise.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{exercise_id}", response_model=ExerciseOut)
+def get_exercise(
+ exercise_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ return _exercise_or_404(exercise_id, current_user, db)
+
+
+@router.get("/{exercise_id}/export-docx")
+def export_exercise_docx(
+ exercise_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ ex = _exercise_or_404(exercise_id, current_user, db)
+ document = Document()
+ document.add_heading(ex.title or "课堂练习", level=1)
+ meta_parts = [ex.subject, ex.exercise_type, f"{len(ex.questions or [])}题"]
+ if ex.knowledge_points:
+ meta_parts.append("知识点:" + "、".join(str(item) for item in ex.knowledge_points if str(item).strip()))
+ document.add_paragraph(" / ".join(str(part) for part in meta_parts if part))
+
+ for index, question in enumerate(ex.questions or [], start=1):
+ if not isinstance(question, dict):
+ document.add_paragraph(f"{index}. {question}")
+ continue
+ q_type = question.get("type") or ex.exercise_type or "练习"
+ difficulty = question.get("difficulty")
+ heading = f"{index}. [{q_type}]"
+ if difficulty:
+ heading += f"({difficulty})"
+ document.add_paragraph(heading)
+ document.add_paragraph(str(question.get("question") or question.get("content") or question.get("stem") or ""))
+ options = _as_list(question.get("options"))
+ for option_index, option in enumerate(options):
+ prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
+ document.add_paragraph(f"{prefix}. {option}")
+ answer = question.get("answer")
+ if answer not in (None, ""):
+ document.add_paragraph(f"答案:{answer}")
+ explanation = question.get("explanation") or question.get("analysis")
+ if explanation:
+ document.add_paragraph(f"解析:{explanation}")
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(f"{ex.title or '课堂练习'}-练习", ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.put("/{exercise_id}", response_model=ExerciseOut)
+def update_exercise(
+ exercise_id: int,
+ data: ExerciseUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
+ if not ex:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(ex, key, value)
+ db.commit()
+ db.refresh(ex)
+ return ex
+
+
+@router.post("/{exercise_id}/remix", response_model=ExerciseOut, status_code=201)
+def remix_exercise(
+ exercise_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ source = db.query(Exercise).filter(Exercise.id == exercise_id).first()
+ if not source:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ if source.user_id != current_user.id and not has_public_resource(db, exercise_id):
+ raise HTTPException(status_code=403, detail="无权改编该练习")
+
+ clone = Exercise(
+ user_id=current_user.id,
+ title=f"{source.title}(改编)",
+ exercise_type=source.exercise_type or "choice",
+ subject=source.subject or "",
+ knowledge_points=list(source.knowledge_points or []),
+ questions=list(source.questions or []),
+ settings=dict(source.settings or {}),
+ )
+ db.add(clone)
+ db.commit()
+ db.refresh(clone)
+ return clone
+
+
+@router.delete("/{exercise_id}", status_code=204)
+def delete_exercise(exercise_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
+ if not ex:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ db.query(Resource).filter(
+ Resource.user_id == current_user.id,
+ Resource.content_ref == f"exercise:{exercise_id}",
+ Resource.status == "active",
+ ).update({"status": "archived"}, synchronize_session=False)
+ db.delete(ex)
+ db.commit()
+
+
+@router.post("/{exercise_id}/attempt", response_model=dict, status_code=201)
+def submit_attempt(exercise_id: int, data: ExerciseAttemptCreate, db: Session = Depends(get_db)):
+ exercise = db.query(Exercise).filter(Exercise.id == exercise_id).first()
+ if not exercise:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ attempt = ExerciseAttempt(
+ exercise_id=exercise_id,
+ student_name=data.student_name,
+ answers=data.answers,
+ score=data.score,
+ total=data.total,
+ duration=data.duration,
+ )
+ db.add(attempt)
+ db.commit()
+ db.refresh(attempt)
+ return {"success": True, "id": attempt.id}
+
+
+@router.get("/{exercise_id}/attempts", response_model=list[ExerciseAttemptOut])
+def list_attempts(
+ exercise_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ exercise = db.query(Exercise).filter(
+ Exercise.id == exercise_id,
+ Exercise.user_id == current_user.id,
+ ).first()
+ if not exercise:
+ raise HTTPException(status_code=404, detail="练习不存在")
+ return (
+ db.query(ExerciseAttempt)
+ .filter(ExerciseAttempt.exercise_id == exercise_id)
+ .order_by(ExerciseAttempt.created_at.desc())
+ .limit(200)
+ .all()
+ )
diff --git a/backend/routers/lesson_plan.py b/backend/routers/lesson_plan.py
new file mode 100644
index 0000000..71130ab
--- /dev/null
+++ b/backend/routers/lesson_plan.py
@@ -0,0 +1,245 @@
+import re
+from io import BytesIO
+from urllib.parse import quote
+
+from docx import Document
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+from database import get_db
+from models.lesson_plan import LessonPlan
+from models.resource import Resource
+from models.user import User
+from schemas.lesson_plan import LessonPlanGenerate, LessonPlanCreate, LessonPlanOut, LessonPlanUpdate
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user, get_optional_current_user
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/lesson-plans", tags=["教案管理"])
+ai_service = AIService()
+
+
+def _safe_filename(name: str, suffix: str) -> str:
+ stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教案"
+ return f"{stem[:80]}{suffix}"
+
+
+def _as_list(value) -> list:
+ if isinstance(value, list):
+ return value
+ if value in (None, ""):
+ return []
+ return [value]
+
+
+def _add_bullets(document: Document, items: list) -> None:
+ for item in items:
+ if isinstance(item, dict):
+ text = ";".join(f"{key}:{value}" for key, value in item.items() if value not in (None, "", []))
+ else:
+ text = str(item)
+ if text.strip():
+ document.add_paragraph(text.strip(), style="List Bullet")
+
+
+def _lesson_plan_or_404(plan_id: int, current_user: User | None, db: Session) -> LessonPlan:
+ lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
+ if not lp:
+ raise HTTPException(status_code=404, detail="教案不存在")
+ if (not current_user or lp.user_id != current_user.id) and not has_public_resource(db, plan_id):
+ raise HTTPException(status_code=403, detail="无权访问该教案")
+ return lp
+
+
+def has_public_resource(db: Session, plan_id: int) -> bool:
+ return db.query(Resource).filter(
+ Resource.content_ref == f"lesson_plan:{plan_id}",
+ Resource.status == "active",
+ Resource.is_public == 1,
+ ).first() is not None
+
+
+@limiter.limit("10/minute")
+@router.post("/ai-generate", response_model=dict)
+async def ai_generate_lesson_plan(
+ request: Request,
+ data: LessonPlanGenerate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "lesson_plan_generate", f"生成教案:{data.title[:80]}")
+ db.commit()
+ result = await ai_service.generate_lesson_plan(
+ title=data.title, subject=data.subject, grade=data.grade,
+ objectives=data.objectives, duration=data.duration,
+ extra_requirements=data.extra_requirements,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "lesson_plan_generate")}
+
+
+@router.post("/", response_model=LessonPlanOut, status_code=201)
+def create_lesson_plan(data: LessonPlanCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ lp = LessonPlan(user_id=current_user.id, **data.model_dump())
+ db.add(lp)
+ db.commit()
+ db.refresh(lp)
+ return lp
+
+
+@router.get("/", response_model=list[LessonPlanOut])
+def list_lesson_plans(
+ subject: str = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(LessonPlan).filter(LessonPlan.user_id == current_user.id)
+ if subject:
+ query = query.filter(LessonPlan.subject == subject)
+ return query.order_by(LessonPlan.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{plan_id}", response_model=LessonPlanOut)
+def get_lesson_plan(
+ plan_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ return _lesson_plan_or_404(plan_id, current_user, db)
+
+
+@router.get("/{plan_id}/export-docx")
+def export_lesson_plan_docx(
+ plan_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ lp = _lesson_plan_or_404(plan_id, current_user, db)
+ content = lp.content or {}
+
+ document = Document()
+ document.add_heading(lp.title or content.get("title") or "大单元教案", level=1)
+ meta = " / ".join(str(part) for part in [lp.subject, lp.grade, f"{lp.duration or 45}分钟"] if part)
+ if meta:
+ document.add_paragraph(meta)
+
+ sections = [
+ ("教学目标", lp.objectives or content.get("objectives")),
+ ("教学重点", lp.key_points or content.get("key_points")),
+ ("教学难点", lp.difficulties or content.get("difficulties")),
+ ("教学材料", lp.materials or content.get("materials")),
+ ]
+ for title, items in sections:
+ items = _as_list(items)
+ if items:
+ document.add_heading(title, level=2)
+ _add_bullets(document, items)
+
+ phases = _as_list(content.get("phases") or content.get("steps"))
+ if phases:
+ document.add_heading("教学过程", level=2)
+ for index, phase in enumerate(phases, start=1):
+ if not isinstance(phase, dict):
+ document.add_paragraph(f"{index}. {phase}")
+ continue
+ name = phase.get("name") or phase.get("title") or f"教学环节 {index}"
+ duration = phase.get("duration")
+ heading = f"{index}. {name}"
+ if duration:
+ heading += f"({duration}分钟)"
+ document.add_heading(heading, level=3)
+ for label, key in [
+ ("教学活动", "activities"),
+ ("教师行为", "teacher_actions"),
+ ("学生行为", "student_actions"),
+ ("资源材料", "resources"),
+ ("评价方式", "assessment"),
+ ]:
+ items = _as_list(phase.get(key))
+ if items:
+ document.add_paragraph(label)
+ _add_bullets(document, items)
+
+ homework = _as_list(lp.homework or content.get("homework"))
+ if homework:
+ document.add_heading("课后作业", level=2)
+ _add_bullets(document, homework)
+
+ reflection = content.get("reflection")
+ if reflection:
+ document.add_heading("教学反思", level=2)
+ document.add_paragraph(str(reflection))
+
+ buffer = BytesIO()
+ document.save(buffer)
+ buffer.seek(0)
+ filename = _safe_filename(f"{lp.title or '大单元教案'}-教案", ".docx")
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
+ )
+
+
+@router.put("/{plan_id}", response_model=LessonPlanOut)
+def update_lesson_plan(
+ plan_id: int,
+ data: LessonPlanUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
+ if not lp:
+ raise HTTPException(status_code=404, detail="教案不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(lp, key, value)
+ db.commit()
+ db.refresh(lp)
+ return lp
+
+
+@router.post("/{plan_id}/remix", response_model=LessonPlanOut, status_code=201)
+def remix_lesson_plan(
+ plan_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ source = db.query(LessonPlan).filter(LessonPlan.id == plan_id).first()
+ if not source:
+ raise HTTPException(status_code=404, detail="教案不存在")
+ if source.user_id != current_user.id and not has_public_resource(db, plan_id):
+ raise HTTPException(status_code=403, detail="无权改编该教案")
+
+ clone = LessonPlan(
+ user_id=current_user.id,
+ title=f"{source.title}(改编)",
+ subject=source.subject or "",
+ grade=source.grade or "",
+ objectives=list(source.objectives or []),
+ key_points=list(source.key_points or []),
+ difficulties=list(source.difficulties or []),
+ content=dict(source.content or {}),
+ duration=source.duration or 45,
+ materials=list(source.materials or []),
+ homework=list(source.homework or []),
+ )
+ db.add(clone)
+ db.commit()
+ db.refresh(clone)
+ return clone
+
+
+@router.delete("/{plan_id}", status_code=204)
+def delete_lesson_plan(plan_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ lp = db.query(LessonPlan).filter(LessonPlan.id == plan_id, LessonPlan.user_id == current_user.id).first()
+ if not lp:
+ raise HTTPException(status_code=404, detail="教案不存在")
+ db.query(Resource).filter(
+ Resource.user_id == current_user.id,
+ Resource.content_ref == f"lesson_plan:{plan_id}",
+ Resource.status == "active",
+ ).update({"status": "archived"}, synchronize_session=False)
+ db.delete(lp)
+ db.commit()
diff --git a/backend/routers/material.py b/backend/routers/material.py
new file mode 100644
index 0000000..3fdc5be
--- /dev/null
+++ b/backend/routers/material.py
@@ -0,0 +1,98 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models.material import Material
+from models.user import User
+from schemas.material import MaterialCreate, MaterialOut, MaterialUpdate
+from services.auth import get_current_user
+
+router = APIRouter(prefix="/api/materials", tags=["素材库"])
+
+
+@router.post("/", response_model=MaterialOut, status_code=201)
+def create_material(
+ data: MaterialCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ material = Material(user_id=current_user.id, **data.model_dump())
+ db.add(material)
+ db.commit()
+ db.refresh(material)
+ return material
+
+
+@router.get("/", response_model=list[MaterialOut])
+def list_materials(
+ material_type: Optional[str] = None,
+ subject: Optional[str] = None,
+ keyword: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
+ if material_type:
+ query = query.filter(Material.material_type == material_type)
+ if subject:
+ query = query.filter(Material.subject == subject)
+ if keyword:
+ query = query.filter(or_(
+ Material.title.contains(keyword),
+ Material.filename.contains(keyword),
+ Material.summary.contains(keyword),
+ Material.subject.contains(keyword),
+ Material.grade.contains(keyword),
+ ))
+ return query.order_by(Material.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{material_id}", response_model=MaterialOut)
+def get_material(
+ material_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ material = db.query(Material).filter(
+ Material.id == material_id,
+ Material.user_id == current_user.id,
+ Material.status == "active",
+ ).first()
+ if not material:
+ raise HTTPException(status_code=404, detail="素材不存在")
+ return material
+
+
+@router.put("/{material_id}", response_model=MaterialOut)
+def update_material(
+ material_id: int,
+ data: MaterialUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
+ if not material or material.status == "archived":
+ raise HTTPException(status_code=404, detail="素材不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(material, key, value)
+ db.commit()
+ db.refresh(material)
+ return material
+
+
+@router.delete("/{material_id}", status_code=204)
+def delete_material(
+ material_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ material = db.query(Material).filter(Material.id == material_id, Material.user_id == current_user.id).first()
+ if not material or material.status == "archived":
+ raise HTTPException(status_code=404, detail="素材不存在")
+ material.status = "archived"
+ db.commit()
diff --git a/backend/routers/mindmap.py b/backend/routers/mindmap.py
new file mode 100644
index 0000000..f30a5b6
--- /dev/null
+++ b/backend/routers/mindmap.py
@@ -0,0 +1,108 @@
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from sqlalchemy.orm import Session
+from database import get_db
+from models.mindmap import MindMap
+from models.user import User
+from schemas.mindmap import (
+ MindMapGenerate, MindMapCreate, MindMapUpdate, MindMapOut,
+)
+from services.credits import credits_payload, spend_credits
+from services.limiter import limiter
+from services.auth import get_current_user
+from services.ai_service import AIService
+
+router = APIRouter(prefix="/api/mind-maps", tags=["思维导图管理"])
+ai_service = AIService()
+
+
+@limiter.limit("10/minute")
+@router.post("/ai-generate", response_model=dict)
+async def ai_generate_mind_map(
+ request: Request,
+ data: MindMapGenerate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ account = spend_credits(db, current_user, "mindmap_generate", f"生成思维导图:{data.topic[:80]}")
+ db.commit()
+ result = await ai_service.generate_mindmap(
+ topic=data.topic, subject=data.subject, grade=data.grade,
+ )
+ return {"success": True, "data": result, "credits": credits_payload(account, "mindmap_generate")}
+
+
+@router.post("/", response_model=MindMapOut, status_code=201)
+def create_mind_map(
+ data: MindMapCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = MindMap(user_id=current_user.id, **data.model_dump())
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+ return item
+
+
+@router.get("/", response_model=list[MindMapOut])
+def list_mind_maps(
+ keyword: Optional[str] = None,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(MindMap).filter(MindMap.user_id == current_user.id)
+ if keyword:
+ query = query.filter(MindMap.title.contains(keyword))
+ return query.order_by(MindMap.updated_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/{map_id}", response_model=MindMapOut)
+def get_mind_map(
+ map_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(MindMap).filter(
+ MindMap.id == map_id, MindMap.user_id == current_user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="思维导图不存在")
+ return item
+
+
+@router.put("/{map_id}", response_model=MindMapOut)
+def update_mind_map(
+ map_id: int,
+ data: MindMapUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(MindMap).filter(
+ MindMap.id == map_id, MindMap.user_id == current_user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="思维导图不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(item, key, value)
+ db.commit()
+ db.refresh(item)
+ return item
+
+
+@router.delete("/{map_id}", status_code=204)
+def delete_mind_map(
+ map_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(MindMap).filter(
+ MindMap.id == map_id, MindMap.user_id == current_user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="思维导图不存在")
+ db.delete(item)
+ db.commit()
+ return None
diff --git a/backend/routers/notification.py b/backend/routers/notification.py
new file mode 100644
index 0000000..aa5add4
--- /dev/null
+++ b/backend/routers/notification.py
@@ -0,0 +1,101 @@
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+from database import get_db
+from models.notification import Notification
+from models.user import User
+from schemas.notification import NotificationOut
+from services.auth import get_current_user
+
+router = APIRouter(prefix="/api/notifications", tags=["通知"])
+
+
+def push_notification(
+ db: Session,
+ user_id: int,
+ actor_id: Optional[int],
+ ntype: str,
+ title: str,
+ content: str = "",
+ link: str = "",
+) -> None:
+ """创建一条通知,不通知自己。"""
+ if actor_id and actor_id == user_id:
+ return
+ db.add(Notification(
+ user_id=user_id, actor_id=actor_id, ntype=ntype,
+ title=title, content=content, link=link, is_read=False,
+ ))
+
+
+@router.get("", response_model=list[NotificationOut])
+def list_notifications(
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ unread_only: bool = Query(False),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Notification).filter(Notification.user_id == current_user.id)
+ if unread_only:
+ query = query.filter(Notification.is_read == False)
+ return query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all()
+
+
+@router.get("/unread-count")
+def unread_count(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ count = db.query(Notification).filter(
+ Notification.user_id == current_user.id,
+ Notification.is_read == False,
+ ).count()
+ return {"count": count}
+
+
+@router.put("/{notification_id}/read", response_model=dict)
+def mark_read(
+ notification_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(Notification).filter(
+ Notification.id == notification_id,
+ Notification.user_id == current_user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="通知不存在")
+ item.is_read = True
+ db.commit()
+ return {"success": True}
+
+
+@router.put("/read-all", response_model=dict)
+def mark_all_read(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ db.query(Notification).filter(
+ Notification.user_id == current_user.id,
+ Notification.is_read == False,
+ ).update({"is_read": True}, synchronize_session=False)
+ db.commit()
+ return {"success": True}
+
+
+@router.delete("/{notification_id}", status_code=204)
+def delete_notification(
+ notification_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ item = db.query(Notification).filter(
+ Notification.id == notification_id,
+ Notification.user_id == current_user.id,
+ ).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="通知不存在")
+ db.delete(item)
+ db.commit()
+ return None
diff --git a/backend/routers/resource.py b/backend/routers/resource.py
new file mode 100644
index 0000000..a8c068e
--- /dev/null
+++ b/backend/routers/resource.py
@@ -0,0 +1,267 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+from typing import Optional
+from database import get_db
+from models.resource import Resource, ResourceFavorite
+from models.user import User
+from schemas.resource import ResourceCreate, ResourceOut, ResourceUpdate
+from services.auth import get_current_user, get_optional_current_user
+from routers.notification import push_notification
+from services.audit import log_action
+
+router = APIRouter(prefix="/api/resources", tags=["资源库"])
+
+
+def _sync_favorite_count(res: Resource, db: Session) -> None:
+ res.likes = db.query(ResourceFavorite).filter(ResourceFavorite.resource_id == res.id).count()
+
+
+def _serialize_resource(res: Resource, current_user: User | None, db: Session) -> Resource:
+ is_favorited = False
+ if current_user:
+ is_favorited = db.query(ResourceFavorite).filter(
+ ResourceFavorite.user_id == current_user.id,
+ ResourceFavorite.resource_id == res.id,
+ ).first() is not None
+ setattr(res, "is_favorited", is_favorited)
+ setattr(res, "author", res.author)
+ return res
+
+
+
+def _apply_sort(query, sort: str | None):
+ sort = (sort or "latest").lower()
+ if sort == "popular":
+ return query.order_by(Resource.views.desc(), Resource.created_at.desc())
+ if sort in {"downloads", "download"}:
+ return query.order_by(Resource.downloads.desc(), Resource.created_at.desc())
+ if sort in {"likes", "like"}:
+ return query.order_by(Resource.likes.desc(), Resource.created_at.desc())
+ return query.order_by(Resource.created_at.desc())
+
+@router.post("/", response_model=ResourceOut, status_code=201)
+def create_resource(data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ res = Resource(user_id=current_user.id, **data.model_dump())
+ db.add(res)
+ db.commit()
+ db.refresh(res)
+ return _serialize_resource(res, current_user, db)
+
+
+@router.post("/publish", response_model=ResourceOut, status_code=201)
+def publish_resource(request: Request, data: ResourceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ query = db.query(Resource).filter(Resource.user_id == current_user.id)
+ if data.content_ref:
+ query = query.filter(Resource.content_ref == data.content_ref)
+ else:
+ query = query.filter(Resource.title == data.title, Resource.resource_type == data.resource_type)
+ res = query.first()
+ is_new = res is None
+ if res:
+ for key, value in data.model_dump().items():
+ setattr(res, key, value)
+ res.status = "active"
+ else:
+ res = Resource(user_id=current_user.id, status="active", **data.model_dump())
+ db.add(res)
+ db.commit()
+ db.refresh(res)
+ log_action(db, action="publish_resource", user=current_user, request=request, target_type="resource", target_id=res.id, detail=f"{'新建' if is_new else '更新'}资源《{res.title}》({res.resource_type})")
+ return _serialize_resource(res, current_user, db)
+
+
+@router.get("/", response_model=list[ResourceOut])
+def list_resources(
+ resource_type: Optional[str] = None,
+ subject: Optional[str] = None,
+ keyword: Optional[str] = None,
+ sort: Optional[str] = Query("latest"),
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Resource).filter(Resource.status == "active")
+ if resource_type:
+ query = query.filter(Resource.resource_type == resource_type)
+ if subject:
+ query = query.filter(Resource.subject == subject)
+ if keyword:
+ query = query.filter(or_(
+ Resource.title.contains(keyword),
+ Resource.description.contains(keyword),
+ Resource.subject.contains(keyword),
+ Resource.grade.contains(keyword),
+ ))
+ if current_user:
+ query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
+ else:
+ query = query.filter(Resource.is_public == 1)
+ resources = _apply_sort(query, sort).offset(skip).limit(limit).all()
+ return [_serialize_resource(res, current_user, db) for res in resources]
+
+
+@router.put("/{resource_id}", response_model=ResourceOut)
+def update_resource(
+ resource_id: int,
+ data: ResourceUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ for key, value in data.model_dump(exclude_unset=True).items():
+ setattr(res, key, value)
+ db.commit()
+ db.refresh(res)
+ return _serialize_resource(res, current_user, db)
+
+
+@router.get("/favorites/me", response_model=list[ResourceOut])
+def list_favorite_resources(
+ resource_type: Optional[str] = None,
+ subject: Optional[str] = None,
+ keyword: Optional[str] = None,
+ sort: Optional[str] = Query("latest"),
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = (
+ db.query(Resource)
+ .join(ResourceFavorite, ResourceFavorite.resource_id == Resource.id)
+ .filter(ResourceFavorite.user_id == current_user.id, Resource.status == "active")
+ )
+ if resource_type:
+ query = query.filter(Resource.resource_type == resource_type)
+ if subject:
+ query = query.filter(Resource.subject == subject)
+ if keyword:
+ query = query.filter(or_(
+ Resource.title.contains(keyword),
+ Resource.description.contains(keyword),
+ Resource.subject.contains(keyword),
+ Resource.grade.contains(keyword),
+ ))
+ order = ResourceFavorite.created_at.desc() if (sort or "latest") == "latest" else None
+ resources = (query.order_by(order) if order is not None else _apply_sort(query, sort)).offset(skip).limit(limit).all()
+ return [_serialize_resource(res, current_user, db) for res in resources]
+
+
+@router.get("/mine", response_model=list[ResourceOut])
+def list_my_resources(
+ resource_type: Optional[str] = None,
+ subject: Optional[str] = None,
+ keyword: Optional[str] = None,
+ sort: Optional[str] = Query("latest"),
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ query = db.query(Resource).filter(Resource.user_id == current_user.id, Resource.status == "active")
+ if resource_type:
+ query = query.filter(Resource.resource_type == resource_type)
+ if subject:
+ query = query.filter(Resource.subject == subject)
+ if keyword:
+ query = query.filter(or_(
+ Resource.title.contains(keyword),
+ Resource.description.contains(keyword),
+ Resource.subject.contains(keyword),
+ Resource.grade.contains(keyword),
+ ))
+ resources = (_apply_sort(query, sort) if (sort or "latest") != "latest" else query.order_by(Resource.updated_at.desc())).offset(skip).limit(limit).all()
+ return [_serialize_resource(res, current_user, db) for res in resources]
+
+
+@router.get("/{resource_id}", response_model=ResourceOut)
+def get_resource(
+ resource_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ res = db.query(Resource).filter(Resource.id == resource_id).first()
+ if not res or res.status == "archived":
+ raise HTTPException(status_code=404, detail="资源不存在")
+ if not res.is_public and (not current_user or res.user_id != current_user.id):
+ raise HTTPException(status_code=403, detail="无权访问该资源")
+ res.views = (res.views or 0) + 1
+ db.commit()
+ db.refresh(res)
+ return _serialize_resource(res, current_user, db)
+
+
+@router.post("/{resource_id}/download", response_model=dict)
+def download_resource(
+ resource_id: int,
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ if not res.is_public and (not current_user or res.user_id != current_user.id):
+ raise HTTPException(status_code=403, detail="无权访问该资源")
+ res.downloads = (res.downloads or 0) + 1
+ db.commit()
+ return {"success": True, "downloads": res.downloads, "file_url": res.file_url}
+
+
+@router.post("/{resource_id}/like", response_model=dict)
+def like_resource(
+ resource_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ if not res.is_public and res.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权访问该资源")
+ favorite = db.query(ResourceFavorite).filter(
+ ResourceFavorite.user_id == current_user.id,
+ ResourceFavorite.resource_id == resource_id,
+ ).first()
+ if not favorite:
+ db.add(ResourceFavorite(user_id=current_user.id, resource_id=resource_id))
+ db.flush()
+ push_notification(db, user_id=res.user_id, actor_id=current_user.id, ntype="like", title=f"{current_user.name} 收藏了你的资源", content=res.title[:60], link=f"/resources/{res.id}")
+ _sync_favorite_count(res, db)
+ db.commit()
+ return {"success": True, "likes": res.likes, "is_favorited": True}
+
+
+@router.delete("/{resource_id}/like", response_model=dict)
+def unlike_resource(
+ resource_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ res = db.query(Resource).filter(Resource.id == resource_id, Resource.status == "active").first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ if not res.is_public and res.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权访问该资源")
+ favorite = db.query(ResourceFavorite).filter(
+ ResourceFavorite.user_id == current_user.id,
+ ResourceFavorite.resource_id == resource_id,
+ ).first()
+ if favorite:
+ db.delete(favorite)
+ db.flush()
+ _sync_favorite_count(res, db)
+ db.commit()
+ return {"success": True, "likes": res.likes, "is_favorited": False}
+
+
+@router.delete("/{resource_id}", status_code=204)
+def delete_resource(resource_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ res = db.query(Resource).filter(Resource.id == resource_id, Resource.user_id == current_user.id).first()
+ if not res:
+ raise HTTPException(status_code=404, detail="资源不存在")
+ res.status = "archived"
+ db.commit()
diff --git a/backend/routers/search.py b/backend/routers/search.py
new file mode 100644
index 0000000..c333274
--- /dev/null
+++ b/backend/routers/search.py
@@ -0,0 +1,301 @@
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models.animation import Animation
+from models.classroom import ClassroomActivity
+from models.community import Post
+from models.courseware import Courseware
+from models.exam import Exam
+from models.exercise import Exercise
+from models.lesson_plan import LessonPlan
+from models.material import Material
+from models.mindmap import MindMap
+from models.resource import Resource
+from models.user import User
+from schemas.search import SearchResponse
+from services.auth import get_optional_current_user
+
+router = APIRouter(prefix="/api/search", tags=["全局搜索"])
+
+
+def _has_keyword(*values, keyword: str) -> bool:
+ if not keyword:
+ return True
+ haystack = " ".join(str(value or "") for value in values).lower()
+ return keyword.lower() in haystack
+
+
+def _tag_list(value) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ return [str(item) for item in value if str(item).strip()][:6]
+
+
+def _item(
+ *,
+ id,
+ source: str,
+ source_label: str,
+ title: str,
+ route: str,
+ description: str = "",
+ subject: str = "",
+ grade: str = "",
+ tags=None,
+ action_route: str = "",
+ is_public: bool = False,
+ updated_at=None,
+) -> dict:
+ return {
+ "id": id,
+ "source": source,
+ "source_label": source_label,
+ "title": title or "未命名",
+ "description": description or "",
+ "subject": subject or "",
+ "grade": grade or "",
+ "tags": _tag_list(tags),
+ "route": route,
+ "action_route": action_route,
+ "is_public": bool(is_public),
+ "updated_at": updated_at,
+ }
+
+
+def _sort_key(item: dict):
+ return item["updated_at"] or ""
+
+
+def _resource_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
+ query = db.query(Resource).filter(Resource.status == "active")
+ if current_user:
+ query = query.filter((Resource.user_id == current_user.id) | (Resource.is_public == 1))
+ else:
+ query = query.filter(Resource.is_public == 1)
+ if keyword:
+ query = query.filter(or_(
+ Resource.title.contains(keyword),
+ Resource.description.contains(keyword),
+ Resource.subject.contains(keyword),
+ Resource.grade.contains(keyword),
+ ))
+ return [
+ _item(
+ id=res.id,
+ source="resource",
+ source_label="资源",
+ title=res.title,
+ description=res.description,
+ subject=res.subject,
+ grade=res.grade,
+ tags=res.tags,
+ route=f"/resources/{res.id}",
+ action_route=f"/resources/{res.id}",
+ is_public=bool(res.is_public),
+ updated_at=res.updated_at,
+ )
+ for res in query.order_by(Resource.updated_at.desc()).limit(limit).all()
+ ]
+
+
+def _community_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
+ query = db.query(Post)
+ if keyword:
+ query = query.filter(or_(Post.title.contains(keyword), Post.content.contains(keyword)))
+ return [
+ _item(
+ id=post.id,
+ source="community",
+ source_label="模板社区",
+ title=post.title,
+ description=post.content,
+ tags=post.tags,
+ route=f"/community/{post.id}",
+ action_route=f"/community/{post.id}",
+ is_public=True,
+ updated_at=post.updated_at,
+ )
+ for post in query.order_by(Post.updated_at.desc()).limit(limit).all()
+ ]
+
+
+def _my_work_queries(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
+ if not current_user:
+ return []
+ user_id = current_user.id
+ items = []
+
+ coursewares = db.query(Courseware).filter(Courseware.user_id == user_id, Courseware.status != "archived").all()
+ for work in coursewares:
+ if _has_keyword(work.title, work.description, work.subject, work.grade, " ".join(_tag_list(work.tags)), keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="courseware",
+ source_label="我的课件",
+ title=work.title,
+ description=work.description,
+ subject=work.subject,
+ grade=work.grade,
+ tags=work.tags,
+ route=f"/courseware/{work.id}",
+ action_route=f"/courseware/{work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ animations = db.query(Animation).filter(Animation.user_id == user_id).all()
+ for work in animations:
+ if _has_keyword(work.title, work.description, work.anim_type, keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="animation",
+ source_label="我的动画",
+ title=work.title,
+ description=work.description,
+ subject=str(work.anim_type or ""),
+ route=f"/animation?open={work.id}",
+ action_route=f"/animation?open={work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ exercises = db.query(Exercise).filter(Exercise.user_id == user_id).all()
+ for work in exercises:
+ if _has_keyword(work.title, work.subject, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="exercise",
+ source_label="我的练习",
+ title=work.title,
+ subject=work.subject,
+ tags=work.knowledge_points,
+ route=f"/exercise?open={work.id}",
+ action_route=f"/exercise?open={work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ lesson_plans = db.query(LessonPlan).filter(LessonPlan.user_id == user_id).all()
+ for work in lesson_plans:
+ if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.objectives)), keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="lesson_plan",
+ source_label="我的教案",
+ title=work.title,
+ subject=work.subject,
+ grade=work.grade,
+ tags=work.objectives,
+ route=f"/lesson-plan?open={work.id}",
+ action_route=f"/lesson-plan?open={work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ mind_maps = db.query(MindMap).filter(MindMap.user_id == user_id).all()
+ for work in mind_maps:
+ if _has_keyword(work.title, work.subject, keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="mindmap",
+ source_label="我的导图",
+ title=work.title,
+ subject=work.subject,
+ route=f"/mindmap?open={work.id}",
+ action_route=f"/mindmap?open={work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ exams = db.query(Exam).filter(Exam.user_id == user_id).all()
+ for work in exams:
+ if _has_keyword(work.title, work.subject, work.grade, " ".join(_tag_list(work.knowledge_points)), keyword=keyword):
+ items.append(_item(
+ id=work.id,
+ source="exam",
+ source_label="我的试卷",
+ title=work.title,
+ subject=work.subject,
+ grade=work.grade,
+ tags=work.knowledge_points,
+ route=f"/exam?open={work.id}",
+ action_route=f"/exam?open={work.id}",
+ updated_at=work.updated_at,
+ ))
+
+ items.sort(key=_sort_key, reverse=True)
+ return items[:limit]
+
+
+def _material_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
+ if not current_user:
+ return []
+ query = db.query(Material).filter(Material.user_id == current_user.id, Material.status == "active")
+ if keyword:
+ query = query.filter(or_(
+ Material.title.contains(keyword),
+ Material.filename.contains(keyword),
+ Material.summary.contains(keyword),
+ Material.subject.contains(keyword),
+ Material.grade.contains(keyword),
+ ))
+ return [
+ _item(
+ id=item.id,
+ source="material",
+ source_label="素材",
+ title=item.title,
+ description=item.summary,
+ subject=item.subject,
+ grade=item.grade,
+ tags=item.tags,
+ route="/materials",
+ action_route=f"/courseware/create",
+ updated_at=item.updated_at,
+ )
+ for item in query.order_by(Material.updated_at.desc()).limit(limit).all()
+ ]
+
+
+def _classroom_query(keyword: str, current_user: User | None, db: Session, limit: int) -> list[dict]:
+ if not current_user:
+ return []
+ activities = db.query(ClassroomActivity).filter(ClassroomActivity.user_id == current_user.id).all()
+ items = []
+ for activity in activities:
+ config = activity.config or {}
+ if _has_keyword(activity.title, activity.description, config.get("question"), activity.activity_type, keyword=keyword):
+ items.append(_item(
+ id=activity.id,
+ source="classroom",
+ source_label="课堂活动",
+ title=activity.title,
+ description=activity.description or config.get("question") or "",
+ subject=activity.activity_type or "",
+ route=f"/classroom?open={activity.id}",
+ action_route=f"/classroom?open={activity.id}",
+ updated_at=activity.updated_at,
+ ))
+ items.sort(key=_sort_key, reverse=True)
+ return items[:limit]
+
+
+@router.get("", response_model=SearchResponse)
+def global_search(
+ keyword: str = Query("", max_length=100),
+ limit: int = Query(6, ge=1, le=20),
+ current_user: User | None = Depends(get_optional_current_user),
+ db: Session = Depends(get_db),
+):
+ keyword = keyword.strip()
+ resources = _resource_query(keyword, current_user, db, limit)
+ community = _community_query(keyword, current_user, db, limit)
+ works = _my_work_queries(keyword, current_user, db, limit)
+ materials = _material_query(keyword, current_user, db, limit)
+ classroom = _classroom_query(keyword, current_user, db, limit)
+ return {
+ "keyword": keyword,
+ "total": len(resources) + len(community) + len(works) + len(materials) + len(classroom),
+ "resources": resources,
+ "community": community,
+ "works": works,
+ "materials": materials,
+ "classroom": classroom,
+ }
diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py
new file mode 100644
index 0000000..084b95c
--- /dev/null
+++ b/backend/schemas/__init__.py
@@ -0,0 +1,9 @@
+from .user import *
+from .courseware import *
+from .animation import *
+from .exercise import *
+from .lesson_plan import *
+from .exam import *
+from .resource import *
+from .community import *
+from .ai import *
diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py
new file mode 100644
index 0000000..634688e
--- /dev/null
+++ b/backend/schemas/admin.py
@@ -0,0 +1,53 @@
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class AdminUserOut(BaseModel):
+ id: int
+ phone: str
+ name: str
+ avatar: str = ""
+ subject: str = ""
+ school: str = ""
+ grade: str = ""
+ role: str = "teacher"
+ is_active: bool = True
+ credits: int = 0
+ created_at: datetime | None = None
+
+ @field_validator("avatar", "subject", "school", "grade", "name", "role", mode="before")
+ @classmethod
+ def _none_to_empty(cls, v):
+ return "" if v is None else v
+
+ model_config = {"from_attributes": True}
+
+
+class AdminUserUpdate(BaseModel):
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ name: Optional[str] = None
+
+
+class AdminGrantCredits(BaseModel):
+ amount: int = Field(..., gt=0, le=10000, description="积分数量")
+
+
+class AdminDashboardOut(BaseModel):
+ total_users: int = 0
+ active_users: int = 0
+ admin_users: int = 0
+ total_resources: int = 0
+ public_resources: int = 0
+ total_coursewares: int = 0
+ total_animations: int = 0
+ total_exercises: int = 0
+ total_lesson_plans: int = 0
+ total_exams: int = 0
+ total_views: int = 0
+ total_downloads: int = 0
+ total_favorites: int = 0
+ total_credits_used: int = 0
+ new_users_today: int = 0
diff --git a/backend/schemas/ai.py b/backend/schemas/ai.py
new file mode 100644
index 0000000..fe37bff
--- /dev/null
+++ b/backend/schemas/ai.py
@@ -0,0 +1,38 @@
+from pydantic import BaseModel, Field
+from typing import Any
+
+
+class AIRequest(BaseModel):
+ prompt: str = Field(..., min_length=1, description="AI请求内容")
+ context: dict[str, Any] = Field(default_factory=dict, description="上下文信息")
+ stream: bool = Field(default=False, description="是否流式返回")
+
+
+class EssayGradeRequest(BaseModel):
+ essay_text: str = Field(..., min_length=10, description="作文文本")
+ grade_level: str = Field(default="初中", description="年级段")
+ essay_type: str = Field(default="记叙文", description="作文类型")
+ total_score: int = Field(default=50, description="满分")
+
+
+class ExamGenerateRequest(BaseModel):
+ subject: str = Field(..., min_length=1)
+ grade: str = ""
+ knowledge_points: list[str] = []
+ difficulty: str = Field(default="medium", pattern=r"^(easy|medium|hard)$")
+ question_types: list[str] = Field(default=["choice"])
+ count: int = Field(default=10, ge=1, le=50)
+ total_score: int = Field(default=100)
+
+
+class HtmlExportRequest(BaseModel):
+ title: str = Field(default="教学内容", max_length=200)
+ html: str = Field(..., min_length=1)
+
+
+class ExamExportRequest(BaseModel):
+ title: str = Field(default="试卷", max_length=200)
+ subject: str = ""
+ grade: str = ""
+ questions: list[dict[str, Any]] = []
+ answers: list[Any] = []
diff --git a/backend/schemas/animation.py b/backend/schemas/animation.py
new file mode 100644
index 0000000..d39818f
--- /dev/null
+++ b/backend/schemas/animation.py
@@ -0,0 +1,40 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Optional, Any
+
+
+class AnimationGenerate(BaseModel):
+ prompt: str = Field(..., min_length=1, description="教学动画描述")
+ anim_type: str = Field(default="general", pattern=r"^(math|physics|chemistry|biology|geography|general)$")
+ subject: str = Field(default="", max_length=50)
+
+
+class AnimationCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ anim_type: str = "general"
+ description: str = ""
+ config: dict[str, Any] = {}
+
+
+class AnimationUpdate(BaseModel):
+ title: Optional[str] = None
+ anim_type: Optional[str] = None
+ description: Optional[str] = None
+ config: Optional[dict[str, Any]] = None
+ thumbnail: Optional[str] = None
+ status: Optional[str] = None
+
+
+class AnimationOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ anim_type: str = "general"
+ description: str = ""
+ config: dict = {}
+ thumbnail: str = ""
+ status: str = "draft"
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/chat.py b/backend/schemas/chat.py
new file mode 100644
index 0000000..26647fc
--- /dev/null
+++ b/backend/schemas/chat.py
@@ -0,0 +1,41 @@
+from datetime import datetime
+from pydantic import BaseModel, Field
+
+
+class ConversationCreate(BaseModel):
+ title: str | None = Field(default=None, max_length=200)
+ subject: str = ""
+ grade: str = ""
+
+
+class ConversationRename(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ pinned: bool | None = None
+
+
+class ChatSend(BaseModel):
+ content: str = Field(..., min_length=1, max_length=4000)
+ subject: str = ""
+ grade: str = ""
+
+
+class MessageOut(BaseModel):
+ id: int
+ role: str
+ content: str
+ created_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class ConversationOut(BaseModel):
+ id: int
+ title: str
+ subject: str = ""
+ grade: str = ""
+ pinned: bool = False
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+ messages: list[MessageOut] = Field(default_factory=list)
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/classroom.py b/backend/schemas/classroom.py
new file mode 100644
index 0000000..d76ae71
--- /dev/null
+++ b/backend/schemas/classroom.py
@@ -0,0 +1,71 @@
+from datetime import datetime
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+
+class ClassroomActivityCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ activity_type: str = Field(default="poll", max_length=50)
+ description: str = ""
+ config: dict[str, Any] = {}
+ status: str = "draft"
+
+
+class ClassroomActivityUpdate(BaseModel):
+ title: Optional[str] = None
+ activity_type: Optional[str] = None
+ description: Optional[str] = None
+ config: Optional[dict[str, Any]] = None
+ responses: Optional[list[dict[str, Any]]] = None
+ status: Optional[str] = None
+
+
+class ClassroomResponseCreate(BaseModel):
+ student_name: str = Field(default="", max_length=50)
+ answer: Any
+ meta: dict[str, Any] = {}
+
+
+class ClassroomActivityOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ activity_type: str = "poll"
+ description: str = ""
+ config: dict = {}
+ responses: list = []
+ status: str = "draft"
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class ClassroomOptionStat(BaseModel):
+ option: str
+ count: int = 0
+ percent: int = 0
+
+
+class ClassroomKeywordStat(BaseModel):
+ keyword: str
+ count: int = 0
+
+
+class ClassroomAnalysisOut(BaseModel):
+ activity_id: int
+ title: str
+ activity_type: str = "poll"
+ question: str = ""
+ response_count: int = 0
+ participant_count: int = 0
+ option_stats: list[ClassroomOptionStat] = Field(default_factory=list)
+ keywords: list[ClassroomKeywordStat] = Field(default_factory=list)
+ common_answers: list[str] = Field(default_factory=list)
+ mastery_level: str = "暂无数据"
+ accuracy: int | None = None
+ diagnosis: list[str] = Field(default_factory=list)
+ teaching_suggestions: list[str] = Field(default_factory=list)
+ followup_prompt: str = ""
+ generated_at: datetime | None = None
diff --git a/backend/schemas/community.py b/backend/schemas/community.py
new file mode 100644
index 0000000..6d15cd3
--- /dev/null
+++ b/backend/schemas/community.py
@@ -0,0 +1,67 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Any, Optional
+
+
+class CommunityAuthorOut(BaseModel):
+ id: int
+ name: str = ""
+ avatar: str = ""
+ subject: str = ""
+ school: str = ""
+
+ model_config = {"from_attributes": True}
+
+
+class PostCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ content: str = Field(..., min_length=1)
+ post_type: str = "discussion"
+ tags: list[str] = Field(default_factory=list)
+ attachments: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class PostUpdate(BaseModel):
+ title: Optional[str] = Field(None, min_length=1, max_length=200)
+ content: Optional[str] = Field(None, min_length=1)
+ post_type: Optional[str] = None
+ tags: Optional[list[str]] = None
+ attachments: Optional[list[dict[str, Any]]] = None
+
+
+class PostOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ content: str = ""
+ post_type: str = "discussion"
+ tags: list = Field(default_factory=list)
+ attachments: list = Field(default_factory=list)
+ views: int = 0
+ likes: int = 0
+ comments_count: int = 0
+ is_pinned: int = 0
+ is_favorited: bool = False
+ author: CommunityAuthorOut | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class CommentCreate(BaseModel):
+ content: str = Field(..., min_length=1)
+ parent_id: Optional[int] = None
+
+
+class CommentOut(BaseModel):
+ id: int
+ post_id: int
+ user_id: int
+ content: str
+ parent_id: Optional[int] = None
+ likes: int = 0
+ author: CommunityAuthorOut | None = None
+ created_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/courseware.py b/backend/schemas/courseware.py
new file mode 100644
index 0000000..6e9d785
--- /dev/null
+++ b/backend/schemas/courseware.py
@@ -0,0 +1,69 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Optional, Any
+
+
+class CoursewareCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subject: str = Field(default="", max_length=50)
+ grade: str = Field(default="", max_length=50)
+ description: str = ""
+ content: list[Any] = []
+ tags: list[str] = []
+ status: str = "draft"
+
+
+class CoursewareAIGenerate(BaseModel):
+ prompt: str = Field(..., min_length=1, description="教学想法/描述")
+ subject: str = Field(default="", max_length=50)
+ grade: str = Field(default="", max_length=50)
+ page_count: int = Field(default=8, ge=3, le=30)
+ aspect_ratio: str = Field(default="16:9", description="画面比例: 16:9, 4:3, 1:1")
+
+
+class CoursewareUpdate(BaseModel):
+ title: Optional[str] = None
+ subject: Optional[str] = None
+ grade: Optional[str] = None
+ description: Optional[str] = None
+ content: Optional[list[Any]] = None
+ cover_image: Optional[str] = None
+ status: Optional[str] = None
+ tags: Optional[list[str]] = None
+
+
+class CoursewareOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ subject: str = ""
+ grade: str = ""
+ description: str = ""
+ content: list = []
+ cover_image: str = ""
+ status: str = "draft"
+ version: int = 1
+ tags: list = []
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class CoursewareShareOut(BaseModel):
+ id: int
+ courseware_id: int
+ token: str
+ title: str
+ subject: str = ""
+ grade: str = ""
+ description: str = ""
+ content: list = []
+ tags: list = []
+ views: int = 0
+ status: str = "active"
+ share_url: str = ""
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/essay.py b/backend/schemas/essay.py
new file mode 100644
index 0000000..f87a98f
--- /dev/null
+++ b/backend/schemas/essay.py
@@ -0,0 +1,27 @@
+from datetime import datetime
+from typing import Any
+from pydantic import BaseModel, Field
+
+
+class EssayGradeCreate(BaseModel):
+ title: str = Field(default="作文批改报告", min_length=1, max_length=200)
+ essay_text: str = Field(..., min_length=1)
+ grade_level: str = "初中"
+ essay_type: str = "记叙文"
+ total_score: int = 50
+ result: dict[str, Any] = Field(default_factory=dict)
+
+
+class EssayGradeOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ essay_text: str = ""
+ grade_level: str = "初中"
+ essay_type: str = "记叙文"
+ total_score: int = 50
+ result: dict = Field(default_factory=dict)
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/exam.py b/backend/schemas/exam.py
new file mode 100644
index 0000000..9f2a290
--- /dev/null
+++ b/backend/schemas/exam.py
@@ -0,0 +1,46 @@
+from datetime import datetime
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+
+class ExamCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subject: str = ""
+ grade: str = ""
+ questions: list[dict[str, Any]] = []
+ answers: list[Any] = []
+ duration: int = 90
+ total_score: int = 100
+ difficulty: str = "medium"
+ knowledge_points: list[str] = []
+
+
+class ExamUpdate(BaseModel):
+ title: Optional[str] = None
+ subject: Optional[str] = None
+ grade: Optional[str] = None
+ questions: Optional[list[dict[str, Any]]] = None
+ answers: Optional[list[Any]] = None
+ duration: Optional[int] = None
+ total_score: Optional[int] = None
+ difficulty: Optional[str] = None
+ knowledge_points: Optional[list[str]] = None
+
+
+class ExamOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ subject: str = ""
+ grade: str = ""
+ questions: list = []
+ answers: list = []
+ duration: int = 90
+ total_score: int = 100
+ difficulty: str = "medium"
+ knowledge_points: list = []
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/exercise.py b/backend/schemas/exercise.py
new file mode 100644
index 0000000..8b051cc
--- /dev/null
+++ b/backend/schemas/exercise.py
@@ -0,0 +1,65 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Optional, Any
+
+
+class ExerciseAIGenerate(BaseModel):
+ prompt: str = Field(..., min_length=1, description="练习生成描述")
+ exercise_type: str = Field(default="choice")
+ subject: str = ""
+ knowledge_points: list[str] = []
+ count: int = Field(default=5, ge=1, le=50)
+
+
+class ExerciseCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ exercise_type: str = "choice"
+ subject: str = ""
+ knowledge_points: list[str] = []
+ questions: list[dict[str, Any]] = []
+ settings: dict[str, Any] = {}
+
+
+class ExerciseUpdate(BaseModel):
+ title: Optional[str] = None
+ exercise_type: Optional[str] = None
+ subject: Optional[str] = None
+ knowledge_points: Optional[list[str]] = None
+ questions: Optional[list[dict[str, Any]]] = None
+ settings: Optional[dict[str, Any]] = None
+
+
+class ExerciseAttemptCreate(BaseModel):
+ student_name: str = ""
+ answers: list[Any] = []
+ score: int = 0
+ total: int = 0
+ duration: int = 0
+
+
+class ExerciseAttemptOut(BaseModel):
+ id: int
+ exercise_id: int
+ student_name: str = ""
+ answers: list[Any] = []
+ score: int = 0
+ total: int = 0
+ duration: int = 0
+ created_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class ExerciseOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ exercise_type: str = "choice"
+ subject: str = ""
+ knowledge_points: list = []
+ questions: list = []
+ settings: dict = {}
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/lesson_plan.py b/backend/schemas/lesson_plan.py
new file mode 100644
index 0000000..4643778
--- /dev/null
+++ b/backend/schemas/lesson_plan.py
@@ -0,0 +1,57 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Optional, Any
+
+
+class LessonPlanGenerate(BaseModel):
+ title: str = Field(..., min_length=1)
+ subject: str = ""
+ grade: str = ""
+ objectives: list[str] = []
+ duration: int = Field(default=45, ge=20, le=120)
+ extra_requirements: str = ""
+
+
+class LessonPlanCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subject: str = ""
+ grade: str = ""
+ objectives: list[str] = []
+ key_points: list[str] = []
+ difficulties: list[str] = []
+ content: dict[str, Any] = {}
+ duration: int = 45
+ materials: list[str] = []
+ homework: list[str] = []
+
+
+class LessonPlanUpdate(BaseModel):
+ title: Optional[str] = None
+ subject: Optional[str] = None
+ grade: Optional[str] = None
+ objectives: Optional[list[str]] = None
+ key_points: Optional[list[str]] = None
+ difficulties: Optional[list[str]] = None
+ content: Optional[dict[str, Any]] = None
+ duration: Optional[int] = None
+ materials: Optional[list[str]] = None
+ homework: Optional[list[str]] = None
+
+
+class LessonPlanOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ subject: str = ""
+ grade: str = ""
+ objectives: list = []
+ key_points: list = []
+ difficulties: list = []
+ content: dict = {}
+ duration: int = 45
+ materials: list = []
+ homework: list = []
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/material.py b/backend/schemas/material.py
new file mode 100644
index 0000000..b6cf860
--- /dev/null
+++ b/backend/schemas/material.py
@@ -0,0 +1,44 @@
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+class MaterialCreate(BaseModel):
+ filename: str = Field(..., min_length=1, max_length=255)
+ title: str = Field(..., min_length=1, max_length=200)
+ material_type: str = "text"
+ subject: str = ""
+ grade: str = ""
+ tags: list[str] = Field(default_factory=list)
+ summary: str = ""
+ char_count: int = 0
+ size: int = 0
+ source: str = "upload"
+
+
+class MaterialUpdate(BaseModel):
+ title: Optional[str] = None
+ subject: Optional[str] = None
+ grade: Optional[str] = None
+ tags: Optional[list[str]] = None
+ summary: Optional[str] = None
+ status: Optional[str] = None
+
+
+class MaterialOut(BaseModel):
+ id: int
+ user_id: int
+ filename: str
+ title: str
+ material_type: str = "text"
+ subject: str = ""
+ grade: str = ""
+ tags: list = Field(default_factory=list)
+ summary: str = ""
+ char_count: int = 0
+ size: int = 0
+ source: str = "upload"
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/mindmap.py b/backend/schemas/mindmap.py
new file mode 100644
index 0000000..b987d90
--- /dev/null
+++ b/backend/schemas/mindmap.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+from typing import Any
+from pydantic import BaseModel, Field
+
+
+class MindMapGenerate(BaseModel):
+ topic: str = Field(..., min_length=1, max_length=200)
+ subject: str = "综合"
+ grade: str = ""
+
+
+class MindMapCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subject: str = "综合"
+ nodes: list[Any] = Field(default_factory=list)
+
+
+class MindMapUpdate(BaseModel):
+ title: str | None = None
+ nodes: list[Any] | None = None
+
+
+class MindMapOut(BaseModel):
+ id: int
+ user_id: int
+ title: str
+ subject: str = "综合"
+ nodes: list[Any] = Field(default_factory=list)
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/notification.py b/backend/schemas/notification.py
new file mode 100644
index 0000000..a78b7cf
--- /dev/null
+++ b/backend/schemas/notification.py
@@ -0,0 +1,16 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class NotificationOut(BaseModel):
+ id: int
+ user_id: int
+ actor_id: int | None = None
+ ntype: str = "comment"
+ title: str
+ content: str = ""
+ link: str = ""
+ is_read: bool = False
+ created_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/resource.py b/backend/schemas/resource.py
new file mode 100644
index 0000000..ef19a93
--- /dev/null
+++ b/backend/schemas/resource.py
@@ -0,0 +1,63 @@
+from pydantic import BaseModel, Field
+from datetime import datetime
+from typing import Optional, Any
+
+
+class ResourceAuthorOut(BaseModel):
+ id: int
+ name: str = ""
+ avatar: str = ""
+ subject: str = ""
+ school: str = ""
+
+ model_config = {"from_attributes": True}
+
+
+class ResourceCreate(BaseModel):
+ resource_type: str = "other"
+ title: str = Field(..., min_length=1, max_length=200)
+ description: str = ""
+ content_ref: str = ""
+ file_url: str = ""
+ tags: list[str] = Field(default_factory=list)
+ subject: str = ""
+ grade: str = ""
+ is_public: int = 1
+
+
+class ResourceUpdate(BaseModel):
+ resource_type: Optional[str] = None
+ title: Optional[str] = None
+ description: Optional[str] = None
+ content_ref: Optional[str] = None
+ file_url: Optional[str] = None
+ cover_image: Optional[str] = None
+ tags: Optional[list[str]] = None
+ subject: Optional[str] = None
+ grade: Optional[str] = None
+ is_public: Optional[int] = None
+ status: Optional[str] = None
+
+
+class ResourceOut(BaseModel):
+ id: int
+ user_id: int
+ resource_type: str
+ title: str
+ description: str = ""
+ content_ref: str = ""
+ file_url: str = ""
+ cover_image: str = ""
+ tags: list = Field(default_factory=list)
+ subject: str = ""
+ grade: str = ""
+ downloads: int = 0
+ likes: int = 0
+ views: int = 0
+ is_public: int = 1
+ is_favorited: bool = False
+ author: ResourceAuthorOut | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/schemas/search.py b/backend/schemas/search.py
new file mode 100644
index 0000000..b74af70
--- /dev/null
+++ b/backend/schemas/search.py
@@ -0,0 +1,28 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class SearchResultItem(BaseModel):
+ id: int | str
+ source: str
+ source_label: str
+ title: str
+ description: str = ""
+ subject: str = ""
+ grade: str = ""
+ tags: list[str] = Field(default_factory=list)
+ route: str
+ action_route: str = ""
+ is_public: bool = False
+ updated_at: datetime | None = None
+
+
+class SearchResponse(BaseModel):
+ keyword: str = ""
+ total: int = 0
+ resources: list[SearchResultItem] = Field(default_factory=list)
+ community: list[SearchResultItem] = Field(default_factory=list)
+ works: list[SearchResultItem] = Field(default_factory=list)
+ materials: list[SearchResultItem] = Field(default_factory=list)
+ classroom: list[SearchResultItem] = Field(default_factory=list)
diff --git a/backend/schemas/user.py b/backend/schemas/user.py
new file mode 100644
index 0000000..f5c5568
--- /dev/null
+++ b/backend/schemas/user.py
@@ -0,0 +1,130 @@
+from pydantic import BaseModel, Field, field_validator
+from datetime import datetime
+from typing import Optional
+
+
+class UserLogin(BaseModel):
+ phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
+ password: str = Field(..., min_length=6, max_length=50, description="密码")
+
+
+class UserRegister(BaseModel):
+ phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
+ password: str = Field(..., min_length=6, max_length=50)
+ name: str = Field(..., min_length=2, max_length=50)
+ subject: str = Field(default="", max_length=50)
+ school: str = Field(default="", max_length=100)
+ grade: str = Field(default="", max_length=50)
+
+
+class UserUpdate(BaseModel):
+ name: Optional[str] = None
+ avatar: Optional[str] = None
+ subject: Optional[str] = None
+ school: Optional[str] = None
+ grade: Optional[str] = None
+
+
+class PasswordChange(BaseModel):
+ old_password: str = Field(..., min_length=6, max_length=50, description="当前密码")
+ new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
+
+
+class UserOut(BaseModel):
+ id: int
+ phone: str
+ name: str
+ avatar: str = ""
+ subject: str = ""
+ school: str = ""
+ grade: str = ""
+ role: str = "teacher"
+ credits: int = 0
+ total_credits_granted: int = 0
+ total_credits_used: int = 0
+ created_at: datetime | None = None
+
+ @field_validator("avatar", "subject", "school", "grade", "name", "role", mode="before")
+ @classmethod
+ def _none_to_empty(cls, v):
+ return "" if v is None else v
+
+ model_config = {"from_attributes": True}
+
+
+class Token(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+
+
+class TokenPayload(BaseModel):
+ sub: int
+ exp: datetime
+
+
+class ProfileRecentItem(BaseModel):
+ id: int
+ item_type: str
+ title: str
+ subtitle: str = ""
+ status: str = ""
+ updated_at: datetime | None = None
+
+
+class CreditTransactionOut(BaseModel):
+ id: int
+ amount: int
+ action: str = ""
+ description: str = ""
+ balance_after: int = 0
+ created_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class CreditBenefitOut(BaseModel):
+ credits: int = 0
+ daily_amount: int = 0
+ daily_claimed: bool = False
+ next_claim_text: str = ""
+ recent_checkins: list[CreditTransactionOut] = Field(default_factory=list)
+
+
+class ProfileStatsOut(BaseModel):
+ credits: int = 0
+ total_credits_granted: int = 0
+ total_credits_used: int = 0
+ total_works: int = 0
+ draft_works: int = 0
+ published_works: int = 0
+ published_resources: int = 0
+ public_resources: int = 0
+ private_resources: int = 0
+ favorite_resources: int = 0
+ total_views: int = 0
+ total_downloads: int = 0
+ total_favorites: int = 0
+ classroom_activities: int = 0
+ active_classroom_activities: int = 0
+ classroom_responses: int = 0
+ community_posts: int = 0
+ community_favorites: int = 0
+ community_comments: int = 0
+ works_by_type: dict[str, int] = Field(default_factory=dict)
+ resources_by_type: dict[str, int] = Field(default_factory=dict)
+ recent_works: list[ProfileRecentItem] = Field(default_factory=list)
+ recent_resources: list[ProfileRecentItem] = Field(default_factory=list)
+ recent_interactions: list[ProfileRecentItem] = Field(default_factory=list)
+ recent_credit_transactions: list[CreditTransactionOut] = Field(default_factory=list)
+
+
+
+class SendCodeRequest(BaseModel):
+ phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
+
+
+class ResetPasswordRequest(BaseModel):
+ phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
+ code: str = Field(..., min_length=6, max_length=6, description="6位验证码")
+ new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
diff --git a/backend/services/__init__.py b/backend/services/__init__.py
new file mode 100644
index 0000000..70e03d1
--- /dev/null
+++ b/backend/services/__init__.py
@@ -0,0 +1,7 @@
+from .auth import get_password_hash, verify_password, create_access_token, create_refresh_token, get_current_user
+from .ai_service import AIService
+
+__all__ = [
+ "get_password_hash", "verify_password", "create_access_token",
+ "create_refresh_token", "get_current_user", "AIService",
+]
diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py
new file mode 100644
index 0000000..c5c7c07
--- /dev/null
+++ b/backend/services/ai_service.py
@@ -0,0 +1,3190 @@
+import json
+import re
+import html as html_lib
+import httpx
+import logging
+import time
+import base64
+from config import get_settings
+
+settings = get_settings()
+logger = logging.getLogger(__name__)
+
+
+class AIService:
+ # Shared circuit-breaker timestamp: all instances share one breaker so a
+ # single AI outage trips the breaker globally across every router.
+ _ai_unavailable_until: float = 0.0
+
+ def __init__(self):
+ self.api_key = settings.AI_API_KEY
+ self.api_base = settings.AI_API_BASE.rstrip("/")
+ self.model = settings.AI_MODEL
+ self.client = httpx.AsyncClient(
+ base_url=self.api_base,
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ timeout=httpx.Timeout(connect=10.0, read=120.0, write=15.0, pool=10.0),
+ )
+
+ async def _chat(self, messages: list[dict], temperature: float = 0.7, max_tokens: int = 8192) -> str:
+ if time.monotonic() < AIService._ai_unavailable_until:
+ logger.warning("AI provider skipped (circuit breaker active for %.0fs more)", AIService._ai_unavailable_until - time.monotonic())
+ raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
+ try:
+ response = await self.client.post(
+ "/chat/completions",
+ json={
+ "model": self.model,
+ "messages": messages,
+ "temperature": temperature,
+ "max_tokens": max_tokens,
+ # glm-5.x is a reasoning model: without disabling
+ # thinking it burns the entire token budget on
+ # chain-of-thought and returns EMPTY content
+ # (finish_reason=length), forcing every feature to
+ # fall back to static templates. Disable for real output.
+ "enable_thinking": False,
+ "thinking": {"type": "disabled"},
+ },
+ )
+ response.raise_for_status()
+ except httpx.ConnectError as exc:
+ # Connection-level failure: server likely down. Short breaker so we retry sooner.
+ logger.error("AI provider connect failed: %s: %s", type(exc).__name__, exc)
+ AIService._ai_unavailable_until = time.monotonic() + 20
+ raise RuntimeError("AI_PROVIDER_CONNECT_ERROR") from exc
+ except httpx.TimeoutException as exc:
+ # Timeout (connect/read): server slow or unreachable. Short breaker.
+ logger.error("AI provider timeout: %s: %s", type(exc).__name__, exc)
+ AIService._ai_unavailable_until = time.monotonic() + 20
+ raise RuntimeError("AI_PROVIDER_TIMEOUT") from exc
+ except httpx.HTTPStatusError as exc:
+ # HTTP error (4xx/5xx): server is up but rejected the request.
+ logger.error("AI provider HTTP %s: %s", exc.response.status_code, str(exc.response.text)[:200])
+ breaker = 30 if exc.response.status_code >= 500 else 120
+ AIService._ai_unavailable_until = time.monotonic() + breaker
+ raise RuntimeError("AI_PROVIDER_HTTP_ERROR") from exc
+ except Exception as exc:
+ logger.error("AI provider request failed: %s: %s", type(exc).__name__, exc)
+ AIService._ai_unavailable_until = time.monotonic() + 30
+ raise RuntimeError("AI_PROVIDER_UNAVAILABLE") from exc
+ data = response.json()
+ content = data["choices"][0]["message"]["content"] or ""
+ # Reasoning models may return empty content if thinking consumed the token budget
+ if not content.strip():
+ logger.warning("AI provider returned empty content (finish=%s, model=%s) - reasoning budget likely exhausted", data["choices"][0].get("finish_reason","?"), self.model)
+ raise RuntimeError("AI_PROVIDER_EMPTY_RESPONSE")
+ finish_reason = data["choices"][0].get("finish_reason", "")
+ if finish_reason == "length":
+ content = content + '..."}'
+ return content
+
+ async def _chat_json(self, messages: list[dict], temperature: float = 0.3, max_tokens: int = 8192) -> dict:
+ content = await self._chat(messages, temperature, max_tokens)
+ return self._extract_json(content)
+
+
+ async def ocr_image(self, image_bytes: bytes, filename: str = "") -> str:
+ """Extract text from an image using the AI model's vision capability."""
+ if not self.api_key or self.api_key == "change-me-in-production":
+ raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
+ if time.monotonic() < AIService._ai_unavailable_until:
+ raise RuntimeError("AI_PROVIDER_UNAVAILABLE")
+
+ b64 = base64.b64encode(image_bytes).decode("ascii")
+ ext = "png"
+ lower_name = (filename or "").lower()
+ if lower_name.endswith((".jpg", ".jpeg")):
+ ext = "jpeg"
+ elif lower_name.endswith(".webp"):
+ ext = "webp"
+ elif lower_name.endswith(".gif"):
+ ext = "gif"
+
+ messages = [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": (
+ "请仔细识别这张图片中的所有文字内容,包括题目、公式、表格和标注。"
+ "完整提取文字,保持原有结构和层次。"
+ "如果是题目,请保留题号和选项格式。"
+ "数学公式用可读文本表示(例如 a² + b² = c²)。"
+ "只输出识别到的文字内容,不要添加解释或说明。"
+ "如果图片中没有文字或无法识别,请回复:[无法识别文字内容]"
+ ),
+ },
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/{ext};base64,{b64}"},
+ },
+ ],
+ }
+ ]
+
+ try:
+ response = await self.client.post(
+ "/chat/completions",
+ json={
+ "model": self.model,
+ "messages": messages,
+ "temperature": 0.1,
+ "max_tokens": 4096,
+ "enable_thinking": False,
+ "thinking": {"type": "disabled"},
+ },
+ timeout=httpx.Timeout(connect=10.0, read=90.0, write=15.0, pool=10.0),
+ )
+ response.raise_for_status()
+ except Exception as exc:
+ logger.error("Vision OCR request failed: %s", exc)
+ AIService._ai_unavailable_until = time.monotonic() + 60
+ raise RuntimeError("AI_PROVIDER_UNAVAILABLE") from exc
+
+ data = response.json()
+ return data["choices"][0]["message"]["content"].strip()
+
+ def _extract_json(self, content: str) -> dict:
+ content = content.strip()
+
+ # Strip markdown code fences
+ import re as _re
+ content = _re.sub(r'^```\w*\n?', '', content)
+ content = _re.sub(r'\n?```$', '', content)
+ content = content.strip()
+
+ first_brace = content.find('{')
+ if first_brace == -1:
+ return {"raw_content": content}
+
+ last_brace = content.rfind('}')
+ if last_brace <= first_brace:
+ return {"raw_content": content}
+
+ # Try the full span first
+ candidate = content[first_brace:last_brace + 1]
+ try:
+ result = json.loads(candidate)
+ if isinstance(result, dict):
+ return result
+ except json.JSONDecodeError:
+ pass
+
+ # Try auto-fixing truncated JSON by closing open structures
+ fixed = self._try_fix_truncated_json(candidate)
+ if fixed:
+ return fixed
+
+ # Try replacing HTML double quotes with single quotes inside the html field
+ fixed2 = _re.sub(r'(style|onclick|onchange)=\\?"([^"]*?)\\?"', r"\1='\2'", candidate)
+ if fixed2 != candidate:
+ try:
+ result = json.loads(fixed2)
+ if isinstance(result, dict):
+ return result
+ except json.JSONDecodeError:
+ pass
+
+ # Find all '}' positions from end to start
+ pos = last_brace
+ while pos > first_brace:
+ if content[pos] == '}':
+ try:
+ result = json.loads(content[first_brace:pos + 1])
+ if isinstance(result, dict):
+ return result
+ except json.JSONDecodeError:
+ pass
+ pos -= 1
+
+ # Last resort: try to extract html field via regex
+ html_match = _re.search(r'"html"\s*:\s*"((?:[^"\\]|\\.)*)"', content, _re.DOTALL)
+ if html_match:
+ try:
+ html_val = json.loads('"' + html_match.group(1) + '"')
+ return {"title": "动画", "description": "", "html": html_val}
+ except:
+ pass
+
+ # All attempts failed - save debug info
+ try:
+ with open("debug_json_fail.txt", "w", encoding="utf-8") as f:
+ f.write(f"Content length: {len(content)}\n")
+ f.write(f"First 500 chars:\n{content[:500]}\n\n")
+ f.write(f"Last 500 chars:\n{content[-500:]}\n\n")
+ f.write(f"Full content:\n{content}\n")
+ except:
+ pass
+
+ return {"raw_content": content}
+
+ def _try_fix_truncated_json(self, candidate: str) -> dict | None:
+ """Try to fix truncated JSON by closing open strings/brackets."""
+ import re as _re
+ # Count brackets
+ opens = candidate.count('{') - candidate.count('}')
+ opens_bracket = candidate.count('[') - candidate.count(']')
+
+ # Check if we're inside a string
+ in_string = False
+ escape = False
+ for ch in candidate:
+ if escape:
+ escape = False
+ continue
+ if ch == '\\':
+ escape = True
+ continue
+ if ch == '"':
+ in_string = not in_string
+
+ fixed = candidate
+ if in_string:
+ fixed += ''
+ # Close open string if inside one
+ if in_string:
+ fixed += '"'
+ # Close open brackets
+ for _ in range(max(0, opens_bracket)):
+ fixed += ']'
+ for _ in range(max(0, opens)):
+ fixed += '}'
+
+ try:
+ result = json.loads(fixed)
+ if isinstance(result, dict):
+ # If html field exists, this is usable
+ if 'html' in result:
+ return result
+ except json.JSONDecodeError:
+ pass
+
+ # Try simpler fix: just close the outermost object
+ if in_string:
+ # Find last complete key-value pair before truncation
+ # Try: add closing quote, skip to end of object
+ for suffix in ['"}', '"}]}', '"}]}', '"]}', '"}' ]:
+ try:
+ result = json.loads(candidate + suffix)
+ if isinstance(result, dict):
+ return result
+ except json.JSONDecodeError:
+ continue
+
+ return None
+
+ async def chat_teaching(self, history: list[dict], user_message: str, subject: str = "", grade: str = "") -> str:
+ """General-purpose conversational teaching assistant (multi-turn)."""
+ context_bits = []
+ if subject:
+ context_bits.append(f"任教学科:{subject}")
+ if grade:
+ context_bits.append(f"教学学段/年级:{grade}")
+ context_line = (";".join(context_bits) + "。") if context_bits else ""
+
+ system_prompt = (
+ "你是“智教助手”,一位经验丰富、耐心细致的 K12 全学科教学顾问与备课搭档。"
+ "你的任务是帮助一线教师解决真实的教学问题:备课思路、知识点讲解、活动设计、"
+ "课堂提问、学情诊断、家校沟通、教法选择等。\n"
+ "回答要求:\n"
+ "1. 用简体中文回答,语气专业、亲切、可落地,避免空话套话。\n"
+ "2. 结构清晰,善用标题、要点、表格和示例,必要时分步骤说明。\n"
+ "3. 紧扣中国课程标准和一线课堂实际,给出可直接使用的内容(如完整提问、活动脚本、讲解话术)。\n"
+ "4. 遇到模糊请求时主动追问关键信息(年级、学科、目标、时长)再给出方案。\n"
+ "5. 对学生的易错点、认知难点要有针对性提醒;涉及数值/事实要准确。\n"
+ f"{context_line}"
+ )
+
+ messages: list[dict] = [{"role": "system", "content": system_prompt}]
+ for turn in (history or []):
+ role = turn.get("role")
+ content = (turn.get("content") or "").strip()
+ if role in {"user", "assistant"} and content:
+ messages.append({"role": role, "content": content})
+ messages.append({"role": "user", "content": user_message})
+ try:
+ return await self._chat(messages, temperature=0.6, max_tokens=8192)
+ except RuntimeError:
+ return self._fallback_chat(history or [], user_message, subject, grade)
+
+ async def generate_mindmap(self, topic: str, subject: str = "综合", grade: str = "") -> dict:
+ """生成知识思维导图,返回层级节点结构。"""
+ topic = self._repair_text(topic)
+ grade_hint = f",适用学段:{grade}" if grade else ""
+ system_prompt = f"""你是一位资深学科教学专家,擅长梳理知识结构。根据教师给出的主题,生成一份层次清晰、内容专业的知识思维导图。
+
+要求:
+- 围绕主题梳理 4 到 6 个一级分支(核心知识维度)
+- 每个一级分支下展开 2 到 4 个二级子节点
+- 节点标题用精炼短语(4 到 14 字),不要长句
+- 内容要符合学科规范与教学逻辑,覆盖该主题的核心知识脉络(学科:{subject}{grade_hint})
+- 如果主题偏小众,也尽量给出合理、可教学的结构
+
+直接输出JSON(不要markdown代码块,不要其他文字):
+{{"title":"主题名","nodes":[{{"title":"一级分支标题","color":"#00a870","children":[{{"title":"二级子节点标题","children":[{{"title":"可展开的细节要点"}}]}}]}}]}}"""
+ try:
+ result = await self._chat_json([
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": topic},
+ ], temperature=0.5, max_tokens=8192)
+ return self._normalize_mindmap_result(result, topic, subject)
+ except RuntimeError:
+ return self._fallback_mindmap(topic, subject)
+
+ def _normalize_mindmap_result(self, result: dict, topic: str, subject: str) -> dict:
+ topic = self._repair_text(topic)
+ nodes = result.get("nodes")
+ if not isinstance(nodes, list) or not nodes:
+ return self._fallback_mindmap(topic, subject)
+ palette = ["#00a870", "#2563eb", "#d97706", "#db2777", "#7c3aed", "#0891b2", "#4f46e5", "#ea580c"]
+ clean: list = []
+ for i, branch in enumerate(nodes):
+ if not isinstance(branch, dict) or not str(branch.get("title", "")).strip():
+ continue
+ color = branch.get("color") or palette[i % len(palette)]
+ children = self._collect_mindmap_children(branch.get("children"))
+ if not children:
+ continue
+ clean.append({"title": str(branch["title"]).strip()[:30], "color": color, "children": children})
+ if len(clean) < 2:
+ return self._fallback_mindmap(topic, subject)
+ return {"title": str(result.get("title") or topic)[:80], "nodes": clean[:8]}
+
+ def _collect_mindmap_children(self, raw) -> list:
+ if not isinstance(raw, list):
+ return []
+ out: list = []
+ for item in raw:
+ if isinstance(item, dict) and str(item.get("title", "")).strip():
+ title = str(item["title"]).strip()[:30]
+ sub = self._collect_mindmap_children(item.get("children"))
+ node = {"title": title}
+ if sub:
+ node["children"] = sub
+ out.append(node)
+ elif isinstance(item, str) and item.strip():
+ out.append({"title": item.strip()[:30]})
+ return out[:6]
+
+ def _fallback_chat(self, history: list, user_message: str, subject: str = "", grade: str = "") -> str:
+ """AI 离线时的教学顾问兜底:按意图匹配给出可落地、结构化的教学建议。"""
+ subject = (subject or "").strip()
+ grade = (grade or "").strip()
+ msg = self._repair_text(user_message).strip()
+ ctx_bits = []
+ if subject:
+ ctx_bits.append(subject)
+ if grade:
+ ctx_bits.append(grade)
+ ctx = ("(" + "·".join(ctx_bits) + ")") if ctx_bits else ""
+ offline_note = (
+ "\n\n— 当前为离线知识模式(AI 云端未连通),以上为基于教学经验的通用建议;"
+ "如需针对你具体班级、教材版本的个性化方案,请在 AI 服务恢复后再次提问。"
+ )
+
+ if any(k in msg for k in ["你好", "您好", "在吗", "谢谢", "感谢", "辛苦"]) and len(msg) <= 12:
+ hi = "你好!我是智教助手,你的教学顾问与备课搭档。" + (f"已记录你的任教学段{ctx}。" if ctx else "")
+ return (
+ hi + "\n\n我可以帮你:备课与教学设计、知识点讲解思路、课堂活动与提问设计、"
+ "学情诊断与分层教学、家校沟通、复习备考、课堂管理、学生动机激发等。\n\n"
+ "直接告诉我你想解决的教学问题,例如:\n"
+ "- \"如何讲清分数乘法的算理?\"\n"
+ "- \"设计一节《草船借箭》的导入环节\"\n"
+ "- \"班上两极分化严重怎么办?\"\n"
+ "- \"家长群里如何回复投诉?\"" + offline_note
+ )
+
+ subj_resp = self._subject_advice(msg, subject, grade)
+ if subj_resp:
+ return subj_resp + offline_note
+
+ if any(k in msg for k in ["备课", "教学设计", "教案", "教学环节", "教学目标", "怎么设计", "如何设计", "设计一节", "设计这节", "课时方案"]):
+ return self._chat_lesson_prep(ctx) + offline_note
+ if any(k in msg for k in ["怎么讲", "如何讲", "怎么解释", "如何解释", "讲清楚", "讲不清", "学生不懂", "听不懂", "理解不了", "突破难点", "重难点", "算理", "为什么这样", "原理"]):
+ return self._chat_explain(ctx) + offline_note
+ if any(k in msg for k in ["导入", "开场", "引入", "情境", "激发兴趣", "吸引"]):
+ return self._chat_intro(ctx) + offline_note
+ if any(k in msg for k in ["课堂活动", "互动活动", "小组活动", "合作学习", "游戏", "动手", "探究", "角色扮演", "情境表演"]):
+ return self._chat_activity(ctx) + offline_note
+ if any(k in msg for k in ["提问", "追问", "问题设计", "提问技巧", "课堂提问"]):
+ return self._chat_questioning(ctx) + offline_note
+ if any(k in msg for k in ["纪律", "管纪律", "课堂管理", "课堂常规", "调皮", "捣乱", "走神", "注意力", "不专心", "讲话", "吵闹", "坐不住"]):
+ return self._chat_management(ctx) + offline_note
+ if any(k in msg for k in ["学困生", "后进生", "学困", "待优生", "跟不上", "两极分化", "差距大", "分层", "差异化", "因材施教", "培优补差"]):
+ return self._chat_differentiation(ctx) + offline_note
+ if any(k in msg for k in ["家长", "家校", "家访", "家长会", "家长群", "沟通家长", "投诉"]):
+ return self._chat_parents(ctx) + offline_note
+ if any(k in msg for k in ["作业", "布置作业", "评价", "批改", "反馈", "形成性评价", "作业量", "作业设计"]):
+ return self._chat_assessment(ctx) + offline_note
+ if any(k in msg for k in ["不想学", "没兴趣", "没动力", "厌学", "积极性", "不想听", "敷衍", "内驱力", "学习动机"]):
+ return self._chat_motivation(ctx) + offline_note
+ if any(k in msg for k in ["复习", "备考", "期末", "期中", "考试", "复习课", "冲刺", "考点", "应试"]):
+ return self._chat_review(ctx) + offline_note
+ if any(k in msg for k in ["新教师", "新手", "刚入职", "实习", "第一次上课", "新老师", "刚上班"]):
+ return self._chat_newteacher(ctx) + offline_note
+
+ return self._chat_general(msg, ctx) + offline_note
+
+ def _subject_advice(self, msg: str, subject: str, grade: str) -> str:
+ """识别学科相关提问,返回学科特色建议;无匹配返回空串。"""
+ is_math = any(k in msg for k in ["数学", "算理", "算式", "方程", "函数", "几何", "分数", "小数", "乘法", "除法", "加减法", "应用题"])
+ is_chinese = any(k in msg for k in ["语文", "课文", "识字", "写字", "阅读理解", "作文", "古诗词", "古诗", "文言文", "拼音", "草船借箭", "荷塘月色"])
+ is_english = any(k in msg for k in ["英语", "单词", "语法", "口语", "听力", "时态", "从句", "because"])
+ is_physics = any(k in msg for k in ["物理", "力学", "电学", "电路", "牛顿", "压强", "浮力", "光学"])
+ if is_math:
+ return (
+ "【数学教学建议】\n\n"
+ "一、算理优于算法\n"
+ "数学核心是让学生懂\"为什么\",而非记\"怎么做\"。讲新运算先用实物/画图/数轴把算理可视化(如分数乘法用面积模型),再给法则。\n\n"
+ "二、三讲三练结构\n"
+ "- 讲例题(边讲边写思维过程)→ 练模仿题\n"
+ "- 讲变式(改一个条件/数据)→ 练变式题\n"
+ "- 讲错题(学生典型错误当反面教材)→ 练易错题\n\n"
+ "三、让错误可见\n"
+ "数学最怕假装会。多用板演、白板小测、手势即时暴露思维。错题不只对答案,让学生讲\"我当时怎么想的\"。\n\n"
+ "四、应用题三部曲\n"
+ "读题(圈关键信息)→ 画图/列表(文字转成关系)→ 列式(再算)。低年级卡第一步,高年级卡第二步。\n\n"
+ "告诉我具体课题(如\"分数除法\"\"平行四边形面积\"),我给更细的活动设计。"
+ )
+ if is_chinese:
+ return (
+ "【语文教学建议】\n\n"
+ "一、朗读是地基\n"
+ "低中年级把朗读做扎实:教师范读→跟读→指名读→齐读,注意停顿、重音、语气。朗读到位,理解自然跟上。\n\n"
+ "二、课文\"三问法\"\n"
+ "- 写了什么?(整体感知)\n"
+ "- 怎么写的?(品词析句、写法)\n"
+ "- 为什么这样写?(情感、主旨)\n\n"
+ "三、作文从\"有话写\"到\"写得好\"\n"
+ "先解决没素材:多搞观察课、活动作文。再解决写不具体:训练动作分解(把\"他跑了\"拆成 5 个动作)。最后才润色文采。\n\n"
+ "四、识字/字词\n"
+ "字理识字(讲字源)、归类识字(同偏旁)、语境识字(放词句里记),比机械抄写有效。\n\n"
+ "告诉我具体课文或课型,我给针对性设计。"
+ )
+ if is_english:
+ return (
+ "【英语教学建议】\n\n"
+ "一、输入先行(i+1 原则)\n"
+ "新语言点先大量听/看→理解,再要求说/写。用 TPR、图片、情境让学生先懂意思,不要一上来就机械跟读。\n\n"
+ "二、词不离句,句不离境\n"
+ "单词放进有意义的句子里记,句子放进情境里用。避免单词—中文翻译的孤立记忆,那样学生只会背不会用。\n\n"
+ "三、PPP 模式\n"
+ "Presentation(呈现)→ Practice(控制练习)→ Production(自由产出)。第三步最常被省略,却最关键——要给真实任务去用语言。\n\n"
+ "四、让沉默期变短\n"
+ "不爱开口的学生:先做非语言回应(指、选、画),再重复性输出(跟读),最后创造性输出。降低开口焦虑。\n\n"
+ "告诉我具体单元/课型,我给细化方案。"
+ )
+ if is_physics:
+ return (
+ "【物理教学建议】\n\n"
+ "一、实验是物理的灵魂\n"
+ "能做实验绝不只讲实验。演示实验要让现象出乎意料(引发认知冲突),分组实验让每个人动手。器材不足用生活物品替代(矿泉水瓶测压强)。\n\n"
+ "二、从现象到规律四步\n"
+ "观察现象 → 提出问题 → 猜想/设计实验 → 得出规律。规律不要直接给,让学生发现。\n\n"
+ "三、建模与数学化\n"
+ "讲公式时一定讲清每个字母的物理意义和单位,强调公式是规律的语言,而非要背的符号。\n\n"
+ "四、典型误区预防\n"
+ "力与运动(惯性误解)、电流方向、浮力沉浮条件……把这些高频误区编成判断题课前测,对症讲解。"
+ )
+ return ""
+
+ def _chat_lesson_prep(self, ctx: str) -> str:
+ return (
+ f"关于备课,建议你按\"目标—学情—活动—评价\"四步设计{ctx}:\n\n"
+ "一、明确可检验的教学目标(最关键)\n"
+ "用\"学生能做什么 + 什么条件下 + 达到什么程度\"来写。例:学生能在 5 分钟内独立完成 3 道两位数乘法,正确率≥80%。\n"
+ "避免\"让学生理解…\"\"培养…能力\"这类无法检测的目标。一节课 2-3 个核心目标即可。\n\n"
+ "二、诊断学情\n"
+ "这节课学生已有什么基础?常见误区是什么?课前用 2 道前测题摸底,或翻看上节课作业的典型错误。\n\n"
+ "三、设计主干活动(建议 3 环节)\n"
+ "- 导入(3-5 分钟):旧知、生活情境或认知冲突引入。\n"
+ "- 新知建构(15-20 分钟):不要直接给结论,设计让学生发现的活动——观察、操作、讨论、归纳。\n"
+ "- 巩固迁移(10-15 分钟):模仿→变式→应用,难度阶梯上升。\n\n"
+ "四、嵌入评价(防止假装听懂)\n"
+ "每个环节末尾设一个检测点:提问、小测、板演、同伴互评,让你随时知道学生学到哪了。\n\n"
+ "时间提醒:教师讲授总时长控制在 15 分钟内,把时间还给学生练与思。\n\n"
+ "告诉我具体课题和年级,我给完整的环节脚本与活动设计。"
+ )
+
+ def _chat_explain(self, ctx: str) -> str:
+ return (
+ f"讲清一个难点,核心是搭台阶 + 可视化 + 证伪误区{ctx}:\n\n"
+ "一、先找到学生的卡点\n"
+ "学生不懂通常卡在某一个具体环节,不是整体不懂。问自己:这个知识依赖哪个旧知?哪个概念是转折点?把卡点定位到最小单元。\n\n"
+ "二、搭台阶(最近发展区)\n"
+ "把难点拆成 3-4 个递进小问题,每个学生能答上来,最终自己推出结论。例:讲分数除法→先复习分数意义→再问除以一个数=乘它倒数为什么成立→用面积图验证。\n\n"
+ "三、可视化(多通道呈现)\n"
+ "能画图就画图,能摆实物就摆实物。抽象概念配具象表征(数轴、面积模型、流程图、表格),让学生看见关系。\n\n"
+ "四、用错误反证(认知冲突)\n"
+ "先抛一个看起来对其实是错的判断,让多数学生踩坑,再一起分析为什么错——比直接讲正确结论印象深 3 倍。\n\n"
+ "五、让学生复述/教别人\n"
+ "讲完别问懂了吗(学生都会点头),而让 1-2 个学生用自己的话复述,或讲给同桌听。能讲清楚才是真懂。\n\n"
+ "把具体知识点告诉我,我帮你拆台阶、设计冲突问题。"
+ )
+
+ def _chat_intro(self, ctx: str) -> str:
+ return (
+ f"好的导入能在 3-5 分钟抓住学生,常用 5 种方式{ctx}:\n\n"
+ "1. 情境导入:用学生熟悉的生活场景切入(买菜算账、校园事件、新闻热点),让知识有用。\n"
+ "2. 冲突导入:抛一个反直觉的现象或问题(如:1千克铁和1千克棉花谁重?),引发好奇。\n"
+ "3. 悬念导入:讲半截故事/留个谜,答案藏在今天的内容里。\n"
+ "4. 旧知导入:从上节课内容自然延伸,温故知新。\n"
+ "5. 操作导入:动手做个小实验/小游戏,身体先参与,注意力立刻集中。\n\n"
+ "避坑:导入别超过 5 分钟,别为热闹而热闹——导入必须直接服务于本课目标。\n\n"
+ "告诉我课题,我帮你设计 1-2 个具体导入脚本。"
+ )
+
+ def _chat_activity(self, ctx: str) -> str:
+ return (
+ f"设计有效课堂活动,关键是任务清晰 + 人人有事做 + 及时反馈{ctx}:\n\n"
+ "常用活动类型\n"
+ "- 小组讨论:4 人一组,问题要有争议性/开放性,讨论前给 1 分钟独立思考,避免搭便车。\n"
+ "- 角色扮演:语文/英语/历史特别适合,提前给角色卡和台词框架。\n"
+ "- 操作探究:数学/科学课用学具、实验,让学生做出结论。\n"
+ "- 游戏竞赛:抢答、卡片配对、知识闯关,适合巩固环节,注意控制节奏。\n"
+ "- 同伴互教:会的学生教不会的,教的人反而学得更深。\n\n"
+ "让活动不失控的 3 个原则\n"
+ "1. 活动前讲清规则和产出(讨论结束每组派代表说 1 条),黑板上挂任务。\n"
+ "2. 活动中教师巡视、记录、点拨,不站在讲台。\n"
+ "3. 活动后必须汇报/展示/点评,否则学生会觉得讨论了没用。\n\n"
+ "告诉我学科和课型,我帮你设计一个具体活动。"
+ )
+
+ def _chat_questioning(self, ctx: str) -> str:
+ return (
+ f"高质量课堂提问,记住三秒原则 + 追问 + 全员应答{ctx}:\n\n"
+ "一、提问前的设计\n"
+ "备课时写下 3-5 个核心问题,分三类:\n"
+ "- 记忆型(是什么)→ 唤醒旧知\n"
+ "- 理解型(为什么/怎么说)→ 促思考\n"
+ "- 应用/评价型(如果…会怎样/你同意吗)→ 促迁移\n"
+ "一节好课,后两类要占多数。\n\n"
+ "二、提问技巧\n"
+ "- 先问后叫人:先抛问题,停 3-5 秒(让所有人想),再随机点名。\n"
+ "- 追问:学生答完,追问你怎么想到的?还有别的可能吗?把思维引向深处。\n"
+ "- 不轻易否定:错误答案也是资源,追问这个想法哪里有问题,让全班分析。\n"
+ "- 全员应答:用白板、手势、答题卡、随机抽签,让每个学生被迫回应。\n\n"
+ "避坑:别只问对不对/是不是;别一问完立刻自己答。\n\n"
+ "把要讲的课题告诉我,我帮你设计一组阶梯式提问。"
+ )
+
+ def _chat_management(self, ctx: str) -> str:
+ return (
+ f"课堂纪律靠规则前置 + 节奏控制 + 关系先行,而非吼叫{ctx}:\n\n"
+ "一、预防优于纠正\n"
+ "- 开学第一周把课堂常规立清楚(举手发言、不插嘴、安静信号),坚持执行。\n"
+ "- 用固定的安静信号(拍手节奏、举手倒数、手势),比喊安静有效。\n\n"
+ "二、走神/讲话:用教学手段拉回,而非批评\n"
+ "- 突然停顿 3 秒(全班会安静看你)。\n"
+ "- 把走神学生的名字编进例题(假设小明买了 3 个本子…),温和提醒。\n"
+ "- 走下讲台站到他旁边继续讲,距离本身就是管理。\n\n"
+ "三、调皮/对抗:私下处理,保护自尊\n"
+ "- 课堂上不当众发作(易激化),课后单独谈。\n"
+ "- 谈话用我观察到…+ 我担心…+ 我希望…句式,而非指责。\n"
+ "- 找到行为背后的需求(求关注?听不懂?家庭问题?),对症下药。\n\n"
+ "四、根本:让课足够有趣 + 师生关系好\n"
+ "纪律问题 80% 来自课堂太无聊或学生不喜欢老师。把课上精彩、多关心学生,纪律自然好转。\n\n"
+ "具体场景(哪个年级、什么行为)告诉我,我给针对性策略。"
+ )
+
+ def _chat_differentiation(self, ctx: str) -> str:
+ return (
+ f"应对两极分化/学困生,核心是分层不减负、抓两头带中间{ctx}:\n\n"
+ "一、分层不分班(课内分层)\n"
+ "- 目标分层:同一节课,学困生只要达成基础目标,学优生有挑战任务。\n"
+ "- 任务分层:布置 A(基础)/B(提高)/C(拓展)三档作业,学生选做或指定。\n"
+ "- 提问分层:简单问题给学困生(建立信心),难题给学优生。\n\n"
+ "二、补差要抓前置基础\n"
+ "学困生卡住往往是旧知识漏洞,不是这节课听不懂。诊断他缺哪块基础,用课前 10 分钟或课后针对性补。\n\n"
+ "三、同伴互助(最省力)\n"
+ "小老师制:1 个学优生帮 1 个学困生,捆绑考核(两人都进步才表扬)。教的人学得更深,被教的人压力小、敢问。\n\n"
+ "四、保护学困生自尊\n"
+ "- 永远不当众羞辱(这么简单都不会是毒药)。\n"
+ "- 抓住任何小进步公开表扬,建立我能学的信念。\n"
+ "- 错题私下反馈,不公布排名。\n\n"
+ "五、培优要给空间\n"
+ "学优生最怕吃不饱被打发。给自主探究任务、当小老师、参加竞赛,让能量有出口。\n\n"
+ "具体学科和年级告诉我,我帮你设计分层任务清单。"
+ )
+
+ def _chat_parents(self, ctx: str) -> str:
+ return (
+ f"家校沟通关键是先共情 + 讲事实 + 给方案,而非告状{ctx}:\n\n"
+ "一、沟通前心态\n"
+ "家长不是对手,是同盟。即使投诉的家长,本质也是为孩子好。先听他说完,别急着辩解。\n\n"
+ "二、报问题的标准话术(三明治法)\n"
+ "1. 先说一个孩子的优点/进步(让家长放下防备)。\n"
+ "2. 客观陈述问题(只讲事实,不带评价:最近三次作业有两次没交,而非这孩子太懒)。\n"
+ "3. 表达关心 + 给具体方案(我担心影响他后续学习,咱们一起想想办法,我建议…您看行吗?)。\n\n"
+ "三、家长群沟通禁忌\n"
+ "- 不在群里点名批评个别学生(保护隐私)。\n"
+ "- 不在群里跟家长争论(私聊处理)。\n"
+ "- 通知类信息简洁清晰,避免长语音。\n"
+ "- 投诉先回应已收到,X 点前给您答复,别晾着。\n\n"
+ "四、家访/约谈\n"
+ "- 提前预约,不突袭。\n"
+ "- 先表扬再谈问题,结束时达成 1-2 个可执行小目标。\n"
+ "- 记录谈话要点,下次跟进。\n\n"
+ "具体场景(投诉什么/孩子什么问题)告诉我,我帮你写一段沟通话术。"
+ )
+
+ def _chat_assessment(self, ctx: str) -> str:
+ return (
+ f"作业与评价的核心是少而精 + 即时反馈 + 促学而非排名{ctx}:\n\n"
+ "一、作业设计原则\n"
+ "- 少而精:3 道有思维含量的题 > 30 道机械重复。\n"
+ "- 分层:基础题全员必做,挑战题选做,避免学困生抄、学优生闲。\n"
+ "- 形式多样:除书面外,加口头、实践、项目作业(如用本周学的统计调查家里一周用电量)。\n"
+ "- 必批必改必反馈:不批的作业等于没布置;批改后要讲评典型错题。\n\n"
+ "二、形成性评价(重过程)\n"
+ "不只看期末考试。日常用:课堂提问记录、小测、作品集、同伴互评,让评价贯穿学习全过程。\n\n"
+ "三、反馈的艺术\n"
+ "- 具体:第二步符号写反了 > 粗心。\n"
+ "- 可改进:指出下一步怎么做,而非只判对错。\n"
+ "- 及时:当天/次日反馈,隔一周就失效。\n"
+ "- 成长导向:比上次进步了比 85 分更有激励作用。\n\n"
+ "具体学科和题型告诉我,我帮你设计一份分层作业。"
+ )
+
+ def _chat_motivation(self, ctx: str) -> str:
+ return (
+ f"激发学习动机,从胜任感 + 自主感 + 归属感入手{ctx}:\n\n"
+ "一、胜任感:让他尝到成功的甜头\n"
+ "没兴趣的学生,往往因为长期学不会而放弃。给他够得着的小目标,让他体验我会了——一次小测进步、一道题做对,立刻肯定。信心是兴趣的前提。\n\n"
+ "二、自主感:给他选择权\n"
+ "人对被命令的事天然抗拒。给有限选择:今天作业 A 还是 B?小组讨论你想当记录员还是汇报员?参与感提升投入度。\n\n"
+ "三、归属感:让他感到被看见\n"
+ "记住每个学生的名字和一件小事,课堂上让每个人都有发言机会。学生因为喜欢这个老师而喜欢这门课是真实存在的。\n\n"
+ "四、让知识有用 + 有趣\n"
+ "- 联系生活/热点/学生关心的事,让知识活起来。\n"
+ "- 用游戏、竞赛、悬念,让过程有乐趣。\n"
+ "- 慎用外在奖励:物质奖励/加分短期有效,长期会削弱内驱力。多用成长反馈、公开认可。\n\n"
+ "五、排查外部原因\n"
+ "突然厌学,先了解:家庭变故?同伴关系?沉迷手机?身体/心理问题?对症才能解。\n\n"
+ "具体是哪个学生/什么表现告诉我,我给针对性方案。"
+ )
+
+ def _chat_review(self, ctx: str) -> str:
+ return (
+ f"复习备考要结构化 + 错题驱动 + 模拟实战,而非题海{ctx}:\n\n"
+ "一、先建知识结构(脑图)\n"
+ "复习不是把新课重讲一遍。先带学生画整章/整册知识脑图,理清脉络,让零散知识串成网。结构清晰,提取才快。\n\n"
+ "二、错题驱动(最高效)\n"
+ "把全学期高频错题/易错点整理成专题,针对性突破。会的题不重复刷,时间留给真问题。建议每个学生建错题本,定期重做。\n\n"
+ "三、三轮复习节奏\n"
+ "- 第一轮:单元过关,扫清知识盲点(重基础)。\n"
+ "- 第二轮:专题整合,跨章节串联(重综合)。\n"
+ "- 第三轮:模拟实战,限时训练(重应试与心态)。\n\n"
+ "四、模拟考试要仿真\n"
+ "限时、独立、评分标准对齐正式考试。考后不只对答案,要分析:哪类题失分?知识漏洞还是应试失误?制定针对性补救。\n\n"
+ "五、别忘了心态\n"
+ "考前焦虑的学生很多。适当减压,强调尽力就好,别把分数和人格绑定。\n\n"
+ "具体学科和学段告诉我,我帮你列复习专题清单。"
+ )
+
+ def _chat_newteacher(self, ctx: str) -> str:
+ return (
+ f"新手教师上路,先稳住课堂 + 关系 + 心态三件事{ctx}:\n\n"
+ "一、第一年别追求花哨,先求稳\n"
+ "- 把常规立住(纪律、作业、发言规则),课堂乱什么都做不好。\n"
+ "- 备课写详案(每环节说什么、问什么、学生可能怎么答),别只写框架。\n"
+ "- 多听老教师的课,模仿是最好的学习。\n\n"
+ "二、师生关系:严慈相济\n"
+ "- 开学先立威(规则严、说到做到),再慢慢施恩(关心、幽默、认可)。顺序反了很难管。\n"
+ "- 记住每个学生名字,课后多聊天。被看见的学生才会配合你。\n"
+ "- 永远不当众发火/羞辱学生,一次就可能毁掉整学期关系。\n\n"
+ "三、教学:少讲多练,别贪多\n"
+ "- 一节课 2-3 个核心点足够,讲透练透比走马观花强。\n"
+ "- 教师讲授控制在 15 分钟内,剩下时间给学生。\n"
+ "- 每节课留 5 分钟检测,知道自己教得怎么样。\n\n"
+ "四、心态:别和名师比,和自己比\n"
+ "- 第一年上不好很正常,三年才成型,五年才出风格。\n"
+ "- 别把所有问题归咎于自己(学生基础、家庭都是变量)。\n"
+ "- 找一个愿意带你/听你课的师傅,成长快 3 倍。\n\n"
+ "具体困惑(管不住班?备课没底?家长沟通?)告诉我,我针对性支招。"
+ )
+
+ def _chat_general(self, msg: str, ctx: str) -> str:
+ return (
+ f"我收到了你的问题。为了让建议更精准,先按问题—对象—目标帮你理一理{ctx}:\n\n"
+ "一、你想解决的核心问题是什么?\n"
+ "比如:备课没思路?某个知识点学生听不懂?课堂纪律乱?学生没兴趣?家长难沟通?复习效率低?\n\n"
+ "二、针对的对象和情境?\n"
+ "学科、年级、班级特点(人数、两极分化程度)、这节课/这个学生的具体情况。\n\n"
+ "三、你期望达成的目标?\n"
+ "想要一份完整教案?一个活动设计?一段沟通话术?一个分层作业?还是某个具体问题的解决思路?\n\n"
+ "把这些信息补充给我,我能给出可直接用的方案。你也可以直接试试这些常见问题:\n"
+ "- 如何讲清[某知识点]?\n"
+ "- 设计一节[某课题]的导入/活动\n"
+ "- 班上两极分化怎么办?\n"
+ "- 学生上课走神怎么管理?\n"
+ "- 如何回复家长的投诉?"
+ )
+
+ def _fallback_mindmap(self, topic: str, subject: str) -> dict:
+ topic = self._repair_text(topic)
+ title = topic[:24] or "思维导图"
+ for keys, nodes in self._mindmap_kb():
+ if any(k in topic for k in keys):
+ return {"title": title, "nodes": nodes}
+ return {"title": title, "nodes": self._mindmap_subject_template(subject)}
+
+ # ---- 主题感知思维导图知识库 ----
+ _MM_COLORS = ["#00a870", "#2563eb", "#d97706", "#db2777", "#7c3aed", "#0891b2"]
+
+ def _mm_nodes(self, *branches):
+ out = []
+ for i, (title, kids) in enumerate(branches):
+ out.append({"title": title, "color": self._MM_COLORS[i % len(self._MM_COLORS)],
+ "children": [{"title": k} for k in kids]})
+ return out
+
+ def _mindmap_kb(self):
+ mm = self._mm_nodes
+ # 历史
+ if False:
+ pass
+ kb = [
+ (["朝代", "历代", "王朝", "中国古代史"], mm(
+ ("先秦文明", ["夏商周更替", "春秋五霸", "战国七雄"]),
+ ("秦汉大一统", ["秦始皇统一", "汉武帝强盛", "丝绸之路"]),
+ ("隋唐盛世", ["隋朝开科", "贞观之治", "开元盛世"]),
+ ("宋元变革", ["宋代理学", "元朝行省制", "四大发明"]),
+ )),
+ (["丝绸之路"], mm(
+ ("陆上丝路", ["张骞出使西域", "长安出发", "途经河西走廊"]),
+ ("海上丝路", ["泉州广州港口", "瓷器丝绸外销", "郑和下西洋"]),
+ ("贸易商品", ["丝绸", "瓷器", "茶叶", "香料"]),
+ ("文化交流", ["佛教东传", "四大发明西传", "中外互通"]),
+ )),
+ (["工业革命"], mm(
+ ("第一次工业革命", ["蒸汽机", "纺织业机械化", "铁路交通"]),
+ ("第二次工业革命", ["电力", "内燃机", "化学工业"]),
+ ("社会影响", ["城市化", "阶级变化", "殖民扩张"]),
+ ("科技进步", ["生产力飞跃", "世界市场形成", "环境污染"]),
+ )),
+ (["抗日战争", "抗战"], mm(
+ ("局部抗战", ["九一八事变", "东北沦陷", "义勇军抗争"]),
+ ("全面抗战", ["七七事变", "正面战场", "敌后战场"]),
+ ("重大战役", ["台儿庄", "百团大战", "平型关"]),
+ ("伟大胜利", ["统一战线", "世界反法西斯", "日本投降"]),
+ )),
+ (["文艺复兴"], mm(
+ ("兴起背景", ["意大利城邦", "资本主义萌芽", "人文主义"]),
+ ("代表人物", ["但丁", "达芬奇", "莎士比亚"]),
+ ("核心思想", ["以人为本", "个性解放", "反对神权"]),
+ ("深远影响", ["思想解放", "科学革命", "宗教改革"]),
+ )),
+ (["辛亥革命"], mm(
+ ("革命背景", ["清末危机", "民族资本主义", "革命思想传播"]),
+ ("革命过程", ["武昌起义", "各省响应", "建立民国"]),
+ ("历史意义", ["推翻帝制", "民主共和", "思想解放"]),
+ ("局限性", ["革命不彻底", "军阀割据", "任务未完成"]),
+ )),
+ # 地理
+ (["气候", "气候类型", "气温降水"], mm(
+ ("影响因子", ["纬度", "海陆", "地形", "洋流"]),
+ ("主要类型", ["热带雨林", "季风气候", "温带大陆性", "地中海气候"]),
+ ("降水规律", ["赤道多雨", "副热带少雨", "温带增多"]),
+ ("中国气候", ["季风显著", "雨热同期", "气候复杂多样"]),
+ )),
+ (["地形", "地貌", "地势"], mm(
+ ("基本地形", ["山地", "平原", "高原", "盆地", "丘陵"]),
+ ("中国地势", ["西高东低", "阶梯分布", "大河东流"]),
+ ("外力作用", ["流水侵蚀", "风力沉积", "冰川作用"]),
+ ("典型地貌", ["喀斯特", "丹霞", "黄土高原", "冲积平原"]),
+ )),
+ (["河流", "水系", "长江", "黄河"], mm(
+ ("长江", ["发源青藏", "流经十一省", "入东海", "黄金水道"]),
+ ("黄河", ["发源巴颜喀拉", "泥沙含量大", "地上河", "入渤海"]),
+ ("河流作用", ["供水灌溉", "航运发电", "塑造平原"]),
+ ("治理保护", ["防洪堤坝", "水土保持", "生态修复"]),
+ )),
+ (["地球", "地球运动", "自转公转"], mm(
+ ("自转", ["绕轴旋转", "昼夜交替", "时差"]),
+ ("公转", ["绕日运行", "四季更替", "五带划分"]),
+ ("黄赤交角", ["23.5度", "太阳直射点移动", "昼夜长短变化"]),
+ ("地理意义", ["正午太阳高度", "节气", "气候形成"]),
+ )),
+ # 生物
+ (["细胞"], mm(
+ ("细胞结构", ["细胞膜", "细胞质", "细胞核", "细胞壁(植物)"]),
+ ("细胞器", ["线粒体", "叶绿体", "核糖体", "液泡"]),
+ ("细胞分裂", ["有丝分裂", "减数分裂", "无丝分裂"]),
+ ("细胞分化", ["形态变化", "功能特化", "形成组织"]),
+ )),
+ (["光合作用"], mm(
+ ("反应条件", ["光照", "叶绿体", "适宜温度"]),
+ ("原料产物", ["二氧化碳", "水", "氧气", "有机物"]),
+ ("两个阶段", ["光反应", "暗反应", "能量转化"]),
+ ("重要意义", ["制造有机物", "释放氧气", "碳氧平衡"]),
+ )),
+ (["生态系统"], mm(
+ ("组成成分", ["非生物物质", "生产者", "消费者", "分解者"]),
+ ("食物链网", ["捕食关系", "能量单向", "物质循环"]),
+ ("能量流动", ["太阳能输入", "逐级递减", "10%-20%传递"]),
+ ("生态平衡", ["自我调节", "稳定性", "人类影响"]),
+ )),
+ (["遗传", "基因", "DNA"], mm(
+ ("遗传物质", ["DNA", "基因", "染色体"]),
+ ("基本规律", ["分离定律", "自由组合", "显隐性"]),
+ ("遗传变异", ["基因突变", "基因重组", "染色体变异"]),
+ ("应用拓展", ["育种", "遗传咨询", "基因工程"]),
+ )),
+ (["人体", "消化", "呼吸", "循环"], mm(
+ ("消化系统", ["口腔胃小肠", "消化酶", "吸收营养"]),
+ ("呼吸系统", ["呼吸道", "肺泡", "气体交换"]),
+ ("循环系统", ["心脏", "血管", "血液循环"]),
+ ("神经系统", ["大脑", "神经传导", "反射调节"]),
+ )),
+ # 化学
+ (["元素周期表", "元素"], mm(
+ ("结构规律", ["周期", "族", "原子序数", "递变规律"]),
+ ("主族元素", ["碱金属", "卤素", "氧族", "氮族"]),
+ ("周期变化", ["原子半径递减", "金属性减弱", "非金属性增强"]),
+ ("应用价值", ["预测性质", "寻找新材料", "指导分类"]),
+ )),
+ (["化学反应"], mm(
+ ("反应类型", ["化合", "分解", "置换", "复分解"]),
+ ("反应条件", ["加热", "点燃", "催化", "通电"]),
+ ("能量变化", ["放热反应", "吸热反应", "化学能转化"]),
+ ("方程式", ["质量守恒", "配平", "符号规范"]),
+ )),
+ (["酸碱盐", "酸碱"], mm(
+ ("常见酸", ["盐酸", "硫酸", "硝酸", "醋酸"]),
+ ("常见碱", ["氢氧化钠", "氢氧化钙", "氨水"]),
+ ("酸碱反应", ["中和反应", "生成盐和水", "pH变化"]),
+ ("盐的性质", ["溶解性", "复分解", "焰色反应"]),
+ )),
+ (["原子", "分子", "物质构成"], mm(
+ ("原子结构", ["原子核", "质子", "中子", "核外电子"]),
+ ("分子构成", ["共价键", "分子间作用力", "分子特性"]),
+ ("离子化合物", ["得失电子", "离子键", "晶体结构"]),
+ ("物质分类", ["单质", "化合物", "混合物"]),
+ )),
+ # 物理
+ (["力学", "力", "牛顿"], mm(
+ ("常见的力", ["重力", "弹力", "摩擦力", "浮力"]),
+ ("牛顿三定律", ["惯性定律", "加速度定律", "作用反作用"]),
+ ("压强", ["固体压强", "液体压强", "大气压强"]),
+ ("简单机械", ["杠杆", "滑轮", "斜面", "功的原理"]),
+ )),
+ (["电学", "电流", "电路", "欧姆"], mm(
+ ("基本物理量", ["电流", "电压", "电阻", "电功率"]),
+ ("欧姆定律", ["I=U/R", "串并联电阻", "电压电流关系"]),
+ ("电路连接", ["串联", "并联", "混联", "短路断路"]),
+ ("电与磁", ["电流磁效应", "电磁感应", "电动机发电机"]),
+ )),
+ (["光学", "光", "反射折射"], mm(
+ ("光的传播", ["直线传播", "光速", "影子小孔成像"]),
+ ("光的反射", ["反射定律", "镜面反射", "漫反射"]),
+ ("光的折射", ["折射定律", "全反射", "色散"]),
+ ("透镜成像", ["凸透镜", "凹透镜", "成像规律", "应用"]),
+ )),
+ (["运动", "速度", "加速度"], mm(
+ ("运动描述", ["参照物", "路程位移", "速度"]),
+ ("匀速运动", ["速度恒定", "s=vt", "图像分析"]),
+ ("变速运动", ["加速度", "v=v0+at", "匀变速直线"]),
+ ("相对运动", ["相对速度", "相遇追及", "实际应用"]),
+ )),
+ # 数学
+ (["函数"], mm(
+ ("函数概念", ["定义域", "值域", "对应关系"]),
+ ("基本初等函数", ["一次函数", "二次函数", "反比例函数"]),
+ ("指数对数", ["指数函数", "对数函数", "运算法则"]),
+ ("函数性质", ["单调性", "奇偶性", "周期性"]),
+ )),
+ (["几何", "三角形", "圆"], mm(
+ ("线与角", ["点线面", "相交平行", "角的关系"]),
+ ("三角形", ["内角和", "全等相似", "勾股定理"]),
+ ("四边形", ["平行四边形", "矩形菱形", "梯形"]),
+ ("圆", ["圆的性质", "切线", "弧长扇形"]),
+ )),
+ (["方程", "一元", "二元", "不等式"], mm(
+ ("一元一次", ["等式性质", "求解步骤", "应用题"]),
+ ("方程组", ["二元一次", "代入消元", "加减消元"]),
+ ("一元二次", ["因式分解", "公式法", "韦达定理"]),
+ ("不等式", ["性质", "一元一次不等式", "不等式组"]),
+ )),
+ (["概率", "统计"], mm(
+ ("数据收集", ["普查抽样", "频数频率", "统计图表"]),
+ ("数据特征", ["平均数", "中位数众数", "方差极差"]),
+ ("概率初步", ["事件分类", "古典概型", "频率估计", "树状图列举"]),
+ ("应用", ["决策分析", "风险评估", "生活实例"]),
+ )),
+ # 语文
+ (["古诗", "古诗词", "唐诗宋词"], mm(
+ ("发展脉络", ["诗经楚辞", "唐诗", "宋词", "元曲"]),
+ ("代表诗人", ["李白杜甫", "白居易", "苏轼李清照"]),
+ ("常见题材", ["山水田园", "边塞", "咏物言志", "送别"]),
+ ("鉴赏方法", ["意象意境", "表现手法", "炼字", "情感主旨"]),
+ )),
+ (["记叙文"], mm(
+ ("六要素", ["时间地点", "人物", "起因经过结果"]),
+ ("结构层次", ["开头引入", "主体展开", "结尾点题"]),
+ ("表达方式", ["叙述", "描写", "议论抒情"]),
+ ("写作技巧", ["详略得当", "线索贯穿", "细节描写"]),
+ )),
+ (["议论文"], mm(
+ ("三要素", ["论点", "论据", "论证"]),
+ ("论点确立", ["中心论点", "分论点", "鲜明准确"]),
+ ("论证方法", ["举例论证", "道理论证", "对比比喻"]),
+ ("结构思路", ["提出问题", "分析问题", "解决问题"]),
+ )),
+ # 英语
+ (["时态"], mm(
+ ("一般时态", ["一般现在", "一般过去", "一般将来"]),
+ ("进行时", ["现在进行", "过去进行", "be+doing"]),
+ ("完成时", ["现在完成", "过去完成", "have+done"]),
+ ("时间状语", ["always/often", "yesterday", "already/yet"]),
+ )),
+ (["从句"], mm(
+ ("名词性从句", ["主语从句", "宾语从句", "表语从句"]),
+ ("定语从句", ["关系代词", "关系副词", "限制非限制"]),
+ ("状语从句", ["时间原因", "条件让步", "目的结果"]),
+ ("用法要点", ["连接词", "语序", "时态呼应"]),
+ )),
+ ]
+ return kb
+
+ def _mindmap_subject_template(self, subject: str) -> list:
+ subject = (subject or "").strip()
+ mm = self._mm_nodes
+ templates = {
+ "数学": mm(
+ ("概念理解", ["定义", "性质", "判定条件"]),
+ ("公式法则", ["基本公式", "运算法则", "推导过程"]),
+ ("典型例题", ["基础题", "应用题", "易错题"]),
+ ("知识应用", ["实际问题", "跨学科", "拓展提升"]),
+ ),
+ "物理": mm(
+ ("物理概念", ["定义", "物理意义", "单位"]),
+ ("规律定律", ["适用条件", "公式表达", "实验基础"]),
+ ("实验探究", ["实验器材", "操作步骤", "数据处理"]),
+ ("生活应用", ["现象解释", "技术应用", "综合计算"]),
+ ),
+ "化学": mm(
+ ("物质组成", ["元素", "结构", "性质"]),
+ ("变化规律", ["反应类型", "反应条件", "实验现象"]),
+ ("化学计算", ["化学式", "方程式", "质量计算"]),
+ ("实际应用", ["材料能源", "环境保护", "生命健康"]),
+ ),
+ "生物": mm(
+ ("生命现象", ["结构基础", "生理功能", "生命活动"]),
+ ("核心概念", ["定义", "原理", "机制"]),
+ ("实验观察", ["方法", "现象", "结论"]),
+ ("联系应用", ["生产实践", "健康生活", "生态保护"]),
+ ),
+ "历史": mm(
+ ("时代背景", ["政治", "经济", "文化"]),
+ ("重要事件", ["时间", "人物", "过程"]),
+ ("深远影响", ["制度变革", "思想文化", "社会发展"]),
+ ("历史启示", ["经验教训", "规律认识", "现实意义"]),
+ ),
+ "地理": mm(
+ ("空间分布", ["位置", "范围", "界线"]),
+ ("自然要素", ["地形气候", "水文土壤", "植被"]),
+ ("人文要素", ["人口城市", "农业工业", "交通"]),
+ ("人地关系", ["资源利用", "环境问题", "可持续发展"]),
+ ),
+ "语文": mm(
+ ("基础知识", ["字词", "语句", "修辞"]),
+ ("文本理解", ["内容", "结构", "主旨"]),
+ ("表达技巧", ["描写方法", "表现手法", "语言特色"]),
+ ("拓展运用", ["写作借鉴", "迁移阅读", "文化传承"]),
+ ),
+ "英语": mm(
+ ("词汇积累", ["核心单词", "短语搭配", "词形变化"]),
+ ("语法要点", ["句型结构", "时态语态", "从句"]),
+ ("功能话题", ["日常交际", "话题表达", "文化背景"]),
+ ("技能训练", ["听说", "读写", "综合运用"]),
+ ),
+ }
+ if subject in templates:
+ return templates[subject]
+ return mm(
+ ("核心概念", ["定义", "特征", "分类"]),
+ ("知识结构", ["基本原理", "重要规律", "内在联系"]),
+ ("重点方法", ["解题思路", "分析方法", "易错提示"]),
+ ("应用拓展", ["生活实例", "学科关联", "拓展提升"]),
+ )
+
+
+ async def generate_animation(self, prompt: str, anim_type: str = "general") -> dict:
+ prompt = self._repair_text(prompt)
+ system_prompt = f"""你是一位教学动画设计专家。根据教师描述,生成教学动画的HTML页面。
+
+重要规则:
+- 所有HTML属性必须用单引号,如 style='color:red;' onclick='fn()'
+- 不要使用"
+ )
+ if qtype == "fill_blank":
+ return (
+ ""
+ "" + stem + " "
+ + "
"
+ + "
提交 "
+ + "
"
+ + ""
+ )
+ return "
" + stem + " "
+
+ def _fallback_lesson_plan(self, title, subject, grade, objectives, duration):
+ t = title or "大单元教案"
+ subj = (subject or "").strip()
+ prof = self._lesson_plan_profile(subj)
+ new_dur = max(10, duration - 20)
+ practice_dur = max(8, duration // 4)
+ return {
+ "title": t,
+ "objectives": objectives or prof["objectives"](t),
+ "key_points": prof["key_points"](t),
+ "difficulties": prof["difficulties"](t),
+ "phases": [
+ {"name": "导入", "duration": 5, "activities": [prof["intro"](t)], "teacher_actions": ["创设情境,激发兴趣。", "提出核心问题。"], "student_actions": ["观察思考。", "联系已有经验。"], "resources": prof["intro_resources"]},
+ {"name": "新授", "duration": new_dur, "activities": prof["new_activities"](t), "teacher_actions": prof["new_teacher"], "student_actions": prof["new_student"], "resources": prof["new_resources"]},
+ {"name": "练习巩固", "duration": practice_dur, "activities": prof["practice"](t), "teacher_actions": ["巡视指导,个别辅导。"], "student_actions": prof["practice_student"], "resources": prof["practice_resources"]},
+ {"name": "总结", "duration": 5, "activities": [prof["summary"](t)], "teacher_actions": ["梳理知识脉络,提炼方法。"], "student_actions": ["回顾反思,记录收获。"], "resources": ["板书", "思维导图"]},
+ ],
+ "homework": prof["homework"](t),
+ "reflection": f"根据课堂反馈与学生掌握情况,调整「{t}」后续教学的深度、广度与练习分层策略。",
+ }
+
+ def _lesson_plan_profile(self, subject: str) -> dict:
+ s = subject
+ profiles = {
+ "数学": {
+ "objectives": lambda t: [f"理解「{t}」的概念内涵与几何或代数意义", f"掌握「{t}」的基本公式与运算法则", "能运用所学知识分析并解决实际问题"],
+ "key_points": lambda t: [f"「{t}」的定义与核心性质", "公式推导与规范书写"],
+ "difficulties": lambda t: [f"「{t}」的灵活运用与变形", "数形结合思想的建立"],
+ "intro": lambda t: f"借助生活实例或已有知识,引出「{t}」的研究必要性。",
+ "new_activities": lambda t: [f"通过探究活动归纳「{t}」的概念与性质。", "推导公式并分析适用条件。"],
+ "new_teacher": ["引导探究,板书推导过程。", "组织小组讨论。"],
+ "new_student": ["动手操作或画图探究。", "小组合作归纳结论。"],
+ "intro_resources": ["课件", "实物教具", "几何画板"],
+ "new_resources": ["学案", "探究任务单", "动态演示"],
+ "practice": lambda t: ["基础题巩固概念。", "变式训练提升能力。", "实际问题应用。"],
+ "practice_student": ["独立完成,规范步骤。", "同桌互批,错题订正。"],
+ "practice_resources": ["分层练习", "答题卡"],
+ "summary": lambda t: f"梳理「{t}」的知识结构图,提炼解题方法与易错点。",
+ "homework": lambda t: [f"完成「{t}」分层作业(基础+提升)。", "整理本节易错题集。"],
+ },
+ "物理": {
+ "objectives": lambda t: [f"理解「{t}」的物理意义与适用条件", "掌握相关公式并能进行简单计算", "经历实验探究过程,培养科学思维"],
+ "key_points": lambda t: [f"「{t}」的概念建立与公式表达", "实验现象的观察与分析"],
+ "difficulties": lambda t: [f"「{t}」物理模型的构建", "实验方案的设计与数据分析"],
+ "intro": lambda t: f"通过演示实验或生活现象,引发对「{t}」的思考。",
+ "new_activities": lambda t: ["分组实验探究规律。", "分析实验数据,归纳结论。"],
+ "new_teacher": ["演示关键实验,强调安全。", "指导小组实验,引导分析。"],
+ "new_student": ["分组实验,记录数据。", "分析数据,得出结论。"],
+ "intro_resources": ["演示器材", "课件", "视频"],
+ "new_resources": ["学生实验器材", "数据记录表"],
+ "practice": lambda t: ["基础概念辨析。", "公式应用计算。", "实验数据分析题。"],
+ "practice_student": ["独立解题,规范作图。", "小组互查实验报告。"],
+ "practice_resources": ["练习卷", "实验报告单"],
+ "summary": lambda t: f"构建「{t}」的知识网络,强调实验方法与物理思想。",
+ "homework": lambda t: [f"完成「{t}」课后练习。", "撰写实验探究报告。"],
+ },
+ }
+ # 化学/生物共用理科探究模板
+ sci = {
+ "objectives": lambda t: [f"理解「{t}」的基本概念与原理", "掌握核心知识并能解释相关现象", "培养实验观察与科学探究能力"],
+ "key_points": lambda t: [f"「{t}」的核心概念与特征", "实验现象的观察与解释"],
+ "difficulties": lambda t: [f"「{t}」微观机制的理解", "知识在真实情境中的迁移应用"],
+ "intro": lambda t: f"通过实验现象或生活实例,引出「{t}」的探究问题。",
+ "new_activities": lambda t: ["实验观察与操作。", "小组讨论归纳知识要点。"],
+ "new_teacher": ["组织实验,强调规范操作。", "引导分析现象背后的原理。"],
+ "new_student": ["动手实验,如实记录。", "讨论交流,形成结论。"],
+ "intro_resources": ["实验器材", "课件", "标本/模型"],
+ "new_resources": ["实验材料", "学习单"],
+ "practice": lambda t: ["概念辨析与判断。", "现象解释与应用。"],
+ "practice_student": ["独立完成练习。", "小组互评实验记录。"],
+ "practice_resources": ["练习卡", "实验记录册"],
+ "summary": lambda t: f"梳理「{t}」的知识体系,强调科学方法与探究思路。",
+ "homework": lambda t: [f"完成「{t}」巩固练习。", "预习下一节内容并完成预习单。"],
+ }
+ for k in ("化学", "生物"):
+ profiles[k] = sci
+ profiles["语文"] = {
+ "objectives": lambda t: [f"品味「{t}」的语言特色与表达技巧", "理解文章主旨与作者情感", "学习并运用相关的写作方法"],
+ "key_points": lambda t: [f"「{t}」的文本内容与结构梳理", "关键语句的赏析与理解"],
+ "difficulties": lambda t: [f"「{t}」深层意蕴与作者情感的体悟", "写作手法在表达中的迁移运用"],
+ "intro": lambda t: f"借助背景介绍或朗读导入,唤起对「{t}」的阅读期待。",
+ "new_activities": lambda t: ["初读感知,整体把握文意。", "精读品味,赏析关键语段。"],
+ "new_teacher": ["范读指导,组织品析。", "点拨赏析方法。"],
+ "new_student": ["朗读体会,圈点批注。", "小组交流赏析心得。"],
+ "intro_resources": ["课件", "背景资料", "音频朗读"],
+ "new_resources": ["文本", "批注学习单"],
+ "practice": lambda t: ["仿写练习。", "片段赏析与表达。"],
+ "practice_student": ["独立完成仿写。", "分享交流,互评互改。"],
+ "practice_resources": ["仿写学习单", "评价量表"],
+ "summary": lambda t: f"总结「{t}」的写法与主旨,提炼可借鉴的表达技巧。",
+ "homework": lambda t: [f"围绕「{t}」完成读写结合小练笔。", "积累本文好词好句。"],
+ }
+ profiles["英语"] = {
+ "objectives": lambda t: [f"掌握「{t}」相关核心词汇与句型", "能在真实情境中运用所学进行交流", "提升听、说、读、写综合语言能力"],
+ "key_points": lambda t: [f"「{t}」的核心词汇与目标句型", "语言功能的得体运用"],
+ "difficulties": lambda t: [f"「{t}」相关语法结构的正确使用", "在真实交际中的灵活表达"],
+ "intro": lambda t: f"通过歌曲、游戏或情境对话导入「{t}」话题。",
+ "new_activities": lambda t: ["词汇与句型学习。", "情境对话操练与角色扮演。"],
+ "new_teacher": ["创设情境,示范句型。", "组织pair work与group work。"],
+ "new_student": ["跟读模仿,记忆词汇。", "结对操练,大胆表达。"],
+ "intro_resources": ["课件", "音频视频", "单词卡"],
+ "new_resources": ["对话任务单", "角色卡片"],
+ "practice": lambda t: ["词汇句型巩固。", "情境交际任务。"],
+ "practice_student": ["完成听力与填空。", "小组表演对话。"],
+ "practice_resources": ["练习单", "评价表"],
+ "summary": lambda t: f"回顾「{t}」核心语言知识,梳理交际策略。",
+ "homework": lambda t: [f"完成「{t}」听读作业。", "用目标句型写一段对话。"],
+ }
+ profiles["历史"] = {
+ "objectives": lambda t: [f"了解「{t}」的基本史实与发展脉络", "分析历史事件的因果关系与影响", "培养历史思维与家国情怀"],
+ "key_points": lambda t: [f"「{t}」的关键事件、人物与时间", "历史发展规律与时代特征"],
+ "difficulties": lambda t: [f"对「{t}」历史现象的多角度分析", "史论结合,以史为鉴"],
+ "intro": lambda t: f"通过史料、图片或视频导入「{t}」的时代背景。",
+ "new_activities": lambda t: ["梳理时间线与重大事件。", "研读史料,分析因果关系。"],
+ "new_teacher": ["提供史料,引导分析。", "组织讨论,启发思考。"],
+ "new_student": ["阅读史料,做时间轴。", "小组讨论,发表见解。"],
+ "intro_resources": ["课件", "历史地图", "史料摘录"],
+ "new_resources": ["史料学习单", "时间轴模板"],
+ "practice": lambda t: ["史实梳理填空。", "材料分析题。"],
+ "practice_student": ["独立完成基础题。", "小组研讨材料题。"],
+ "practice_resources": ["练习卷", "材料研读单"],
+ "summary": lambda t: f"构建「{t}」的知识框架,总结历史启示。",
+ "homework": lambda t: [f"完成「{t}」巩固练习。", "撰写一段历史小短评。"],
+ }
+ profiles["地理"] = {
+ "objectives": lambda t: [f"认识「{t}」的空间分布与基本特征", "理解各地理要素的相互关系", "树立人地协调与可持续发展观念"],
+ "key_points": lambda t: [f"「{t}」的位置、分布与主要特征", "自然与人文地理要素的相互作用"],
+ "difficulties": lambda t: [f"「{t}」空间格局的综合分析", "读图分析与地理推理"],
+ "intro": lambda t: f"借助地图、卫星图或景观图导入「{t}」。",
+ "new_activities": lambda t: ["读图分析空间分布。", "小组探究地理要素关系。"],
+ "new_teacher": ["指导读图方法。", "组织探究活动。"],
+ "new_student": ["读图标注,提取信息。", "小组合作,归纳特征。"],
+ "intro_resources": ["课件", "地图", "景观图片"],
+ "new_resources": ["空白地图", "探究任务单"],
+ "practice": lambda t: ["读图填图训练。", "综合分析题。"],
+ "practice_student": ["独立完成读图题。", "小组研讨分析题。"],
+ "practice_resources": ["练习图册", "分析学习单"],
+ "summary": lambda t: f"整理「{t}」知识结构,强调人地关系与可持续发展。",
+ "homework": lambda t: [f"完成「{t}」读图与练习。", "调查身边的地理现象。"],
+ }
+ if s in profiles:
+ return profiles[s]
+ # 通用模板
+ return {
+ "objectives": lambda t: [f"理解「{t}」的核心内容与基本要求", "掌握关键知识与基本方法", "培养分析思维与合作探究能力"],
+ "key_points": lambda t: [f"「{t}」的核心知识建构", "方法与技能的训练"],
+ "difficulties": lambda t: [f"「{t}」的深入理解与灵活运用", "知识的迁移与综合"],
+ "intro": lambda t: f"创设情境,引出「{t}」的学习主题。",
+ "new_activities": lambda t: [f"围绕「{t}」进行讲解与互动探究。"],
+ "new_teacher": ["讲解引导,组织探究。"],
+ "new_student": ["主动思考,合作探究。"],
+ "intro_resources": ["课件", "情境素材"],
+ "new_resources": ["学习单", "探究材料"],
+ "practice": lambda t: ["分层练习巩固。", "互评与订正。"],
+ "practice_student": ["独立完成,规范作答。"],
+ "practice_resources": ["练习卡"],
+ "summary": lambda t: f"梳理「{t}」知识结构,提炼重点方法。",
+ "homework": lambda t: [f"完成「{t}」巩固作业。", "预习下节内容。"],
+ }
+
+ def _fallback_essay_grade(self, essay_text, total_score):
+ text = essay_text or ""
+ char_count = len(text)
+ import re as _re2
+ sentences = [s.strip() for s in _re2.split(r"[。!?]", text) if s.strip()]
+ sent_count = len(sentences)
+ has_dialog = "“" in text or "说" in text
+ has_detail = any(w in text for w in ("有一次", "记得", "那天", "当时", "忽然", "只见"))
+ avg_sent = char_count / max(sent_count, 1)
+ score_ratio = 0.82 if avg_sent > 25 else 0.74
+ score = max(1, int(total_score * score_ratio))
+ annotations = []
+ if has_detail:
+ for kw in ("有一次", "记得", "那天", "当时"):
+ pos = text.find(kw)
+ if pos >= 0:
+ snippet = text[max(0,pos-3):pos+12]
+ annotations.append({"text": snippet, "comment": "这里用具体事例展开,增加了文章的真实感。", "type": "good"})
+ break
+ if char_count < 100:
+ annotations.append({"text": text[:20], "comment": "文章字数偏少,建议增加具体描写。", "type": "error"})
+ strengths = []
+ weaknesses = []
+ suggestions = []
+ if has_detail:
+ strengths.append("能用具体事例展开叙述,增加了文章的感染力。")
+ else:
+ weaknesses.append("缺乏具体事例和细节描写,内容较空洞。")
+ suggestions.append("增加一到两个具体事例,展开细节描写。")
+ if has_dialog:
+ strengths.append("运用了对话描写,使人物形象更加鲜活。")
+ else:
+ suggestions.append("可以加入对话描写,让人物更鲜活。")
+ if sent_count <= 2:
+ weaknesses.append("句子偏少,结构较单谬。")
+ suggestions.append("增加句子数量,丰富文章层次。")
+ if avg_sent > 40:
+ weaknesses.append("部分句子较长,建议适当断句。")
+ suggestions.append("将过长的句子拆分为短句,提高可读性。")
+ content_ratio = 0.32 if has_detail else 0.25
+ content_s = int(total_score * content_ratio)
+ structure_s = int(total_score * 0.22)
+ language_s = int(total_score * 0.18)
+ writing_s = score - content_s - structure_s - language_s
+ if not strengths:
+ strengths = ["主题明确,有基本的表达。"]
+ if not weaknesses:
+ weaknesses = ["可以在细节上进一步丰富。"]
+ if not suggestions:
+ suggestions = ["继续保持认真观察和真诚表达。"]
+ level = "优秀" if score_ratio > 0.80 else ("良好" if score_ratio > 0.72 else "中等")
+ return {
+ "total_score": score,
+ "scores": {
+ "content": {"score": content_s, "max": int(total_score * 0.3), "comment": "内容较为充实。" if has_detail else "内容基本完整,建议增加细节。"},
+ "structure": {"score": structure_s, "max": int(total_score * 0.25), "comment": "结构较清晰。" if sent_count > 2 else "结构较为单谬,建议增加层次。"},
+ "language": {"score": language_s, "max": int(total_score * 0.25), "comment": "表达较通顺。" if avg_sent <= 40 else "部分句子较长,可适当断句。"},
+ "writing": {"score": writing_s, "max": int(total_score * 0.2), "comment": "书写规范尚可。"},
+ },
+ "overall_comment": "作文围绕主题展开," + ("能用具体事例进行叙述," if has_detail else "但缺乏具体细节,") + "表达有一定的真实感。" + ("建议继续加强细节描写和语言表现力,让文章更有感染力。" if not has_detail else "建议在语言精炼和结构层次上进一步提升。"),
+ "strengths": strengths,
+ "weaknesses": weaknesses,
+ "suggestions": suggestions,
+ "annotations": annotations,
+ "level": level,
+ }
+
+ def _fallback_exam(self, subject, grade, knowledge_points, difficulty, question_types, count, total_score):
+ total = max(1, min(count or 10, 20))
+ each = max(1, total_score // max(total, 1))
+ subj = subject or "综合"
+ kp = "、".join(knowledge_points) if knowledge_points else "基础知识"
+ bank = self._question_bank(kp, subj)
+ if not bank:
+ bank = self._generic_question_bank(kp, subj)
+ questions = []
+ for idx in range(total):
+ q = dict(bank[idx % len(bank)])
+ q["id"] = idx + 1
+ q["type"] = q.get("type", (question_types or ["choice"])[0])
+ q["score"] = each
+ q["difficulty"] = difficulty if isinstance(difficulty, str) else ["easy", "medium", "hard"][idx % 3]
+ q["content"] = q.pop("question", None) or q.get("content", "")
+ q["analysis"] = q.get("explanation", q.get("analysis", ""))
+ if "explanation" in q:
+ q.pop("explanation", None)
+ if "html" in q:
+ q.pop("html", None)
+ questions.append(q)
+ return {"title": f"{subj}{grade or ''}智能试卷", "questions": questions}
+
+ def _courseware_category(self, prompt: str) -> str:
+ prompt = self._repair_text(prompt)
+ if any(k in prompt for k in ["圆柱", "圆柱的体积", "体积公式", "底面积", "高"]):
+ return "cylinder"
+ return "general"
+
+ def _courseware_shell(self, title: str, subtitle: str, body: str, footer_left: str = "") -> str:
+ title_html = html_lib.escape(title)
+ subtitle_html = html_lib.escape(subtitle)
+ footer_html = html_lib.escape(footer_left)
+ return f"""
+
+
+
{title_html} {subtitle_html}
+
AI生成
+
+
{body}
+
+
"""
+
+ def _fallback_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
+ prompt = self._repair_text(prompt)
+ if self._courseware_category(prompt) == "cylinder":
+ return self._fallback_cylinder_courseware(prompt, subject, grade, page_count)
+ return self._fallback_general_courseware(prompt, subject, grade, page_count)
+
+ def _normalize_courseware_result(self, result: dict, prompt: str, subject: str, grade: str, page_count: int, aspect_ratio: str) -> dict | None:
+ if not isinstance(result, dict):
+ return None
+ pages = result.get("pages")
+ if not isinstance(pages, list) or len(pages) < 5:
+ return None
+ normalized_pages = []
+ for page in pages:
+ if not isinstance(page, dict):
+ continue
+ content = page.get("content")
+ if not isinstance(content, str):
+ continue
+ cleaned = content.replace("100vh", "100%").replace("100vw", "100%")
+ if not cleaned.strip().lower().startswith("{cleaned}
"
+ normalized_pages.append({
+ "type": page.get("type") or "content",
+ "title": self._repair_text(str(page.get("title") or "")),
+ "content": cleaned,
+ "notes": self._repair_text(str(page.get("notes") or "")),
+ })
+ if len(normalized_pages) < 5:
+ return None
+ return {
+ "title": self._repair_text(str(result.get("title") or prompt[:24] or "课件")),
+ "summary": self._repair_text(str(result.get("summary") or "")),
+ "tags": [self._repair_text(str(tag)) for tag in (result.get("tags") or []) if str(tag).strip()],
+ "pages": normalized_pages[:max(5, min(page_count, len(normalized_pages)))],
+ "aspect_ratio": aspect_ratio,
+ "subject": subject,
+ "grade": grade,
+ }
+
+ def _courseware_topic(self, prompt: str) -> str:
+ prompt = self._repair_text(prompt)
+ text = prompt.lower()
+ if any(k in prompt for k in ["地球公转", "四季", "春分", "夏至", "秋分", "冬至", "昼夜长短", "太阳直射"]) or any(k in text for k in ["orbit", "season"]):
+ return "orbit"
+ if any(k in prompt for k in ["圆柱", "体积", "底面积", "圆锥", "长方体"]):
+ return "cylinder"
+ return "general"
+
+ def _courseware_shell_clean(self, title: str, subtitle: str, body: str, footer_left: str = "") -> str:
+ title_html = html_lib.escape(title)
+ subtitle_html = html_lib.escape(subtitle)
+ footer_html = html_lib.escape(footer_left)
+ return (
+ ""
+ ""
+ ""
+ "
" + title_html + " " + subtitle_html + "
AI生成
"
+ "
"
+ + body +
+ "
"
+ ""
+ "
"
+ )
+
+ def _fallback_cylinder_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
+ prompt = self._repair_text(prompt)
+ title = "圆柱的体积(第一课时)"
+ pages = [
+ {
+ "type": "title",
+ "title": "封面",
+ "content": self._courseware_shell_clean(
+ title,
+ f"{subject or '数学'} · {grade or '六年级'}",
+ """
+
+
课堂导入
+
+
+
数学 · 形体与测量
+
圆柱的体积
+
围绕“底面积 × 高”展开探究,通过观察、切分、比较和例题,把抽象公式变成可理解、可操作的课堂体验。
+
+ 层次清楚
+ 页面可切换
+ 支持课堂互动
+
+
+
+
+
+""",
+ "封面页",
+ ),
+ "notes": "封面",
+ },
+ {
+ "type": "content",
+ "title": "知识回顾",
+ "content": self._courseware_shell_clean(
+ "知识回顾",
+ "先把旧知识拉回来,再进入新课探究",
+ """
+
+
+
+ 长方体体积
+
+
+ 正方体体积
+
+
+ 迁移到新知
+
+
+
+
+
旧知 1
+
长方体体积公式
+
体积和底面积、高有关。先看“底面铺了多少”,再看“竖起来有多高”。
+
+
+
旧知 2
+
正方体体积公式
+
棱长相等,体积计算更简单,但核心思想仍是“每层面积 × 层数”。
+
+
+
新课前置
+
圆柱能否转化?
+
如果把圆柱沿着高切开,再拼成近似长方体,体积关系会更清楚。
+
+
+
+""",
+ "点击卡片切换旧知",
+ ),
+ "notes": "知识回顾",
+ },
+ {
+ "type": "interactive",
+ "title": "引入新知",
+ "content": self._courseware_shell_clean(
+ "引入新知",
+ "先看形状和高度,再让学生猜一猜体积和什么有关",
+ """
+
+
+
+
课堂提问
+
你觉得圆柱体积和什么有关?
+
+
A. 只和底面形状有关
+
B. 和底面积、高都有关系
+
C. 只和颜色有关
+
+
课堂结论:体积计算要抓住“底面积 × 高”这条主线。
+
+
+""",
+ "学生先猜想,再看图验证",
+ ),
+ "notes": "引入",
+ },
+ {
+ "type": "content",
+ "title": "公式探究",
+ "content": self._courseware_shell_clean(
+ "公式探究",
+ "用切、拼、比的方式,把圆柱体积想清楚",
+ """
+
+
+
+ 切一切
+
+
+ 拼一拼
+
+
+ 想一想
+
+
+
+
+
切分观察
+
把圆柱切成很多薄片
+
圆柱越像被分成很多很薄的小层,每一层越接近一个圆形小面。层数越多,拼出的形状就越接近长方体。
+
+
+
转化比较
+
拼成近似长方体
+
切开后重新排列,底面形状变了,但每一层的面积总和没有变,高也没有变,所以体积也没有变。
+
V = 底面积 × 高
+
+
+
归纳结论
+
圆柱体积公式
+
V = S × h
+
S 表示底面积,h 表示高。知道这两个量,就能求圆柱的体积。
+
+
+
+""",
+ "切、拼、比,逐步导出公式",
+ ),
+ "notes": "公式探究",
+ },
+ {
+ "type": "interactive",
+ "title": "例题讲解",
+ "content": self._courseware_shell_clean(
+ "例题讲解",
+ "把公式落到计算里,按步骤说清楚",
+ """
+
+
+
例题
+
一个圆柱的底面积是 28.26 cm²,高是 10 cm,体积是多少?
+
+
+ 第 1 步
+
+
+ 第 2 步
+
+
+ 第 3 步
+
+
+
+
+
已知
+
S = 28.26 cm²,h = 10 cm
+
+
+
公式
+
V = S × h
+
V = 28.26 × 10
+
+
+
结果
+
V = 282.6 cm³
+
答:这个圆柱的体积是 282.6 立方厘米。
+
+
+
+
+
解题提醒
+
圆柱体积公式中的 S 是底面积,不是底面周长。单位也要对应写成立方单位。
+
写答语时要带上单位,例如 cm³、m³。
+
+
+""",
+ "按步骤理解计算过程",
+ ),
+ "notes": "例题",
+ },
+ {
+ "type": "exercise",
+ "title": "巩固练习",
+ "content": self._courseware_shell_clean(
+ "巩固练习",
+ "点击选项,立即查看判断结果",
+ """
+
+
+
选择题
+
圆柱体积公式正确的是哪一个?
+
+
+ A. V = 底面周长 × 高
+
+
+ B. V = 底面积 × 高
+
+
+ C. V = 半径 × 高
+
+
+
+
+
+
反馈
+
A 不是正确答案。圆柱体积不是把周长直接相乘。
+
+
+
反馈
+
回答正确。圆柱体积就是“底面积 × 高”。
+
+
+
反馈
+
C 不是正确答案。半径只是求底面积的一个中间量。
+
+
+
+""",
+ "选择答案后直接反馈",
+ ),
+ "notes": "练习",
+ },
+ {
+ "type": "summary",
+ "title": "总结",
+ "content": self._courseware_shell_clean(
+ "总结与作业",
+ "把公式、方法和易错点一次收好",
+ """
+
+
+
本课总结
+
+
1. 圆柱体积公式:V = S × h
+
2. 核心方法:切、拼、转化成近似长方体
+
3. 易错提醒:S 是底面积,不是底面周长
+
+
+
+
课后任务
+
1. 完成课本练习。 2. 找一个生活中的圆柱体,量一量底面和高,试着算体积。
+
点击展开提醒
+
记得带单位,写成立方厘米、立方分米或立方米。
+
+
+""",
+ "总结方法并布置作业",
+ ),
+ "notes": "总结",
+ },
+ ]
+ total = max(5, min(page_count or 7, len(pages)))
+ return {"title": title, "summary": "围绕底面积与高探究圆柱体积", "tags": ["圆柱", "体积", "互动课件"], "pages": pages[:total]}
+
+ def _fallback_orbit_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
+ prompt = self._repair_text(prompt)
+ title = "地球公转与四季变化"
+ pages = [
+ {
+ "type": "title",
+ "title": "封面",
+ "content": self._courseware_shell_clean(
+ title,
+ f"{subject or '科学'} · {grade or '六年级'}",
+ """
+
+
情境导入
+
+
+
科学 · 地球与宇宙
+
地球公转与四季变化
+
通过轨道、倾角和阳光照射方向的变化,把春夏秋冬的形成过程讲清楚、看明白、能点击。
+
+ 轨道示意
+ 四季按钮
+ 课堂可演示
+
+
+
+
+
+""",
+ "封面页",
+ ),
+ "notes": "封面",
+ },
+ {
+ "type": "content",
+ "title": "知识回顾",
+ "content": self._courseware_shell_clean(
+ "知识回顾",
+ "先把轨道、地轴倾斜和太阳光照这三个要点记住",
+ """
+
+
+
要点 1
+
地球在公转
+
地球绕太阳转一圈,需要大约一年。
+
+
+
要点 2
+
地轴是倾斜的
+
地轴倾斜让同一地区在不同时间受到的阳光不同。
+
+
+
要点 3
+
太阳光照变化
+
阳光照射角度和昼夜长短,会随着公转位置变化。
+
+
+""",
+ "三个关键概念先对齐",
+ ),
+ "notes": "知识回顾",
+ },
+ {
+ "type": "interactive",
+ "title": "四季探究",
+ "content": self._courseware_shell_clean(
+ "四季探究",
+ "点击春夏秋冬,观察光照和气候变化",
+ """
+
+
+ 春天
+ 夏天
+ 秋天
+ 冬天
+
+
+
+
春季
+
阳光开始变强,气温慢慢回升
+
春季时太阳直射位置逐渐北移,北半球白天变长,天气回暖,植物也开始生长。
+
+
+
夏季
+
阳光更强,白天更长
+
夏季时北半球受到的太阳光更直接,地面获得的热量更多,所以气温更高、白昼更长。
+
+
+
秋季
+
阳光减弱,天气转凉
+
秋季太阳直射位置继续变化,白天变短,气温下降,作物开始成熟和收获。
+
+
+
冬季
+
阳光较弱,白天更短
+
冬季时太阳光照更斜,地面接收热量少,所以天气寒冷,白昼也更短。
+
+
+
+
+""",
+ "四季按钮可直接切换",
+ ),
+ "notes": "四季探究",
+ },
+ {
+ "type": "content",
+ "title": "现象解释",
+ "content": self._courseware_shell_clean(
+ "现象解释",
+ "同样是太阳,为什么不同季节感觉不一样?",
+ """
+
+
+
原因 1
+
阳光直射角度不同
+
角度越直,单位面积接收到的热量越多;角度越斜,热量越分散。
+
+
+
原因 2
+
白昼长短不同
+
白天越长,太阳照射时间越久,地面吸收热量也更多。
+
+
+""",
+ "从光照与时间两个角度解释",
+ ),
+ "notes": "现象解释",
+ },
+ {
+ "type": "summary",
+ "title": "总结",
+ "content": self._courseware_shell_clean(
+ "总结与作业",
+ "把公转、地轴倾斜和四季变化连起来理解",
+ """
+
+
+
本课总结
+
+
1. 地球围绕太阳公转,形成一年四季的周期变化。
+
2. 地轴倾斜,让不同季节接受的阳光不同。
+
3. 阳光直射角度和白昼长短,决定了冷热变化。
+
+
+
+
课后任务
+
1. 画一张地球绕太阳公转示意图。 2. 说一说春夏秋冬各自最明显的特点。
+
点击展开提示
+
答题时可以先写“原因”,再写“现象”,这样更完整。
+
+
+""",
+ "把四季原因说完整",
+ ),
+ "notes": "总结",
+ },
+ ]
+ total = max(5, min(page_count or 6, len(pages)))
+ return {"title": title, "summary": "围绕地球公转与太阳光照变化解释四季形成", "tags": ["地球公转", "四季", "科学探究"], "pages": pages[:total]}
+
+ def _fallback_general_courseware(self, prompt: str, subject: str, grade: str, page_count: int) -> dict:
+ prompt = self._repair_text(prompt)
+ title = prompt[:24] or "互动课件"
+ pages = [
+ {
+ "type": "title",
+ "title": "封面",
+ "content": self._courseware_shell_clean(
+ title,
+ f"{subject or '综合'} · {grade or '课堂教学'}",
+ """
+
+
课堂导入
+
+
+
知识生成 · 互动呈现
+
""" + html_lib.escape(title) + """
+
用清爽、分层、可点击的页面组织课堂内容,适合投屏展示和逐步讲解。
+
+ 层次清楚
+ 页面可切换
+ 支持课堂互动
+
+
+
+
+
+""",
+ "封面页",
+ ),
+ "notes": "封面",
+ },
+ {
+ "type": "content",
+ "title": "核心概念",
+ "content": self._courseware_shell_clean(
+ "核心概念",
+ "先把问题拆开,再逐步组织内容",
+ """
+
+
+
第 1 点
+
要讲什么
+
把主题拆成 2 到 3 个核心问题,再分别展开。
+
+
+
第 2 点
+
怎么讲
+
先观察,再归纳,最后用例子验证。
+
+
+
第 3 点
+
怎么练
+
用点击反馈、小练习和总结页收尾。
+
+
+""",
+ "搭好课堂结构",
+ ),
+ "notes": "核心概念",
+ },
+ {
+ "type": "interactive",
+ "title": "互动探究",
+ "content": self._courseware_shell_clean(
+ "互动探究",
+ "点击左侧选项,右侧显示不同内容",
+ """
+
+
+ 观察
+ 分析
+ 总结
+
+
+
+
观察
+
先看现象,再找规律
+
围绕图片、数据或情境,先让学生说出“看见了什么”,再引导他们说“为什么会这样”。
+
+
+
分析
+
把线索整理成逻辑
+
把原因、结果和条件分开写清楚,再用一两句话把关系串起来。
+
+
+
总结
+
把关键结论收拢
+
最后把今天最重要的一句话写成结论,方便学生复述和记忆。
+
+
+
+""",
+ "交互区可直接切换",
+ ),
+ "notes": "互动探究",
+ },
+ {
+ "type": "exercise",
+ "title": "课堂练习",
+ "content": self._courseware_shell_clean(
+ "课堂练习",
+ "选择一个答案,马上看反馈",
+ """
+
+
+
练习题
+
下面哪一种说法最符合今天的主题?
+
+
+ A. 只要记住一个结论就够了
+
+
+ B. 要把观察、分析和总结连起来
+
+
+ C. 课堂上不需要互动
+
+
+
+
+
+
反馈
+
A 不完整。课堂结构不能只靠一个结论支撑。
+
+
+
反馈
+
回答正确。课堂内容应该有完整的观察、分析和总结链条。
+
+
+
+
+""",
+ "选择后看反馈",
+ ),
+ "notes": "课堂练习",
+ },
+ {
+ "type": "summary",
+ "title": "总结",
+ "content": self._courseware_shell_clean(
+ "总结与作业",
+ "把今天的重点提炼成一页",
+ """
+
+
+
本课总结
+
+
1. 先观察,再分析,再总结。
+
2. 把复杂内容拆成若干清楚的部分。
+
3. 用点击互动提高课堂参与感。
+
+
+
+
课后任务
+
1. 把本课内容整理成 3 条要点。 2. 试着为一个新的知识点设计 1 个互动问题。
+
点击展开提示
+
提示:结论要短,任务要具体,方便学生完成。
+
+
+""",
+ "整理成可复述的结论",
+ ),
+ "notes": "总结",
+ },
+ ]
+ total = max(5, min(page_count or 5, len(pages)))
+ return {"title": title, "summary": "把主题拆分成观察、分析、总结的互动课件", "tags": ["互动课件", "可点击", "课堂讲解"], "pages": pages[:total]}
+
+ async def generate_courseware(self, prompt: str, subject: str = "", grade: str = "", page_count: int = 8, aspect_ratio: str = "16:9") -> dict:
+ prompt = self._repair_text(prompt)
+ topic = self._courseware_topic(prompt)
+ if topic == "cylinder":
+ return self._fallback_cylinder_courseware(prompt, subject or "数学", grade or "六年级", page_count)
+ if topic == "orbit":
+ return self._fallback_orbit_courseware(prompt, subject or "科学", grade or "六年级", page_count)
+
+ try:
+ result = await self._chat_json([
+ {"role": "system", "content": "请生成多页互动课件 JSON,包含 title、summary、tags、pages。每页 content 必须是可直接渲染的 HTML 片段,页面结构清爽,适合教室投屏。"},
+ {"role": "user", "content": f"学科:{subject or '未指定'}\n年级:{grade or '未指定'}\n页数要求:{page_count}\n\n教师需求:{prompt}"},
+ ], temperature=0.45, max_tokens=12000)
+ normalized = self._normalize_courseware_result(result, prompt, subject, grade, page_count, aspect_ratio)
+ if normalized:
+ return normalized
+ except Exception:
+ pass
+ return self._fallback_general_courseware(prompt, subject, grade, page_count)
+
diff --git a/backend/services/audit.py b/backend/services/audit.py
new file mode 100644
index 0000000..561eb2c
--- /dev/null
+++ b/backend/services/audit.py
@@ -0,0 +1,96 @@
+"""安全审计日志服务。
+
+提供 log_action() 用于在任意路由中记录安全相关事件。日志写入数据库 audit_logs 表,
+同时输出到 logger,便于后续接入文件/集中式日志收集。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional
+
+from fastapi import Request
+from sqlalchemy.orm import Session
+
+from models.audit import AuditLog
+from models.user import User
+
+logger = logging.getLogger(__name__)
+
+
+def _user_label(user: Optional[User]) -> str:
+ """返回用户可读标识:优先 username,其次 phone,最后 id。"""
+ if user is None:
+ return ""
+ for attr in ("username", "phone", "name"):
+ val = getattr(user, attr, None)
+ if val:
+ return str(val)
+ return str(getattr(user, "id", ""))
+
+
+def _client_ip(request: Optional[Request]) -> str:
+ if request is None:
+ return ""
+ try:
+ fwd = request.headers.get("x-forwarded-for")
+ if fwd:
+ return fwd.split(",")[0].strip()[:64]
+ client = getattr(request, "client", None)
+ if client and client.host:
+ return client.host[:64]
+ except Exception:
+ pass
+ return ""
+
+
+def _user_agent(request: Optional[Request]) -> str:
+ if request is None:
+ return ""
+ try:
+ return (request.headers.get("user-agent") or "")[:255]
+ except Exception:
+ return ""
+
+
+def log_action(
+ db: Session,
+ *,
+ action: str,
+ user: Optional[User] = None,
+ request: Optional[Request] = None,
+ target_type: str = "",
+ target_id: Any = "",
+ detail: str = "",
+ status: str = "success",
+) -> None:
+ """记录一条审计日志。失败不影响主流程。"""
+ try:
+ log = AuditLog(
+ user_id=user.id if user else None,
+ username=_user_label(user),
+ action=action[:64],
+ target_type=target_type[:32] if target_type else "",
+ target_id=str(target_id)[:64] if target_id else "",
+ detail=detail[:2000] if detail else "",
+ ip=_client_ip(request),
+ user_agent=_user_agent(request),
+ status=status[:16] if status else "success",
+ )
+ db.add(log)
+ db.commit()
+ except Exception as exc: # pragma: no cover - 日志失败不应中断业务
+ logger.warning("audit log failed: %s", exc)
+ try:
+ db.rollback()
+ except Exception:
+ pass
+ # 同时输出到日志通道
+ logger.info(
+ "audit action=%s user=%s target=%s/%s status=%s ip=%s",
+ action,
+ _user_label(user),
+ target_type,
+ target_id,
+ status,
+ _client_ip(request),
+ )
diff --git a/backend/services/auth.py b/backend/services/auth.py
new file mode 100644
index 0000000..ccb31a9
--- /dev/null
+++ b/backend/services/auth.py
@@ -0,0 +1,87 @@
+import logging
+from datetime import datetime, timedelta, timezone
+from jose import jwt, JWTError
+import bcrypt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from sqlalchemy.orm import Session
+from config import get_settings
+from database import get_db
+from models.user import User
+
+logger = logging.getLogger(__name__)
+security = HTTPBearer()
+optional_security = HTTPBearer(auto_error=False)
+settings = get_settings()
+
+
+def get_password_hash(password: str) -> str:
+ salt = bcrypt.gensalt()
+ hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
+ return hashed.decode("utf-8")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
+
+
+def create_access_token(user_id: int) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+ payload = {"sub": str(user_id), "exp": expire, "type": "access"}
+ token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+ logger.info(f"Created token for user {user_id}")
+ return token
+
+
+def create_refresh_token(user_id: int) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
+ payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+def get_current_user(
+ credentials: HTTPAuthorizationCredentials = Depends(security),
+ db: Session = Depends(get_db),
+) -> User:
+ token = credentials.credentials
+ try:
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+ user_id = int(payload.get("sub"))
+ logger.info(f"Token decoded for user {user_id}")
+ except JWTError as e:
+ logger.error(f"JWT decode error: {e}")
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
+ except (TypeError, ValueError) as e:
+ logger.error(f"Invalid sub claim: {e}")
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
+
+ user = db.query(User).filter(User.id == user_id).first()
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
+ if not user.is_active:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
+ return user
+
+
+def get_optional_current_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(optional_security),
+ db: Session = Depends(get_db),
+) -> User | None:
+ if credentials is None:
+ return None
+ try:
+ payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+ user_id = int(payload.get("sub"))
+ except (JWTError, TypeError, ValueError):
+ return None
+
+ user = db.query(User).filter(User.id == user_id).first()
+ if user is None or not user.is_active:
+ return None
+ return user
+
+
+def get_admin_user(user: User = Depends(get_current_user)) -> User:
+ if user.role != "admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
+ return user
diff --git a/backend/services/credits.py b/backend/services/credits.py
new file mode 100644
index 0000000..5799b8f
--- /dev/null
+++ b/backend/services/credits.py
@@ -0,0 +1,103 @@
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from models.credit import CreditAccount, CreditTransaction
+from models.user import User
+
+DEFAULT_CREDIT_BALANCE = 130
+DAILY_CHECKIN_CREDITS = 10
+
+CREDIT_COSTS = {
+ "courseware_generate": 8,
+ "animation_generate": 6,
+ "exercise_generate": 4,
+ "lesson_plan_generate": 5,
+ "exam_generate": 5,
+ "essay_grade": 2,
+ "chat_generate": 1,
+ "material_parse": 1,
+}
+
+
+def ensure_credit_account(db: Session, user: User) -> CreditAccount:
+ account = db.query(CreditAccount).filter(CreditAccount.user_id == user.id).first()
+ if account:
+ return account
+ account = CreditAccount(
+ user_id=user.id,
+ balance=DEFAULT_CREDIT_BALANCE,
+ total_granted=DEFAULT_CREDIT_BALANCE,
+ total_used=0,
+ )
+ db.add(account)
+ db.flush()
+ return account
+
+
+def spend_credits(db: Session, user: User, action: str, description: str = "") -> CreditAccount:
+ cost = CREDIT_COSTS.get(action, 1)
+ account = ensure_credit_account(db, user)
+ if account.balance < cost:
+ raise HTTPException(status_code=402, detail=f"积分不足,本次需要 {cost} 分,当前剩余 {account.balance} 分")
+ account.balance -= cost
+ account.total_used += cost
+ db.add(CreditTransaction(
+ user_id=user.id,
+ amount=-cost,
+ action=action,
+ description=description,
+ balance_after=account.balance,
+ ))
+ db.flush()
+ return account
+
+
+def credits_payload(account: CreditAccount, action: str) -> dict:
+ return {
+ "balance": account.balance,
+ "cost": CREDIT_COSTS.get(action, 1),
+ "action": action,
+ }
+
+
+def grant_credits(db: Session, user: User, amount: int, action: str = "grant", description: str = "") -> CreditAccount:
+ if amount <= 0:
+ raise HTTPException(status_code=400, detail="积分数量必须大于 0")
+ account = ensure_credit_account(db, user)
+ account.balance += amount
+ account.total_granted += amount
+ db.add(CreditTransaction(
+ user_id=user.id,
+ amount=amount,
+ action=action,
+ description=description,
+ balance_after=account.balance,
+ ))
+ db.flush()
+ return account
+
+
+def has_credit_transaction_today(db: Session, user: User, action: str) -> bool:
+ from datetime import datetime, time
+
+ today = datetime.now().date()
+ start = datetime.combine(today, time.min)
+ end = datetime.combine(today, time.max)
+ return db.query(CreditTransaction).filter(
+ CreditTransaction.user_id == user.id,
+ CreditTransaction.action == action,
+ CreditTransaction.created_at >= start,
+ CreditTransaction.created_at <= end,
+ ).first() is not None
+
+
+def grant_daily_checkin(db: Session, user: User) -> CreditAccount:
+ if has_credit_transaction_today(db, user, "daily_checkin"):
+ raise HTTPException(status_code=409, detail="今日积分已领取")
+ return grant_credits(
+ db,
+ user,
+ DAILY_CHECKIN_CREDITS,
+ action="daily_checkin",
+ description="每日登录领取积分",
+ )
diff --git a/backend/services/limiter.py b/backend/services/limiter.py
new file mode 100644
index 0000000..38404a8
--- /dev/null
+++ b/backend/services/limiter.py
@@ -0,0 +1,4 @@
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
+limiter = Limiter(key_func=get_remote_address)
diff --git a/backend/services/password_policy.py b/backend/services/password_policy.py
new file mode 100644
index 0000000..a6e99df
--- /dev/null
+++ b/backend/services/password_policy.py
@@ -0,0 +1,27 @@
+"""密码强度校验策略:拒绝常见弱密码,要求字母+数字组合。"""
+import re
+
+WEAK_PATTERNS = [
+ "123456", "123456789", "password", "111111", "000000", "888888",
+ "abc123", "qwerty", "654321", "12345678", "1234567", "admin",
+]
+
+CONSECUTIVE = ["0123456789", "9876543210", "abcdef", "qwerty", "asdfgh"]
+
+
+def validate_password_strength(password: str) -> str | None:
+ """返回错误提示;通过则返回 None。要求至少 6 位且含字母与数字。"""
+ if not password or len(password) < 6:
+ return "密码至少需要 6 个字符"
+ low = password.lower()
+ if low in WEAK_PATTERNS:
+ return "密码过于简单,请使用更复杂的密码"
+ if any(seq in low for seq in CONSECUTIVE):
+ return "密码包含连续字符,请更换"
+ has_letter = bool(re.search(r"[a-zA-Z]", password))
+ has_digit = bool(re.search(r"\d", password))
+ if not has_letter:
+ return "密码必须包含字母"
+ if not has_digit:
+ return "密码必须包含数字"
+ return None
diff --git a/backend/services/upload_security.py b/backend/services/upload_security.py
new file mode 100644
index 0000000..8fc9a96
--- /dev/null
+++ b/backend/services/upload_security.py
@@ -0,0 +1,130 @@
+"""文件上传安全校验。
+
+提供 validate_upload() 对上传文件做:
+1. 文件名清洗(去除路径、危险字符、控制长度)
+2. 扩展名白名单校验
+3. MIME 与扩展名一致性校验
+4. 文件魔数(magic bytes)校验,防止伪装
+5. 文件大小校验
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Optional
+
+from fastapi import HTTPException, UploadFile
+
+
+# 允许的扩展名 → 期望的 MIME 前缀(; 前部分)
+ALLOWED_TYPES: dict[str, str] = {
+ "txt": "text/",
+ "md": "text/",
+ "csv": "text/",
+ "json": "application/json",
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "pdf": "application/pdf",
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "xls": "application/vnd.ms-excel",
+ "png": "image/png",
+ "jpg": "image/jpeg",
+ "jpeg": "image/jpeg",
+ "gif": "image/gif",
+ "webp": "image/webp",
+ "bmp": "image/bmp",
+}
+
+# 魔数签名(前若干字节)
+MAGIC_SIGNATURES: dict[str, list[bytes]] = {
+ "pdf": [b"%PDF-"],
+ "png": [b"\x89PNG\r\n\x1a\n"],
+ "jpg": [b"\xff\xd8\xff"],
+ "jpeg": [b"\xff\xd8\xff"],
+ "gif": [b"GIF87a", b"GIF89a"],
+ "bmp": [b"BM"],
+ "webp": [b"RIFF"],
+ # Office 文件均为 ZIP 容器(PK\x03\x04)
+ "docx": [b"PK\x03\x04"],
+ "pptx": [b"PK\x03\x04"],
+ "xlsx": [b"PK\x03\x04"],
+ "xls": [b"\xd0\xcf\x11\xe0"], # OLE 复合文档
+}
+
+MAX_FILENAME_LEN = 200
+
+
+@dataclass
+class UploadCheck:
+ filename: str
+ suffix: str
+ media_type: str
+ size: int
+
+
+_FILENAME_BAD = re.compile("[\x00-\x1f<>:\"/\\|?*]")
+
+
+def sanitize_filename(name: str) -> str:
+ """清洗文件名:去路径、去控制/危险字符、限长。"""
+ # 仅保留文件名部分
+ name = re.split(r"[\\/]", name)[-1].strip()
+ name = _FILENAME_BAD.sub("_", name)
+ name = name.strip(". ")
+ if not name:
+ name = "upload"
+ if len(name) > MAX_FILENAME_LEN:
+ stem, dot, ext = name.rpartition(".")
+ if dot:
+ name = stem[: MAX_FILENAME_LEN - len(ext) - 1] + "." + ext
+ else:
+ name = name[:MAX_FILENAME_LEN]
+ return name
+
+
+def _check_magic(suffix: str, head: bytes) -> bool:
+ sigs = MAGIC_SIGNATURES.get(suffix)
+ if not sigs:
+ return True # 无签名要求(文本类)默认放行
+ return any(head.startswith(sig) for sig in sigs)
+
+
+def validate_upload(
+ file: UploadFile,
+ *,
+ content: bytes,
+ max_size: Optional[int] = None,
+) -> UploadCheck:
+ """校验上传文件,失败抛出 HTTPException。
+
+ 参数 content 为已读取的字节内容(用于魔数校验)。
+ """
+ if max_size is not None and len(content) > max_size:
+ raise HTTPException(status_code=413, detail=f"文件不能超过 {max_size // (1024 * 1024)}MB")
+ if not content:
+ raise HTTPException(status_code=400, detail="上传文件为空")
+
+ raw_name = file.filename or "material"
+ filename = sanitize_filename(raw_name)
+ suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
+ media_type = (file.content_type or "").split(";")[0].strip().lower()
+
+ if suffix not in ALLOWED_TYPES:
+ raise HTTPException(
+ status_code=415,
+ detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片",
+ )
+
+ # MIME 一致性:图片/office/pdf 必须匹配;文本类型放宽
+ expected_mime = ALLOWED_TYPES[suffix]
+ if expected_mime != "text/" and media_type and not media_type.startswith(expected_mime.split("/")[0]):
+ # 宽松:只校验主类型一致(image/* application/* 等)
+ if media_type.split("/")[0] != expected_mime.split("/")[0]:
+ raise HTTPException(status_code=415, detail="文件类型与扩展名不一致")
+
+ # 魔数校验
+ head = content[:16]
+ if not _check_magic(suffix, head):
+ raise HTTPException(status_code=415, detail="文件内容与声明类型不符")
+
+ return UploadCheck(filename=filename, suffix=suffix, media_type=media_type or expected_mime, size=len(content))
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1a44459
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,57 @@
+version: '3.8'
+
+services:
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "8000:8000"
+ environment:
+ - DATABASE_URL=postgresql://jiaoyu:jiaoyu123@db:5432/jiaoyu
+ - REDIS_URL=redis://redis:6379/0
+ - AI_API_KEY=${AI_API_KEY}
+ - AI_API_BASE=${AI_API_BASE:-https://api.openai.com/v1}
+ - AI_MODEL=${AI_MODEL:-gpt-4o}
+ - SECRET_KEY=${SECRET_KEY:-change-me-in-production}
+ - CORS_ORIGINS=["http://localhost","http://localhost:5173"]
+ volumes:
+ - ./uploads:/app/uploads
+ depends_on:
+ - db
+ - redis
+ restart: unless-stopped
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "80:80"
+ depends_on:
+ - backend
+ restart: unless-stopped
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: jiaoyu
+ POSTGRES_USER: jiaoyu
+ POSTGRES_PASSWORD: jiaoyu123
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+ redis_data:
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..123446c
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,109 @@
+# API 接口文档
+
+Base URL: `http://localhost:8000`
+
+## 认证模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/auth/register | 教师注册 |
+| POST | /api/auth/login | 教师登录 |
+| GET | /api/auth/me | 获取个人信息 |
+| PUT | /api/auth/me | 更新个人信息 |
+
+## 课件模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/coursewares/ | 创建课件 |
+| GET | /api/coursewares/ | 课件列表(支持筛选) |
+| GET | /api/coursewares/{id} | 课件详情 |
+| PUT | /api/coursewares/{id} | 更新课件 |
+| DELETE | /api/coursewares/{id} | 删除课件 |
+| POST | /api/coursewares/ai-generate | AI生成课件 |
+
+## 教学动画模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/animations/ | 创建动画 |
+| GET | /api/animations/ | 动画列表 |
+| GET | /api/animations/{id} | 动画详情 |
+| PUT | /api/animations/{id} | 更新动画 |
+| DELETE | /api/animations/{id} | 删除动画 |
+| POST | /api/animations/ai-generate | AI生成动画 |
+
+## 互动练习模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/exercises/ | 创建练习 |
+| GET | /api/exercises/ | 练习列表 |
+| GET | /api/exercises/{id} | 练习详情 |
+| DELETE | /api/exercises/{id} | 删除练习 |
+| POST | /api/exercises/ai-generate | AI生成练习 |
+| POST | /api/exercises/{id}/attempt | 提交答题 |
+
+## 教案模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/lesson-plans/ | 创建教案 |
+| GET | /api/lesson-plans/ | 教案列表 |
+| GET | /api/lesson-plans/{id} | 教案详情 |
+| DELETE | /api/lesson-plans/{id} | 删除教案 |
+| POST | /api/lesson-plans/ai-generate | AI生成教案 |
+
+## AI能力模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/ai/essay-grade | 作文批改 |
+| POST | /api/ai/exam-generate | AI命题 |
+
+## 资源库模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/resources/ | 上传资源 |
+| GET | /api/resources/ | 资源列表 |
+| GET | /api/resources/{id} | 资源详情 |
+| POST | /api/resources/{id}/like | 点赞 |
+| DELETE | /api/resources/{id} | 删除资源 |
+
+## 教师社区模块
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/community/posts | 发布帖子 |
+| GET | /api/community/posts | 帖子列表 |
+| GET | /api/community/posts/{id} | 帖子详情 |
+| POST | /api/community/posts/{id}/like | 点赞帖子 |
+| DELETE | /api/community/posts/{id} | 删除帖子 |
+| GET | /api/community/posts/{id}/comments | 评论列表 |
+| POST | /api/community/posts/{id}/comments | 发表评论 |
+
+## 认证方式
+
+所有需要认证的接口请在请求头中携带 JWT Token:
+
+```
+Authorization: Bearer
+```
+
+## 通用响应格式
+
+成功响应:
+```json
+{
+ "success": true,
+ "data": { ... }
+}
+```
+
+错误响应:
+```json
+{
+ "detail": "错误信息描述"
+}
+```
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..3a13200
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,202 @@
+# 系统架构设计
+
+## 整体架构
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Nginx 反向代理 │
+├────────────────────────┬────────────────────────────────┤
+│ 前端 (Vue 3 SPA) │ 静态资源 / CDN │
+├────────────────────────┴────────────────────────────────┤
+│ API Gateway (FastAPI) │
+├──────────┬──────────┬──────────┬──────────┬─────────────┤
+│ 认证模块 │ 课件模块 │ AI模块 │ 课堂模块 │ 社区模块 │
+├──────────┴──────────┴──────────┴──────────┴─────────────┤
+│ 服务层 (Business Logic) │
+│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐ │
+│ │AI服务 │ │课件引擎 │ │动画引擎 │ │ 练习生成引擎 │ │
+│ └─────────┘ └──────────┘ └─────────┘ └──────────────┘ │
+├─────────────────────────────────────────────────────────┤
+│ 数据层 (Data Layer) │
+│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
+│ │PostgreSQL │ │ Redis │ │ 文件存储 (OSS) │ │
+│ └──────────┘ └──────────┘ └─────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+## 模块划分
+
+### 1. 用户与认证模块 (auth)
+
+- 教师注册/登录 (手机号 + 验证码)
+- JWT Token 认证
+- 角色权限管理 (教师/管理员)
+- 个人信息管理
+
+### 2. AI课件生成模块 (courseware)
+
+**核心流程:**
+```
+教师输入 → AI理解意图 → 结构化课件大纲 → 逐页生成内容 → 互动元素注入 → 预览/编辑 → 发布
+```
+
+**子功能:**
+- 文本输入 → 课件生成
+- 语音输入 → 转写 → 课件生成
+- 上传教材/文档 → 内容提取 → 课件生成
+- 课件模板管理
+- 课件在线编辑 (富文本 + 互动组件)
+
+### 3. 教学动画模块 (animation)
+
+**动画类型:**
+- 数学: 函数图像动画、几何变换、公式推导
+- 物理: 力学模拟、电路实验、光学实验
+- 化学: 分子结构、化学反应过程
+- 通用: 流程图动画、时间轴动画
+
+**技术实现:**
+- 基于 Canvas/SVG 的动画引擎
+- AI生成动画参数配置 (JSON Schema)
+- 动画模板库 + 自定义动画
+
+### 4. 互动练习模块 (exercise)
+
+**练习类型:**
+- 游戏化练习: 贪吃蛇(单词)、消消乐(配对)、闯关
+- 选择题 / 判断题 / 填空题
+- 拖拽排序 / 连线匹配
+- 口语跟读 (语音识别)
+- AI自适应出题
+
+**生成流程:**
+```
+知识点/单词表 → AI分析 → 选择游戏模板 → 生成题目 → 预览 → 发布
+```
+
+### 5. 教案生成模块 (lesson-plan)
+
+- 根据课程标准和知识点生成教案
+- 教案模板: 导入→新授→练习→总结→作业
+- 支持自定义教案结构
+- 教案与课件/练习关联
+
+### 6. AI命题模块 (exam)
+
+- 按知识点、难度、题型智能组卷
+- 题型支持: 选择、填空、判断、简答、计算
+- 题目去重与相似度检测
+- 自动生成答案与解析
+
+### 7. 作文批改模块 (essay)
+
+- 多维度评分: 内容、结构、语言、书写
+- 逐段/逐句批注
+- 优秀范文推荐
+- 常见错误统计分析
+
+### 8. 课堂互动模块 (classroom)
+
+- 在线授课 (WebRTC)
+- 实时白板 (Canvas)
+- 计时器 / 随机点名
+- 课堂答题器 (实时统计)
+- 课堂评价
+
+### 9. 资源库模块 (resource)
+
+- 个人资源管理
+- 课件/动画/教案收藏
+- 标签分类与搜索
+- 资源版本管理
+
+### 10. 教师社区模块 (community)
+
+- 资源发布与分享
+- 经验交流帖
+- 点赞/评论/收藏
+- 关注体系
+
+## 数据库设计 (核心表)
+
+```sql
+-- 用户表
+users: id, phone, name, avatar, subject, school, role, created_at
+
+-- 课件表
+coursewares: id, user_id, title, subject, grade, description,
+ content(JSON), status, version, created_at, updated_at
+
+-- 动画表
+animations: id, user_id, title, type, config(JSON),
+ thumbnail, status, created_at
+
+-- 练习表
+exercises: id, user_id, title, type, questions(JSON),
+ subject, knowledge_points, created_at
+
+-- 教案表
+lesson_plans: id, user_id, title, subject, grade, content(JSON),
+ objectives, key_points, created_at
+
+-- 试卷表
+exams: id, user_id, title, subject, questions(JSON),
+ answers(JSON), duration, total_score, created_at
+
+-- 资源表
+resources: id, user_id, type, title, content_ref,
+ tags, downloads, likes, status, created_at
+
+-- 社区帖子表
+posts: id, user_id, title, content, type, tags,
+ views, likes, comments_count, created_at
+
+-- 评论表
+comments: id, post_id, user_id, content, created_at
+```
+
+## AI服务架构
+
+```
+┌──────────────────────────────────────┐
+│ AI Service Layer │
+├──────────────────────────────────────┤
+│ │
+│ ┌────────────┐ ┌────────────────┐ │
+│ │ Prompt模板 │ │ 教育知识库 │ │
+│ │ 管理 │ │ (RAG) │ │
+│ └────────────┘ └────────────────┘ │
+│ │
+│ ┌────────────────────────────────┐ │
+│ │ LLM API 调用层 │ │
+│ │ (OpenAI / 自研模型 / 本地模型) │ │
+│ └────────────────────────────────┘ │
+│ │
+│ ┌────────────────────────────────┐ │
+│ │ 输出处理与验证 │ │
+│ │ (JSON Schema验证/安全过滤) │ │
+│ └────────────────────────────────┘ │
+│ │
+└──────────────────────────────────────┘
+```
+
+### Prompt 工程
+
+每种AI功能对应一套精心设计的Prompt模板:
+
+1. **课件生成Prompt**: 输入教学意图 → 输出结构化课件JSON
+2. **动画生成Prompt**: 输入知识点 → 输出动画参数配置
+3. **练习生成Prompt**: 输入知识点/单词 → 输出游戏化练习数据
+4. **教案生成Prompt**: 输入课程信息 → 输出结构化教案
+5. **命题Prompt**: 输入知识点/难度 → 输出题目+答案+解析
+6. **批改Prompt**: 输入作文 → 输出评分+批注+建议
+
+## 安全设计
+
+- JWT认证 + Refresh Token
+- API限流 (Redis)
+- 输入校验 (Pydantic)
+- XSS/CSRF防护
+- 文件上传安全检查
+- 数据库参数化查询
+- 敏感信息加密存储
diff --git a/docs/platform-parity-roadmap.md b/docs/platform-parity-roadmap.md
new file mode 100644
index 0000000..87e5964
--- /dev/null
+++ b/docs/platform-parity-roadmap.md
@@ -0,0 +1,83 @@
+# 同类 AI 教学平台功能路线
+
+本项目目标是实现同类 AI 教学创作平台能力,功能逻辑对标,但品牌、文案、视觉素材和生成模板保持原创。
+
+## 已落地
+
+| 能力 | 当前实现 |
+| --- | --- |
+| 创作广场 | 首页资源卡片、分类筛选、快速创作入口 |
+| 一句话生成互动课件 | `/courseware/create`,AI 生成多页 HTML 互动课件 |
+| 教学动画 | `/animation`,AI 生成 HTML/CSS 教学动画,支持数学/物理/化学/生物/地理/通用 |
+| 教学游戏/互动练习 | `/exercise`,8种题型,互动答题(选择/填空)、即时反馈、计分、答案解析 |
+| AI 教案 | `/lesson-plan`,按课题、学科、年级、目标生成教案 |
+| AI 命题 | `/exam`,按学科、知识点、题型、难度生成试卷 |
+| 作文批改 | `/essay-grade`,作文评分、评语、优缺点和修改建议 |
+| 模板社区/资源库 | `/community`、`/resources`,资源展示、发布、点赞、评论;站内通知系统(评论/收藏自动触发、头部铃铛未读计数、一键已读) |
+| 课堂工具/数据回收 | `/classroom`,投票、问答、测验、签到、反馈,支持提交统计 |
+| 多模态入口雏形 | 首页支持文本材料上传、图片描述注入、浏览器语音识别 |
+| 成果导出 | 课件/动画类 HTML 下载,试卷/教案 Word 下载 + 打印 PDF(浏览器原生打印对话框,可另存为 PDF) |
+| 材料智能解析 | 后端解析 docx、pptx、pdf、xlsx/txt/md/csv/json,图片经 AI 视觉 OCR 提取文字 |
+| 数据回收学生端 | `/classroom/join` 公开提交页、投放码/链接、投放大屏、实时统计、Excel 导出 |
+| 会员/积分体系 | 生成扣费、每日签到、积分流水、余额实时同步 |
+| 课件编辑器页面管理 | 添加/复制/移动/删除页面,页面标题、类型与备注编辑 |
+| 课件编辑器撤销重做 | 历史栈撤销/重做,Ctrl+Z / Ctrl+Shift+Z 快捷键 |
+| 管理后台 | 运营看板、用户管理(启停/角色/发积分)、资源审核(上下架) |
+| 数据库迁移 | Alembic 初始化,autogenerate + batch 模式,初始迁移已 stamp |
+| 接口限流 | slowapi 限流:登录/注册、AI 生成、材料解析接口 |
+| 账号安全 | 修改密码(校验旧密码/禁止新旧相同/密码强度策略/审计日志)+ 找回密码(手机号验证码重置、限流、验证码 5 分钟过期)+ 头像上传(图片白名单/魔数校验/5MB) |
+| 令牌刷新 | access_token 过期自动用 refresh_token 静默刷新(单飞去重,避免并发 401 重复刷新),刷新失败才跳登录;登录/注册同时存储 refresh_token |
+| AI 思维导图 | /mindmap,AI 生成层次化知识思维导图,交互式可折叠树、分支配色、展开/折叠、HTML 导出、保存到我的导图(CRUD + 耗积分);后端 mind_maps 表与路由、AIService.generate_mindmap + fallback |
+| 动画模板库 | 36 个参数化学科动画模板:二次函数、正弦波、斜抛运动、简谐振动、分子运动、细胞分裂、太阳系公转、昼夜四季、几何变换、串并联电路、食物链能量流动、水循环、历史时间线、朝代更替轮、诗词意境、分数可视化、光的折射与全反射、酸碱中和滴定、光合作用过程、概率模拟(大数定律)、单词卡片翻转记忆(英语词汇)、英语时态时间轴(英语语法)、汉字笔顺动画(语文识字)、钢琴键盘与简谱(音乐)、三原色混色实验(美术)、二进制拨码开关(信息技术)、拼音四声调对比(语文)、元素周期表交互、化学键与分子结构、pH酸碱指示剂(化学拓展);覆盖数学/物理/化学(5)/生物/地理/历史/语文/英语/音乐/美术/信息技术/通用 12 学科;TemplateGallery 组件接入动画生成页,免积分即用即改 |
+| 游戏模板库 | 20 个参数化教学游戏模板:知识贪吃蛇、翻牌记忆配对、排序拖拽、冒险闯关问答、抢答打地鼠、填词连连看、转盘答题、找词游戏、知识飞行棋、知识合成 2048、打字竞速、猜词游戏(猜字母拼单词)、判断对错速答(限时连对奖励)、选词填空(Cloze 拖放)、算术竞速(四则限时);TemplateGallery 接入练习生成页,模板自动映射题型;并修复贪吃蛇/转盘/找词三个旧模板的脚本语法错误 |
+| 课件编辑器增强 | 6 套主题模板(简洁/暖橙/海洋/森林/深夜/日落)一键换肤 + 模板插入按钮(TemplateGallery 复用);保留页面增删改/撤销重做 |
+| 品牌替换 | 汉字/拼音/CSS 前缀全量切换为自有品牌 zj-(前端样式与后端生成模板),源码与 AI 生成内容(课件/动画/教案等)经全工作区扫描 + 多类型生成验证零残留 |
+| 模板插值修复 | 修复 strokeOrder/4 个新模板的反引号字符串插值 bug:原 `var X=\"+name+\"` 写法在浏览器里被解析为字面字符串 `\"+name+\"` 而非变量值,导致参数实际未生效(仅靠 `\|\| 默认值` 兜底掩盖);统一改为正确的 `${name}` 模板插值,参数现在真正传入 IIFE |
+| AI 实时生成可用 | 修复核心瓶颈:`_chat` 读取超时 8s→120s(推理模型需 20-90s 思考)、OCR 30s→90s;空内容守卫(reasoning 模型思考耗尽预算时抛 `AI_PROVIDER_EMPTY_RESPONSE` 触发兜底);`max_tokens` 默认 4096→8192(防复杂生成 JSON 截断),chat_teaching 2048→8192、mindmap 4096→8192;端到端验证:练习(勾股定理几何意义,92s,5题)+试卷(一元二次方程,64s,6题)均产出高质量真实内容,无截断、无兜底模板 |
+| JWT 密钥安全 | 修复高危漏洞:SECRET_KEY 原硬编码为 `change-me-in-production` 默认值,源码泄露即可伪造任意用户/管理员 token。改为 pydantic field_validator 启动校验——生产模式(DEBUG=False)遇到弱/默认/空/短(<32)密钥直接 RuntimeError 拒绝启动;开发模式自动生成临时密钥并警告;.env 已配置 64 字符随机强密钥。端到端验证:旧密钥伪造 token 返回 401 |
+| 生产安全 | 上传安全校验(扩展名白名单/MIME 一致性/魔数检测/文件名清洗/大小限制)+ 审计日志覆盖登录/注册/材料解析/发布资源/管理员发积分/改用户/资源审核,admin 可查询 + AuditLog 表与 Alembic 迁移 |
+
+
+| 练习/命题兜底题库扩展 | _exercise_kb() 从 14 个主题扩展到 35 个,覆盖历史(朝代/丝路/工业革命/抗战/文艺复兴/辛亥革命)、地理(气候/地形/河流/地球运动)、生物(细胞/光合/生态/遗传/人体)、化学(元素/反应/酸碱盐/原子)、物理(力学/电学/光学/运动)、数学(几何三角形/函数/方程/一元二次/二次函数/概率统计)、语文(荷塘月色/古诗词/记叙文/议论文)、英语(时态/从句);修复核心缺口:用户请求「三角形内角和」时不再退回无关算术,而是生成三角形内角和(180°)、直角三角形判定、勾股定理等主题相关题目 |
+| AI 熔断器全局共享 | _ai_unavailable_until 从实例变量改为类变量,8 个路由模块的 AIService 实例共享一个熔断器;首次 AI 超时后,所有端点的后续调用立即走兜底(0.2s),不再各自独立等待 10s 超时(5 端点连续调用从 ~50s 降至 ~11s) |
+
+## 端到端验证覆盖(双账号真实链路)
+
+以下链路均经真实 HTTP 调用端到端验证通过:
+
+| 链路 | 验证结果 |
+| --- | --- |
+| 认证积分 | 注册201/登录/me初始130积分/签到+10/140 通过 |
+| AI生成8端点 | 课件/动画/教案/练习/思维导图/命题/作文批改全200,内容质量已核验 |
+| 社交链路 | A发资源→B收藏通知→B下载→B发帖→A评论→A点赞双向通知→全局搜索7维度 通过 |
+| 收藏状态 | 跨账号is_favorited正确/收藏列表同步/取消重收准确 通过 |
+| 课堂数据回收 | 创建投票→4学生公开提交→分析统计(选项50%/掌握度/教学建议)→Excel导出 通过 |
+| 课件分享 | 生成token→公开访问→浏览数递增→我的分享列表→撤销后404 通过 |
+| 旧品牌清除 | 源码0残留+所有生成类型产出0残留(课件原117处旧前缀全部清零) 通过 |
+| 平台完整性审计 | 24视图无空壳(最小366行)、17路由+~125个前端API端点与后端对齐、3处TODO标记均为UI文案非未实现、找回密码/分享/通知/搜索等关键链路前后端完整;Playwright真实浏览器端到端验证:登录→首页(H1正确)→课件/动画/练习页加载→动画生成API 200→0控制台错误→无旧品牌残留 |
+| AI 兜底质量增强 | 修复 `backend/services/ai_service.py` 中 `_exercise_kb` 重复定义导致的 SyntaxError(`""书名""` 双引号嵌套解析失败,删除冗余副本,保留已用《》/「」的正确版本);思维导图/练习/教案 fallback 升级为学科+主题感知题库(30+ 高频主题:朝代/丝绸之路/光合作用/细胞/元素周期表/酸碱盐/欧姆定律/重力/一元二次方程/二次函数/荷塘月色/古诗词/时态/气候),冷门主题回退通用题库无回归;时态关键词扩充(现在时/过去时/进行时/完成时)、朝代关键词扩充(唐/汉/宋/明/清/秦);HTTP + Playwright 端到端验证:一元二次方程→x²-5x+6=0、光合作用→叶绿体、欧姆定律→I=U/R、荷塘月色→朱自清、唐朝→朝代题库、0 控制台错误 |
+
+
+| 练习标题去重 | 修复 `_fallback_exercise` 兜底标题把整段 prompt 当主题再拼「练习」导致「…练习练习」重复:改为正则去尾(练习题/练习/题目/试题/习题/题库/选择题/填空题/判断题/应用题/解答题/计算题/题 等)后再拼;已端到端验证 prompt「二次函数练习」→ 标题「二次函数练习」(原「二次函数练习练习」),3 题正常返回;全工作区旧品牌残留复核:源码+数据库+node_modules 字节级扫描 0 命中;16 个主视图浏览器遍历 0 控制台错误,登录/课件生成链路运行时健康 |
+
+
+| 练习作答追踪(attempt) | 后端修复 POST `/{id}/attempt`(原忽略路径参数 exercise_id,改为显式赋值并校验练习存在);新增 GET `/{id}/attempts` 供教师查询作答记录;schema 新增 ExerciseAttemptOut、ExerciseAttemptCreate 去掉冗余 exercise_id;前端新增 submitExerciseAttempt/getExerciseAttempts API + Exercise.vue 自动提交(答完全部题目或重置时触发,仅对已保存练习生效)+ 预览时加载并展示「已有 N 人完成 · 平均正确率 M%」徽章;端到端验证:创建练习→提交2条作答(2/3, 3/3)→GET列表返回2条完整记录 |
+| 全链路深度审计 | 品牌复核(源码+DB+node_modules 字节级 0 残留);87前端API路径全部有对应后端端点;16主视图浏览器遍历 0 控制台错误;资源CRUD/课堂回收/社交/AI对话/签到/Profile统计 各链路端到端 HTTP 验证通过;TypeScript 零编译错误 |
+
+
+| 管理后台致命 Bug 修复 | admin.py 缺少 `from services.audit import log_action` 导入,导致 update_user / grant_user_credits / moderate_resource 三个端点全部 500(NameError)。修复后端到端验证:发放积分 200、启用/禁用用户 200、资源上下架 200,审计日志正确记录;全路由文件 AST 扫描确认无其他缺失导入 |
+| 全功能端到端审计 | 七大生成流(课件/动画/练习/教案/思维导图/命题/作文) × 全生命周期(生成→保存→列表→详情→导出→改编→删除);课件分享链路(token→公开访问→列表→撤销→404);社交链路(发帖→评论→点赞→收藏→搜索5维度);AI对话(创建→发消息→历史加载→删除);课堂数据回收(创建→发布→4学生公开提交→分析统计→Excel导出);密码安全(改密/旧密码校验/新旧不同/强度策略);材料解析(文件上传→解析→入库);管理后台(看板15指标/用户CRUD/资源审核/审计日志50条) |
+
+## 待完善(持续打磨)
+
+| 能力 | 建议实现 |
+| --- | --- |
+| 模板库持续扩展 | 持续补充学科/题型覆盖(当前 36 动画 + 20 游戏),打磨生成与模板质量 |
+| 安全增强 | 余项:病毒扫描集成;✅ JWT 密钥安全已落地(SECRET_KEY 启动校验:生产模式拒绝弱/默认/空密钥,开发模式生成临时密钥并警告;当前 .env 已配置 64 字符强密钥;旧默认密钥伪造的 token 端到端验证被拒);密码强度策略已覆盖注册/改密/重置,刷新令牌限流 30/min 已落地 |
+
+## 开发优先级
+
+1. 材料解析、数据回收学生端、积分体系和管理后台已落地,持续打磨生成质量。
+2. ✅ 课件编辑器组件化(主题模板 + 模板插入)已落地。
+3. ✅ 动画/游戏模板库(36 + 20)已落地,覆盖数学/物理/化学/生物/地理/历史/语文/英语/通用多学科场景;并修复 3 个旧游戏模板的脚本错误。
+4. ✅ 生产安全(上传校验 + 审计日志 + 修改密码)已落地,持续扩展审计覆盖与安全增强。
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..174a0f6
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/_audit.cjs b/frontend/_audit.cjs
new file mode 100644
index 0000000..0fee104
--- /dev/null
+++ b/frontend/_audit.cjs
@@ -0,0 +1,76 @@
+const { chromium } = require("playwright-core");
+
+(async () => {
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => {
+ if (msg.type() === "error") errors.push(`CONSOLE: ${msg.text()}`);
+ });
+ page.on("pageerror", err => errors.push(`PAGEERROR: ${err.message}`));
+
+ const BASE = "http://127.0.0.1:5175";
+
+ // Login first via API
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ headers: { "Content-Type": "application/json" },
+ });
+ const loginData = await loginResp.json();
+ const token = loginData.access_token;
+
+ // Inject token into localStorage
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => {
+ localStorage.setItem("token", t);
+ localStorage.setItem("access_token", t);
+ localStorage.setItem("zj_token", t);
+ }, token);
+
+ const routes = [
+ "/", "/courseware", "/courseware/create", "/animation", "/exercise",
+ "/lesson-plan", "/exam", "/essay-grade", "/mindmap", "/materials",
+ "/resources", "/community", "/classroom", "/assistant", "/profile",
+ "/admin", "/search",
+ ];
+
+ for (const route of routes) {
+ const routeErrors = [];
+ page.removeAllListeners("console");
+ page.removeAllListeners("pageerror");
+ page.on("console", msg => { if (msg.type() === "error") routeErrors.push(msg.text()); });
+ page.on("pageerror", err => routeErrors.push(err.message));
+
+ try {
+ await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 15000 });
+ await page.waitForTimeout(1500);
+
+ // Check for empty/blank pages
+ const bodyText = await page.evaluate(() => document.body.innerText.trim());
+ const isEmpty = bodyText.length < 10;
+ const hasRouterView = await page.evaluate(() => {
+ const app = document.querySelector("#app");
+ return app && app.children.length > 0;
+ });
+
+ // Filter expected errors (401/403 for admin views)
+ const realErrors = routeErrors.filter(e =>
+ !e.includes("401") && !e.includes("403") &&
+ !e.includes("Failed to fetch") &&
+ !e.includes("NetworkError")
+ );
+
+ const status = realErrors.length === 0 && !isEmpty && hasRouterView ? "OK" : "CHECK";
+ const errSummary = realErrors.length > 0 ? ` ERR:[${realErrors.slice(0,2).join(" | ").substring(0,100)}]` : "";
+ const emptyFlag = isEmpty ? " BLANK" : "";
+ console.log(`${status} ${route.padEnd(20)} ${bodyText.length}chars${emptyFlag}${errSummary}`);
+ } catch (e) {
+ console.log(`FAIL ${route.padEnd(20)} ${e.message.substring(0, 80)}`);
+ }
+ }
+
+ await browser.close();
+})();
diff --git a/frontend/_audit.mjs b/frontend/_audit.mjs
new file mode 100644
index 0000000..266ae86
--- /dev/null
+++ b/frontend/_audit.mjs
@@ -0,0 +1,58 @@
+import { chromium } from 'playwright-core';
+import fs from 'fs';
+import path from 'path';
+
+const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
+});
+const loginData = await loginRes.json();
+const accessToken = loginData.access_token;
+const refreshToken = loginData.refresh_token;
+
+const browser = await chromium.launch({
+ channel: 'chrome', headless: true,
+ executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+});
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+await ctx.addInitScript(([at, rt]) => {
+ localStorage.setItem('access_token', at);
+ localStorage.setItem('refresh_token', rt);
+}, [accessToken, refreshToken]);
+const page = await ctx.newPage();
+const errors = [];
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+const shotDir = path.resolve('audit_shots');
+if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir);
+
+const pages = [
+ { name: 'home', path: '/home' },
+ { name: 'courseware_list', path: '/courseware' },
+ { name: 'courseware_create', path: '/courseware/create' },
+ { name: 'animation', path: '/animation' },
+ { name: 'exercise', path: '/exercise' },
+ { name: 'exam', path: '/exam' },
+ { name: 'lesson_plan', path: '/lesson-plan' },
+ { name: 'mindmap', path: '/mindmap' },
+ { name: 'essay_grade', path: '/essay-grade' },
+ { name: 'materials', path: '/materials' },
+ { name: 'profile', path: '/profile' },
+ { name: 'classroom', path: '/classroom' },
+ { name: 'assistant', path: '/assistant' },
+ { name: 'admin', path: '/admin' },
+];
+for (const p of pages) {
+ await page.goto('http://127.0.0.1:5175' + p.path, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: path.join(shotDir, p.name + '.png'), fullPage: false });
+ const url = page.url();
+ const redirected = url.includes('login');
+ const h = await page.textContent('h1, h2').catch(() => '');
+ console.log('[' + p.name + '] ' + (redirected ? 'REDIRECT_LOGIN' : url.split('/').slice(-2).join('/')) + ' h="' + (h||'').trim().substring(0,30) + '"');
+}
+console.log('ERRORS:', errors.length);
+errors.slice(0,10).forEach(e => console.log(' ', e.substring(0,140)));
+await browser.close();
diff --git a/frontend/_audit2.cjs b/frontend/_audit2.cjs
new file mode 100644
index 0000000..5862ffc
--- /dev/null
+++ b/frontend/_audit2.cjs
@@ -0,0 +1,73 @@
+const { chromium } = require("playwright-core");
+
+(async () => {
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
+ page.on("pageerror", err => errors.push(err.message));
+
+ const BASE = "http://127.0.0.1:5175";
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ });
+ const token = (await loginResp.json()).access_token;
+
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
+
+ // === TEST 1: Animation template gallery ===
+ console.log("=== ANIMATION TEMPLATES ===");
+ await page.goto(BASE + "/animation", { waitUntil: "networkidle" });
+ await page.waitForTimeout(2000);
+ const tplButtons = await page.$$(".tpl-btn");
+ console.log("template buttons found:", tplButtons.length);
+
+ // Click first template
+ if (tplButtons.length > 0) {
+ await tplButtons[0].click();
+ await page.waitForTimeout(2000);
+ const previewIframe = await page.$(".tg-preview-wrap iframe, .preview-wrap iframe, iframe");
+ if (previewIframe) {
+ console.log("preview iframe: FOUND");
+ } else {
+ console.log("preview iframe: NOT FOUND");
+ const allIframes = await page.$$("iframe");
+ console.log("total iframes:", allIframes.length);
+ }
+ const cards = await page.$$(".tg-card, .result-card");
+ console.log("result cards:", cards.length);
+ }
+
+ // === TEST 2: Exercise template gallery ===
+ console.log("\n=== EXERCISE TEMPLATES ===");
+ await page.goto(BASE + "/exercise", { waitUntil: "networkidle" });
+ await page.waitForTimeout(2000);
+ const exTplBtns = await page.$$(".tpl-btn");
+ console.log("exercise template buttons:", exTplBtns.length);
+
+ // === TEST 3: Courseware create page ===
+ console.log("\n=== COURSEWARE CREATE ===");
+ await page.goto(BASE + "/courseware/create", { waitUntil: "networkidle" });
+ await page.waitForTimeout(1500);
+ const textareas = await page.$$("textarea, input[type=text], .el-input__inner");
+ console.log("input fields:", textareas.length);
+ const genButtons = await page.$$("button:has-text('生成'), button:has-text('创建'), .gen-btn");
+ console.log("generate buttons:", genButtons.length);
+
+ // === TEST 4: Classroom ===
+ console.log("\n=== CLASSROOM ===");
+ await page.goto(BASE + "/classroom", { waitUntil: "networkidle" });
+ await page.waitForTimeout(1500);
+ const classroomText = await page.evaluate(() => document.body.innerText.substring(0, 500));
+ console.log("classroom has content:", classroomText.length > 50);
+
+ const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
+ console.log("\n=== TOTAL ERRORS (filtered):", realErrors.length, "===");
+ realErrors.slice(0, 5).forEach(e => console.log(" ", e.substring(0, 120)));
+
+ await browser.close();
+})();
diff --git a/frontend/_audit3.cjs b/frontend/_audit3.cjs
new file mode 100644
index 0000000..52db8a9
--- /dev/null
+++ b/frontend/_audit3.cjs
@@ -0,0 +1,74 @@
+const { chromium } = require("playwright-core");
+
+(async () => {
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
+ page.on("pageerror", err => errors.push(err.message));
+
+ const BASE = "http://127.0.0.1:5175";
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ });
+ const token = (await loginResp.json()).access_token;
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
+
+ // Get existing courseware list
+ const cwResp = await page.request.get("http://127.0.0.1:8010/api/coursewares", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const coursewares = await cwResp.json();
+ console.log("existing coursewares:", coursewares.length);
+ if (coursewares.length > 0) {
+ console.log("first courseware id:", coursewares[0].id, "title:", coursewares[0].title?.substring(0, 30));
+ }
+
+ // Navigate to courseware list and check if preview works
+ console.log("\n=== COURSEWARE LIST ===");
+ await page.goto(BASE + "/courseware", { waitUntil: "networkidle" });
+ await page.waitForTimeout(2000);
+
+ // Count cards
+ const cards = await page.$$(".courseware-card, .cw-card, .el-card");
+ console.log("courseware cards on page:", cards.length);
+
+ // Check for action buttons
+ const previewBtns = await page.$$("button:has-text('预览'), button:has-text('查看'), a:has-text('预览')");
+ console.log("preview buttons:", previewBtns.length);
+
+ // Navigate to first courseware preview if exists
+ if (coursewares.length > 0) {
+ console.log("\n=== COURSEWARE PREVIEW ===");
+ await page.goto(`${BASE}/courseware/${coursewares[0].id}/preview`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(3000);
+
+ // Check if slides rendered
+ const iframes = await page.$$("iframe");
+ console.log("iframes:", iframes.length);
+
+ const slideContent = await page.evaluate(() => {
+ const slide = document.querySelector(".slide-container, .preview-slide, .cw-slide");
+ return slide ? "slide found" : "no slide element";
+ });
+ console.log("slide element:", slideContent);
+
+ // Check page navigation
+ const navBtns = await page.$$(".page-nav, .nav-btn, button:has-text('下一页'), button:has-text('上一页')");
+ console.log("nav buttons:", navBtns.length);
+
+ // Take screenshot
+ await page.screenshot({ path: "_cw_preview.png" });
+ console.log("screenshot saved");
+ }
+
+ const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
+ console.log("\nerrors (filtered):", realErrors.length);
+ realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
+
+ await browser.close();
+})();
diff --git a/frontend/_audit4.cjs b/frontend/_audit4.cjs
new file mode 100644
index 0000000..972474a
--- /dev/null
+++ b/frontend/_audit4.cjs
@@ -0,0 +1,66 @@
+const { chromium } = require("playwright-core");
+const fs = require("fs");
+
+(async () => {
+ const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
+ console.log("testing courseware preview for id:", cwId);
+
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
+ page.on("pageerror", err => errors.push(err.message));
+
+ const BASE = "http://127.0.0.1:5175";
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ });
+ const token = (await loginResp.json()).access_token;
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
+
+ // Go to preview
+ await page.goto(`${BASE}/courseware/${cwId}/preview`, { waitUntil: "networkidle", timeout: 20000 });
+ await page.waitForTimeout(3000);
+
+ // Check rendering
+ const iframes = await page.$$("iframe");
+ console.log("iframes:", iframes.length);
+
+ const bodyText = await page.evaluate(() => document.body.innerText.substring(0, 200));
+ console.log("body text preview:", bodyText.substring(0, 120));
+
+ // Check for slide/navigation elements
+ const allDivs = await page.$$("#app div");
+ console.log("total divs:", allDivs.length);
+
+ // Check for specific preview elements
+ const slideEls = await page.$$("[class*='slide'], [class*='preview'], [class*='page-nav'], [class*='cw-']");
+ console.log("slide/preview elements:", slideEls.length);
+
+ // Look for iframe content
+ for (let i = 0; i < Math.min(iframes.length, 3); i++) {
+ try {
+ const frame = iframes[i].contentFrame();
+ if (frame) {
+ const content = await frame.evaluate(() => document.body ? document.body.innerText.substring(0, 60) : "empty");
+ console.log(`iframe[${i}] content:`, content);
+ }
+ } catch(e) {
+ console.log(`iframe[${i}] error:`, e.message.substring(0, 60));
+ }
+ }
+
+ // Screenshot
+ await page.screenshot({ path: "_cw_preview.png" });
+ console.log("screenshot saved");
+
+ const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
+ console.log("\nerrors (filtered):", realErrors.length);
+ realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
+
+ await browser.close();
+})();
diff --git a/frontend/_audit5.cjs b/frontend/_audit5.cjs
new file mode 100644
index 0000000..6ba6f6a
--- /dev/null
+++ b/frontend/_audit5.cjs
@@ -0,0 +1,67 @@
+const { chromium } = require("playwright-core");
+const fs = require("fs");
+
+(async () => {
+ const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
+ console.log("testing courseware preview at /preview/" + cwId);
+
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
+ page.on("pageerror", err => errors.push(err.message));
+
+ const BASE = "http://127.0.0.1:5175";
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ });
+ const token = (await loginResp.json()).access_token;
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
+
+ // Correct URL: /preview/:id
+ await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
+ await page.waitForTimeout(4000);
+
+ const iframes = await page.$$("iframe");
+ console.log("iframes:", iframes.length);
+
+ // Check iframe content
+ for (let i = 0; i < Math.min(iframes.length, 2); i++) {
+ try {
+ const frame = iframes[i].contentFrame();
+ if (frame) {
+ const html = await frame.evaluate(() => document.documentElement.outerHTML.substring(0, 200));
+ console.log(`iframe[${i}] html:`, html.substring(0, 150));
+ }
+ } catch(e) {
+ console.log(`iframe[${i}] error:`, e.message.substring(0, 80));
+ }
+ }
+
+ // Check page navigation
+ const navBtns = await page.$$("button");
+ const navTexts = [];
+ for (const btn of navBtns) {
+ const text = await btn.textContent();
+ if (text.trim()) navTexts.push(text.trim().substring(0, 20));
+ }
+ console.log("buttons:", navTexts.slice(0, 10));
+
+ // Page counter
+ const bodyText = await page.evaluate(() => document.body.innerText);
+ const pageMatch = bodyText.match(/(\d+)\s*\/\s*(\d+)/);
+ if (pageMatch) console.log("page counter:", pageMatch[0]);
+
+ await page.screenshot({ path: "_cw_preview2.png" });
+ console.log("screenshot saved");
+
+ const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
+ console.log("\nerrors (filtered):", realErrors.length);
+ realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
+
+ await browser.close();
+})();
diff --git a/frontend/_audit6.cjs b/frontend/_audit6.cjs
new file mode 100644
index 0000000..9e93f31
--- /dev/null
+++ b/frontend/_audit6.cjs
@@ -0,0 +1,55 @@
+const { chromium } = require("playwright-core");
+const fs = require("fs");
+
+(async () => {
+ const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
+ const browser = await chromium.launch({
+ executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ args: ["--disable-blink-features=AutomationControlled"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
+ page.on("pageerror", err => errors.push(err.message));
+
+ const BASE = "http://127.0.0.1:5175";
+ const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
+ data: { phone: "13943441149", password: "Test1234" },
+ });
+ const token = (await loginResp.json()).access_token;
+ await page.goto(BASE + "/login", { waitUntil: "networkidle" });
+ await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
+
+ await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
+ await page.waitForTimeout(4000);
+
+ const iframes = await page.$$("iframe");
+ console.log("iframes:", iframes.length);
+
+ // Check main slide iframe content via srcdoc
+ const slideIframe = await page.$(".slide-raw-iframe");
+ if (slideIframe) {
+ const srcdoc = await slideIframe.getAttribute("srcdoc");
+ console.log("slide srcdoc length:", srcdoc ? srcdoc.length : 0);
+ if (srcdoc) console.log("srcdoc has content:", srcdoc.length > 100);
+ } else {
+ console.log("no .slide-raw-iframe found");
+ }
+
+ // Check page counter
+ const pageCounter = await page.$eval(".bottom-page-info, .ov-page", el => el.textContent).catch(() => "none");
+ console.log("page counter:", pageCounter);
+
+ // Check thumbnails
+ const thumbs = await page.$$(".thumb-raw-iframe, .thumb-scaler");
+ console.log("thumbnails:", thumbs.length);
+
+ await page.screenshot({ path: "_cw_preview3.png" });
+ console.log("screenshot saved");
+
+ const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
+ console.log("errors (filtered):", realErrors.length);
+ realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
+
+ await browser.close();
+})();
diff --git a/frontend/_br_test.cjs b/frontend/_br_test.cjs
new file mode 100644
index 0000000..6d8c0c1
--- /dev/null
+++ b/frontend/_br_test.cjs
@@ -0,0 +1,35 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
+ const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERR: ' + e.message));
+ // 登录
+ await page.goto('http://127.0.0.1:5173/login', { waitUntil: 'networkidle' });
+ await page.waitForTimeout(800);
+ await page.fill('input[placeholder="请输入手机号"]', '13943441149');
+ await page.fill('input[type="password"]', 'Test1234');
+ await page.click('button.gradient-btn');
+ await page.waitForTimeout(3000);
+ const url1 = page.url();
+ console.log('登录后URL:', url1);
+ // 思维导图
+ await page.goto('http://127.0.0.1:5173/mindmap', { waitUntil: 'networkidle' });
+ await page.waitForTimeout(1000);
+ await page.fill('input[placeholder*="光合作用"]', '中国历史朝代');
+ await page.waitForTimeout(400);
+ await page.click('button:has-text("生成导图")');
+ console.log('已点生成导图');
+ await page.waitForTimeout(9000);
+ await page.screenshot({ path: 'D:\\AI\\jiaoyu\\_shot_mindmap.png', fullPage: true });
+ const bodyText = await page.evaluate(() => document.body.innerText);
+ console.log('含先秦文明:', bodyText.includes('先秦文明'));
+ console.log('含秦汉大一统:', bodyText.includes('秦汉大一统'));
+ console.log('含隋唐盛世:', bodyText.includes('隋唐盛世'));
+ console.log('含概念定义(旧通用):', bodyText.includes('概念定义'));
+ console.log('含知识结构(旧通用):', bodyText.includes('知识结构'));
+ console.log('错误数:', errors.length);
+ if (errors.length) console.log('错误:', errors.slice(0,5).join(' | '));
+ await browser.close();
+})();
diff --git a/frontend/_dbg.cjs b/frontend/_dbg.cjs
new file mode 100644
index 0000000..872378c
--- /dev/null
+++ b/frontend/_dbg.cjs
@@ -0,0 +1,22 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage();
+ const errs = [];
+ page.on('console', m => errs.push(m.type()+': '+m.text()));
+ page.on('pageerror', e => errs.push('PAGEERR: '+e.message));
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).data?.access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(3000);
+ console.log('URL:', page.url());
+ console.log('has tpl-btn:', await page.locator('.tpl-btn').count());
+ console.log('body text (first 200):', (await page.locator('body').innerText()).slice(0,200));
+ console.log('errors:', errs.slice(0,8));
+ await page.screenshot({ path: '../audit_shots/_debug_page.png' });
+ await browser.close();
+})();
diff --git a/frontend/_debug_prob.cjs b/frontend/_debug_prob.cjs
new file mode 100644
index 0000000..8aecd3c
--- /dev/null
+++ b/frontend/_debug_prob.cjs
@@ -0,0 +1,29 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage();
+ page.on('pageerror', e => console.log('PAGEERROR:', e.message, '\nSTACK:', e.stack));
+ page.on('console', m => { if (m.type()==='error') console.log('CONSOLE_ERR:', m.text()); });
+ // Load the module and render probability template
+ await page.goto('http://127.0.0.1:5175/animation');
+ // we need the template render output; use the gallery instead but isolate
+ await page.waitForTimeout(1500);
+ // inject token
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).data?.access_token;
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(2000);
+ // open gallery and jump directly to probability card
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1200);
+ // search for probability
+ await page.locator('.tg-search input').fill('概率');
+ await page.waitForTimeout(600);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(2000);
+ console.log('done');
+ await browser.close();
+})();
diff --git a/frontend/_diag.mjs b/frontend/_diag.mjs
new file mode 100644
index 0000000..272dac9
--- /dev/null
+++ b/frontend/_diag.mjs
@@ -0,0 +1,46 @@
+import { chromium } from 'playwright-core';
+import fs from 'fs';
+import path from 'path';
+
+const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
+});
+const loginData = await loginRes.json();
+const browser = await chromium.launch({
+ channel: 'chrome', headless: true,
+ executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+});
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+await ctx.addInitScript(([at, rt]) => {
+ localStorage.setItem('access_token', at);
+ localStorage.setItem('refresh_token', rt);
+}, [loginData.access_token, loginData.refresh_token]);
+const page = await ctx.newPage();
+const errors = [];
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+// Go to home and wait longer
+await page.goto('http://127.0.0.1:5175/home', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
+await page.waitForTimeout(4000);
+
+// Diagnose
+const info = await page.evaluate(() => {
+ const body = document.body;
+ return {
+ bodyTextLen: body.innerText.length,
+ bodyTextHead: body.innerText.substring(0, 300),
+ url: location.href,
+ hasH1: !!document.querySelector('h1'),
+ h1Text: document.querySelector('h1')?.textContent || '(none)',
+ mainContent: document.querySelector('.home-page, .home-container, .hero, main, #app')?.innerHTML?.length || 0,
+ childCount: document.querySelector('#app')?.children?.length || 0,
+ };
+});
+console.log(JSON.stringify(info, null, 2));
+await page.screenshot({ path: path.resolve('audit_shots/home2.png') });
+console.log('ERRORS:', errors.length);
+errors.slice(0,8).forEach(e => console.log(' ', e.substring(0,160)));
+await browser.close();
diff --git a/frontend/_diag2.mjs b/frontend/_diag2.mjs
new file mode 100644
index 0000000..fa8c537
--- /dev/null
+++ b/frontend/_diag2.mjs
@@ -0,0 +1,15 @@
+import { chromium } from 'playwright-core';
+import fs from 'fs';
+import path from 'path';
+const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
+const loginData = await loginRes.json();
+const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+await ctx.addInitScript(([at, rt]) => { localStorage.setItem('access_token', at); localStorage.setItem('refresh_token', rt); }, [loginData.access_token, loginData.refresh_token]);
+const page = await ctx.newPage();
+await page.goto('http://127.0.0.1:5175/', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
+await page.waitForTimeout(4000);
+const info = await page.evaluate(() => ({ bodyTextLen: document.body.innerText.length, bodyTextHead: document.body.innerText.substring(0, 200), url: location.href, h1Text: document.querySelector('h1')?.textContent || '(none)', childCount: document.querySelector('#app')?.children?.length || 0 }));
+console.log(JSON.stringify(info, null, 2));
+await page.screenshot({ path: path.resolve('audit_shots/home_root.png') });
+await browser.close();
diff --git a/frontend/_final_tpl.cjs b/frontend/_final_tpl.cjs
new file mode 100644
index 0000000..a4a7340
--- /dev/null
+++ b/frontend/_final_tpl.cjs
@@ -0,0 +1,43 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errors = [], warnings = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('console', m => { if (m.type()==='error') errors.push(m.text()); if (m.type()==='warning' && m.text().includes('resolve component')) warnings.push(m.text()); });
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const j = await r.json();
+ const tok = j.access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(2500);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+ const cards = await page.locator('.tg-card').count();
+ const ours = ['勾股定理','单位圆','排序算法','DNA','牛顿第二','板块构造'];
+ const results = [];
+ for (let i = 0; i < cards; i++) {
+ await page.locator('.tg-card').nth(i).click();
+ await page.waitForTimeout(800);
+ const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
+ if (ours.some(k => name.includes(k))) {
+ const frame = page.frameLocator('.tg-preview-wrap iframe').first();
+ let ok = false, iconOk = false;
+ try { ok = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
+ // check the card icon rendered (svg present)
+ try { iconOk = await page.locator('.tg-card').nth(i).locator('svg').count() > 0; } catch(e) {}
+ results.push({ name, ok, iconOk });
+ }
+ }
+ await page.screenshot({ path: '../audit_shots/_tpl_final2.png' });
+ console.log('TOTAL_CARDS:', cards);
+ console.log('CONSOLE_ERRORS:', errors.length, 'ICON_WARNINGS:', warnings.length);
+ if (errors.length) console.log('ERROR_SAMPLES:', errors.slice(0,5));
+ if (warnings.length) console.log('WARN_SAMPLES:', warnings.slice(0,3));
+ console.log('NEW_TEMPLATES:', JSON.stringify(results));
+ console.log('ALL_NEW_OK:', results.length === 6 && results.every(r => r.ok && r.iconOk));
+ await browser.close();
+})();
diff --git a/frontend/_find_bad_games.cjs b/frontend/_find_bad_games.cjs
new file mode 100644
index 0000000..476b6a3
--- /dev/null
+++ b/frontend/_find_bad_games.cjs
@@ -0,0 +1,30 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/exercise');
+ await page.waitForTimeout(2500);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+ const cards = await page.locator('.tg-card').count();
+ const ours = ['知识分类','序列记忆','成语接龙','定时炸弹','入门数独'];
+ const bad = [];
+ for (let i = 0; i < cards; i++) {
+ const errs = [];
+ const handler = e => errs.push(e.message);
+ page.on('pageerror', handler);
+ await page.locator('.tg-card').nth(i).click();
+ await page.waitForTimeout(1000);
+ const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
+ page.off('pageerror', handler);
+ if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
+ }
+ console.log('BAD_GAMES:', JSON.stringify(bad, null, 2));
+ await browser.close();
+})();
diff --git a/frontend/_find_bad_tpl.cjs b/frontend/_find_bad_tpl.cjs
new file mode 100644
index 0000000..0e30dd2
--- /dev/null
+++ b/frontend/_find_bad_tpl.cjs
@@ -0,0 +1,32 @@
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const base = 'http://127.0.0.1:8010';
+ const loginRes = await fetch(base + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
+ const loginData = await loginRes.json();
+ const token = loginData.data?.access_token || loginData.access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', 'r'); }, token);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(1800);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1200);
+ const cards = await page.locator('.tg-card').count();
+ const bad = [];
+ for (let i = 0; i < cards; i++) {
+ let beforeErr = 0;
+ const errs = [];
+ const handler = e => errs.push(e.message);
+ page.on('pageerror', handler);
+ await page.locator('.tg-card').nth(i).click();
+ await page.waitForTimeout(700);
+ const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
+ page.off('pageerror', handler);
+ if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
+ }
+ console.log('BAD_TEMPLATES:', JSON.stringify(bad, null, 2));
+ await browser.close();
+})();
diff --git a/frontend/_iso_prob.cjs b/frontend/_iso_prob.cjs
new file mode 100644
index 0000000..b2c7651
--- /dev/null
+++ b/frontend/_iso_prob.cjs
@@ -0,0 +1,36 @@
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const src = fs.readFileSync('src/utils/animationTemplates.ts','utf8');
+ const start = src.indexOf('const probability');
+ const end = src.indexOf('// ===== 21', start);
+ const block = src.slice(start, end);
+ const m = block.match(/return SHELL\('概率模拟',\s*'',\s*`([\s\S]*?)`\s*\);\s*\}/);
+ if (!m) { console.log('no match'); process.exit(1); }
+ let script = m[1];
+ script = script.split('${typ}').join('coin');
+ script = script.split('${per}').join('20');
+ const html = '
';
+ fs.writeFileSync('../audit_shots/_prob_isolated.html', html);
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage();
+ page.on('pageerror', e => console.log('PAGEERROR:', e.message));
+ page.on('console', msg => { if(msg.type()==='error') console.log('CONSOLE:', msg.text()); });
+ await page.goto('file:///D:/AI/jiaoyu/audit_shots/_prob_isolated.html');
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: '../audit_shots/_prob_isolated.png' });
+ console.log('isolated test done');
+ await browser.close();
+})();
diff --git a/frontend/_race_test.cjs b/frontend/_race_test.cjs
new file mode 100644
index 0000000..cd646f5
--- /dev/null
+++ b/frontend/_race_test.cjs
@@ -0,0 +1,43 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(2500);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+ // Click a setInterval template (solar-system), wait, then click probability
+ await page.locator('.tg-search input').fill('行星');
+ await page.waitForTimeout(800);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(3000); // let its interval run
+ errors.length = 0; // reset
+ await page.locator('.tg-search input').fill('概率');
+ await page.waitForTimeout(800);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(2500);
+ console.log('ERRORS_AFTER_SWITCH:', errors.length);
+ if (errors.length) console.log('ERRORS:', errors.slice(0,5));
+ // also test: DNA (mine, uses setInterval) then probability
+ errors.length = 0;
+ await page.locator('.tg-search input').fill('DNA');
+ await page.waitForTimeout(800);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(3000);
+ await page.locator('.tg-search input').fill('概率');
+ await page.waitForTimeout(800);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(2500);
+ console.log('ERRORS_AFTER_DNA_TO_PROB:', errors.length);
+ if (errors.length) console.log('ERRORS:', errors.slice(0,5));
+ await browser.close();
+})();
diff --git a/frontend/_screens.cjs b/frontend/_screens.cjs
new file mode 100644
index 0000000..fc88708
--- /dev/null
+++ b/frontend/_screens.cjs
@@ -0,0 +1,67 @@
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+const path = require('path');
+
+const SHOTS = 'D:/AI/jiaoyu/_shots';
+if (!fs.existsSync(SHOTS)) fs.mkdirSync(SHOTS, { recursive: true });
+
+(async () => {
+ const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ const page = await ctx.newPage();
+ const allErrors = [];
+ page.on('pageerror', e => allErrors.push('PAGE: ' + e.message));
+ page.on('console', m => { if (m.type() === 'error') { const t = m.text(); if (!t.includes('favicon') && !t.includes('404')) allErrors.push('CON: ' + t.slice(0,150)); } });
+
+ // Login
+ const loginResp = await page.request.post('http://localhost:8000/api/auth/login', {
+ data: { phone: '13943441149', password: 'Test1234' }
+ });
+ const { access_token } = await loginResp.json();
+
+ const pages = [
+ { name: 'home', url: 'http://localhost:5174/', wait: 2500 },
+ { name: 'courseware-create', url: 'http://localhost:5174/courseware/create', wait: 2000 },
+ { name: 'animation', url: 'http://localhost:5174/animation', wait: 2000 },
+ { name: 'exercise', url: 'http://localhost:5174/exercise', wait: 2000 },
+ { name: 'lesson-plan', url: 'http://localhost:5174/lesson-plan', wait: 2000 },
+ { name: 'exam', url: 'http://localhost:5174/exam', wait: 2000 },
+ { name: 'essay-grade', url: 'http://localhost:5174/essay-grade', wait: 2000 },
+ { name: 'mindmap', url: 'http://localhost:5174/mindmap', wait: 2000 },
+ { name: 'resources', url: 'http://localhost:5174/resources', wait: 2000 },
+ { name: 'community', url: 'http://localhost:5174/community', wait: 2000 },
+ { name: 'classroom', url: 'http://localhost:5174/classroom', wait: 2000 },
+ { name: 'materials', url: 'http://localhost:5174/materials', wait: 2000 },
+ { name: 'profile', url: 'http://localhost:5174/profile', wait: 2000 },
+ { name: 'search', url: 'http://localhost:5174/search?q=数学', wait: 2000 },
+ { name: 'assistant', url: 'http://localhost:5174/assistant', wait: 2000 },
+ ];
+
+ const results = [];
+ for (const p of pages) {
+ const errorsBefore = allErrors.length;
+ await page.goto(p.url, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
+ // Inject token on first navigation
+ if (p.name === 'home') {
+ await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', t); }, access_token);
+ await page.reload({ waitUntil: 'networkidle' });
+ }
+ await page.waitForTimeout(p.wait);
+ await page.screenshot({ path: path.join(SHOTS, p.name + '.png'), fullPage: false });
+ const bodyLen = await page.evaluate(() => document.body.innerText.length);
+ const h1 = await page.locator('h1, h2, .page-title, .tpl-title').first().textContent().catch(() => '');
+ const newErrors = allErrors.slice(errorsBefore);
+ results.push({ name: p.name, bodyLen, title: h1.trim().slice(0,40), errors: newErrors.length });
+ console.log('[' + p.name + '] bodyLen=' + bodyLen + ' title="' + h1.trim().slice(0,35) + '" errors=' + newErrors.length);
+ if (newErrors.length) newErrors.slice(0,2).forEach(e => console.log(' ' + e));
+ }
+
+ console.log('\n=== Summary ===');
+ console.log('Total pages:', results.length);
+ console.log('Total errors:', allErrors.length);
+ const small = results.filter(r => r.bodyLen < 200);
+ console.log('Pages with thin content (<200 chars):', small.length);
+ small.forEach(r => console.log(' ' + r.name + ': ' + r.bodyLen + ' chars'));
+
+ await browser.close();
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
\ No newline at end of file
diff --git a/frontend/_smoke.mjs b/frontend/_smoke.mjs
new file mode 100644
index 0000000..bc2a6fc
--- /dev/null
+++ b/frontend/_smoke.mjs
@@ -0,0 +1,34 @@
+import { chromium } from 'playwright-core';
+const browser = await chromium.launch({
+ channel: 'chrome', headless: true,
+ executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+});
+const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+const errors = [];
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+// Login
+await page.goto('http://127.0.0.1:5175/login', { waitUntil: 'networkidle' });
+await page.fill('input[placeholder*="手机"], input[type="tel"]', '13943441149');
+await page.fill('input[type="password"]', 'Test1234');
+await page.click('button:has-text("登录")');
+await page.waitForURL(/\/(home|courseware|exercise)/, { timeout: 8000 }).catch(() => {});
+await page.waitForTimeout(1500);
+
+// Visit AI generation pages, check they render
+const pages = ['/exercise', '/exam', '/animation', '/lesson-plan', '/mindmap', '/essay-grade', '/courseware/create'];
+for (const p of pages) {
+ await page.goto('http://127.0.0.1:5175' + p, { waitUntil: 'networkidle' }).catch(() => {});
+ await page.waitForTimeout(800);
+ const h = await page.textContent('h1, h2').catch(() => '(none)');
+ console.log(p, '->', h?.trim()?.substring(0, 40));
+}
+
+// Check exercise loading tip text exists in DOM (even if hidden)
+const tipExists = await page.evaluate(() => document.body.innerHTML.includes('模型深度推理中'));
+console.log('TIP_TEXT_PRESENT:', tipExists);
+
+console.log('CONSOLE_ERRORS:', errors.length);
+errors.slice(0, 8).forEach(e => console.log(' ERR:', e.substring(0, 120)));
+await browser.close();
diff --git a/frontend/_test_idiom.cjs b/frontend/_test_idiom.cjs
new file mode 100644
index 0000000..e6f9ba1
--- /dev/null
+++ b/frontend/_test_idiom.cjs
@@ -0,0 +1,30 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errs = [];
+ page.on('pageerror', e => errs.push(e.message));
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/exercise');
+ await page.waitForTimeout(2500);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+ await page.locator('.tg-search input').fill('成语接龙');
+ await page.waitForTimeout(800);
+ await page.locator('.tg-card').first().click();
+ await page.waitForTimeout(2000);
+ // type and play
+ const frame = page.frameLocator('.tg-preview-wrap iframe').first();
+ await frame.locator('#inp').fill('一心一意');
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(2500);
+ const chainCount = await frame.locator('#list > div').count().catch(()=>0);
+ console.log('ERRORS:', errs.length, 'CHAIN_ITEMS:', chainCount);
+ if (errs.length) console.log('ERR:', errs.slice(0,3));
+ await browser.close();
+})();
diff --git a/frontend/_verify_404.cjs b/frontend/_verify_404.cjs
new file mode 100644
index 0000000..589a49e
--- /dev/null
+++ b/frontend/_verify_404.cjs
@@ -0,0 +1,55 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ const errs = [];
+ page.on('pageerror', e => errs.push(e.message));
+ page.on('console', m => { if (m.type()==='error') errs.push(m.text()); });
+
+ // 1) Verify SEO meta tags on a normal page
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.waitForTimeout(2000);
+ const desc = await page.locator('meta[name="description"]').getAttribute('content').catch(()=> '');
+ const ogTitle = await page.locator('meta[property="og:title"]').getAttribute('content').catch(()=> '');
+ const themeColor = await page.locator('meta[name="theme-color"]').getAttribute('content').catch(()=> '');
+ const noscript = await page.locator('noscript').count();
+ console.log('META description:', desc ? desc.slice(0,50)+'...' : 'MISSING');
+ console.log('META og:title:', ogTitle || 'MISSING');
+ console.log('META theme-color:', themeColor || 'MISSING');
+ console.log('noscript fallback:', noscript > 0 ? 'PRESENT' : 'MISSING');
+
+ // 2) Test shallow 404 (within AppLayout)
+ await page.goto('http://127.0.0.1:5175/nonexistent-page');
+ await page.waitForTimeout(1500);
+ const title404 = (await page.locator('.nf-title').textContent().catch(()=> '')) || '';
+ const code404 = (await page.locator('.nf-code').textContent().catch(()=> '')) || '';
+ console.log('\nSHALLOW 404 (/nonexistent-page):');
+ console.log(' nf-code:', code404.trim());
+ console.log(' nf-title:', title404.trim());
+ console.log(' has home btn:', await page.locator('.nf-btn.primary').count());
+
+ // 3) Test deep 404
+ await page.goto('http://127.0.0.1:5175/foo/bar/baz/deep');
+ await page.waitForTimeout(1500);
+ const deepTitle = (await page.locator('.nf-title').textContent().catch(()=> '')) || '';
+ console.log('\nDEEP 404 (/foo/bar/baz/deep):');
+ console.log(' nf-title:', deepTitle.trim());
+
+ // 4) Test that clicking "返回首页" works
+ await page.goto('http://127.0.0.1:5175/xyz');
+ await page.waitForTimeout(1500);
+ await page.locator('.nf-btn.primary').click();
+ await page.waitForTimeout(1500);
+ console.log('\nCLICK HOME -> url:', page.url());
+ console.log(' landed on home:', page.url().endsWith('/login') || page.url().endsWith('/5175/'));
+
+ // 5) screenshot
+ await page.goto('http://127.0.0.1:5175/nonexistent');
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: '../audit_shots/_404_page.png' });
+
+ console.log('\nERRORS:', errs.length);
+ if (errs.length) console.log('ERR_DETAIL:', [...new Set(errs)].slice(0,5));
+ await browser.close();
+})();
diff --git a/frontend/_verify_games.cjs b/frontend/_verify_games.cjs
new file mode 100644
index 0000000..f384424
--- /dev/null
+++ b/frontend/_verify_games.cjs
@@ -0,0 +1,38 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ await page.goto('http://127.0.0.1:5175/exercise');
+ await page.waitForTimeout(2500);
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+ const cards = await page.locator('.tg-card').count();
+ const ours = ['知识分类','序列记忆','成语接龙','定时炸弹','入门数独'];
+ const results = [];
+ for (let i = 0; i < cards; i++) {
+ await page.locator('.tg-card').nth(i).click();
+ await page.waitForTimeout(900);
+ const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
+ if (ours.some(k => name.includes(k))) {
+ const frame = page.frameLocator('.tg-preview-wrap iframe').first();
+ let ok = false;
+ try { ok = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
+ results.push({ name, ok });
+ }
+ }
+ console.log('TOTAL_GAME_CARDS:', cards);
+ console.log('NEW_GAMES:', JSON.stringify(results));
+ console.log('ALL_NEW_OK:', results.length === 5 && results.every(r => r.ok));
+ console.log('ERRORS_DURING_NEW:', errors.length);
+ await page.screenshot({ path: '../audit_shots/_games_final.png' });
+ await browser.close();
+})();
diff --git a/frontend/_verify_tpl.cjs b/frontend/_verify_tpl.cjs
new file mode 100644
index 0000000..7ea4fcf
--- /dev/null
+++ b/frontend/_verify_tpl.cjs
@@ -0,0 +1,56 @@
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ const base = 'http://127.0.0.1:8010';
+ const loginRes = await fetch(base + '/api/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
+ });
+ const loginData = await loginRes.json();
+ const token = loginData.data?.access_token || loginData.access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', 'r'); }, token);
+ await page.goto('http://127.0.0.1:5175/animation');
+ await page.waitForTimeout(2000);
+
+ // open gallery via the tpl-btn
+ await page.locator('.tpl-btn').first().click();
+ await page.waitForTimeout(1500);
+
+ const cards = await page.locator('.tg-card').count();
+ console.log('gallery cards:', cards);
+
+ const ours = ['勾股定理','单位圆','排序算法','DNA','牛顿第二','板块构造'];
+ const results = [];
+ const allCards = page.locator('.tg-card');
+ for (let i = 0; i < cards; i++) {
+ await allCards.nth(i).click();
+ await page.waitForTimeout(700);
+ const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
+ const isOurs = ours.some(k => name.includes(k));
+ if (isOurs) {
+ const beforeErr = errors.length;
+ const frame = page.frameLocator('.tg-preview-wrap iframe').first();
+ let stageOk = false;
+ try { stageOk = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
+ await page.waitForTimeout(300);
+ const newErr = errors.length - beforeErr;
+ results.push({ name, stageOk, newErr });
+ console.log(`[${name}] rendered=${stageOk} newErr=${newErr}`);
+ }
+ }
+
+ await page.screenshot({ path: '../audit_shots/_tpl_gallery.png' });
+ console.log('TOTAL_CONSOLE_ERRORS:', errors.length);
+ console.log('OURS_CHECKED:', results.length, 'ALL_RENDERED:', results.every(r => r.stageOk));
+ if (errors.length) console.log('ERR_SAMPLES:', errors.slice(0,5));
+ fs.writeFileSync('../audit_shots/_tpl_results.json', JSON.stringify({ totalCards: cards, errors: errors.slice(0,15), results }, null, 2));
+ await browser.close();
+})();
diff --git a/frontend/_walk.cjs b/frontend/_walk.cjs
new file mode 100644
index 0000000..af470fc
--- /dev/null
+++ b/frontend/_walk.cjs
@@ -0,0 +1,49 @@
+const { chromium } = require('playwright-core');
+const http = require('http');
+
+const BASE = 'http://127.0.0.1:8010';
+const UI = 'http://127.0.0.1:5175';
+
+function apiPost(path, body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = http.request(BASE + path, { method:'POST', headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)} }, res => {
+ let b=''; res.on('data',c=>b+=c); res.on('end',()=>resolve({status:res.statusCode, body:b}));
+ });
+ req.on('error', reject); req.write(data); req.end();
+ });
+}
+
+(async () => {
+ const login = await apiPost('/api/auth/login', {phone:'13943441149', password:'Test1234'});
+ const tok = JSON.parse(login.body).access_token;
+ console.log('token:', tok ? 'yes' : 'NO');
+
+ const browser = await chromium.launch({ executablePath:'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless:true });
+ const ctx = await browser.newContext({ viewport:{width:1440,height:900} });
+ const page = await ctx.newPage();
+
+ const errors = [];
+ page.on('console', m => { if (m.type()==='error') errors.push('CONSOLE: '+m.text().slice(0,150)); });
+ page.on('pageerror', e => errors.push('PAGEERR: '+e.message.slice(0,150)));
+
+ // inject token
+ await page.goto(UI + '/', { waitUntil:'networkidle' });
+ await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('token', t); }, tok);
+
+ const views = ['/', '/animation', '/exercise', '/mindmap', '/lesson-plan', '/exam', '/essay-grade', '/courseware/create', '/community', '/resources', '/classroom', '/assistant', '/profile', '/search'];
+ for (let i=0;inull);
+ const cnt = await page.locator('h1').count();
+ console.log(v, '| h1:', (h1||'').trim().slice(0,40), '| h1count:', cnt);
+ await page.screenshot({ path:'audit_shots/v'+i+'.png', fullPage:false });
+ } catch(e){ console.log(v, 'NAV-ERR', e.message.slice(0,100)); }
+ }
+ console.log('=== ERRORS ('+errors.length+') ===');
+ errors.slice(0,15).forEach(e=>console.log(e));
+ await browser.close();
+})();
diff --git a/frontend/_walk_views.cjs b/frontend/_walk_views.cjs
new file mode 100644
index 0000000..92d3912
--- /dev/null
+++ b/frontend/_walk_views.cjs
@@ -0,0 +1,39 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
+ const errs = [];
+ page.on('pageerror', e => errs.push(e.message));
+ page.on('console', m => { if (m.type()==='error' && !m.text().includes('401')) errs.push(m.text()); });
+ const base = 'http://127.0.0.1:8010';
+ const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
+ const tok = (await r.json()).access_token;
+ await page.goto('http://127.0.0.1:5175/login');
+ await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
+ const views = [
+ {p:'/', n:'Home'},
+ {p:'/courseware/create', n:'CoursewareCreate'},
+ {p:'/courseware', n:'CoursewareList'},
+ {p:'/materials', n:'Materials'},
+ {p:'/resources', n:'Resources'},
+ {p:'/classroom', n:'Classroom'},
+ {p:'/community', n:'Community'},
+ {p:'/admin', n:'Admin'},
+ {p:'/profile', n:'Profile'},
+ ];
+ const results = [];
+ for (const v of views) {
+ const before = errs.length;
+ await page.goto('http://127.0.0.1:5175' + v.p);
+ await page.waitForTimeout(1800);
+ const after = errs.length - before;
+ const h1 = (await page.locator('h1').first().textContent().catch(()=> '')) || '';
+ const empty = await page.locator('.el-empty').count().catch(()=>0);
+ results.push({ view: v.n, path: v.p, newErrs: after, h1: (h1||'').slice(0,40), emptyState: empty });
+ }
+ console.log(JSON.stringify(results, null, 1));
+ console.log('TOTAL_NEW_ERRORS:', errs.length);
+ if (errs.length) console.log('ERRORS:', [...new Set(errs)].slice(0,6));
+ await browser.close();
+})();
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c48547b
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+ 智教助手 - 教师专属AI备课工具
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
智教助手需要启用 JavaScript
+
请 在浏览器设置中开启 JavaScript 后刷新本页,以使用 AI 备课、课件生成等全部功能。
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..bdce902
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,21 @@
+server {
+ listen 80;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api/ {
+ proxy_pass http://backend:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_read_timeout 120s;
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml;
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..41ddb08
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,3874 @@
+{
+ "name": "ai-teaching-platform",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ai-teaching-platform",
+ "version": "1.0.0",
+ "dependencies": {
+ "@element-plus/icons-vue": "^2.3.1",
+ "@wangeditor/editor": "^5.1.23",
+ "@wangeditor/editor-for-vue": "^5.1.12",
+ "axios": "^1.7.9",
+ "echarts": "^5.5.1",
+ "element-plus": "^2.9.1",
+ "lottie-web": "^5.12.2",
+ "marked": "^18.0.4",
+ "pinia": "^2.3.0",
+ "vue": "^3.5.13",
+ "vue-echarts": "^7.0.3",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "sass": "^1.83.4",
+ "typescript": "^5.7.2",
+ "unplugin-auto-import": "^0.18.6",
+ "unplugin-vue-components": "^0.27.5",
+ "vite": "^6.0.7",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "node_modules/@antfu/utils": {
+ "version": "0.7.10",
+ "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz",
+ "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@ctrl/tinycolor": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+ "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@element-plus/icons-vue": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+ "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz",
+ "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.6",
+ "@parcel/watcher-darwin-arm64": "2.5.6",
+ "@parcel/watcher-darwin-x64": "2.5.6",
+ "@parcel/watcher-freebsd-x64": "2.5.6",
+ "@parcel/watcher-linux-arm-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm-musl": "2.5.6",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm64-musl": "2.5.6",
+ "@parcel/watcher-linux-x64-glibc": "2.5.6",
+ "@parcel/watcher-linux-x64-musl": "2.5.6",
+ "@parcel/watcher-win32-arm64": "2.5.6",
+ "@parcel/watcher-win32-ia32": "2.5.6",
+ "@parcel/watcher-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
+ "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
+ "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
+ "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
+ "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
+ "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
+ "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
+ "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "name": "@sxzz/popperjs-es",
+ "version": "2.11.8",
+ "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+ "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@transloadit/prettier-bytes": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
+ "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/event-emitter": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz",
+ "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "license": "MIT"
+ },
+ "node_modules/@uppy/companion-client": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz",
+ "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/utils": "^4.1.2",
+ "namespace-emitter": "^2.0.1"
+ }
+ },
+ "node_modules/@uppy/core": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
+ "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@transloadit/prettier-bytes": "0.0.7",
+ "@uppy/store-default": "^2.1.1",
+ "@uppy/utils": "^4.1.3",
+ "lodash.throttle": "^4.1.1",
+ "mime-match": "^1.0.2",
+ "namespace-emitter": "^2.0.1",
+ "nanoid": "^3.1.25",
+ "preact": "^10.5.13"
+ }
+ },
+ "node_modules/@uppy/store-default": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz",
+ "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
+ "license": "MIT"
+ },
+ "node_modules/@uppy/utils": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz",
+ "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.throttle": "^4.1.1"
+ }
+ },
+ "node_modules/@uppy/xhr-upload": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
+ "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/companion-client": "^2.2.2",
+ "@uppy/utils": "^4.1.2",
+ "nanoid": "^3.1.25"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^2.3.3"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.14",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz",
+ "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+ "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+ "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/runtime-core": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+ "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "vue": "3.5.34"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT"
+ },
+ "node_modules/@vueuse/core": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
+ "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.3.0",
+ "@vueuse/shared": "14.3.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
+ "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
+ "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@wangeditor/basic-modules": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
+ "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-url": "^1.2.4"
+ },
+ "peerDependencies": {
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "lodash.throttle": "^4.1.1",
+ "nanoid": "^3.2.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/code-highlight": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
+ "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
+ "license": "MIT",
+ "dependencies": {
+ "prismjs": "^1.23.0"
+ },
+ "peerDependencies": {
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/core": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz",
+ "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/event-emitter": "^0.3.3",
+ "event-emitter": "^0.3.5",
+ "html-void-elements": "^2.0.0",
+ "i18next": "^20.4.0",
+ "scroll-into-view-if-needed": "^2.2.28",
+ "slate-history": "^0.66.0"
+ },
+ "peerDependencies": {
+ "@uppy/core": "^2.1.1",
+ "@uppy/xhr-upload": "^2.0.3",
+ "dom7": "^3.0.0",
+ "is-hotkey": "^0.2.0",
+ "lodash.camelcase": "^4.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.debounce": "^4.0.8",
+ "lodash.foreach": "^4.5.0",
+ "lodash.isequal": "^4.5.0",
+ "lodash.throttle": "^4.1.1",
+ "lodash.toarray": "^4.4.0",
+ "nanoid": "^3.2.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/editor": {
+ "version": "5.1.23",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz",
+ "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@uppy/core": "^2.1.1",
+ "@uppy/xhr-upload": "^2.0.3",
+ "@wangeditor/basic-modules": "^1.1.7",
+ "@wangeditor/code-highlight": "^1.0.3",
+ "@wangeditor/core": "^1.1.19",
+ "@wangeditor/list-module": "^1.0.5",
+ "@wangeditor/table-module": "^1.1.4",
+ "@wangeditor/upload-image-module": "^1.0.2",
+ "@wangeditor/video-module": "^1.1.4",
+ "dom7": "^3.0.0",
+ "is-hotkey": "^0.2.0",
+ "lodash.camelcase": "^4.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.debounce": "^4.0.8",
+ "lodash.foreach": "^4.5.0",
+ "lodash.isequal": "^4.5.0",
+ "lodash.throttle": "^4.1.1",
+ "lodash.toarray": "^4.4.0",
+ "nanoid": "^3.2.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/editor-for-vue": {
+ "version": "5.1.12",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
+ "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@wangeditor/editor": ">=5.1.0",
+ "vue": "^3.0.5"
+ }
+ },
+ "node_modules/@wangeditor/list-module": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz",
+ "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/table-module": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz",
+ "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "lodash.isequal": "^4.5.0",
+ "lodash.throttle": "^4.1.1",
+ "nanoid": "^3.2.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/upload-image-module": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
+ "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@uppy/core": "^2.0.3",
+ "@uppy/xhr-upload": "^2.0.3",
+ "@wangeditor/basic-modules": "1.x",
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "lodash.foreach": "^4.5.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/@wangeditor/video-module": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz",
+ "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@uppy/core": "^2.1.4",
+ "@uppy/xhr-upload": "^2.0.7",
+ "@wangeditor/core": "1.x",
+ "dom7": "^3.0.0",
+ "nanoid": "^3.2.0",
+ "slate": "^0.72.0",
+ "snabbdom": "^3.1.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/compute-scroll-into-view": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+ "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+ "license": "MIT"
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz",
+ "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+ "license": "ISC",
+ "dependencies": {
+ "es5-ext": "^0.10.64",
+ "type": "^2.7.2"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.20",
+ "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
+ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom7": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
+ "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
+ "license": "MIT",
+ "dependencies": {
+ "ssr-window": "^3.0.0-alpha.1"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/echarts": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
+ "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "5.6.1"
+ }
+ },
+ "node_modules/element-plus": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz",
+ "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@ctrl/tinycolor": "^4.2.0",
+ "@element-plus/icons-vue": "^2.3.2",
+ "@floating-ui/dom": "^1.0.1",
+ "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+ "@types/lodash": "^4.17.24",
+ "@types/lodash-es": "^4.17.12",
+ "@vueuse/core": "14.3.0",
+ "async-validator": "^4.2.5",
+ "dayjs": "^1.11.20",
+ "lodash": "^4.18.1",
+ "lodash-es": "^4.18.1",
+ "lodash-unified": "^1.0.3",
+ "memoize-one": "^6.0.0",
+ "normalize-wheel-es": "^1.2.0",
+ "vue-component-type-helpers": "^3.2.8"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.7"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es5-ext": {
+ "version": "0.10.64",
+ "resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz",
+ "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+ "hasInstallScript": true,
+ "license": "ISC",
+ "dependencies": {
+ "es6-iterator": "^2.0.3",
+ "es6-symbol": "^3.1.3",
+ "esniff": "^2.0.1",
+ "next-tick": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/es6-iterator": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz",
+ "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+ "license": "MIT",
+ "dependencies": {
+ "d": "1",
+ "es5-ext": "^0.10.35",
+ "es6-symbol": "^3.1.1"
+ }
+ },
+ "node_modules/es6-symbol": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz",
+ "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+ "license": "ISC",
+ "dependencies": {
+ "d": "^1.0.2",
+ "ext": "^1.7.0"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/esniff": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz",
+ "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+ "license": "ISC",
+ "dependencies": {
+ "d": "^1.0.1",
+ "es5-ext": "^0.10.62",
+ "event-emitter": "^0.3.5",
+ "type": "^2.7.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/event-emitter": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz",
+ "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+ "license": "MIT",
+ "dependencies": {
+ "d": "1",
+ "es5-ext": "~0.10.14"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ext": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz",
+ "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+ "license": "ISC",
+ "dependencies": {
+ "type": "^2.7.2"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz",
+ "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "20.6.1",
+ "resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz",
+ "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.0"
+ }
+ },
+ "node_modules/immer": {
+ "version": "9.0.21",
+ "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
+ "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz",
+ "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hotkey": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
+ "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
+ "license": "MIT"
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-url": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
+ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-unified": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+ "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/lodash-es": "*",
+ "lodash": "*",
+ "lodash-es": "*"
+ }
+ },
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.foreach": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
+ "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
+ "node_modules/lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.toarray": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+ "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
+ "license": "MIT"
+ },
+ "node_modules/lottie-web": {
+ "version": "5.13.0",
+ "resolved": "https://registry.npmmirror.com/lottie-web/-/lottie-web-5.13.0.tgz",
+ "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/marked": {
+ "version": "18.0.4",
+ "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.4.tgz",
+ "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz",
+ "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
+ "license": "ISC",
+ "dependencies": {
+ "wildcard": "^1.1.0"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/namespace-emitter": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
+ "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next-tick": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz",
+ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
+ "license": "ISC"
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-wheel-es": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+ "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.29.2",
+ "resolved": "https://registry.npmmirror.com/preact/-/preact-10.29.2.tgz",
+ "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz",
+ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/quansync": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
+ "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
+ "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/sass": {
+ "version": "1.100.0",
+ "resolved": "https://registry.npmmirror.com/sass/-/sass-1.100.0.tgz",
+ "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^5.0.0",
+ "immutable": "^5.1.5",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
+ },
+ "node_modules/scroll-into-view-if-needed": {
+ "version": "2.2.31",
+ "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+ "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+ "license": "MIT",
+ "dependencies": {
+ "compute-scroll-into-view": "^1.0.20"
+ }
+ },
+ "node_modules/scule": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
+ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/slate": {
+ "version": "0.72.8",
+ "resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
+ "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
+ "license": "MIT",
+ "dependencies": {
+ "immer": "^9.0.6",
+ "is-plain-object": "^5.0.0",
+ "tiny-warning": "^1.0.3"
+ }
+ },
+ "node_modules/slate-history": {
+ "version": "0.66.0",
+ "resolved": "https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz",
+ "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-object": "^5.0.0"
+ },
+ "peerDependencies": {
+ "slate": ">=0.65.3"
+ }
+ },
+ "node_modules/snabbdom": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz",
+ "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.17.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ssr-window": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
+ "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
+ "node_modules/type": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
+ "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
+ "license": "ISC"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unimport": {
+ "version": "3.14.6",
+ "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
+ "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.4",
+ "acorn": "^8.14.0",
+ "escape-string-regexp": "^5.0.0",
+ "estree-walker": "^3.0.3",
+ "fast-glob": "^3.3.3",
+ "local-pkg": "^1.0.0",
+ "magic-string": "^0.30.17",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1",
+ "picomatch": "^4.0.2",
+ "pkg-types": "^1.3.0",
+ "scule": "^1.3.0",
+ "strip-literal": "^2.1.1",
+ "unplugin": "^1.16.1"
+ }
+ },
+ "node_modules/unimport/node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unimport/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/unimport/node_modules/local-pkg": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
+ "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.4",
+ "pkg-types": "^2.3.0",
+ "quansync": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/unplugin": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz",
+ "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/unplugin-auto-import": {
+ "version": "0.18.6",
+ "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.18.6.tgz",
+ "integrity": "sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/utils": "^0.7.10",
+ "@rollup/pluginutils": "^5.1.3",
+ "fast-glob": "^3.3.2",
+ "local-pkg": "^0.5.1",
+ "magic-string": "^0.30.14",
+ "minimatch": "^9.0.5",
+ "unimport": "^3.13.4",
+ "unplugin": "^1.16.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": "^3.2.2",
+ "@vueuse/core": "*"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "@vueuse/core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/unplugin-vue-components": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.27.5.tgz",
+ "integrity": "sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/utils": "^0.7.10",
+ "@rollup/pluginutils": "^5.1.3",
+ "chokidar": "^3.6.0",
+ "debug": "^4.3.7",
+ "fast-glob": "^3.3.2",
+ "local-pkg": "^0.5.1",
+ "magic-string": "^0.30.14",
+ "minimatch": "^9.0.5",
+ "mlly": "^1.7.3",
+ "unplugin": "^1.16.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@babel/parser": "^7.15.8",
+ "@nuxt/kit": "^3.2.2",
+ "vue": "2 || 3"
+ },
+ "peerDependenciesMeta": {
+ "@babel/parser": {
+ "optional": true
+ },
+ "@nuxt/kit": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/unplugin-vue-components/node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/unplugin-vue-components/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/unplugin-vue-components/node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz",
+ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-sfc": "3.5.34",
+ "@vue/runtime-dom": "3.5.34",
+ "@vue/server-renderer": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-component-type-helpers": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.1.tgz",
+ "integrity": "sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==",
+ "license": "MIT"
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-echarts": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-7.0.3.tgz",
+ "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==",
+ "license": "MIT",
+ "dependencies": {
+ "vue-demi": "^0.13.11"
+ },
+ "peerDependencies": {
+ "@vue/runtime-core": "^3.0.0",
+ "echarts": "^5.5.1",
+ "vue": "^2.7.0 || ^3.1.1"
+ },
+ "peerDependenciesMeta": {
+ "@vue/runtime-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-echarts/node_modules/vue-demi": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.13.11.tgz",
+ "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wildcard": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
+ "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
+ "license": "MIT"
+ },
+ "node_modules/zrender": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
+ "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..beb75a1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "ai-teaching-platform",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@element-plus/icons-vue": "^2.3.1",
+ "@wangeditor/editor": "^5.1.23",
+ "@wangeditor/editor-for-vue": "^5.1.12",
+ "axios": "^1.7.9",
+ "echarts": "^5.5.1",
+ "element-plus": "^2.9.1",
+ "lottie-web": "^5.12.2",
+ "marked": "^18.0.4",
+ "pinia": "^2.3.0",
+ "vue": "^3.5.13",
+ "vue-echarts": "^7.0.3",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "sass": "^1.83.4",
+ "typescript": "^5.7.2",
+ "unplugin-auto-import": "^0.18.6",
+ "unplugin-vue-components": "^0.27.5",
+ "vite": "^6.0.7",
+ "vue-tsc": "^2.2.0"
+ }
+}
diff --git a/frontend/public/debug.html b/frontend/public/debug.html
new file mode 100644
index 0000000..9eea042
--- /dev/null
+++ b/frontend/public/debug.html
@@ -0,0 +1,107 @@
+
+
+
+
+API调试工具
+
+
+
+AI课件生成 - 调试工具
+1. 登录获取Token
+2. 调用AI生成课件
+
+
+
+
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..beb2ecb
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ AI
+
diff --git a/frontend/public/test-content.html b/frontend/public/test-content.html
new file mode 100644
index 0000000..9f4515c
--- /dev/null
+++ b/frontend/public/test-content.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ 初一数学
+
+
一元一次方程
+
+
从生活问题到代数思维的桥梁
+
+
+
点击下方箭头开始学习 →
+
\ No newline at end of file
diff --git a/frontend/public/test-iframe.html b/frontend/public/test-iframe.html
new file mode 100644
index 0000000..77737de
--- /dev/null
+++ b/frontend/public/test-iframe.html
@@ -0,0 +1,51 @@
+
+
+Test
+
+Test: iframe srcdoc rendering
+
+ Load Content into iframe
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/test-js-iframe.html b/frontend/public/test-js-iframe.html
new file mode 100644
index 0000000..96d1505
--- /dev/null
+++ b/frontend/public/test-js-iframe.html
@@ -0,0 +1,46 @@
+
+Vue Iframe Test
+
+Test: Does srcdoc work?
+
+
+
\ No newline at end of file
diff --git a/frontend/public/test-render.html b/frontend/public/test-render.html
new file mode 100644
index 0000000..afa316f
--- /dev/null
+++ b/frontend/public/test-render.html
@@ -0,0 +1,40 @@
+
+Iframe Test
+
+Iframe srcdoc Test
+Below should show the courseware page:
+
+
+
+
\ No newline at end of file
diff --git a/frontend/shot.cjs b/frontend/shot.cjs
new file mode 100644
index 0000000..4ca7605
--- /dev/null
+++ b/frontend/shot.cjs
@@ -0,0 +1,17 @@
+const { chromium } = require('playwright');
+(async () => {
+ const tok = require('fs').readFileSync('D:/AI/jiaoyu/_tok.txt','utf8').trim();
+ const browser = await chromium.launch({ channel: 'chrome' });
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ const page = await ctx.newPage();
+ await page.goto('http://localhost:5173/login', { waitUntil: 'domcontentloaded' });
+ await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', t); }, tok);
+ await page.goto('http://localhost:5173/assistant', { waitUntil: 'networkidle' });
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: 'D:/AI/jiaoyu/_assistant_empty.png' });
+ await page.fill('textarea', '帮我设计一节小学三年级分数初步认识的导入活动');
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: 'D:/AI/jiaoyu/_assistant_compose.png' });
+ await browser.close();
+ console.log('OK');
+})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..cd9578a
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts
new file mode 100644
index 0000000..fa54575
--- /dev/null
+++ b/frontend/src/api/admin.ts
@@ -0,0 +1,22 @@
+import request from '@/utils/request'
+
+export const getAdminDashboard = () =>
+ request.get('/api/admin/dashboard') as unknown as Promise
+
+export const getAdminUsers = (params?: any) =>
+ request.get('/api/admin/users', { params }) as unknown as Promise
+
+export const updateAdminUser = (id: number, data: any) =>
+ request.put(`/api/admin/users/${id}`, data) as unknown as Promise
+
+export const grantAdminCredits = (id: number, amount: number) =>
+ request.post(`/api/admin/users/${id}/grant-credits`, { amount }) as unknown as Promise
+
+export const getAdminResources = (params?: any) =>
+ request.get('/api/admin/resources', { params }) as unknown as Promise
+
+export const moderateResource = (id: number, status: string) =>
+ request.put(`/api/admin/resources/${id}/moderate?status=${status}`) as unknown as Promise
+
+export const getAdminAuditLogs = (params?: any) =>
+ request.get('/api/admin/audit-logs', { params }) as unknown as Promise
diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts
new file mode 100644
index 0000000..4a6346e
--- /dev/null
+++ b/frontend/src/api/ai.ts
@@ -0,0 +1,21 @@
+import request from '@/utils/request'
+
+export const gradeEssay = (data: { essay_text: string; grade_level?: string; essay_type?: string; total_score?: number }) =>
+ request.post('/api/ai/essay-grade', data)
+
+export const generateExam = (data: { subject: string; grade?: string; knowledge_points?: string[]; difficulty?: string; question_types?: string[]; count?: number; total_score?: number }) =>
+ request.post('/api/ai/exam-generate', data)
+
+export const parseMaterial = (file: File) => {
+ const formData = new FormData()
+ formData.append('file', file)
+ return request.post('/api/ai/materials/parse', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ }) as unknown as Promise<{ success: boolean; data: { material_id: number; filename: string; title: string; material_type: string; size: number; summary: string; char_count: number } }>
+}
+
+export const exportHtml = (data: { title: string; html: string }): Promise =>
+ request.post('/api/ai/export/html', data, { responseType: 'blob' }) as unknown as Promise
+
+export const exportExamDocx = (data: { title: string; subject?: string; grade?: string; questions: any[]; answers?: any[] }): Promise =>
+ request.post('/api/ai/export/exam-docx', data, { responseType: 'blob' }) as unknown as Promise
diff --git a/frontend/src/api/animation.ts b/frontend/src/api/animation.ts
new file mode 100644
index 0000000..b34f059
--- /dev/null
+++ b/frontend/src/api/animation.ts
@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+export const getAnimations = (params?: any) =>
+ request.get('/api/animations/', { params })
+
+export const getAnimation = (id: number) =>
+ request.get(`/api/animations/${id}`)
+
+export const createAnimation = (data: any) =>
+ request.post('/api/animations/', data)
+
+export const updateAnimation = (id: number, data: any) =>
+ request.put(`/api/animations/${id}`, data)
+
+export const remixAnimation = (id: number) =>
+ request.post(`/api/animations/${id}/remix`)
+
+export const aiGenerateAnimation = (data: { prompt: string; anim_type?: string; subject?: string }) =>
+ request.post('/api/animations/ai-generate', data, {
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ })
+
+export const deleteAnimation = (id: number) =>
+ request.delete(`/api/animations/${id}`)
+
+export const exportAnimationHtml = (id: number): Promise =>
+ request.get(`/api/animations/${id}/export-html`, { responseType: 'blob' }) as unknown as Promise
diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts
new file mode 100644
index 0000000..36c4077
--- /dev/null
+++ b/frontend/src/api/chat.ts
@@ -0,0 +1,44 @@
+import request from '@/utils/request'
+
+export interface ChatMessage {
+ id?: number
+ role: 'user' | 'assistant' | 'system'
+ content: string
+ created_at?: string
+}
+
+export interface ChatConversation {
+ id: number
+ title: string
+ subject?: string
+ grade?: string
+ pinned?: boolean
+ created_at?: string
+ updated_at?: string
+ messages: ChatMessage[]
+}
+
+export interface SendMessageResult {
+ success: boolean
+ data: ChatMessage
+ title?: string
+ credits?: { balance: number; cost: number; action: string }
+}
+
+export const listConversations = (params?: { keyword?: string }) =>
+ request.get('/api/chat/conversations', { params }) as unknown as Promise
+
+export const createConversation = (data: { title?: string; subject?: string; grade?: string }) =>
+ request.post('/api/chat/conversations', data) as unknown as Promise
+
+export const getConversation = (id: number) =>
+ request.get(`/api/chat/conversations/${id}`) as unknown as Promise
+
+export const renameConversation = (id: number, data: { title: string; pinned?: boolean }) =>
+ request.put(`/api/chat/conversations/${id}`, data) as unknown as Promise
+
+export const deleteConversation = (id: number) =>
+ request.delete(`/api/chat/conversations/${id}`) as unknown as Promise
+
+export const sendMessage = (id: number, data: { content: string; subject?: string; grade?: string }) =>
+ request.post(`/api/chat/conversations/${id}/messages`, data) as unknown as Promise
diff --git a/frontend/src/api/classroom.ts b/frontend/src/api/classroom.ts
new file mode 100644
index 0000000..29572cc
--- /dev/null
+++ b/frontend/src/api/classroom.ts
@@ -0,0 +1,28 @@
+import request from '@/utils/request'
+
+export const getClassroomActivities = (params?: any) =>
+ request.get('/api/classroom/activities', { params }) as unknown as Promise
+
+export const getClassroomAnalysis = (id: number) =>
+ request.get(`/api/classroom/activities/${id}/analysis`) as unknown as Promise
+
+export const exportClassroomAnalysis = (id: number): Promise =>
+ request.get(`/api/classroom/activities/${id}/analysis/export`, { responseType: 'blob' }) as unknown as Promise
+
+export const exportClassroomResponses = (id: number): Promise =>
+ request.get(`/api/classroom/activities/${id}/responses/export`, { responseType: 'blob' }) as unknown as Promise
+
+export const getPublicClassroomActivity = (id: number) =>
+ request.get(`/api/classroom/public/activities/${id}`) as unknown as Promise
+
+export const createClassroomActivity = (data: any) =>
+ request.post('/api/classroom/activities', data) as unknown as Promise
+
+export const updateClassroomActivity = (id: number, data: any) =>
+ request.put(`/api/classroom/activities/${id}`, data) as unknown as Promise
+
+export const submitClassroomResponse = (id: number, data: any) =>
+ request.post(`/api/classroom/activities/${id}/responses`, data) as unknown as Promise
+
+export const deleteClassroomActivity = (id: number) =>
+ request.delete(`/api/classroom/activities/${id}`)
diff --git a/frontend/src/api/community.ts b/frontend/src/api/community.ts
new file mode 100644
index 0000000..f499bea
--- /dev/null
+++ b/frontend/src/api/community.ts
@@ -0,0 +1,37 @@
+import request from '@/utils/request'
+
+export const getPosts = (params?: any) =>
+ request.get('/api/community/posts', { params })
+
+export const getPostRanking = (params?: any) =>
+ request.get('/api/community/posts/ranking', { params })
+
+export const createPost = (data: any) =>
+ request.post('/api/community/posts', data)
+
+export const getPost = (id: number) =>
+ request.get(`/api/community/posts/${id}`)
+
+export const updatePost = (id: number, data: any) =>
+ request.put(`/api/community/posts/${id}`, data)
+
+export const deletePost = (id: number) =>
+ request.delete(`/api/community/posts/${id}`)
+
+export const likePost = (id: number) =>
+ request.post(`/api/community/posts/${id}/like`)
+
+export const unlikePost = (id: number) =>
+ request.delete(`/api/community/posts/${id}/like`)
+
+export const getComments = (postId: number) =>
+ request.get(`/api/community/posts/${postId}/comments`)
+
+export const createComment = (postId: number, data: { content: string; parent_id?: number }) =>
+ request.post(`/api/community/posts/${postId}/comments`, data)
+
+export const deleteComment = (postId: number, commentId: number) =>
+ request.delete(`/api/community/posts/${postId}/comments/${commentId}`)
+
+export const getFavoritePosts = (params?: any) =>
+ request.get('/api/community/posts', { params: { ...(params || {}), scope: 'favorite' } })
diff --git a/frontend/src/api/courseware.ts b/frontend/src/api/courseware.ts
new file mode 100644
index 0000000..a97f51a
--- /dev/null
+++ b/frontend/src/api/courseware.ts
@@ -0,0 +1,37 @@
+import request from '@/utils/request'
+
+export const getCoursewares = (params?: any) =>
+ request.get('/api/coursewares/', { params })
+
+export const getCourseware = (id: number) =>
+ request.get(`/api/coursewares/${id}`)
+
+export const createCourseware = (data: any) =>
+ request.post('/api/coursewares/', data)
+
+export const updateCourseware = (id: number, data: any) =>
+ request.put(`/api/coursewares/${id}`, data)
+
+export const remixCourseware = (id: number) =>
+ request.post(`/api/coursewares/${id}/remix`)
+
+export const createCoursewareShare = (id: number) =>
+ request.post(`/api/coursewares/${id}/share`) as unknown as Promise
+
+export const getCoursewareShare = (token: string) =>
+ request.get(`/api/coursewares/shares/${token}`) as unknown as Promise
+
+export const getMyCoursewareShares = (params?: any) =>
+ request.get('/api/coursewares/shares/mine/list', { params }) as unknown as Promise
+
+export const revokeCoursewareShare = (id: number) =>
+ request.delete(`/api/coursewares/shares/mine/${id}`)
+
+export const deleteCourseware = (id: number) =>
+ request.delete(`/api/coursewares/${id}`)
+
+export const exportCoursewareDocx = (id: number): Promise =>
+ request.get(`/api/coursewares/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise
+
+export const aiGenerateCourseware = (data: { prompt: string; subject?: string; grade?: string; page_count?: number; aspect_ratio?: string }) =>
+ request.post('/api/coursewares/ai-generate', data)
diff --git a/frontend/src/api/essay.ts b/frontend/src/api/essay.ts
new file mode 100644
index 0000000..7aab3dc
--- /dev/null
+++ b/frontend/src/api/essay.ts
@@ -0,0 +1,13 @@
+import request from '@/utils/request'
+
+export const getEssayGrades = (params?: any) =>
+ request.get('/api/essay-grades/', { params })
+
+export const getEssayGrade = (id: number) =>
+ request.get(`/api/essay-grades/${id}`)
+
+export const createEssayGrade = (data: any) =>
+ request.post('/api/essay-grades/', data)
+
+export const deleteEssayGrade = (id: number) =>
+ request.delete(`/api/essay-grades/${id}`)
diff --git a/frontend/src/api/exam.ts b/frontend/src/api/exam.ts
new file mode 100644
index 0000000..519f895
--- /dev/null
+++ b/frontend/src/api/exam.ts
@@ -0,0 +1,22 @@
+import request from '@/utils/request'
+
+export const getExams = (params?: any) =>
+ request.get('/api/exams/', { params })
+
+export const getExam = (id: number) =>
+ request.get(`/api/exams/${id}`)
+
+export const createExam = (data: any) =>
+ request.post('/api/exams/', data)
+
+export const updateExam = (id: number, data: any) =>
+ request.put(`/api/exams/${id}`, data)
+
+export const remixExam = (id: number) =>
+ request.post(`/api/exams/${id}/remix`)
+
+export const deleteExam = (id: number) =>
+ request.delete(`/api/exams/${id}`)
+
+export const exportSavedExamDocx = (id: number): Promise =>
+ request.get(`/api/exams/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise
diff --git a/frontend/src/api/exercise.ts b/frontend/src/api/exercise.ts
new file mode 100644
index 0000000..78e0450
--- /dev/null
+++ b/frontend/src/api/exercise.ts
@@ -0,0 +1,40 @@
+import request from '@/utils/request'
+
+export const getExercises = (params?: any) =>
+ request.get('/api/exercises/', { params })
+
+export const getExercise = (id: number) =>
+ request.get(`/api/exercises/${id}`)
+
+export const createExercise = (data: any) =>
+ request.post('/api/exercises/', data)
+
+export const updateExercise = (id: number, data: any) =>
+ request.put(`/api/exercises/${id}`, data)
+
+export const remixExercise = (id: number) =>
+ request.post(`/api/exercises/${id}/remix`)
+
+export const aiGenerateExercise = (data: { prompt: string; exercise_type?: string; subject?: string; knowledge_points?: string[]; count?: number }) =>
+ request.post('/api/exercises/ai-generate', data)
+
+export const deleteExercise = (id: number) =>
+ request.delete(`/api/exercises/${id}`)
+
+export const exportExerciseDocx = (id: number): Promise =>
+ request.get(`/api/exercises/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise
+
+
+export interface ExerciseAttemptData {
+ student_name?: string
+ answers: any[]
+ score: number
+ total: number
+ duration?: number
+}
+
+export const submitExerciseAttempt = (id: number, data: ExerciseAttemptData) =>
+ request.post(`/api/exercises/${id}/attempt`, data)
+
+export const getExerciseAttempts = (id: number) =>
+ request.get(`/api/exercises/${id}/attempts`)
\ No newline at end of file
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..5cf037e
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -0,0 +1,39 @@
+import request from '@/utils/request'
+
+export const login = (data: { phone: string; password: string }) =>
+ request.post('/api/auth/login', data)
+
+export const register = (data: { phone: string; password: string; name: string; subject?: string; school?: string }) =>
+ request.post('/api/auth/register', data)
+
+export const getProfile = () =>
+ request.get('/api/auth/me')
+
+export const updateProfile = (data: any) =>
+ request.put('/api/auth/me', data)
+
+export const getProfileStats = () =>
+ request.get('/api/auth/stats')
+
+export const getCreditBenefits = () =>
+ request.get('/api/auth/credits/benefits')
+
+export const claimDailyCheckin = () =>
+ request.post('/api/auth/credits/daily-checkin')
+export const changePassword = (data: { old_password: string; new_password: string }) =>
+ request.post('/api/auth/change-password', data)
+export const uploadAvatar = (file: File) => {
+ const fd = new FormData()
+ fd.append('file', file)
+ return request.post('/api/auth/avatar', fd, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+}
+export const refreshAccessToken = (refreshToken: string) =>
+ request.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
+
+export const sendResetCode = (phone: string) =>
+ request.post('/api/auth/send-code', { phone })
+
+export const resetPassword = (data: { phone: string; code: string; new_password: string }) =>
+ request.post('/api/auth/reset-password', data)
diff --git a/frontend/src/api/lessonPlan.ts b/frontend/src/api/lessonPlan.ts
new file mode 100644
index 0000000..faec707
--- /dev/null
+++ b/frontend/src/api/lessonPlan.ts
@@ -0,0 +1,25 @@
+import request from '@/utils/request'
+
+export const getLessonPlans = (params?: any) =>
+ request.get('/api/lesson-plans/', { params })
+
+export const getLessonPlan = (id: number) =>
+ request.get(`/api/lesson-plans/${id}`)
+
+export const createLessonPlan = (data: any) =>
+ request.post('/api/lesson-plans/', data)
+
+export const updateLessonPlan = (id: number, data: any) =>
+ request.put(`/api/lesson-plans/${id}`, data)
+
+export const remixLessonPlan = (id: number) =>
+ request.post(`/api/lesson-plans/${id}/remix`)
+
+export const aiGenerateLessonPlan = (data: { title: string; subject?: string; grade?: string; objectives?: string[]; duration?: number; extra_requirements?: string }) =>
+ request.post('/api/lesson-plans/ai-generate', data)
+
+export const deleteLessonPlan = (id: number) =>
+ request.delete(`/api/lesson-plans/${id}`)
+
+export const exportLessonPlanDocx = (id: number): Promise =>
+ request.get(`/api/lesson-plans/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise
diff --git a/frontend/src/api/material.ts b/frontend/src/api/material.ts
new file mode 100644
index 0000000..f525ba8
--- /dev/null
+++ b/frontend/src/api/material.ts
@@ -0,0 +1,16 @@
+import request from '@/utils/request'
+
+export const getMaterials = (params?: any) =>
+ request.get('/api/materials/', { params })
+
+export const getMaterial = (id: number) =>
+ request.get(`/api/materials/${id}`)
+
+export const createMaterial = (data: any) =>
+ request.post('/api/materials/', data)
+
+export const updateMaterial = (id: number, data: any) =>
+ request.put(`/api/materials/${id}`, data)
+
+export const deleteMaterial = (id: number) =>
+ request.delete(`/api/materials/${id}`)
diff --git a/frontend/src/api/mindmap.ts b/frontend/src/api/mindmap.ts
new file mode 100644
index 0000000..3a0fa49
--- /dev/null
+++ b/frontend/src/api/mindmap.ts
@@ -0,0 +1,30 @@
+import request from '@/utils/request'
+
+export interface MindMapNode {
+ title: string
+ color?: string
+ children?: MindMapNode[]
+}
+
+export interface MindMapData {
+ title: string
+ nodes: MindMapNode[]
+}
+
+export const aiGenerateMindMap = (data: { topic: string; subject?: string; grade?: string }) =>
+ request.post('/api/mind-maps/ai-generate', data)
+
+export const getMindMaps = (params?: any) =>
+ request.get('/api/mind-maps/', { params })
+
+export const getMindMap = (id: number) =>
+ request.get(`/api/mind-maps/${id}`)
+
+export const createMindMap = (data: { title: string; subject?: string; nodes: MindMapNode[] }) =>
+ request.post('/api/mind-maps/', data)
+
+export const updateMindMap = (id: number, data: { title?: string; nodes?: MindMapNode[] }) =>
+ request.put(`/api/mind-maps/${id}`, data)
+
+export const deleteMindMap = (id: number) =>
+ request.delete(`/api/mind-maps/${id}`)
diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts
new file mode 100644
index 0000000..dc77f9e
--- /dev/null
+++ b/frontend/src/api/notification.ts
@@ -0,0 +1,28 @@
+import request from '@/utils/request'
+
+export interface NotificationItem {
+ id: number
+ user_id: number
+ actor_id: number | null
+ ntype: string
+ title: string
+ content: string
+ link: string
+ is_read: boolean
+ created_at: string | null
+}
+
+export const getNotifications = (params?: { unread_only?: boolean; limit?: number }) =>
+ request.get('/api/notifications', { params })
+
+export const getUnreadCount = () =>
+ request.get('/api/notifications/unread-count')
+
+export const markRead = (id: number) =>
+ request.put(`/api/notifications/${id}/read`)
+
+export const markAllRead = () =>
+ request.put('/api/notifications/read-all')
+
+export const deleteNotification = (id: number) =>
+ request.delete(`/api/notifications/${id}`)
diff --git a/frontend/src/api/resource.ts b/frontend/src/api/resource.ts
new file mode 100644
index 0000000..9e494e2
--- /dev/null
+++ b/frontend/src/api/resource.ts
@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+export const getResources = (params?: any) =>
+ request.get('/api/resources/', { params })
+
+export const createResource = (data: any) =>
+ request.post('/api/resources/', data)
+
+export const publishResource = (data: any) =>
+ request.post('/api/resources/publish', data)
+
+export const getResource = (id: number) =>
+ request.get(`/api/resources/${id}`)
+
+export const updateResource = (id: number, data: any) =>
+ request.put(`/api/resources/${id}`, data)
+
+export const likeResource = (id: number) =>
+ request.post(`/api/resources/${id}/like`)
+
+export const unlikeResource = (id: number) =>
+ request.delete(`/api/resources/${id}/like`)
+
+export const getFavoriteResources = (params?: any) =>
+ request.get('/api/resources/favorites/me', { params })
+
+export const getMyResources = (params?: any) =>
+ request.get('/api/resources/mine', { params })
+
+export const deleteResource = (id: number) =>
+ request.delete(`/api/resources/${id}`)
+
+export const downloadResourceStat = (id: number) =>
+ request.post(`/api/resources/${id}/download`)
diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts
new file mode 100644
index 0000000..da6daa8
--- /dev/null
+++ b/frontend/src/api/search.ts
@@ -0,0 +1,4 @@
+import request from '@/utils/request'
+
+export const globalSearch = (params: { keyword?: string; limit?: number }) =>
+ request.get('/api/search', { params }) as unknown as Promise
diff --git a/frontend/src/auto-imports.d.ts b/frontend/src/auto-imports.d.ts
new file mode 100644
index 0000000..f6e2bab
--- /dev/null
+++ b/frontend/src/auto-imports.d.ts
@@ -0,0 +1,88 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+ const EffectScope: typeof import('vue')['EffectScope']
+ const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
+ const computed: typeof import('vue')['computed']
+ const createApp: typeof import('vue')['createApp']
+ const createPinia: typeof import('pinia')['createPinia']
+ const customRef: typeof import('vue')['customRef']
+ const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+ const defineComponent: typeof import('vue')['defineComponent']
+ const defineStore: typeof import('pinia')['defineStore']
+ const effectScope: typeof import('vue')['effectScope']
+ const getActivePinia: typeof import('pinia')['getActivePinia']
+ const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+ const getCurrentScope: typeof import('vue')['getCurrentScope']
+ const h: typeof import('vue')['h']
+ const inject: typeof import('vue')['inject']
+ const isProxy: typeof import('vue')['isProxy']
+ const isReactive: typeof import('vue')['isReactive']
+ const isReadonly: typeof import('vue')['isReadonly']
+ const isRef: typeof import('vue')['isRef']
+ const mapActions: typeof import('pinia')['mapActions']
+ const mapGetters: typeof import('pinia')['mapGetters']
+ const mapState: typeof import('pinia')['mapState']
+ const mapStores: typeof import('pinia')['mapStores']
+ const mapWritableState: typeof import('pinia')['mapWritableState']
+ const markRaw: typeof import('vue')['markRaw']
+ const nextTick: typeof import('vue')['nextTick']
+ const onActivated: typeof import('vue')['onActivated']
+ const onBeforeMount: typeof import('vue')['onBeforeMount']
+ const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
+ const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
+ const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+ const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+ const onDeactivated: typeof import('vue')['onDeactivated']
+ const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+ const onMounted: typeof import('vue')['onMounted']
+ const onRenderTracked: typeof import('vue')['onRenderTracked']
+ const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+ const onScopeDispose: typeof import('vue')['onScopeDispose']
+ const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+ const onUnmounted: typeof import('vue')['onUnmounted']
+ const onUpdated: typeof import('vue')['onUpdated']
+ const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
+ const provide: typeof import('vue')['provide']
+ const reactive: typeof import('vue')['reactive']
+ const readonly: typeof import('vue')['readonly']
+ const ref: typeof import('vue')['ref']
+ const resolveComponent: typeof import('vue')['resolveComponent']
+ const setActivePinia: typeof import('pinia')['setActivePinia']
+ const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
+ const shallowReactive: typeof import('vue')['shallowReactive']
+ const shallowReadonly: typeof import('vue')['shallowReadonly']
+ const shallowRef: typeof import('vue')['shallowRef']
+ const storeToRefs: typeof import('pinia')['storeToRefs']
+ const toRaw: typeof import('vue')['toRaw']
+ const toRef: typeof import('vue')['toRef']
+ const toRefs: typeof import('vue')['toRefs']
+ const toValue: typeof import('vue')['toValue']
+ const triggerRef: typeof import('vue')['triggerRef']
+ const unref: typeof import('vue')['unref']
+ const useAttrs: typeof import('vue')['useAttrs']
+ const useCssModule: typeof import('vue')['useCssModule']
+ const useCssVars: typeof import('vue')['useCssVars']
+ const useId: typeof import('vue')['useId']
+ const useLink: typeof import('vue-router')['useLink']
+ const useModel: typeof import('vue')['useModel']
+ const useRoute: typeof import('vue-router')['useRoute']
+ const useRouter: typeof import('vue-router')['useRouter']
+ const useSlots: typeof import('vue')['useSlots']
+ const useTemplateRef: typeof import('vue')['useTemplateRef']
+ const watch: typeof import('vue')['watch']
+ const watchEffect: typeof import('vue')['watchEffect']
+ const watchPostEffect: typeof import('vue')['watchPostEffect']
+ const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+ // @ts-ignore
+ export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+ import('vue')
+}
diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts
new file mode 100644
index 0000000..9d2bb8a
--- /dev/null
+++ b/frontend/src/components.d.ts
@@ -0,0 +1,47 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+ export interface GlobalComponents {
+ AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
+ AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
+ AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default']
+ ElAside: typeof import('element-plus/es')['ElAside']
+ ElButton: typeof import('element-plus/es')['ElButton']
+ ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
+ ElContainer: typeof import('element-plus/es')['ElContainer']
+ ElDialog: typeof import('element-plus/es')['ElDialog']
+ ElDropdown: typeof import('element-plus/es')['ElDropdown']
+ ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+ ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+ ElEmpty: typeof import('element-plus/es')['ElEmpty']
+ ElForm: typeof import('element-plus/es')['ElForm']
+ ElFormItem: typeof import('element-plus/es')['ElFormItem']
+ ElHeader: typeof import('element-plus/es')['ElHeader']
+ ElIcon: typeof import('element-plus/es')['ElIcon']
+ ElInput: typeof import('element-plus/es')['ElInput']
+ ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+ ElMain: typeof import('element-plus/es')['ElMain']
+ ElOption: typeof import('element-plus/es')['ElOption']
+ ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
+ ElPopover: typeof import('element-plus/es')['ElPopover']
+ ElSelect: typeof import('element-plus/es')['ElSelect']
+ ElTable: typeof import('element-plus/es')['ElTable']
+ ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+ ElTag: typeof import('element-plus/es')['ElTag']
+ HtmlPreview: typeof import('./components/common/HtmlPreview.vue')['default']
+ MarkdownContent: typeof import('./components/common/MarkdownContent.vue')['default']
+ MaterialPicker: typeof import('./components/common/MaterialPicker.vue')['default']
+ MindNode: typeof import('./components/common/MindNode.vue')['default']
+ RouterLink: typeof import('vue-router')['RouterLink']
+ RouterView: typeof import('vue-router')['RouterView']
+ TemplateGallery: typeof import('./components/common/TemplateGallery.vue')['default']
+ }
+ export interface ComponentCustomProperties {
+ vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+ }
+}
diff --git a/frontend/src/components/common/HtmlPreview.vue b/frontend/src/components/common/HtmlPreview.vue
new file mode 100644
index 0000000..8059455
--- /dev/null
+++ b/frontend/src/components/common/HtmlPreview.vue
@@ -0,0 +1,649 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 小
+ 中
+ 大
+
+ ↙
+ ↔
+ ↘
+
+ 替换
+ 删除
+
+
+
+
+
+
+
{{ replacingImg ? '替换图片' : '插入图片' }}
+
+
图片链接
+
+
或上传本地图片
+
+ 上传图片
+
+
+
+
+
+
+
+
+
+
+
+
+
+
插入动画
+
+
加载中...
+
暂无已保存的动画,请先到"教学动画"页面生成并保存
+
+
+
+
+
{{ a.title }}
+
{{ a.description || a.anim_type }}
+
+
插入
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue
new file mode 100644
index 0000000..389208b
--- /dev/null
+++ b/frontend/src/components/common/MarkdownContent.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/MaterialPicker.vue b/frontend/src/components/common/MaterialPicker.vue
new file mode 100644
index 0000000..4fc7106
--- /dev/null
+++ b/frontend/src/components/common/MaterialPicker.vue
@@ -0,0 +1,282 @@
+
+
+
+
+ 素材库
+
+
+
+
+ {{ item.name }}
+ {{ item.typeLabel }} · {{ item.sizeLabel }}
+ ×
+
+
+
+
+
+
+
+
+
{{ item.title || item.filename }}
+
{{ materialTypeLabel(item.material_type, item) }} · {{ item.subject || '通用' }} · {{ formatMaterialFileSize(item.size || 0) }}
+
{{ item.summary || '暂无摘要' }}
+
+ 加入任务
+
+
+ 素材库暂无匹配材料
+ 可以先到素材库上传解析,或换个关键词再试。
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/MindNode.vue b/frontend/src/components/common/MindNode.vue
new file mode 100644
index 0000000..d671689
--- /dev/null
+++ b/frontend/src/components/common/MindNode.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+ {{ node.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/TemplateGallery.vue b/frontend/src/components/common/TemplateGallery.vue
new file mode 100644
index 0000000..a0084e4
--- /dev/null
+++ b/frontend/src/components/common/TemplateGallery.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
+
+
+
+
+
+
{{ selected.name }}
+
{{ selected.description }}
+
+ {{ categoryLabelOf(selected) }}
+
+
+
+
+
+ {{ p.label }}
+ {{ params[p.key] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue
new file mode 100644
index 0000000..7a98288
--- /dev/null
+++ b/frontend/src/components/layout/AppHeader.vue
@@ -0,0 +1,387 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue
new file mode 100644
index 0000000..f801b85
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
new file mode 100644
index 0000000..8b5dccb
--- /dev/null
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts
new file mode 100644
index 0000000..323c78a
--- /dev/null
+++ b/frontend/src/env.d.ts
@@ -0,0 +1,7 @@
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..4e6c396
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,165 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import {
+ ArrowLeft,
+ ArrowRight,
+ ChatDotRound,
+ Check,
+ Collection,
+ CopyDocument,
+ DataLine,
+ Delete,
+ Download,
+ Document,
+ Edit,
+ EditPen,
+ Expand,
+ Film,
+ Finished,
+ Flag,
+ Fold,
+ FolderOpened,
+ FullScreen,
+ HomeFilled,
+ InfoFilled,
+ Iphone,
+ List,
+ Lock,
+ MagicStick,
+ Medal,
+ Microphone,
+ Monitor,
+ MoreFilled,
+ Paperclip,
+ Camera,
+ Picture,
+ Plus,
+ PriceTag,
+ Refresh,
+ Search,
+ Share,
+ Star,
+ Sunrise,
+ Tickets,
+ Top,
+ Trophy,
+ Upload,
+ UploadFilled,
+ User,
+ VideoPlay,
+ View,
+ Warning,
+ Aim,
+ Clock,
+ Cloudy,
+ Coin,
+ Connection,
+ Cpu,
+ Grid,
+ Lightning,
+ Moon,
+ RefreshRight,
+ Sort,
+ Sunny,
+ TrendCharts,
+ TrophyBase,
+ ChatLineRound,
+ Setting,
+ PieChart,
+ Brush,
+ Histogram,
+ Key,
+ Link,
+ Position,
+ Promotion,
+ Timer,
+ CircleCheck,
+} from '@element-plus/icons-vue'
+import App from './App.vue'
+import router from './router'
+import './styles/global.css'
+
+const app = createApp(App)
+
+const icons = {
+ ArrowLeft,
+ ArrowRight,
+ ChatDotRound,
+ Check,
+ Collection,
+ CopyDocument,
+ DataLine,
+ Delete,
+ Download,
+ Document,
+ Edit,
+ EditPen,
+ Expand,
+ Film,
+ Finished,
+ Flag,
+ Fold,
+ FolderOpened,
+ FullScreen,
+ HomeFilled,
+ InfoFilled,
+ Iphone,
+ List,
+ Lock,
+ MagicStick,
+ Medal,
+ Microphone,
+ Monitor,
+ MoreFilled,
+ Paperclip,
+ Camera,
+ Picture,
+ Plus,
+ PriceTag,
+ Refresh,
+ Search,
+ Share,
+ Star,
+ Sunrise,
+ Tickets,
+ Top,
+ Trophy,
+ Upload,
+ UploadFilled,
+ User,
+ VideoPlay,
+ View,
+ Warning,
+ Aim,
+ Clock,
+ Cloudy,
+ Coin,
+ Connection,
+ Cpu,
+ Grid,
+ Lightning,
+ Moon,
+ RefreshRight,
+ Sort,
+ Sunny,
+ TrendCharts,
+ TrophyBase,
+ ChatLineRound,
+ Setting,
+ PieChart,
+ Brush,
+ Histogram,
+ Key,
+ Link,
Position,
+ Promotion,
+ Timer,
+ CircleCheck,
+}
+
+for (const [name, component] of Object.entries(icons)) {
+ app.component(name, component)
+}
+
+app.use(createPinia())
+app.use(router)
+app.mount('#app')
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..af69678
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,99 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ {
+ path: '/login',
+ name: 'Login',
+ component: () => import('@/views/Login.vue'),
+ meta: { title: '登录' },
+ },
+ {
+ path: '/preview/:id?',
+ name: 'CoursewarePreview',
+ component: () => import('@/views/CoursewarePreview.vue'),
+ meta: { title: '课件预览' },
+ },
+ {
+ path: '/preview/share/:token',
+ name: 'CoursewareSharedPreview',
+ component: () => import('@/views/CoursewarePreview.vue'),
+ meta: { title: '课件分享', public: true },
+ },
+ {
+ path: '/demo/:id',
+ name: 'DemoResource',
+ component: () => import('@/views/DemoResource.vue'),
+ meta: { title: '资源演示', public: true },
+ },
+ {
+ path: '/classroom/join/:id',
+ name: 'ClassroomJoin',
+ component: () => import('@/views/ClassroomJoin.vue'),
+ meta: { title: '课堂数据回收', public: true },
+ },
+ {
+ path: '/classroom/join',
+ name: 'ClassroomJoinCode',
+ component: () => import('@/views/ClassroomJoin.vue'),
+ meta: { title: '课堂数据回收', public: true },
+ },
+ {
+ path: '/',
+ component: () => import('@/components/layout/AppLayout.vue'),
+ children: [
+ { path: '', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '首页', public: true } },
+ { path: 'favorites', name: 'Favorites', component: () => import('@/views/Favorites.vue'), meta: { title: '我的收藏' } },
+ { path: 'courseware', name: 'CoursewareList', component: () => import('@/views/CoursewareList.vue'), meta: { title: '我的作品' } },
+ { path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人资料' } },
+ { path: 'materials', name: 'Materials', component: () => import('@/views/Materials.vue'), meta: { title: '我的素材库' } },
+ { path: 'search', name: 'Search', component: () => import('@/views/Search.vue'), meta: { title: '全局搜索', public: true } },
+ { path: 'courseware/create', name: 'CoursewareCreate', component: () => import('@/views/CoursewareCreate.vue'), meta: { title: 'AI互动课件' } },
+ { path: 'courseware/:id', name: 'CoursewareDetail', component: () => import('@/views/CoursewareDetail.vue'), meta: { title: '课件详情' } },
+ { path: 'animation', name: 'Animation', component: () => import('@/views/Animation.vue'), meta: { title: '教学动画' } },
+ { path: 'exercise', name: 'Exercise', component: () => import('@/views/Exercise.vue'), meta: { title: 'AI组题' } },
+ { path: 'lesson-plan', name: 'LessonPlan', component: () => import('@/views/LessonPlan.vue'), meta: { title: 'AI教案 · 大单元' } },
+ { path: 'essay-grade', name: 'EssayGrade', component: () => import('@/views/EssayGrade.vue'), meta: { title: '作文批改' } },
+ { path: 'mindmap', name: 'MindMap', component: () => import('@/views/MindMap.vue'), meta: { title: 'AI思维导图' } },
+ { path: 'assistant', name: 'Assistant', component: () => import('@/views/Assistant.vue'), meta: { title: 'AI教学助手' } },
+ { path: 'exam', name: 'Exam', component: () => import('@/views/Exam.vue'), meta: { title: 'AI命题' } },
+ { path: 'classroom', name: 'Classroom', component: () => import('@/views/Classroom.vue'), meta: { title: '课堂工具' } },
+ { path: 'resources', name: 'Resources', component: () => import('@/views/Resources.vue'), meta: { title: '资源广场', public: true } },
+ { path: 'resources/:id', name: 'ResourceDetail', component: () => import('@/views/ResourceDetail.vue'), meta: { title: '资源详情', public: true } },
+ { path: 'community', name: 'Community', component: () => import('@/views/Community.vue'), meta: { title: '模板社区', public: true } },
+ { path: 'community/:id', name: 'CommunityDetail', component: () => import('@/views/CommunityDetail.vue'), meta: { title: '模板详情', public: true } },
+ { path: 'help', name: 'Help', component: () => import('@/views/Help.vue'), meta: { title: '使用帮助', public: true } },
+ { path: 'admin', name: 'Admin', component: () => import('@/views/Admin.vue'), meta: { title: '管理后台' } },
+ { path: ':pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue'), meta: { title: '页面未找到', public: true } },
+ ],
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'RootNotFound',
+ component: () => import('@/views/NotFound.vue'),
+ meta: { title: '页面未找到', public: true },
+ },
+ ],
+})
+
+router.beforeEach((to, _from, next) => {
+ const token = localStorage.getItem('access_token')
+ const publicPaths = ['/login', '/preview']
+ const isPublic = Boolean(to.meta.public) || publicPaths.some(p => to.path.startsWith(p))
+ document.title = `${to.meta.title || '智教助手'} - 智教助手`
+
+ if (!isPublic && !token) {
+ next({ path: '/login', query: { redirect: to.fullPath } })
+ return
+ }
+
+ if (to.path === '/login' && token) {
+ next('/')
+ return
+ }
+
+ next()
+})
+
+export default router
diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts
new file mode 100644
index 0000000..5f74f8c
--- /dev/null
+++ b/frontend/src/stores/user.ts
@@ -0,0 +1,71 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { getProfile } from '@/api'
+
+export const useUserStore = defineStore('user', () => {
+ const user = ref(null)
+ const token = ref(localStorage.getItem('access_token') || '')
+
+ function setToken(newToken: string) {
+ if (!newToken) return
+ token.value = newToken
+ localStorage.setItem('access_token', newToken)
+ }
+
+ function setTokens(accessToken: string, refreshToken?: string) {
+ if (!accessToken) return
+ token.value = accessToken
+ localStorage.setItem('access_token', accessToken)
+ if (refreshToken) {
+ localStorage.setItem('refresh_token', refreshToken)
+ }
+ }
+
+ function getRefreshToken(): string {
+ return localStorage.getItem('refresh_token') || ''
+ }
+
+ function clearToken() {
+ token.value = ''
+ user.value = null
+ localStorage.removeItem('access_token')
+ localStorage.removeItem('refresh_token')
+ }
+
+ async function fetchProfile() {
+ if (!token.value && localStorage.getItem('access_token')) {
+ token.value = localStorage.getItem('access_token') || ''
+ }
+ if (!token.value) {
+ user.value = null
+ return null
+ }
+ try {
+ user.value = await getProfile()
+ return user.value
+ } catch (error) {
+ clearToken()
+ throw error
+ }
+ }
+
+ async function refreshCreditsFromResponse(response: any) {
+ const credits = response?.credits
+ const balance = typeof credits?.balance === 'number' ? credits.balance : undefined
+ if (typeof balance !== 'number') return
+ if (!user.value) {
+ await fetchProfile().catch(() => undefined)
+ }
+ user.value = {
+ ...(user.value || {}),
+ credits: balance,
+ total_credits_used: typeof credits?.cost === 'number'
+ ? Number(user.value?.total_credits_used || 0) + credits.cost
+ : user.value?.total_credits_used,
+ }
+ }
+
+ const isLoggedIn = () => !!token.value
+
+ return { user, token, setToken, setTokens, getRefreshToken, clearToken, fetchProfile, refreshCreditsFromResponse, isLoggedIn }
+})
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..33164b7
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,403 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body, #app {
+ height: 100%;
+ font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+:root {
+ --primary: #00543D;
+ --primary-light: #98D997;
+ --primary-dark: #004532;
+ --primary-bg: #E8F5EA;
+ --accent: #2563EB;
+ --warning: #F59E0B;
+ --ink: #14213D;
+ --gradient: linear-gradient(135deg, #0F766E 0%, #14B8A6 52%, #2DD4BF 100%);
+ --gradient-blue: linear-gradient(135deg, #2563EB 0%, #06B6D4 100%);
+ --gradient-green: linear-gradient(135deg, #0F766E 0%, #22C55E 100%);
+ --gradient-orange: linear-gradient(135deg, #F59E0B 0%, #F97316 100%);
+ --gradient-red: linear-gradient(135deg, #DC2626 0%, #FB7185 100%);
+ --gradient-pink: linear-gradient(135deg, #DB2777 0%, #F472B6 100%);
+ --gradient-cyan: linear-gradient(135deg, #0891B2 0%, #22D3EE 100%);
+
+ --text-primary: #111827;
+ --text-secondary: #4B5563;
+ --text-muted: #8A94A6;
+ --border-color: #DCE8DE;
+ --bg-color: #F7FAF6;
+ --bg-white: #FFFFFF;
+
+ --sidebar-width: 252px;
+ --sidebar-collapsed: 82px;
+ --header-height: 58px;
+
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
+}
+
+.page-container {
+ width: 100%;
+ max-width: 1480px;
+ margin: 0 auto;
+ padding: 22px 32px 28px;
+ background: var(--bg-color);
+ min-height: calc(100vh - var(--header-height));
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 28px;
+}
+
+.page-header h2 {
+ font-size: 25px;
+ font-weight: 900;
+ color: var(--primary);
+}
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.gen-input-section,
+.guide-card,
+.question-card,
+.phase-content,
+.resource-card,
+.post-card {
+ border-radius: 14px !important;
+ border-color: #dce8de !important;
+ box-shadow: none !important;
+}
+
+.guide-cards {
+ display: grid !important;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) !important;
+ gap: 12px !important;
+}
+
+.settings-row,
+.settings-grid,
+.filter-bar {
+ flex-wrap: wrap;
+}
+
+.settings-grid {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
+}
+
+.gen-hero {
+ max-width: 1120px;
+ margin: 0 auto 18px !important;
+ align-items: center !important;
+}
+
+.gen-hero-icon {
+ width: 42px !important;
+ height: 42px !important;
+ border-radius: 50% !important;
+ background: #fff !important;
+ border: 1px solid #d6e4d8 !important;
+ color: var(--primary) !important;
+}
+
+.gen-hero-icon .el-icon,
+.gen-hero-icon svg {
+ color: var(--primary) !important;
+}
+
+.gen-hero h2 {
+ font-size: 25px !important;
+ color: var(--primary) !important;
+ font-weight: 900 !important;
+}
+
+.gen-hero p {
+ color: #75867f !important;
+ font-size: 14px !important;
+}
+
+.gen-input-section {
+ max-width: 1120px;
+ margin: 0 auto 22px !important;
+ background: rgba(255,255,255,0.86) !important;
+ border: 1px solid #cfe0d3 !important;
+ border-radius: 18px !important;
+ box-shadow: 0 18px 42px rgba(17, 69, 52, 0.07) !important;
+}
+
+.prompt-box :deep(.el-textarea__inner),
+.input-area :deep(.el-textarea__inner),
+.essay-input :deep(.el-textarea__inner),
+.setting-full :deep(.el-textarea__inner) {
+ background: #fff !important;
+ border: 1px solid #dce8de !important;
+ border-radius: 14px !important;
+ color: #173f35 !important;
+}
+
+.prompt-tag,
+.sug-tag,
+.type-check,
+.hot-tag {
+ border-radius: 18px !important;
+ background: #fff !important;
+ border: 1px solid #dce8de !important;
+ color: #51665f !important;
+}
+
+.prompt-tag:hover,
+.sug-tag:hover,
+.type-check.checked,
+.hot-tag:hover {
+ background: #e8f5ea !important;
+ border-color: #a8cfab !important;
+ color: var(--primary) !important;
+}
+
+.gen-btn,
+.generate-btn,
+.create-btn,
+.upload-btn,
+.grade-btn,
+.save-btn,
+.action-btn.primary {
+ background: #00543d !important;
+ border-radius: 13px !important;
+ box-shadow: none !important;
+}
+
+.guide-section,
+.result-section,
+.essay-input-panel,
+.essay-result-panel,
+.info-tags,
+.homework-section,
+.filter-bar,
+.community-layout,
+.content-area {
+ max-width: 1120px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.guide-card,
+.guide-item,
+.question-card,
+.phase-content,
+.resource-card,
+.post-card,
+.cw-card,
+.input-card,
+.result-panel,
+.essay-input-panel,
+.essay-result-panel,
+.hot-tags-card {
+ border: 1px solid #dce8de !important;
+ border-radius: 14px !important;
+ box-shadow: none !important;
+}
+
+.cw-cover,
+.template-cover,
+.card-thumb {
+ border-radius: 10px;
+ margin: 5px;
+}
+
+.el-input__wrapper,
+.el-textarea__inner,
+.el-select__wrapper {
+ box-shadow: none !important;
+ border: 1px solid #dce8de !important;
+ background: #fff !important;
+}
+
+@media (max-width: 760px) {
+ .page-container {
+ padding: 14px;
+ }
+
+ .page-header {
+ align-items: flex-start;
+ flex-direction: column;
+ margin-bottom: 18px;
+ }
+
+ .prompt-footer,
+ .result-header,
+ .header-actions {
+ align-items: stretch !important;
+ flex-direction: column !important;
+ }
+}
+
+/* Element Plus overrides */
+.el-card {
+ border-radius: var(--radius-lg) !important;
+ border: 1px solid var(--border-color) !important;
+ box-shadow: var(--shadow-sm) !important;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
+}
+
+.el-card:hover {
+ box-shadow: var(--shadow-lg) !important;
+ transform: translateY(-2px);
+}
+
+.el-button--primary {
+ background: var(--gradient) !important;
+ border: none !important;
+ border-radius: var(--radius-sm) !important;
+ font-weight: 500 !important;
+}
+
+.el-button--primary:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4) !important;
+}
+
+.el-input__wrapper,
+.el-textarea__inner,
+.el-select__wrapper {
+ border-radius: var(--radius-sm) !important;
+}
+
+.el-form-item__label {
+ font-weight: 500 !important;
+ color: var(--text-secondary) !important;
+}
+
+/* Dialog */
+.el-dialog {
+ border-radius: var(--radius-lg) !important;
+ overflow: hidden;
+}
+.el-dialog__header {
+ border-bottom: 1px solid var(--border-color);
+ padding: 20px 24px !important;
+ margin-right: 0 !important;
+}
+.el-dialog__title {
+ font-weight: 600 !important;
+ font-size: 17px !important;
+ color: var(--text-primary) !important;
+}
+.el-dialog__body {
+ padding: 24px !important;
+}
+.el-dialog__footer {
+ border-top: 1px solid var(--border-color);
+ padding: 16px 24px !important;
+}
+
+/* Tag */
+.el-tag {
+ border-radius: 6px !important;
+}
+
+/* Dropdown */
+.el-dropdown-menu__item {
+ border-radius: 6px !important;
+ margin: 2px 6px !important;
+ padding: 8px 16px !important;
+}
+
+/* Message */
+.el-message {
+ border-radius: var(--radius-sm) !important;
+ box-shadow: var(--shadow-lg) !important;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #CBD5E1;
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #94A3B8;
+}
+
+/* Skeleton loading */
+.skeleton {
+ background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ border-radius: var(--radius-sm);
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Animations */
+@keyframes fadeInUp {
+ from { opacity: 0; transform: translateY(20px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes slideInLeft {
+ from { opacity: 0; transform: translateX(-20px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+@keyframes float {
+ 0%, 100% { transform: translateY(0px); }
+ 50% { transform: translateY(-20px); }
+}
+@keyframes floatSlow {
+ 0%, 100% { transform: translateY(0px) rotate(0deg); }
+ 50% { transform: translateY(-15px) rotate(3deg); }
+}
+@keyframes pulse {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+}
+@keyframes bounce {
+ 0%, 80%, 100% { transform: scale(0); }
+ 40% { transform: scale(1); }
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Utility classes */
+.fade-in-up {
+ animation: fadeInUp 0.5s ease both;
+}
+
+/* Selection */
+::selection {
+ background: var(--primary-bg);
+ color: var(--primary);
+}
diff --git a/frontend/src/utils/animationTemplates.ts b/frontend/src/utils/animationTemplates.ts
new file mode 100644
index 0000000..5dce49f
--- /dev/null
+++ b/frontend/src/utils/animationTemplates.ts
@@ -0,0 +1,3010 @@
+// 教学动画模板库 —— 参数化的本地模板,无需消耗 AI 积分
+// 每个模板 render() 返回一个完整的 HTML 字符串,可直接保存为动画内容
+
+export interface TemplateParam {
+ key: string
+ label: string
+ type: 'number' | 'text' | 'select' | 'color'
+ default: string | number
+ min?: number
+ max?: number
+ step?: number
+ options?: { label: string; value: string | number }[]
+}
+
+export interface AnimationTemplate {
+ id: string
+ name: string
+ category: 'math' | 'physics' | 'chemistry' | 'biology' | 'geography' | 'history' | 'chinese' | 'english' | 'music' | 'art' | 'it' | 'general'
+ description: string
+ icon: string
+ accent: string
+ params: TemplateParam[]
+ defaultTitle: (p: Record) => string
+ render: (p: Record) => string
+}
+
+const SHELL = (title: string, body: string, script: string) => `
+
+
+
+
+${title}
+
+
+
+
+
+ 提示:拖动滑块实时观察变化;点击"播放/暂停"控制动画
+
+
+`;
+
+function num(p: Record, key: string, fallback = 0): number {
+ const v = p[key]
+ const n = typeof v === 'number' ? v : parseFloat(v)
+ return Number.isFinite(n) ? n : fallback
+}
+
+
+// ===== 1. 二次函数 y=ax²+bx+c =====
+const quadratic: AnimationTemplate = {
+ id: 'quadratic',
+ name: '二次函数图像',
+ category: 'math',
+ description: '动态展示 y=ax²+bx+c 图像随系数 a、b、c 变化',
+ icon: 'DataLine',
+ accent: 'linear-gradient(135deg, #6366f1, #4f46e5)',
+ params: [
+ { key: 'a', label: '系数 a', type: 'number', default: 1, min: -3, max: 3, step: 0.1 },
+ { key: 'b', label: '系数 b', type: 'number', default: 0, min: -5, max: 5, step: 0.1 },
+ { key: 'c', label: '系数 c', type: 'number', default: 0, min: -5, max: 5, step: 0.1 },
+ { key: 'xRange', label: '坐标范围', type: 'number', default: 6, min: 2, max: 20, step: 1 },
+ ],
+ defaultTitle: (p) => `二次函数 y=${p.a}x²+${p.b}x+${p.c}`,
+ render: (p) => {
+ const a = num(p, 'a', 1), b = num(p, 'b', 0), c = num(p, 'c', 0), xr = num(p, 'xRange', 6)
+ return SHELL('二次函数图像', '', `
+ (function(){
+ const init={a:${a},b:${b},c:${c},xr:${xr}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let A=init.a,B=init.b,C=init.c,XR=init.xr;
+ function resize(){const w=Math.max(320,stage.clientWidth-40);cv.width=w;cv.height=Math.max(360,w*0.55);draw();}
+ function draw(){
+ const w=cv.width,h=cv.height,ox=w/2,oy=h/2;
+ ctx.clearRect(0,0,w,h);
+ ctx.fillStyle='#fafbff';ctx.fillRect(0,0,w,h);
+ const unit=w/(2*XR+2);
+ ctx.strokeStyle='#eef2ff';ctx.lineWidth=1;
+ for(let i=-XR;i<=XR;i++){ctx.beginPath();ctx.moveTo(ox+i*unit,0);ctx.lineTo(ox+i*unit,h);ctx.stroke();}
+ for(let i=-XR;i<=XR;i++){ctx.beginPath();ctx.moveTo(0,oy+i*unit);ctx.lineTo(w,oy+i*unit);ctx.stroke();}
+ ctx.strokeStyle='#94a3b8';ctx.lineWidth=1.5;
+ ctx.beginPath();ctx.moveTo(0,oy);ctx.lineTo(w,oy);ctx.stroke();
+ ctx.beginPath();ctx.moveTo(ox,0);ctx.lineTo(ox,h);ctx.stroke();
+ ctx.fillStyle='#64748b';ctx.font='12px sans-serif';ctx.textAlign='center';
+ for(let i=-XR;i<=XR;i+=Math.max(1,Math.ceil(XR/8))){if(i===0)continue;ctx.fillText(i,ox+i*unit,oy+14);}
+ for(let i=-XR;i<=XR;i+=Math.max(1,Math.ceil(XR/8))){if(i===0)continue;ctx.fillText(i,ox+12,oy-i*unit+4);}
+ ctx.textAlign='left';
+ ctx.strokeStyle='#4f46e5';ctx.lineWidth=3;ctx.beginPath();
+ let first=true;
+ for(let px=0;px<=w;px+=2){
+ const x=(px-ox)/unit, y=A*x*x+B*x+C, py=oy-y*unit;
+ if(py<-50||py>h+50){first=true;continue;}
+ if(first){ctx.moveTo(px,py);first=false;}else ctx.lineTo(px,py);
+ }
+ ctx.stroke();
+ if(Math.abs(A)>0.001){
+ const vx=-B/(2*A), vy=A*vx*vx+B*vx+C;
+ ctx.fillStyle='#ef4444';ctx.beginPath();ctx.arc(ox+vx*unit,oy-vy*unit,6,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#1f2937';ctx.font='bold 13px sans-serif';
+ ctx.fillText('顶点('+vx.toFixed(2)+', '+vy.toFixed(2)+')',ox+vx*unit+10,oy-vy*unit-10);
+ }
+ }
+ document.getElementById('sa').oninput=e=>{A=+e.target.value;document.getElementById('va').textContent=A;draw();};
+ document.getElementById('sb').oninput=e=>{B=+e.target.value;document.getElementById('vb').textContent=B;draw();};
+ document.getElementById('sc').oninput=e=>{C=+e.target.value;document.getElementById('vc').textContent=C;draw();};
+ document.getElementById('sr').oninput=e=>{XR=+e.target.value;document.getElementById('vr').textContent=XR;draw();};
+ window.addEventListener('resize',resize);resize();
+ })();
+ `);
+ },
+}
+
+
+// ===== 2. 三角函数波 y=A·sin(ωx+φ) =====
+const sineWave: AnimationTemplate = {
+ id: 'sine-wave',
+ name: '正弦波图像',
+ category: 'math',
+ description: '调节振幅 A、角频率 ω、初相 φ 观察波形变化',
+ icon: 'TrendCharts',
+ accent: 'linear-gradient(135deg, #06b6d4, #0891b2)',
+ params: [
+ { key: 'A', label: '振幅 A', type: 'number', default: 1, min: 0.1, max: 3, step: 0.1 },
+ { key: 'omega', label: '角频率 ω', type: 'number', default: 1, min: 0.1, max: 4, step: 0.1 },
+ { key: 'phi', label: '初相 φ', type: 'number', default: 0, min: -3.14, max: 3.14, step: 0.1 },
+ { key: 'animate', label: '自动滚动', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: (p) => `y=${p.A}·sin(${p.omega}x+${p.phi})`,
+ render: (p) => {
+ const A = num(p, 'A', 1), w = num(p, 'omega', 1), phi = num(p, 'phi', 0), an = String(p.animate ?? '1')
+ return SHELL('正弦波图像', '', `
+ (function(){
+ const init={A:${A},w:${w},phi:${phi},an:${an==='1'?1:0}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let A=init.A,W=init.w,Phi=init.phi,shift=0,playing=init.an;
+ function resize(){const w=Math.max(320,stage.clientWidth-40);cv.width=w;cv.height=Math.max(300,w*0.45);draw();}
+ function draw(){
+ const w=cv.width,h=cv.height,oy=h/2;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#fafbff';ctx.fillRect(0,0,w,h);
+ const unit=h/7;
+ // grid
+ ctx.strokeStyle='#eef2ff';ctx.lineWidth=1;
+ for(let i=-3;i<=3;i++){ctx.beginPath();ctx.moveTo(0,oy+i*unit);ctx.lineTo(w,oy+i*unit);ctx.stroke();}
+ for(let x=0;x{A=+e.target.value;document.getElementById('vA').textContent=A;};
+ document.getElementById('sW').oninput=e=>{W=+e.target.value;document.getElementById('vW').textContent=W;};
+ document.getElementById('sP').oninput=e=>{Phi=+e.target.value;document.getElementById('vP').textContent=Phi.toFixed(2);};
+ document.getElementById('playBtn').onclick=e=>{playing=!playing;e.target.textContent=playing?'暂停':'播放';};
+ document.getElementById('resetBtn').onclick=()=>{shift=0;};
+ window.addEventListener('resize',resize);resize();loop();
+ })();
+ `);
+ },
+}
+
+
+// ===== 3. 平抛运动 =====
+const projectile: AnimationTemplate = {
+ id: 'projectile',
+ name: '平抛 / 斜抛运动',
+ category: 'physics',
+ description: '调节初速度与抛射角,观察运动轨迹与分解速度',
+ icon: 'Aim',
+ accent: 'linear-gradient(135deg, #f59e0b, #d97706)',
+ params: [
+ { key: 'v0', label: '初速度 v₀ (m/s)', type: 'number', default: 15, min: 5, max: 40, step: 1 },
+ { key: 'angle', label: '抛射角 θ (°)', type: 'number', default: 45, min: 0, max: 90, step: 1 },
+ { key: 'g', label: '重力 g (m/s²)', type: 'number', default: 9.8, min: 1, max: 20, step: 0.1 },
+ ],
+ defaultTitle: (p) => `斜抛运动 v₀=${p.v0}m/s θ=${p.angle}°`,
+ render: (p) => {
+ const v0 = num(p, 'v0', 15), ang = num(p, 'angle', 45), g = num(p, 'g', 9.8)
+ return SHELL('斜抛运动轨迹', '', `
+ (function(){
+ const init={v0:${v0},ang:${ang},g:${g}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let V0=init.v0,Ang=init.ang*(Math.PI/180),G=init.g;
+ let traj=[],ball=null,t0=0,raf=0;
+ function fit(){const w=Math.max(320,stage.clientWidth-40),h=Math.max(340,w*0.55);cv.width=w;cv.height=h;draw();}
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#fafbff';ctx.fillRect(0,0,w,h);
+ // calc scale
+ const vx=V0*Math.cos(Ang),vy=V0*Math.sin(Ang);
+ const tEnd=Ang<0.01?2*vy/Math.max(0.01,G):(vy+Math.sqrt(vy*vy+2*G*h*0.01))/G;
+ const xMax=Math.max(10,vx*tEnd), yMax=Math.max(10,(vy*vy)/(2*G));
+ const padL=40,padB=30,padT=20,padR=20;
+ const sx=(w-padL-padR)/Math.max(xMax*1.1,10), sy=(h-padT-padB)/Math.max(yMax*1.2,10);
+ const scale=Math.min(sx,sy);
+ const ox=padL, oy=h-padB;
+ // ground
+ ctx.strokeStyle='#cbd5e1';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(0,oy);ctx.lineTo(w,oy);ctx.stroke();
+ // grid + axes
+ ctx.strokeStyle='#eef2ff';ctx.fillStyle='#94a3b8';ctx.font='11px sans-serif';ctx.lineWidth=1;
+ for(let i=0;i<=10;i++){
+ const gx=ox+i*xMax/10*scale;
+ ctx.beginPath();ctx.moveTo(gx,oy);ctx.lineTo(gx,padT);ctx.stroke();
+ ctx.fillText((i*xMax/10).toFixed(0)+'m',gx-8,oy+14);
+ }
+ // trajectory
+ if(traj.length>1){
+ ctx.strokeStyle='#fbbf24';ctx.lineWidth=2;ctx.setLineDash([5,4]);
+ ctx.beginPath();
+ traj.forEach((pt,i)=>{
+ const px=ox+pt.x*scale, py=oy-pt.y*scale;
+ if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
+ });
+ ctx.stroke();ctx.setLineDash([]);
+ }
+ // ball
+ if(ball){
+ const bx=ox+ball.x*scale, by=oy-ball.y*scale;
+ ctx.fillStyle='#d97706';ctx.beginPath();ctx.arc(bx,by,9,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fef3c7';ctx.beginPath();ctx.arc(bx-3,by-3,3,0,Math.PI*2);ctx.fill();
+ // velocity arrow
+ const va=ball.vx*scale*0.3, vb=ball.vy*scale*0.3;
+ ctx.strokeStyle='#ef4444';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(bx,by);ctx.lineTo(bx+va,by-vb);ctx.stroke();
+ ctx.fillStyle='#1f2937';ctx.font='bold 12px sans-serif';
+ ctx.fillText('v='+Math.hypot(ball.vx,ball.vy).toFixed(1)+'m/s',bx+12,by-8);
+ }
+ }
+ function step(ts){
+ if(!t0)t0=ts;
+ const t=(ts-t0)/1000;
+ const vx=V0*Math.cos(Ang),vy0=V0*Math.sin(Ang);
+ const x=vx*t, y=vy0*t-0.5*G*t*t;
+ if(y<0&&t>0.05){ball={x:x,y:0,vx:vx,vy:-vy0+G*t};traj.push(ball);draw();t0=0;return;}
+ ball={x:x,y:y,vx:vx,vy:vy0-G*t};traj.push({...ball});
+ if(traj.length>500)traj.shift();
+ draw();raf=requestAnimationFrame(step);
+ }
+ document.getElementById('sv').oninput=e=>{V0=+e.target.value;document.getElementById('vv').textContent=V0;};
+ document.getElementById('sa').oninput=e=>{Ang=+e.target.value*Math.PI/180;document.getElementById('va').textContent=+e.target.value;};
+ document.getElementById('sg').oninput=e=>{G=+e.target.value;document.getElementById('vg').textContent=G;};
+ document.getElementById('goBtn').onclick=()=>{cancelAnimationFrame(raf);traj=[];t0=0;raf=requestAnimationFrame(step);};
+ document.getElementById('clrBtn').onclick=()=>{cancelAnimationFrame(raf);traj=[];ball=null;t0=0;draw();};
+ window.addEventListener('resize',fit);fit();
+ })();
+ `);
+ },
+}
+
+
+// ===== 4. 简谐振动 (单摆 + 弹簧) =====
+const harmonic: AnimationTemplate = {
+ id: 'harmonic',
+ name: '简谐振动',
+ category: 'physics',
+ description: '单摆与弹簧振子,调节振幅、周期、阻尼',
+ icon: 'Coin',
+ accent: 'linear-gradient(135deg, #10b981, #059669)',
+ params: [
+ { key: 'amplitude', label: '振幅', type: 'number', default: 1, min: 0.2, max: 2, step: 0.1 },
+ { key: 'period', label: '周期 T (s)', type: 'number', default: 2, min: 0.5, max: 6, step: 0.1 },
+ { key: 'damping', label: '阻尼系数', type: 'number', default: 0, min: 0, max: 1, step: 0.05 },
+ { key: 'mode', label: '模型', type: 'select', default: 'pendulum', options: [{label:'单摆',value:'pendulum'},{label:'弹簧',value:'spring'}] },
+ ],
+ defaultTitle: (p) => `简谐振动 ${p.mode==='spring'?'弹簧振子':'单摆'}`,
+ render: (p) => {
+ const amp = num(p, 'amplitude', 1), T = num(p, 'period', 2), damp = num(p, 'damping', 0), mode = String(p.mode ?? 'pendulum')
+ return SHELL('简谐振动', '', `
+ (function(){
+ const init={amp:${amp},T:${T},damp:${damp},mode:'${mode}'};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
振幅 = '+init.amp+'
'+
+ '
周期 T = '+init.T+'s
'+
+ '
阻尼 = '+init.damp+'
'+
+ '
模型 单摆 弹簧
'+
+ '
暂停 '+
+ '重置
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Amp=init.amp,Tp=init.T,Damp=init.damp,Mode=init.mode;
+ let t0=null,playing=true,phase=0;
+ function fit(){const w=Math.max(320,stage.clientWidth-40),h=Math.max(320,w*0.6);cv.width=w;cv.height=h;draw();}
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#fafbff';ctx.fillRect(0,0,w,h);
+ const cx=w/2,cy=80;
+ const x=Amp*Math.cos(phase)*Math.exp(-Damp*phase*0.3);
+ if(Mode==='pendulum'){
+ // pivot
+ ctx.fillStyle='#475569';ctx.fillRect(cx-30,20,60,10);
+ // string
+ const L=h-160;
+ const ang=Math.atan2(x*L*0.5,L);
+ const bx=cx+Math.sin(ang)*L, by=cy+Math.cos(ang)*L;
+ ctx.strokeStyle='#94a3b8';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(bx,by);ctx.stroke();
+ ctx.fillStyle='#059669';ctx.beginPath();ctx.arc(bx,by,22,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#a7f3d0';ctx.beginPath();ctx.arc(bx-7,by-7,6,0,Math.PI*2);ctx.fill();
+ } else {
+ // spring
+ const restY=h-100, bobY=cy+60+x*80;
+ ctx.strokeStyle='#64748b';ctx.lineWidth=3;
+ ctx.beginPath();
+ const coils=10,topY=cy,bottomY=bobY-22;
+ for(let i=0;i<=coils*2;i++){
+ const yy=topY+(bottomY-topY)*i/(coils*2);
+ const xx=cx+(i%2===0?-14:14);
+ if(i===0)ctx.moveTo(cx,topY);else ctx.lineTo(xx,yy);
+ }
+ ctx.lineTo(cx,bottomY);ctx.stroke();
+ // mount
+ ctx.fillStyle='#475569';ctx.fillRect(cx-30,cy-10,60,8);
+ // bob
+ ctx.fillStyle='#059669';ctx.beginPath();ctx.arc(cx,bobY,22,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#a7f3d0';ctx.beginPath();ctx.arc(cx-7,bobY-7,6,0,Math.PI*2);ctx.fill();
+ // equilibrium line
+ ctx.strokeStyle='#e5e7eb';ctx.setLineDash([4,4]);ctx.lineWidth=1;
+ ctx.beginPath();ctx.moveTo(cx-40,cy+60);ctx.lineTo(cx+40,cy+60);ctx.stroke();ctx.setLineDash([]);
+ }
+ // status
+ ctx.fillStyle='#1f2937';ctx.font='bold 13px sans-serif';
+ ctx.fillText('位移 x = '+x.toFixed(2),16,h-16);
+ }
+ function loop(ts){
+ if(!t0)t0=ts;
+ const dt=(ts-t0)/1000;t0=ts;
+ if(playing){phase+=(2*Math.PI/Tp)*dt;}
+ draw();requestAnimationFrame(loop);
+ }
+ document.getElementById('sA').oninput=e=>{Amp=+e.target.value;document.getElementById('vA').textContent=Amp;};
+ document.getElementById('sT').oninput=e=>{Tp=+e.target.value;document.getElementById('vT').textContent=Tp+'s';};
+ document.getElementById('sD').oninput=e=>{Damp=+e.target.value;document.getElementById('vD').textContent=Damp;};
+ document.getElementById('sM').onchange=e=>{Mode=e.target.value;};
+ document.getElementById('playBtn').onclick=e=>{playing=!playing;e.target.textContent=playing?'暂停':'播放';};
+ document.getElementById('resetBtn').onclick=()=>{phase=0;};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+
+// ===== 5. 化学反应 / 分子运动 =====
+const molecule: AnimationTemplate = {
+ id: 'molecule',
+ name: '分子运动模型',
+ category: 'physics',
+ description: '粒子运动模拟,观察温度对分子运动速率的影响',
+ icon: 'Connection',
+ accent: 'linear-gradient(135deg, #ec4899, #db2777)',
+ params: [
+ { key: 'temp', label: '温度 (K)', type: 'number', default: 300, min: 50, max: 1000, step: 10 },
+ { key: 'count', label: '粒子数', type: 'number', default: 40, min: 10, max: 120, step: 5 },
+ { key: 'reaction', label: '反应演示', type: 'select', default: '0', options: [{label:'关闭',value:'0'},{label:'开启',value:'1'}] },
+ ],
+ defaultTitle: (p) => `分子运动模拟 T=${p.temp}K`,
+ render: (p) => {
+ const temp = num(p, 'temp', 300), count = Math.round(num(p, 'count', 40)), rxn = String(p.reaction ?? '0')
+ return SHELL('分子运动模型', '', `
+ (function(){
+ const init={temp:${temp},count:${count},rxn:'${rxn}'==='1'};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
温度 = '+init.temp+'K
'+
+ '
粒子数 = '+init.count+'
'+
+ '
反应演示 关闭 开启
'+
+ '
加入反应物 '+
+ '重置
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Temp=init.temp,N=init.count,Rxn=init.rxn;
+ let parts=[];
+ function makeParts(){
+ parts=[];
+ for(let i=0;i{
+ p.x+=p.vx*speed;p.y+=p.vy*speed;
+ if(p.xw-p.r)p.vx*=-1;
+ if(p.yh-p.r)p.vy*=-1;
+ p.x=Math.max(p.r,Math.min(w-p.r,p.x));
+ p.y=Math.max(p.r,Math.min(h-p.r,p.y));
+ });
+ // reaction check
+ if(Rxn){
+ for(let i=0;i{
+ const col=p.a==='A'?'#3b82f6':p.a==='B'?'#ef4444':'#8b5cf6';
+ ctx.fillStyle=col;ctx.globalAlpha=0.85;
+ ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fill();
+ ctx.globalAlpha=1;ctx.fillStyle='#fff';ctx.font='bold 10px sans-serif';ctx.textAlign='center';
+ ctx.fillText(p.a,p.x,p.y+3);
+ });
+ ctx.textAlign='left';
+ // legend
+ ctx.font='12px sans-serif';ctx.fillStyle='#374151';
+ let lx=12,ly=20;
+ const legend=Rxn?[['#3b82f6','A'],['#ef4444','B'],['#8b5cf6','C(产物)']]:[['#3b82f6','分子 M']];
+ legend.forEach(([c,l])=>{ctx.fillStyle=c;ctx.fillRect(lx,ly-9,12,12);ctx.fillStyle='#374151';ctx.fillText(l,lx+18,ly);lx+=80;});
+ ctx.fillStyle='#374151';ctx.fillText('平均速率 ≈ '+speed.toFixed(2),12,h-12);
+ }
+ function loop(){draw();requestAnimationFrame(loop);}
+ document.getElementById('sT').oninput=e=>{Temp=+e.target.value;document.getElementById('vT').textContent=Temp+'K';};
+ document.getElementById('sN').oninput=e=>{N=+e.target.value;document.getElementById('vN').textContent=N;makeParts();};
+ document.getElementById('sR').onchange=e=>{Rxn=e.target.value==='1';makeParts();};
+ document.getElementById('addBtn').onclick=()=>{makeParts();};
+ document.getElementById('clrBtn').onclick=()=>{makeParts();};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+
+// ===== 6. 细胞有丝分裂 =====
+const cellDivision: AnimationTemplate = {
+ id: 'cell-division',
+ name: '细胞有丝分裂',
+ category: 'biology',
+ description: '展示有丝分裂各时期:间期、前期、中期、后期、末期',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #84cc16, #65a30d)',
+ params: [
+ { key: 'speed', label: '演示速度', type: 'number', default: 1, min: 0.3, max: 3, step: 0.1 },
+ { key: 'autoPlay', label: '自动播放', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: () => '细胞有丝分裂过程',
+ render: (p) => {
+ const speed = num(p, 'speed', 1), auto = String(p.autoPlay ?? '1')
+ return SHELL('细胞有丝分裂', '', `
+ (function(){
+ const init={speed:${speed},auto:'${auto}'==='1'};
+ const stages=['间期','前期','中期','后期','末期'];
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
当前时期 '+stages.map((s,i)=>''+s+' ').join('')+'
'+
+ '
速度 = '+init.speed+'x
'+
+ '
'+(init.auto?'暂停':'播放')+' '+
+ '下一时期
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let cur=0,prog=0,speed=init.speed,playing=init.auto,lastT=null;
+ function fit(){const w=Math.max(320,stage.clientWidth-40),h=Math.max(320,w*0.6);cv.width=w;cv.height=h;draw();}
+ function draw(){
+ const w=cv.width,h=cv.height,cx=w/2,cy=h/2;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#f0fdf4';ctx.fillRect(0,0,w,h);
+ // cell membrane
+ ctx.strokeStyle='#65a30d';ctx.lineWidth=3;
+ ctx.beginPath();ctx.ellipse(cx,cy,w*0.32,h*0.34,0,0,Math.PI*2);ctx.stroke();
+ ctx.fillStyle='rgba(132,204,22,0.08)';ctx.fill();
+ // nucleus / chromosomes based on stage
+ const t=prog;
+ if(cur===0){ // 间期
+ ctx.fillStyle='rgba(101,163,13,0.3)';
+ ctx.beginPath();ctx.arc(cx,cy,Math.min(w,h)*0.13,0,Math.PI*2);ctx.fill();
+ ctx.strokeStyle='#65a30d';ctx.lineWidth=2;ctx.stroke();
+ ctx.fillStyle='#374151';ctx.font='13px sans-serif';
+ ctx.fillText('染色质复制,核膜核仁完整',16,h-20);
+ } else if(cur===1){ // 前期
+ ctx.strokeStyle='#65a30d';ctx.lineWidth=1.5;
+ ctx.beginPath();ctx.arc(cx,cy,Math.min(w,h)*0.13*(1-t*0.5),0,Math.PI*2);ctx.stroke();
+ drawChroms(8,cx,cy,w*0.05,t*0.6+0.2);
+ // spindle forming
+ drawSpindle(cx,cy,w*0.25,t*0.5);
+ ctx.fillStyle='#374151';ctx.font='13px sans-serif';
+ ctx.fillText('染色质凝缩为染色体,核膜核仁解体',16,h-20);
+ } else if(cur===2){ // 中期
+ ctx.strokeStyle='#9ca3af';ctx.lineWidth=1.5;
+ for(let i=0;i<8;i++){
+ const px=cx-w*0.18+i*w*0.05,py=cy;
+ ctx.fillStyle=i%2?'#ef4444':'#3b82f6';
+ ctx.fillRect(px-4,py-12,8,24);
+ }
+ drawSpindle(cx,cy,w*0.28,1);
+ ctx.fillStyle='#374151';ctx.font='13px sans-serif';
+ ctx.fillText('染色体着丝粒整齐排列在赤道板上',16,h-20);
+ } else if(cur===3){ // 后期
+ const off=w*0.12*t;
+ for(let i=0;i<8;i++){
+ const dir=i%2?1:-1;
+ const px=cx+dir*off+((i%4)-1.5)*10,py=cy;
+ ctx.fillStyle=i%2?'#ef4444':'#3b82f6';
+ ctx.fillRect(px-4,py-12,8,24);
+ }
+ drawSpindle(cx,cy,w*0.28,1);
+ ctx.fillStyle='#374151';ctx.font='13px sans-serif';
+ ctx.fillText('着丝粒分裂,姐妹染色单体移向两极',16,h-20);
+ } else { // 末期
+ const r=Math.min(w,h)*0.1*(0.5+t*0.5);
+ ctx.fillStyle='rgba(101,163,13,0.25)';
+ ctx.beginPath();ctx.arc(cx-w*0.15,cy,r,0,Math.PI*2);ctx.fill();
+ ctx.beginPath();ctx.arc(cx+w*0.15,cy,r,0,Math.PI*2);ctx.fill();
+ ctx.strokeStyle='#65a30d';ctx.lineWidth=2;
+ ctx.beginPath();ctx.arc(cx-w*0.15,cy,r,0,Math.PI*2);ctx.stroke();
+ ctx.beginPath();ctx.arc(cx+w*0.15,cy,r,0,Math.PI*2);ctx.stroke();
+ // cleavage
+ if(t>0.5){ctx.strokeStyle='#65a30d';ctx.setLineDash([6,4]);
+ ctx.beginPath();ctx.moveTo(cx,cy-h*0.3*(t-0.5)*2);ctx.lineTo(cx,cy+h*0.3*(t-0.5)*2);ctx.stroke();ctx.setLineDash([]);}
+ ctx.fillStyle='#374151';ctx.font='13px sans-serif';
+ ctx.fillText('细胞膜内陷,形成两个子细胞',16,h-20);
+ }
+ // title
+ ctx.fillStyle='#1f2937';ctx.font='bold 16px sans-serif';
+ ctx.fillText(stages[cur]+' ('+(cur+1)+'/5)',16,24);
+ // progress
+ ctx.fillStyle='#e5e7eb';ctx.fillRect(16,h-44,w-32,4);
+ ctx.fillStyle='#65a30d';ctx.fillRect(16,h-44,(w-32)*((cur+prog)/5),4);
+ }
+ function drawChroms(n,cx,cy,r,o){
+ for(let i=0;i=1){prog=0;cur=(cur+1)%5;}
+ } else lastT=null;
+ draw();requestAnimationFrame(loop);
+ }
+ document.getElementById('sStage').onchange=e=>{cur=+e.target.value;prog=0;};
+ document.getElementById('sSp').oninput=e=>{speed=+e.target.value;document.getElementById('vSp').textContent=speed+'x';};
+ document.getElementById('playBtn').onclick=e=>{playing=!playing;e.target.textContent=playing?'暂停':'播放';if(!playing)lastT=null;};
+ document.getElementById('nextBtn').onclick=()=>{prog=0;cur=(cur+1)%5;document.getElementById('sStage').value=cur;};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+
+// ===== 7. 太阳系行星公转 =====
+const solarSystem: AnimationTemplate = {
+ id: 'solar-system',
+ name: '行星公转模拟',
+ category: 'geography',
+ description: '太阳系八大行星公转动画,可调节速度',
+ icon: 'Sunny',
+ accent: 'linear-gradient(135deg, #f97316, #ea580c)',
+ params: [
+ { key: 'speed', label: '公转速度', type: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
+ { key: 'showLabels', label: '显示名称', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ { key: 'showOrbits', label: '显示轨道', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: () => '太阳系行星公转',
+ render: (p) => {
+ const sp = num(p, 'speed', 1), lab = String(p.showLabels ?? '1'), orb = String(p.showOrbits ?? '1')
+ return SHELL('太阳系行星公转', '', `
+ (function(){
+ const init={speed:${sp},lab:'${lab}'==='1',orb:'${orb}'==='1'};
+ const planets=[
+ {n:'水星',r:50, s:4.15,c:'#9ca3af',sz:4},
+ {n:'金星',r:80, s:1.62,c:'#fbbf24',sz:7},
+ {n:'地球',r:115, s:1.00,c:'#3b82f6',sz:7.5},
+ {n:'火星',r:150, s:0.53,c:'#ef4444',sz:5},
+ {n:'木星',r:210, s:0.084,c:'#d97706',sz:14},
+ {n:'土星',r:265, s:0.034,c:'#fde68a',sz:12},
+ {n:'天王星',r:310,s:0.012,c:'#67e8f9',sz:9},
+ {n:'海王星',r:350,s:0.006,c:'#1e40af',sz:9}
+ ];
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
速度 = '+init.speed+'x
'+
+ '
名称 显示 隐藏
'+
+ '
轨道 显示 隐藏
'+
+ '
暂停
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Speed=init.speed,Lab=init.lab,Orb=init.orb,t0=null,total=0;
+ const ph=planets.map(()=>Math.random()*Math.PI*2);
+ function fit(){const w=Math.max(360,stage.clientWidth-40),h=Math.max(400,w*0.95);cv.width=w;cv.height=h;}
+ function draw(dt){
+ const w=cv.width,h=cv.height,cx=w/2,cy=h/2;
+ // space bg
+ ctx.fillStyle='#0f172a';ctx.fillRect(0,0,w,h);
+ // stars
+ if(!draw._stars){draw._stars=[];for(let i=0;i<80;i++)draw._stars.push({x:Math.random()*w,y:Math.random()*h,r:Math.random()*1.5});}
+ ctx.fillStyle='#fff';draw._stars.forEach(s=>{ctx.globalAlpha=0.3+Math.random()*0.5;ctx.fillRect(s.x,s.y,s.r,s.r);});
+ ctx.globalAlpha=1;
+ // scale
+ const maxR=Math.min(w,h)/2-30;
+ const sc=maxR/360;
+ // sun
+ const grad=ctx.createRadialGradient(cx,cy,4,cx,cy,30);
+ grad.addColorStop(0,'#fef3c7');grad.addColorStop(0.5,'#fbbf24');grad.addColorStop(1,'rgba(245,158,11,0)');
+ ctx.fillStyle=grad;ctx.beginPath();ctx.arc(cx,cy,30,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fde68a';ctx.beginPath();ctx.arc(cx,cy,14,0,Math.PI*2);ctx.fill();
+ // planets
+ planets.forEach((p,i)=>{
+ ph[i]+=dt*p.s*Speed*0.3;
+ const rr=p.r*sc;
+ if(Orb){ctx.strokeStyle='rgba(148,163,184,0.25)';ctx.lineWidth=1;
+ ctx.beginPath();ctx.arc(cx,cy,rr,0,Math.PI*2);ctx.stroke();}
+ const px=cx+Math.cos(ph[i])*rr, py=cy+Math.sin(ph[i])*rr;
+ ctx.fillStyle=p.c;ctx.beginPath();ctx.arc(px,py,p.sz,0,Math.PI*2);ctx.fill();
+ // saturn ring
+ if(p.n==='土星'){ctx.strokeStyle='#fde68a';ctx.lineWidth=1.5;ctx.globalAlpha=0.6;
+ ctx.beginPath();ctx.ellipse(px,py,p.sz+4,(p.sz+4)*0.3,ph[i]*0.3,0,Math.PI*2);ctx.stroke();ctx.globalAlpha=1;}
+ if(Lab){ctx.fillStyle='#cbd5e1';ctx.font='11px sans-serif';ctx.textAlign='center';
+ ctx.fillText(p.n,px,py-p.sz-6);}
+ });
+ ctx.textAlign='left';
+ ctx.fillStyle='#94a3b8';ctx.font='12px sans-serif';
+ ctx.fillText('公转周期按真实比例简化',12,h-12);
+ }
+ function loop(ts){
+ if(t0===null)t0=ts;
+ const dt=(ts-t0)/1000;t0=ts;
+ draw(dt);requestAnimationFrame(loop);
+ }
+ document.getElementById('sSp').oninput=e=>{Speed=+e.target.value;document.getElementById('vSp').textContent=Speed+'x';};
+ document.getElementById('sL').onchange=e=>{Lab=e.target.value==='1';};
+ document.getElementById('sO').onchange=e=>{Orb=e.target.value==='1';};
+ document.getElementById('playBtn').onclick=e=>{
+ if(t0===null){t0=null;}else{t0=null;}
+ // toggle by freezing dt
+ if(e.target.textContent==='暂停'){e.target.textContent='播放';draw._freeze=true;}
+ else{e.target.textContent='暂停';draw._freeze=false;}
+ };
+ // override loop to honor freeze
+ const origLoop=loop;
+ window._loop2=ts=>{
+ if(!draw._freeze){if(t0===null)t0=ts;const dt=(ts-t0)/1000;t0=ts;draw(dt);}
+ else t0=null;
+ requestAnimationFrame(window._loop2);
+ };
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(window._loop2);
+ })();
+ `);
+ },
+}
+
+
+// ===== 8. 昼夜交替与四季 =====
+const dayNight: AnimationTemplate = {
+ id: 'day-night',
+ name: '昼夜与四季',
+ category: 'geography',
+ description: '地球自转产生昼夜,公转产生四季变化',
+ icon: 'Sunny',
+ accent: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
+ params: [
+ { key: 'tilt', label: '地轴倾角 (°)', type: 'number', default: 23.5, min: 0, max: 45, step: 0.5 },
+ { key: 'spinSpeed', label: '自转速度', type: 'number', default: 1, min: 0.1, max: 5, step: 0.1 },
+ { key: 'season', label: '季节(北半球)', type: 'select', default: 'spring', options: [
+ {label:'春分',value:'spring'},{label:'夏至',value:'summer'},{label:'秋分',value:'autumn'},{label:'冬至',value:'winter'}
+ ] },
+ ],
+ defaultTitle: (p) => '昼夜交替 · ' + ({spring:'春分',summer:'夏至',autumn:'秋分',winter:'冬至'} as any)[p.season || 'spring'],
+ render: (p) => {
+ const tilt = num(p, 'tilt', 23.5), sp = num(p, 'spinSpeed', 1), season = String(p.season ?? 'spring')
+ return SHELL('昼夜交替与四季', '', `
+ (function(){
+ const init={tilt:${tilt},sp:${sp},season:'${season}'};
+ const seasonAng={spring:0,summer:Math.PI/2,autumn:Math.PI,winter:-Math.PI/2};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
地轴倾角 = '+init.tilt+'°
'+
+ '
自转速度 = '+init.sp+'x
'+
+ '
季节 '+
+ ['春分 ',
+ '夏至 ',
+ '秋分 ',
+ '冬至 '].join('')+
+ '
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Tilt=init.tilt*Math.PI/180,Sp=init.sp,Season=init.season,rot=0,lastT=null;
+ function fit(){const w=Math.max(320,stage.clientWidth-40),h=Math.max(340,w*0.6);cv.width=w;cv.height=h;}
+ function draw(){
+ const w=cv.width,h=cv.height,cx=w/2,cy=h/2;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#0c1428';ctx.fillRect(0,0,w,h);
+ // sun rays from left
+ const sunX=40,sunY=cy;
+ ctx.fillStyle='#fde047';ctx.beginPath();ctx.arc(sunX,sunY,22,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='rgba(253,224,71,0.2)';ctx.beginPath();ctx.arc(sunX,sunY,34,0,Math.PI*2);ctx.fill();
+ // sun rays
+ ctx.strokeStyle='rgba(253,224,71,0.25)';ctx.lineWidth=1;
+ for(let i=-3;i<=3;i++){ctx.beginPath();ctx.moveTo(sunX+22,sunY+i*14);ctx.lineTo(cx-90,sunY+i*20);ctx.stroke();}
+ // earth
+ const R=Math.min(w,h)*0.22;
+ ctx.save();ctx.translate(cx,cy);ctx.rotate(Tilt);
+ // day side
+ const dayGrad=ctx.createLinearGradient(-R,0,R,0);
+ dayGrad.addColorStop(0,'#1e40af');dayGrad.addColorStop(0.5,'#3b82f6');dayGrad.addColorStop(1,'#0f172a');
+ ctx.fillStyle=dayGrad;ctx.beginPath();ctx.arc(0,0,R,0,Math.PI*2);ctx.fill();
+ // meridians (longitude lines) showing rotation
+ ctx.strokeStyle='rgba(255,255,255,0.25)';ctx.lineWidth=1;
+ for(let i=0;i<8;i++){
+ const a=rot+i*Math.PI/4;
+ const ex=Math.abs(Math.cos(a))*R;
+ ctx.beginPath();ctx.ellipse(0,0,ex,R,0,-Math.PI/2,Math.PI/2);ctx.stroke();
+ }
+ // equator
+ ctx.strokeStyle='rgba(255,200,100,0.4)';ctx.lineWidth=1.5;
+ ctx.beginPath();ctx.ellipse(0,0,R,R*0.1,0,0,Math.PI*2);ctx.stroke();
+ // axis
+ ctx.strokeStyle='#f59e0b';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(0,-R-15);ctx.lineTo(0,R+15);ctx.stroke();
+ // N/S markers
+ ctx.fillStyle='#fbbf24';ctx.font='bold 12px sans-serif';ctx.textAlign='center';
+ ctx.fillText('N',0,-R-20);ctx.fillText('S',0,R+28);
+ ctx.restore();
+ // day/night divider line
+ ctx.strokeStyle='rgba(253,224,71,0.6)';ctx.setLineDash([5,5]);ctx.lineWidth=1.5;
+ ctx.beginPath();ctx.moveTo(cx-90,cy-R-20);ctx.lineTo(cx-90,cy+R+20);ctx.stroke();ctx.setLineDash([]);
+ ctx.fillStyle='#fbbf24';ctx.textAlign='left';ctx.font='12px sans-serif';
+ ctx.fillText('← 昼',cx-130,cy-R-30);ctx.fillText('夜 →',cx-78,cy-R-30);
+ // season info
+ const sa=seasonAng[Season];
+ const northLit=Season==='summer', northDark=Season==='winter';
+ const info=Season==='summer'?'北半球夏至:昼最长夜最短,阳光直射北回归线':
+ Season==='winter'?'北半球冬至:夜最长昼最短,阳光直射南回归线':
+ Season==='spring'?'春分:全球昼夜平分,阳光直射赤道':
+ '秋分:全球昼夜平分,阳光直射赤道';
+ ctx.fillStyle='#cbd5e1';ctx.font='13px sans-serif';
+ ctx.fillText(info,16,h-16);
+ ctx.fillStyle='#fff';ctx.font='bold 16px sans-serif';
+ ctx.fillText('地轴倾角 '+Math.round(Tilt*180/Math.PI)+'°',16,28);
+ }
+ function loop(ts){
+ if(lastT===null)lastT=ts;
+ const dt=(ts-lastT)/1000;lastT=ts;
+ rot+=dt*Sp*0.8;
+ draw();requestAnimationFrame(loop);
+ }
+ document.getElementById('sT').oninput=e=>{Tilt=+e.target.value*Math.PI/180;document.getElementById('vT').textContent=+e.target.value+'°';};
+ document.getElementById('sS').oninput=e=>{Sp=+e.target.value;document.getElementById('vS').textContent=Sp+'x';};
+ document.getElementById('sSeason').onchange=e=>{Season=e.target.value;};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+
+
+// ===== 9. 几何变换 =====
+const geometricTransform: AnimationTemplate = {
+ id: 'geometric-transform',
+ name: '几何变换',
+ category: 'math',
+ description: '演示图形的平移、旋转、轴对称、缩放四种变换',
+ icon: 'FullScreen',
+ accent: 'linear-gradient(135deg, #8b5cf6, #7c3aed)',
+ params: [
+ { key: 'shape', label: '图形', type: 'select', default: 'triangle', options: [
+ {label:'三角形', value:'triangle'}, {label:'正方形', value:'square'}, {label:'五角星', value:'star'}, {label:'箭头', value:'arrow'}
+ ] },
+ { key: 'transform', label: '变换类型', type: 'select', default: 'rotate', options: [
+ {label:'旋转', value:'rotate'}, {label:'平移', value:'translate'}, {label:'轴对称', value:'reflect'}, {label:'缩放', value:'scale'}
+ ] },
+ { key: 'param', label: '参数 (角度°/距离/倍数)', type: 'number', default: 45, min: -360, max: 360, step: 5 },
+ ],
+ defaultTitle: (p) => '几何变换 · ' + ({rotate:'旋转',translate:'平移',reflect:'轴对称',scale:'缩放'} as any)[p.transform || 'rotate'],
+ render: (p) => {
+ const shape = String(p.shape || 'triangle'), tf = String(p.transform || 'rotate'), param = num(p, 'param', 45)
+ return SHELL('几何变换', '', `
+ (function(){
+ const init={shape:'${shape}',tf:'${tf}',param:${param}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
图形 '+
+ ['triangle','square','star','arrow'].map(s=>''+({triangle:'三角形',square:'正方形',star:'五角星',arrow:'箭头'})[s]+' ').join('')+
+ '
'+
+ '
变换 '+
+ ['rotate','translate','reflect','scale'].map(t=>''+({rotate:'旋转',translate:'平移',reflect:'轴对称',scale:'缩放'})[t]+' ').join('')+
+ '
'+
+ '
参数 = '+init.param+'
'+
+ '
动画演示
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Shape=init.shape,Tf=init.tf,P=init.param,animT=0,playing=false,raf=0,lastT=0;
+ function fit(){const w=Math.max(320,stage.clientWidth-40),h=Math.max(360,w*0.6);cv.width=w;cv.height=h;draw();}
+ function drawShape(cx,cy,size,color,alpha,scale,rot){
+ ctx.save();ctx.translate(cx,cy);ctx.rotate(rot);ctx.scale(scale,scale);
+ ctx.globalAlpha=alpha;ctx.fillStyle=color;ctx.strokeStyle=color;ctx.lineWidth=2;
+ ctx.beginPath();
+ if(Shape==='triangle'){ctx.moveTo(0,-size);ctx.lineTo(-size*0.866,size*0.5);ctx.lineTo(size*0.866,size*0.5);ctx.closePath();}
+ else if(Shape==='square'){ctx.rect(-size,-size,size*2,size*2);}
+ else if(Shape==='star'){for(let i=0;i<10;i++){const a=-Math.PI/2+i*Math.PI/5;const r=i%2?size*0.4:size;if(i===0)ctx.moveTo(Math.cos(a)*r,Math.sin(a)*r);else ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r);}ctx.closePath();}
+ else {ctx.moveTo(0,-size);ctx.lineTo(size*0.6,0);ctx.lineTo(size*0.25,0);ctx.lineTo(size*0.25,size);ctx.lineTo(-size*0.25,size);ctx.lineTo(-size*0.25,0);ctx.lineTo(-size*0.6,0);ctx.closePath();}
+ ctx.fill();ctx.globalAlpha=1;ctx.restore();
+ }
+ function draw(){
+ const w=cv.width,h=cv.height,cx=w/2,cy=h/2;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#faf5ff';ctx.fillRect(0,0,w,h);
+ const size=Math.min(w,h)*0.12;
+ // grid
+ ctx.strokeStyle='#ede9fe';ctx.lineWidth=1;
+ for(let x=0;x=1){animT=1;playing=false;document.getElementById('playBtn').textContent='再次演示';}}
+ draw();if(playing)raf=requestAnimationFrame(loop);else raf=0;}
+ document.getElementById('sShape').onchange=e=>{Shape=e.target.value;draw();};
+ document.getElementById('sTf').onchange=e=>{Tf=e.target.value;draw();};
+ document.getElementById('sP').oninput=e=>{P=+e.target.value;document.getElementById('vP').textContent=P;draw();};
+ document.getElementById('playBtn').onclick=e=>{animT=0;playing=true;e.target.textContent='演示中';cancelAnimationFrame(raf);lastT=0;raf=requestAnimationFrame(loop);};
+ window.addEventListener('resize',fit);fit();
+ })();
+ `);
+ },
+}
+
+
+// ===== 10. 串并联电路 =====
+const circuit: AnimationTemplate = {
+ id: 'circuit',
+ name: '串并联电路',
+ category: 'physics',
+ description: '调节电压与电阻,观察电流与灯泡亮度变化',
+ icon: 'Lightning',
+ accent: 'linear-gradient(135deg, #facc15, #eab308)',
+ params: [
+ { key: 'voltage', label: '电压 (V)', type: 'number', default: 6, min: 1, max: 24, step: 0.5 },
+ { key: 'resistance', label: '电阻 (Ω)', type: 'number', default: 3, min: 0.5, max: 20, step: 0.5 },
+ { key: 'mode', label: '连接方式', type: 'select', default: 'series', options: [{label:'串联',value:'series'},{label:'并联',value:'parallel'}] },
+ ],
+ defaultTitle: (p) => ({series:'串联',parallel:'并联'} as any)[p.mode || 'series'] + '电路 · V=' + p.voltage + 'V',
+ render: (p) => {
+ const v = num(p, 'voltage', 6), r = num(p, 'resistance', 3), mode = String(p.mode || 'series')
+ return SHELL('串并联电路', '', `
+ (function(){
+ const init={v:${v},r:${r},mode:'${mode}'};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
电压 = '+init.v+'V
'+
+ '
电阻 = '+init.r+'Ω
'+
+ '
连接 串联 并联
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let V=init.v,R=init.r,Mode=init.mode;
+ function fit(){const w=Math.max(360,stage.clientWidth-40),h=Math.max(320,w*0.5);cv.width=w;cv.height=h;}
+ function bulb(x,y,brightness){
+ // glow
+ if(brightness>0.05){ctx.fillStyle='rgba(254,240,138,'+(brightness*0.5)+')';
+ ctx.beginPath();ctx.arc(x,y,30+brightness*20,0,Math.PI*2);ctx.fill();}
+ // glass
+ ctx.fillStyle=brightness>0.05?'#fef08a':'#e5e7eb';
+ ctx.beginPath();ctx.arc(x,y,20,0,Math.PI*2);ctx.fill();
+ ctx.strokeStyle='#475569';ctx.lineWidth=2;ctx.stroke();
+ // filament
+ ctx.strokeStyle=brightness>0.05?'#ea580c':'#94a3b8';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(x-8,y);ctx.bezierCurveTo(x-4,y+6,x+4,y+6,x+8,y);ctx.stroke();
+ // base
+ ctx.fillStyle='#475569';ctx.fillRect(x-8,y+18,16,8);
+ }
+ function battery(x,y){
+ ctx.strokeStyle='#475569';ctx.lineWidth=3;
+ ctx.beginPath();ctx.moveTo(x,y-18);ctx.lineTo(x,y+18);ctx.stroke();
+ ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(x+8,y-10);ctx.lineTo(x+8,y+10);ctx.stroke();
+ ctx.fillStyle='#1f2937';ctx.font='bold 13px sans-serif';ctx.textAlign='center';
+ ctx.fillText('+',x-10,y-20);ctx.fillText('−',x+12,y+20);
+ ctx.textAlign='left';
+ }
+ function wire(x1,y1,x2,y2,dashed){ctx.strokeStyle='#475569';ctx.lineWidth=3;
+ if(dashed)ctx.setLineDash([6,4]);ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();ctx.setLineDash([]);}
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#fffbeb';ctx.fillRect(0,0,w,h);
+ // calc
+ const bulbCount=2;
+ let totalR, current, perBright;
+ if(Mode==='series'){totalR=R*bulbCount;current=V/totalR;perBright=Math.min(1,current/2);}
+ else {totalR=R/bulbCount;current=V/totalR;perBright=Math.min(1,(V/R)/2);}
+ const cx=w/2,cy=h/2;
+ // layout
+ const bx1=cx-80,bx2=cx+80,by=cy-30;
+ const batX=cx,batY=cy+70;
+ // wires from battery up to bulbs
+ if(Mode==='series'){
+ wire(batX,batY-10,batX,cy+10);
+ wire(batX,cy+10,bx1,cy+10);
+ wire(bx1,cy+10,bx1,by+20);
+ wire(bx1,by-20,bx2,by+20); // top wire between bulbs (wrong; redo)
+ }
+ // simpler: draw rectangular loop
+ ctx.strokeStyle='#475569';ctx.lineWidth=3;
+ const left=cx-140,right=cx+140,top=by-40,bottom=batY;
+ // vertical & horizontal wires forming a loop
+ ctx.beginPath();
+ ctx.moveTo(left,batY);ctx.lineTo(left,top);ctx.lineTo(right,top);ctx.lineTo(right,batY);
+ ctx.stroke();
+ // battery gap at bottom center
+ ctx.clearRect(batX-3,bottom-2,6,20);
+ battery(batX,bottom+8);
+ // reconnect battery to loop
+ ctx.beginPath();ctx.moveTo(batX-2,bottom+8);ctx.lineTo(batX-2,bottom);ctx.lineTo(left,bottom);
+ ctx.moveTo(batX+2,bottom+8);ctx.lineTo(batX+2,bottom);ctx.lineTo(right,bottom);ctx.stroke();
+ // bulbs
+ if(Mode==='series'){
+ bulb(bx1,top+10,perBright);bulb(bx2,top+10,perBright);
+ } else {
+ // parallel: each bulb on its own vertical branch
+ bulb(bx1,top+10,perBright);bulb(bx2,top+10,perBright);
+ ctx.strokeStyle='#475569';ctx.lineWidth=3;
+ ctx.beginPath();ctx.moveTo(bx1,top+30);ctx.lineTo(bx1,bottom);ctx.stroke();
+ ctx.beginPath();ctx.moveTo(bx2,top+30);ctx.lineTo(bx2,bottom);ctx.stroke();
+ }
+ // resistor markers
+ ctx.fillStyle='#7c3aed';ctx.font='11px sans-serif';
+ ctx.fillText('R='+R+'Ω',bx1-20,top-30);ctx.fillText('R='+R+'Ω',bx2-20,top-30);
+ // readout
+ ctx.fillStyle='#1f2937';ctx.font='bold 14px sans-serif';
+ ctx.fillText('电压 V='+V+'V · 电阻 R='+R+'Ω · 电流 I='+current.toFixed(2)+'A',16,h-16);
+ ctx.fillStyle='#7c3aed';ctx.font='12px sans-serif';
+ ctx.fillText(Mode==='series'?'串联:I=V/(R₁+R₂)='+(V/(R*2)).toFixed(2)+'A':'并联:I=V×(1/R₁+1/R₂)='+(V*(2/R)).toFixed(2)+'A',16,h-36);
+ }
+ document.getElementById('sV').oninput=e=>{V=+e.target.value;document.getElementById('vV').textContent=V+'V';draw();};
+ document.getElementById('sR').oninput=e=>{R=+e.target.value;document.getElementById('vR').textContent=R+'Ω';draw();};
+ document.getElementById('sM').onchange=e=>{Mode=e.target.value;draw();};
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+
+// ===== 11. 食物链与能量流动 =====
+const foodChain: AnimationTemplate = {
+ id: 'food-chain',
+ name: '食物链与能量流动',
+ category: 'biology',
+ description: '展示生产者→消费者→分解者的能量流动与数量金字塔',
+ icon: 'Connection',
+ accent: 'linear-gradient(135deg, #22c55e, #16a34a)',
+ params: [
+ { key: 'energyStart', label: '起始能量 (%)', type: 'number', default: 100, min: 10, max: 100, step: 5 },
+ { key: 'transfer', label: '能量传递效率 (%)', type: 'number', default: 10, min: 5, max: 30, step: 1 },
+ ],
+ defaultTitle: () => '食物链与能量流动',
+ render: (p) => {
+ const e0 = num(p, 'energyStart', 100), tr = num(p, 'transfer', 10)
+ return SHELL('食物链与能量流动', '', `
+ (function(){
+ const init={e0:${e0},tr:${tr}};
+ const stages=[
+ {n:'生产者 (草)',emoji:'🌱',color:'#22c55e'},
+ {n:'初级消费者 (兔)',emoji:'🐰',color:'#84cc16'},
+ {n:'次级消费者 (狐)',emoji:'🦊',color:'#f59e0b'},
+ {n:'顶级消费者 (狼)',emoji:'🐺',color:'#dc2626'}
+ ];
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let E0=init.e0,Tr=init.tr;
+ function fit(){const w=Math.max(360,stage.clientWidth-40),h=Math.max(380,w*0.7);cv.width=w;cv.height=h;}
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#f0fdf4';ctx.fillRect(0,0,w,h);
+ // calc energy at each level
+ const energies=stages.map((s,i)=>E0*Math.pow(Tr/100,i));
+ // draw chain horizontally with arrows
+ const n=stages.length;
+ const padX=80,padY=120;
+ const dx=(w-2*padX)/(n-1);
+ stages.forEach((s,i)=>{
+ const x=padX+i*dx,y=padY;
+ // box
+ ctx.fillStyle=s.color;ctx.globalAlpha=0.15;
+ ctx.fillRect(x-50,y-40,100,80);ctx.globalAlpha=1;
+ ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.strokeRect(x-50,y-40,100,80);
+ // emoji
+ ctx.font='36px sans-serif';ctx.textAlign='center';ctx.fillText(s.emoji,x,y-2);
+ // name
+ ctx.fillStyle='#1f2937';ctx.font='11px sans-serif';ctx.fillText(s.n,x,y+24);
+ // energy
+ ctx.fillStyle='#15803d';ctx.font='bold 13px sans-serif';
+ ctx.fillText(energies[i].toFixed(1)+'%',x,y+38);
+ // arrow to next
+ if(i{
+ const frac=energies[i]/E0;
+ const segW=pyrW*frac;
+ const segX=(w-segW)/2;
+ const segY=pyrTop+i*(pyrH/n);
+ ctx.fillStyle=s.color;ctx.globalAlpha=0.7;
+ ctx.fillRect(segX,segY,segW,pyrH/n-4);ctx.globalAlpha=1;
+ ctx.fillStyle='#fff';ctx.font='11px sans-serif';ctx.textAlign='center';
+ ctx.fillText(s.emoji+' '+energies[i].toFixed(1)+'%',w/2,segY+(pyrH/n)/2+4);
+ });
+ ctx.textAlign='left';
+ ctx.fillStyle='#1f2937';ctx.font='12px sans-serif';
+ ctx.fillText('能量沿食物链单向流动,逐级递减;传递效率 '+Tr+'% 意味着每升一级仅保留约 '+Tr+'% 的能量。',16,h-16);
+ }
+ document.getElementById('sE').oninput=e=>{E0=+e.target.value;document.getElementById('vE').textContent=E0+'%';draw();};
+ document.getElementById('sT').oninput=e=>{Tr=+e.target.value;document.getElementById('vT').textContent=Tr+'%';draw();};
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+
+// ===== 12. 水循环 =====
+const waterCycle: AnimationTemplate = {
+ id: 'water-cycle',
+ name: '水循环过程',
+ category: 'geography',
+ description: '蒸发、凝结、降水、径流——地球水循环的完整过程',
+ icon: 'Cloudy',
+ accent: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
+ params: [
+ { key: 'speed', label: '循环速度', type: 'number', default: 1, min: 0.2, max: 4, step: 0.1 },
+ { key: 'showLabels', label: '显示标注', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: () => '地球水循环',
+ render: (p) => {
+ const sp = num(p, 'speed', 1), lab = String(p.showLabels ?? '1')
+ return SHELL('水循环过程', '', `
+ (function(){
+ const init={sp:${sp},lab:'${lab}'==='1'};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ ''+
+ '
速度 = '+init.sp+'x
'+
+ '
标注 显示 隐藏
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ let Sp=init.sp,Lab=init.lab,t0=null,time=0;
+ // particles: evaporation drops rising, rain falling
+ let vapors=[],rains=[];
+ function fit(){const w=Math.max(360,stage.clientWidth-40),h=Math.max(360,w*0.65);cv.width=w;cv.height=h;}
+ function spawn(w,h){
+ // vapor from sea
+ if(Math.random()<0.3*Sp)vapors.push({x:w*0.2+Math.random()*w*0.3,y:h*0.7,vx:(Math.random()-0.5)*0.3,vy:-0.4-Math.random()*0.5,life:1});
+ // rain from cloud
+ if(Math.random()<0.4*Sp)rains.push({x:w*0.5+Math.random()*w*0.25,y:h*0.3,vx:0.1,vy:1.5+Math.random(),life:1});
+ }
+ function draw(){
+ const w=cv.width,h=cv.height;
+ // sky gradient
+ const sky=ctx.createLinearGradient(0,0,0,h*0.7);
+ sky.addColorStop(0,'#bfdbfe');sky.addColorStop(1,'#e0f2fe');
+ ctx.fillStyle=sky;ctx.fillRect(0,0,w,h*0.7);
+ // sea
+ const sea=ctx.createLinearGradient(0,h*0.7,0,h);
+ sea.addColorStop(0,'#0ea5e9');sea.addColorStop(1,'#0c4a6e');
+ ctx.fillStyle=sea;ctx.fillRect(0,h*0.7,w,h*0.3);
+ // sun
+ ctx.fillStyle='#fde047';ctx.beginPath();ctx.arc(w*0.85,h*0.15,24,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='rgba(253,224,71,0.3)';ctx.beginPath();ctx.arc(w*0.85,h*0.15,36,0,Math.PI*2);ctx.fill();
+ // mountain
+ ctx.fillStyle='#78716c';ctx.beginPath();
+ ctx.moveTo(w*0.1,h*0.7);ctx.lineTo(w*0.25,h*0.35);ctx.lineTo(w*0.4,h*0.7);ctx.closePath();ctx.fill();
+ ctx.fillStyle='#a8a29e';ctx.beginPath();
+ ctx.moveTo(w*0.22,h*0.42);ctx.lineTo(w*0.25,h*0.35);ctx.lineTo(w*0.28,h*0.45);ctx.closePath();ctx.fill();
+ // cloud
+ ctx.fillStyle='#fff';ctx.globalAlpha=0.95;
+ const cx=w*0.6+Math.sin(time*0.5)*15,cy=h*0.28;
+ ctx.beginPath();ctx.arc(cx,cy,22,0,Math.PI*2);ctx.arc(cx+24,cy+4,18,0,Math.PI*2);ctx.arc(cx-22,cy+4,16,0,Math.PI*2);ctx.fill();
+ ctx.globalAlpha=1;
+ // river
+ ctx.strokeStyle='#0284c7';ctx.lineWidth=6;ctx.lineCap='round';
+ ctx.beginPath();ctx.moveTo(w*0.25,h*0.5);ctx.bezierCurveTo(w*0.3,h*0.6,w*0.18,h*0.75,w*0.1,h*0.85);ctx.stroke();
+ // particles
+ spawn(w,h);
+ vapors=vapors.filter(v=>{
+ v.x+=v.vx*Sp;v.y+=v.y*Sp;v.life-=0.005*Sp;
+ ctx.fillStyle='rgba(186,230,253,'+v.life+')';ctx.beginPath();ctx.arc(v.x,v.y,3,0,Math.PI*2);ctx.fill();
+ return v.life>0&&v.y>cy;
+ });
+ rains=rains.filter(r=>{
+ r.x+=r.vx*Sp;r.y+=r.vy*Sp;
+ ctx.strokeStyle='rgba(2,132,199,0.7)';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(r.x,r.y);ctx.lineTo(r.x-2,r.y+8);ctx.stroke();
+ return r.y{Sp=+e.target.value;document.getElementById('vS').textContent=Sp+'x';};
+ document.getElementById('sL').onchange=e=>{Lab=e.target.value==='1';};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+
+
+const timeline: AnimationTemplate = {
+ id: 'history-timeline',
+ name: '历史时间线',
+ category: 'history',
+ description: '可滚动的历史事件时间轴,标注关键节点与年代',
+ icon: 'Clock',
+ accent: 'linear-gradient(135deg, #b45309, #92400e)',
+ params: [
+ { key: 'topic', label: '主题', type: 'text', default: '中国古代重大发明' },
+ { key: 'showDetail', label: '显示详情', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: () => '历史时间线',
+ render: (p) => {
+ const topic = String(p.topic ?? '中国古代重大发明')
+ const det = String(p.showDetail ?? '1')
+ return SHELL('历史时间线', topic, `
+ (function(){
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ const events=[
+ {y:'约公元前 3000',t:'文字雏形',d:'甲骨文前身,记录占卜与记事',col:'#b45309'},
+ {y:'约公元前 1000',t:'青铜冶炼',d:'商周青铜器,工艺登峰造极',col:'#c2410c'},
+ {y:'公元前 200',t:'造纸术雏形',d:'西汉初期已出现早期纸张',col:'#a16207'},
+ {y:'公元 105',t:'蔡伦改良造纸',d:'东汉蔡伦改进造纸工艺',col:'#ca8a04'},
+ {y:'约 9 世纪',t:'火药发明',d:'炼丹术意外发现的化学成就',col:'#d97706'},
+ {y:'约 11 世纪',t:'活字印刷术',d:'北宋毕昇发明胶泥活字',col:'#92400e'},
+ {y:'约 12 世纪',t:'指南针应用',d:'宋元航海广泛使用指南针',col:'#854d0e'},
+ ];
+ let scrollX=0,targetX=0;
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=300;cv.style.height='300px';}
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.fillStyle='#fffbeb';ctx.fillRect(0,0,w,h);
+ const lineY=h/2;
+ const padding=40,gap=Math.max(140,(w-80)/4);
+ const totalW=padding+events.length*gap+padding;
+ // gradient line
+ const lg=ctx.createLinearGradient(0,lineY,totalW,0);
+ lg.addColorStop(0,'#b45309');lg.addColorStop(1,'#ca8a04');
+ ctx.strokeStyle=lg;ctx.lineWidth=4;ctx.beginPath();
+ ctx.moveTo(-scrollX,lineY);ctx.lineTo(-scrollX+totalW,lineY);ctx.stroke();
+ ctx.font='12px sans-serif';
+ events.forEach((ev,i)=>{
+ const cx=padding+i*gap-scrollX;
+ if(cx<-60||cx>w+60)return;
+ ctx.fillStyle=ev.col;ctx.beginPath();ctx.arc(cx,lineY,9,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(cx,lineY,4,0,Math.PI*2);ctx.fill();
+ const above=i%2===0;
+ const ty=above?lineY-70:lineY+70;
+ ctx.strokeStyle=ev.col;ctx.lineWidth=2;ctx.setLineDash([3,3]);
+ ctx.beginPath();ctx.moveTo(cx,lineY);ctx.lineTo(cx,ty);ctx.stroke();ctx.setLineDash([]);
+ // year
+ ctx.fillStyle=ev.col;ctx.font='bold 13px sans-serif';
+ const yw=ctx.measureText(ev.y).width;
+ ctx.fillText(ev.y,cx-yw/2,above?ty-8:ty+20);
+ // title
+ ctx.fillStyle='#1f2937';ctx.font='bold 14px sans-serif';
+ const tw=ctx.measureText(ev.t).width;
+ ctx.fillText(ev.t,cx-tw/2,above?ty+8:ty+40);
+ });
+ // hint
+ ctx.fillStyle='#9ca3af';ctx.font='12px sans-serif';
+ ctx.fillText('拖动浏览时间线 →',w-140,h-16);
+ }
+ let drag=false,lastX=0;
+ cv.addEventListener('mousedown',e=>{drag=true;lastX=e.clientX;});
+ window.addEventListener('mousemove',e=>{if(drag){targetX+=lastX-e.clientX;lastX=e.clientX;}});
+ window.addEventListener('mouseup',()=>drag=false);
+ // touch
+ cv.addEventListener('touchstart',e=>{drag=true;lastX=e.touches[0].clientX;},{passive:true});
+ cv.addEventListener('touchmove',e=>{if(drag){targetX+=lastX-e.touches[0].clientX;lastX=e.touches[0].clientX;}},{passive:true});
+ cv.addEventListener('touchend',()=>drag=false);
+ function loop(){scrollX+=(targetX-scrollX)*0.15;draw();requestAnimationFrame(loop);}
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+const dynastyShift: AnimationTemplate = {
+ id: 'dynasty-shift',
+ name: '朝代更替轮',
+ category: 'history',
+ description: '旋转式展示中国主要朝代,点击切换查看详情',
+ icon: 'RefreshRight',
+ accent: 'linear-gradient(135deg, #7c2d12, #991b1b)',
+ params: [
+ { key: 'speed', label: '自动转速', type: 'number', default: 0.5, min: 0, max: 2, step: 0.1 },
+ { key: 'autoPlay', label: '自动轮播', type: 'select', default: '1', options: [{label:'是',value:'1'},{label:'否',value:'0'}] },
+ ],
+ defaultTitle: () => '朝代更替',
+ render: (p) => {
+ const sp = num(p, 'speed', 0.5), auto = String(p.autoPlay ?? '1')
+ return SHELL('朝代更替轮', '中国主要朝代', `
+ (function(){
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ const dyns=[
+ {n:'夏',f:'约前2070',desc:'中国第一个王朝'},
+ {n:'商',f:'约前1600',desc:'青铜文明鼎盛'},
+ {n:'周',f:'约前1046',desc:'分西周东周'},
+ {n:'秦',f:'前221',desc:'首个大一统帝国'},
+ {n:'汉',f:'前202',desc:'文景之治、丝路'},
+ {n:'唐',f:'618',desc:'贞观之治、开元盛世'},
+ {n:'宋',f:'960',desc:'经济文化繁荣'},
+ {n:'元',f:'1271',desc:'疆域最辽阔'},
+ {n:'明',f:'1368',desc:'郑和下西洋'},
+ {n:'清',f:'1644',desc:'末代王朝'},
+ ];
+ const cols=['#7c2d12','#92400e','#b45309','#a16207','#854d0e','#9a3412','#c2410c','#b91c1c','#991b1b','#7f1d1d'];
+ let angle=0,idx=0;
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=340;cv.style.height='340px';}
+ function draw(){
+ const w=cv.width,h=cv.height,cx=w/2,cy=h/2,R=Math.min(w,h)/2-30;
+ ctx.fillStyle='#fef3c7';ctx.fillRect(0,0,w,h);
+ const n=dyns.length;
+ const step=(Math.PI*2)/n;
+ for(let i=0;i{
+ idx=(idx+1)%dyns.length;angle+=Math.PI*2/dyns.length;
+ });
+ function loop(){
+ if('1'==='${auto}')angle+=0.002*${sp};
+ // keep idx synced with nearest
+ const n=dyns.length,step=Math.PI*2/n;
+ idx=Math.round(angle/step)%n;if(idx<0)idx+=n;
+ draw();requestAnimationFrame(loop);
+ }
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+const poetryScene: AnimationTemplate = {
+ id: 'poetry-scene',
+ name: '诗词意境',
+ category: 'chinese',
+ description: '古诗意境动画,月亮、飞鸟、山水渐次呈现',
+ icon: 'Moon',
+ accent: 'linear-gradient(135deg, #1e3a5f, #0f172a)',
+ params: [
+ { key: 'poem', label: '诗名', type: 'select', default: '0', options: [
+ {label:'静夜思',value:'0'},{label:'枫桥夜泊',value:'1'},{label:'望庐山瀑布',value:'2'}
+ ]},
+ { key: 'speed', label: '动画速度', type: 'number', default: 1, min: 0.3, max: 3, step: 0.1 },
+ ],
+ defaultTitle: () => '诗词意境动画',
+ render: (p) => {
+ const idx = Number(p.poem ?? '0'), sp = num(p, 'speed', 1)
+ return SHELL('诗词意境', '古典诗词动画呈现', `
+ (function(){
+ const poems=[
+ {t:'静夜思',v:'床前明月光,疑是地上霜。举头望明月,低头思故乡。'},
+ {t:'枫桥夜泊',v:'月落乌啼霜满天,江枫渔火对愁眠。姑苏城外寒山寺,夜半钟声到客船。'},
+ {t:'望庐山瀑布',v:'日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。'},
+ ];
+ const poem=poems[${idx}]||poems[0];
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ let t=0;
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=320;cv.style.height='320px';}
+ function draw(){
+ const w=cv.width,h=cv.height,sp=${sp};
+ // night sky
+ const sky=ctx.createLinearGradient(0,0,0,h);
+ sky.addColorStop(0,'#0f172a');sky.addColorStop(0.6,'#1e3a5f');sky.addColorStop(1,'#334155');
+ ctx.fillStyle=sky;ctx.fillRect(0,0,w,h);
+ // stars
+ for(let i=0;i<50;i++){
+ const sx=(i*137.5%w),sy=(i*89.3%(h*0.5));
+ ctx.fillStyle='rgba(255,255,255,'+(0.3+0.5*Math.abs(Math.sin(t*sp*0.5+i)))+')';
+ ctx.fillRect(sx,sy,1.5,1.5);
+ }
+ // moon
+ const mx=w*0.75,my=h*0.22;
+ ctx.fillStyle='rgba(255,251,235,0.15)';ctx.beginPath();ctx.arc(mx,my,42,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fef3c7';ctx.beginPath();ctx.arc(mx,my,26,0,Math.PI*2);ctx.fill();
+ // mountains
+ ctx.fillStyle='#1e293b';ctx.beginPath();ctx.moveTo(0,h*0.7);
+ for(let x=0;x<=w;x+=20)ctx.lineTo(x,h*0.7+Math.sin(x*0.02)*30-15);
+ ctx.lineTo(w,h);ctx.lineTo(0,h);ctx.fill();
+ // water
+ ctx.fillStyle='rgba(30,58,95,0.6)';ctx.fillRect(0,h*0.85,w,h*0.15);
+ // birds flying
+ for(let b=0;b<4;b++){
+ const bx=((t*40*sp+b*120)%(w+100))-50;
+ const by=h*0.2+Math.sin(t*sp+b)*8+b*12;
+ ctx.strokeStyle='#475569';ctx.lineWidth=2;
+ ctx.beginPath();
+ ctx.moveTo(bx-8,by);ctx.quadraticCurveTo(bx-4,by-4,bx,by);
+ ctx.quadraticCurveTo(bx+4,by-4,bx+8,by);ctx.stroke();
+ }
+ // poem text fade in
+ const alpha=Math.min(1,t*sp*0.3);
+ ctx.fillStyle='rgba(254,243,199,'+alpha+')';ctx.textAlign='center';
+ ctx.font='bold 18px serif';ctx.fillText(poem.t,w/2,h*0.5);
+ ctx.font='14px serif';
+ const lines=poem.v.split(',');
+ lines.forEach((ln,i)=>{
+ const sub=ln.replace(/[。]/g,'');
+ ctx.fillText(sub,w/2,h*0.58+i*22);
+ });
+ ctx.textAlign='left';
+ }
+ function loop(ts){t+=0.016;draw();requestAnimationFrame(loop);}
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+// ===== 扩展模板(第二批):分数 / 光的折射 / 酸碱滴定 / 光合作用 / 概率模拟 =====
+const fractions: AnimationTemplate = {
+ id: 'fractions',
+ name: '分数可视化',
+ category: 'math',
+ description: '圆形与条形双重对比两个分数的大小,含小数换算',
+ icon: 'PieChart',
+ accent: 'linear-gradient(135deg, #f59e0b, #d97706)',
+ params: [
+ { key: 'n1', label: '分数1 分子', type: 'number', default: 3, min: 0, max: 12, step: 1 },
+ { key: 'd1', label: '分数1 分母', type: 'number', default: 4, min: 1, max: 12, step: 1 },
+ { key: 'n2', label: '分数2 分子', type: 'number', default: 2, min: 0, max: 12, step: 1 },
+ { key: 'd2', label: '分数2 分母', type: 'number', default: 3, min: 1, max: 12, step: 1 },
+ ],
+ defaultTitle: (p) => `分数对比 ${p.n1}/${p.d1} 与 ${p.n2}/${p.d2}`,
+ render: (p) => {
+ const n1 = num(p, 'n1', 3), d1 = num(p, 'd1', 4), n2 = num(p, 'n2', 2), d2 = num(p, 'd2', 3)
+ return SHELL('分数可视化', '', `
+ (function(){
+ const init={n1:${n1},d1:${d1},n2:${n2},d2:${d2}};
+ const stage=document.getElementById('stage');
+ function sl(id,lbl,val,min,max){return ''+lbl+' = '+val+'
';}
+ stage.innerHTML='
'+sl('n1','分数1 分子',init.n1,0,12)+sl('d1','分数1 分母',init.d1,1,12)+sl('n2','分数2 分子',init.n2,0,12)+sl('d2','分数2 分母',init.d2,1,12)+'
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=320;cv.style.height='320px';}
+ function pie(cx,cy,r,n,d,color){
+ if(d<1)d=1;const filled=Math.max(0,Math.min(n,d))/d;
+ ctx.fillStyle='#f1f5f9';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle=color;ctx.beginPath();ctx.moveTo(cx,cy);ctx.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+filled*Math.PI*2);ctx.closePath();ctx.fill();
+ ctx.strokeStyle='#cbd5e1';ctx.lineWidth=2;ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.stroke();
+ ctx.lineWidth=1.5;for(let i=1;i0?Math.min(s.n1/s.d1,1):0,v2=s.d2>0?Math.min(s.n2/s.d2,1):0;
+ ctx.fillStyle='#1f2937';ctx.textAlign='center';ctx.font='bold 20px sans-serif';
+ ctx.fillText(s.n1+'/'+Math.max(1,s.d1),w*0.27,h*0.36+r+28);
+ ctx.fillText(s.n2+'/'+Math.max(1,s.d2),w*0.73,h*0.36+r+28);
+ ctx.fillStyle='#4f46e5';ctx.font='bold 30px sans-serif';
+ ctx.fillText(v1>v2?'>':v1{const el=document.getElementById('s'+k);el.oninput=()=>{document.getElementById('v'+k).textContent=el.value;draw();};});
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+const lightRefraction: AnimationTemplate = {
+ id: 'light-refraction',
+ name: '光的折射与全反射',
+ category: 'physics',
+ description: '斯涅尔定律演示:调节入射角与两介质折射率,观察折射、反射与全反射',
+ icon: 'Sunny',
+ accent: 'linear-gradient(135deg, #f59e0b, #ea580c)',
+ params: [
+ { key: 'angle', label: '入射角(°)', type: 'number', default: 30, min: 0, max: 89, step: 1 },
+ { key: 'n1', label: '介质1 折射率', type: 'number', default: 1.0, min: 1, max: 2, step: 0.01 },
+ { key: 'n2', label: '介质2 折射率', type: 'number', default: 1.5, min: 1, max: 2, step: 0.01 },
+ ],
+ defaultTitle: (p) => `光的折射(n1=${p.n1} → n2=${p.n2})`,
+ render: (p) => {
+ const angle = num(p, 'angle', 30), n1 = num(p, 'n1', 1), n2 = num(p, 'n2', 1.5)
+ return SHELL('光的折射与全反射', '', `
+ (function(){
+ const init={ang:${angle},n1:${n1},n2:${n2}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=340;cv.style.height='340px';}
+ function draw(){
+ const w=cv.width,h=cv.height,cy=h/2;
+ ctx.fillStyle='#e0f2fe';ctx.fillRect(0,0,w,cy);
+ ctx.fillStyle='#fef3c7';ctx.fillRect(0,cy,w,h-cy);
+ ctx.strokeStyle='#94a3b8';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(0,cy);ctx.lineTo(w,cy);ctx.stroke();
+ const hx=w/2,L=170;
+ const ang=+document.getElementById('sang').value,n1=+document.getElementById('sn1').value,n2=+document.getElementById('sn2').value;
+ document.getElementById('vang').textContent=ang+'°';document.getElementById('vn1').textContent=n1.toFixed(2);document.getElementById('vn2').textContent=n2.toFixed(2);
+ const a1=ang*Math.PI/180;
+ ctx.strokeStyle='#9ca3af';ctx.setLineDash([5,5]);ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(hx,cy-110);ctx.lineTo(hx,cy+110);ctx.stroke();ctx.setLineDash([]);
+ ctx.strokeStyle='#ef4444';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(hx-Math.sin(a1)*L,cy-Math.cos(a1)*L);ctx.lineTo(hx,cy);ctx.stroke();
+ const sinR=Math.sin(a1)*n1/n2;
+ ctx.fillStyle='#1f2937';ctx.textAlign='left';ctx.font='13px sans-serif';
+ if(sinR>1){
+ ctx.fillText('全反射(入射角超过临界角)',12,22);
+ ctx.strokeStyle='#f97316';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(hx,cy);ctx.lineTo(hx+Math.sin(a1)*L,cy-Math.cos(a1)*L);ctx.stroke();
+ }else{
+ const a2=Math.asin(sinR);
+ ctx.strokeStyle='#3b82f6';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(hx,cy);ctx.lineTo(hx+Math.sin(a2)*L,cy+Math.cos(a2)*L);ctx.stroke();
+ ctx.fillText('折射角 = '+(a2*180/Math.PI).toFixed(1)+'°',12,22);
+ }
+ ctx.strokeStyle='rgba(239,68,68,0.4)';ctx.lineWidth=2;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(hx,cy);ctx.lineTo(hx+Math.sin(a1)*L,cy-Math.cos(a1)*L);ctx.stroke();ctx.setLineDash([]);
+ ctx.fillStyle='#0369a1';ctx.font='bold 13px sans-serif';ctx.fillText('介质1 n='+n1.toFixed(2),12,cy-8);
+ ctx.fillStyle='#92400e';ctx.fillText('介质2 n='+n2.toFixed(2),12,cy+22);
+ ctx.textAlign='left';
+ }
+ ['sang','sn1','sn2'].forEach(id=>{document.getElementById(id).oninput=draw;});
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+const acidBase: AnimationTemplate = {
+ id: 'acid-base-titration',
+ name: '酸碱中和滴定',
+ category: 'chemistry',
+ description: '强酸强碱滴定曲线与指示剂变色:调节碱体积观察 pH 跃迁',
+ icon: 'TrendCharts',
+ accent: 'linear-gradient(135deg, #8b5cf6, #6366f1)',
+ params: [
+ { key: 'va', label: '酸体积(mL)', type: 'number', default: 20, min: 5, max: 25, step: 1 },
+ { key: 'ca', label: '酸浓度(M)', type: 'number', default: 0.1, min: 0.05, max: 1, step: 0.05 },
+ { key: 'cb', label: '碱浓度(M)', type: 'number', default: 0.1, min: 0.05, max: 1, step: 0.05 },
+ ],
+ defaultTitle: (p) => `酸碱滴定(${p.ca}M 酸 + ${p.cb}M 碱)`,
+ render: (p) => {
+ const va = num(p, 'va', 20), ca = num(p, 'ca', 0.1), cb = num(p, 'cb', 0.1)
+ return SHELL('酸碱中和滴定', '', `
+ (function(){
+ const init={va:${va},ca:${ca},cb:${cb}};
+ const stage=document.getElementById('stage');
+ function sl(id,lbl,val,min,max,step){return ''+lbl+' = '+val+'
';}
+ stage.innerHTML='
'+
+ sl('vb','加入碱体积(mL)',0,0,40,0.5)+sl('va','酸体积(mL)',init.va,5,25,1)+sl('ca','酸浓度(M)',init.ca.toFixed(2),0.05,1,0.05)+sl('cb','碱浓度(M)',init.cb.toFixed(2),0.05,1,0.05)+'
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=320;cv.style.height='320px';}
+ function clampPh(vb,va,ca,cb){const net=ca*va-cb*vb,V=Math.max(1,va+vb);let p;if(net>1e-9){p=-Math.log10(net/V);}else if(net<-1e-9){p=14+Math.log10((-net)/V);}else{p=7;}return Math.max(0,Math.min(14,p));}
+ function colorFor(p){if(p<3)return '#dc2626';if(p<5)return '#ea580c';if(p<6)return '#ca8a04';if(p<7.5)return '#16a34a';if(p<9)return '#0d9488';if(p<11)return '#2563eb';return '#7c3aed';}
+ function draw(){
+ const w=cv.width,h=cv.height;ctx.fillStyle='#fafbff';ctx.fillRect(0,0,w,h);
+ const vb=+document.getElementById('svb').value,va=+document.getElementById('sva').value,ca=+document.getElementById('sca').value,cb=+document.getElementById('scb').value;
+ document.getElementById('vvb').textContent=vb;['va','ca','cb'].forEach(k=>{document.getElementById('v'+k).textContent=(+document.getElementById('s'+k).value).toFixed(2);});
+ const padL=44,padR=24,padT=20,padB=34,pw=w-padL-padR,phh=h-padT-padB,vmax=40;
+ ctx.strokeStyle='#94a3b8';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(padL,padT);ctx.lineTo(padL,h-padB);ctx.lineTo(w-padR,h-padB);ctx.stroke();
+ ctx.fillStyle='#6b7280';ctx.font='11px sans-serif';ctx.textAlign='center';ctx.fillText('V(碱) / mL',padL+pw/2,h-10);
+ ctx.save();ctx.translate(14,padT+phh/2);ctx.rotate(-Math.PI/2);ctx.fillText('pH',0,0);ctx.restore();
+ ctx.textAlign='right';for(let p=0;p<=14;p+=2){ctx.fillText(p,padL-6,h-padB-p/14*phh);}
+ ctx.textAlign='center';for(let v=0;v<=vmax;v+=10){ctx.fillText(v,padL+v/vmax*pw,h-padB+14);}
+ ctx.strokeStyle='#4f46e5';ctx.lineWidth=2.5;ctx.beginPath();
+ for(let v=0;v<=vmax;v+=0.5){const p=clampPh(v,va,ca,cb);const x=padL+v/vmax*pw,y=h-padB-p/14*phh;if(v===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}
+ ctx.stroke();
+ const veq=ca*va/cb;
+ if(veq<=vmax){const xe=padL+veq/vmax*pw;ctx.strokeStyle='#f59e0b';ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(xe,padT);ctx.lineTo(xe,h-padB);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#f59e0b';ctx.fillText('eq',xe,padT+10);}
+ const cur=clampPh(vb,va,ca,cb);
+ ctx.fillStyle='#ef4444';ctx.beginPath();ctx.arc(padL+vb/vmax*pw,h-padB-cur/14*phh,5,0,Math.PI*2);ctx.fill();
+ const bx=w-padR-46,by=padT+8,bw=46,bh=50;
+ ctx.fillStyle='#e5e7eb';ctx.fillRect(bx,by,bw,bh);ctx.fillStyle=colorFor(cur);ctx.fillRect(bx,by+bh*0.2,bw,bh*0.8);
+ ctx.strokeStyle='#9ca3af';ctx.lineWidth=2;ctx.strokeRect(bx,by,bw,bh);
+ ctx.fillStyle='#1f2937';ctx.font='bold 12px sans-serif';ctx.fillText('pH '+cur.toFixed(2),bx+bw/2,by+bh+16);
+ ctx.textAlign='left';
+ }
+ ['svb','sva','sca','scb'].forEach(id=>{document.getElementById(id).oninput=draw;});
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+const photosynthesis: AnimationTemplate = {
+ id: 'photosynthesis',
+ name: '光合作用过程',
+ category: 'biology',
+ description: '动态呈现光合作用:CO₂、H₂O 进入,光照驱动,释放 O₂ 并合成有机物',
+ icon: 'Sunrise',
+ accent: 'linear-gradient(135deg, #16a34a, #15803d)',
+ params: [
+ { key: 'light', label: '光照强度', type: 'number', default: 5, min: 1, max: 10, step: 1 },
+ { key: 'speed', label: '动画速度', type: 'number', default: 1, min: 0.3, max: 2.5, step: 0.1 },
+ ],
+ defaultTitle: () => '光合作用过程动画',
+ render: (p) => {
+ const light = num(p, 'light', 5), speed = num(p, 'speed', 1)
+ return SHELL('光合作用过程', '', `
+ (function(){
+ const init={light:${light},speed:${speed}};
+ const stage=document.getElementById('stage');
+ function sl(id,lbl,val,min,max,step){return ''+lbl+' = '+val+'
';}
+ stage.innerHTML='
'+sl('light','光照强度',init.light,1,10,1)+sl('speed','动画速度',init.speed,0.3,2.5,0.1)+'
播放/暂停
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=340;cv.style.height='340px';}
+ let t=0,playing=true;
+ function dot(x1,y1,x2,y2,color,label){for(let i=0;i<3;i++){const p=((t+i/3)%1);ctx.fillStyle=color;ctx.beginPath();ctx.arc(x1+(x2-x1)*p,y1+(y2-y1)*p,5,0,Math.PI*2);ctx.fill();}ctx.fillStyle=color;ctx.font='bold 12px sans-serif';ctx.textAlign='center';ctx.fillText(label,x1,y1<24?y1+16:y1-10);}
+ function draw(){
+ const w=cv.width,h=cv.height;ctx.fillStyle='#f0fdf4';ctx.fillRect(0,0,w,h);
+ const light=+document.getElementById('slight').value,sp=+document.getElementById('sspeed').value;
+ document.getElementById('vlight').textContent=light;document.getElementById('vspeed').textContent=sp.toFixed(1);
+ if(playing)t+=0.012*sp;
+ const cx=w/2,cy=h*0.5,sx=cx,sy=34;
+ ctx.fillStyle='rgba(250,204,21,'+(0.5+0.25*Math.sin(t*4))+')';ctx.beginPath();ctx.arc(sx,sy,24,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#facc15';ctx.beginPath();ctx.arc(sx,sy,14,0,Math.PI*2);ctx.fill();
+ for(let i=0;i{playing=!playing;};
+ window.addEventListener('resize',fit);fit();requestAnimationFrame(loop);
+ })();
+ `);
+ },
+}
+
+const probability: AnimationTemplate = {
+ id: 'probability-sim',
+ name: '概率模拟(大数定律)',
+ category: 'math',
+ description: '抛硬币 / 掷骰子模拟,频次条形图随试验增加趋近理论值',
+ icon: 'Coin',
+ accent: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
+ params: [
+ { key: 'type', label: '类型', type: 'select', default: 'coin', options: [{ label: '抛硬币', value: 'coin' }, { label: '掷骰子', value: 'dice' }] },
+ { key: 'per', label: '每次试验数', type: 'number', default: 20, min: 1, max: 200, step: 1 },
+ ],
+ defaultTitle: (p) => `概率模拟 · ${p.type === 'dice' ? '掷骰子' : '抛硬币'}`,
+ render: (p) => {
+ const typ = (p.type === 'dice') ? 'dice' : 'coin'
+ const per = num(p, 'per', 20)
+ return SHELL('概率模拟', '', `
+ (function(){
+ const init={type:'${typ}',per:${per}};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '
类型 '+(init.type==='coin'?'抛硬币':'掷骰子')+' 抛硬币 掷骰子
'+
+ '
每次试验数 = '+init.per+'
'+
+ '
开始试验 清零
';
+ document.getElementById('stype').value=init.type;
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function fit(){const w=Math.max(360,stage.clientWidth-40);cv.width=w;cv.height=320;cv.style.height='320px';}
+ let counts=[],total=0;
+ function faces(){return document.getElementById('stype').value==='coin'?2:6;}
+ function reset(){counts=new Array(faces()).fill(0);total=0;draw();}
+ function run(){const n=+document.getElementById('sper').value,f=faces();document.getElementById('vper').textContent=n;document.getElementById('vtype').textContent=document.getElementById('stype').value==='coin'?'抛硬币':'掷骰子';for(let i=0;i0?counts[i]/total:0;const x=padL+i*gap+(gap-bw)/2;const bh=freq*phh,y=h-padB-bh;ctx.fillStyle='#4f46e5';ctx.fillRect(x,y,bw,bh);ctx.fillStyle='#1f2937';ctx.font='12px sans-serif';ctx.textAlign='center';ctx.fillText(labels[i],x+bw/2,h-padB+16);if(total>0){ctx.fillStyle='#4f46e5';ctx.font='bold 11px sans-serif';ctx.fillText(freq.toFixed(3),x+bw/2,y-4);}}
+ ctx.fillStyle='#6b7280';ctx.font='12px sans-serif';ctx.textAlign='left';ctx.fillText('累计试验:'+total+' 次',padL,h-12);
+ }
+ document.getElementById('run').onclick=run;document.getElementById('reset').onclick=reset;document.getElementById('stype').onchange=reset;
+ document.getElementById('sper').oninput=()=>{document.getElementById('vper').textContent=document.getElementById('sper').value;};
+ window.addEventListener('resize',fit);fit();reset();
+ })();
+ `);
+ },
+}
+
+// ===== 21. 英语单词卡片翻转记忆 =====
+const wordCards: AnimationTemplate = {
+ id: 'word-cards',
+ name: '单词卡片翻转记忆',
+ category: 'english',
+ description: '正面英文+音标,点击翻面查看中文释义与例句,支持主题切换、上下切换与自动播放',
+ icon: 'Collection',
+ accent: 'linear-gradient(135deg, #6366f1, #4f46e5)',
+ params: [
+ { key: 'theme', label: '主题', type: 'select', default: 'school', options: [
+ { label: '校园', value: 'school' },
+ { label: '食物', value: 'food' },
+ { label: '动物', value: 'animal' },
+ { label: '颜色', value: 'color' },
+ ] },
+ { key: 'interval', label: '切换间隔(秒)', type: 'number', default: 3, min: 1, max: 8, step: 1 },
+ ],
+ defaultTitle: (p) => {
+ const m: Record = { school: '校园', food: '食物', animal: '动物', color: '颜色' }
+ return '单词卡片 · ' + (m[p.theme] || '校园')
+ },
+ render: (p) => {
+ const theme = (['school', 'food', 'animal', 'color'].indexOf(p.theme) >= 0 ? p.theme : 'school')
+ const interval = num(p, 'interval', 3)
+ return SHELL('单词卡片记忆', '', `
+ (function(){
+ var DATA={
+ school:[{w:'school',ph:'/skuːl/',m:'n. 学校',e:'I walk to school every day.'},{w:'teacher',ph:'/ˈtiːtʃər/',m:'n. 教师',e:'She is our English teacher.'},{w:'student',ph:'/ˈstjuːdnt/',m:'n. 学生',e:'He is a hard-working student.'},{w:'library',ph:'/ˈlaɪbrəri/',m:'n. 图书馆',e:'I read books in the library.'},{w:'pencil',ph:'/ˈpensl/',m:'n. 铅笔',e:'I write with a pencil.'}],
+ food:[{w:'apple',ph:'/ˈæpl/',m:'n. 苹果',e:'An apple a day keeps the doctor away.'},{w:'bread',ph:'/bred/',m:'n. 面包',e:'I have bread for breakfast.'},{w:'water',ph:'/ˈwɔːtər/',m:'n. 水',e:'Please drink more water every day.'},{w:'milk',ph:'/mɪlk/',m:'n. 牛奶',e:'She drinks milk every morning.'},{w:'rice',ph:'/raɪs/',m:'n. 米饭',e:'We eat rice for lunch.'}],
+ animal:[{w:'cat',ph:'/kæt/',m:'n. 猫',e:'The cat is sleeping on the sofa.'},{w:'dog',ph:'/dɒɡ/',m:'n. 狗',e:'My dog likes to run in the park.'},{w:'bird',ph:'/bɜːd/',m:'n. 鸟',e:'A bird is singing in the tree.'},{w:'fish',ph:'/fɪʃ/',m:'n. 鱼',e:'The fish swims in the water.'},{w:'rabbit',ph:'/ˈræbɪt/',m:'n. 兔子',e:'The rabbit has long ears.'}],
+ color:[{w:'red',ph:'/red/',m:'n. 红色',e:'The apple is red.'},{w:'blue',ph:'/bluː/',m:'n. 蓝色',e:'The sky is blue today.'},{w:'green',ph:'/ɡriːn/',m:'n. 绿色',e:'Leaves turn green in spring.'},{w:'yellow',ph:'/ˈjeləʊ/',m:'n. 黄色',e:'The sun is yellow.'},{w:'white',ph:'/waɪt/',m:'n. 白色',e:'She wears a white dress.'}]
+ };
+ var THEMES={school:'校园',food:'食物',animal:'动物',color:'颜色'};
+ var stage=document.getElementById('stage');
+ var init={theme:'${theme}',interval:${interval}};
+ var idx=0,flipped=false,timer=null,auto=false;
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
主题 '+(THEMES[init.theme]||'校园')+' '+
+ Object.keys(THEMES).map(function(k){return ''+THEMES[k]+' ';}).join('')+
+ '
'+
+ '
切换间隔 = '+init.interval+' 秒
'+
+ '
'+
+ '
'+
+ '
'+
+ '
‹ 上一个 '+
+ '
'+
+ '
下一个 › '+
+ '
'+
+ '
▶ 自动播放 '+
+ '
';
+ function words(){return DATA[document.getElementById('stheme').value]||DATA.school;}
+ function render(){
+ var list=words(),d=list[idx]||list[0];
+ document.getElementById('word').textContent=d.w;
+ document.getElementById('pho').textContent=d.ph;
+ document.getElementById('mean').textContent=d.m;
+ document.getElementById('exa').textContent=d.e;
+ document.getElementById('ctr').textContent=(idx+1)+' / '+list.length;
+ var dk=document.getElementById('dots');dk.innerHTML='';
+ for(var i=0;i '时态时间轴 · ' + (p.verb || 'work'),
+ render: (p) => {
+ const verb = (['work', 'play', 'study', 'write', 'go'].indexOf(p.verb) >= 0 ? p.verb : 'work')
+ const tense = (['present', 'past', 'future', 'progressive', 'perfect'].indexOf(p.tense) >= 0 ? p.tense : 'present')
+ return SHELL('英语时态时间轴', '', `
+ (function(){
+ var VERBS={
+ work:{base:'work',third:'works',past:'worked',pastp:'worked',ing:'working',mean:'工作'},
+ play:{base:'play',third:'plays',past:'played',pastp:'played',ing:'playing',mean:'玩'},
+ study:{base:'study',third:'studies',past:'studied',pastp:'studied',ing:'studying',mean:'学习'},
+ write:{base:'write',third:'writes',past:'wrote',pastp:'written',ing:'writing',mean:'写'},
+ go:{base:'go',third:'goes',past:'went',pastp:'gone',ing:'going',mean:'去'}
+ };
+ var TENSES={
+ past:{label:'一般过去',pos:18,color:'#ef4444',form:'past',zh:'过去某时发生'},
+ present:{label:'一般现在',pos:50,color:'#10b981',form:'third',zh:'习惯性/经常性'},
+ progressive:{label:'现在进行',pos:50,color:'#3b82f6',form:'ing',zh:'此刻正在进行'},
+ perfect:{label:'现在完成',pos:50,color:'#8b5cf6',form:'pastp',zh:'已完成,影响至今'},
+ future:{label:'一般将来',pos:82,color:'#f59e0b',form:'base',zh:'将要发生'}
+ };
+ var stage=document.getElementById('stage');
+ var init={verb:'${verb}',tense:'${tense}'};
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
动词 '+
+ Object.keys(VERBS).map(function(k){return ''+VERBS[k].base+' ('+VERBS[k].mean+') ';}).join('')+
+ '
'+
+ '
时态 '+
+ Object.keys(TENSES).map(function(k){return ''+TENSES[k].label+' ';}).join('')+
+ '
'+
+ '
'+
+ '
'+
+ '
';
+ var marks=document.getElementById('marks');
+ Object.keys(TENSES).forEach(function(k){
+ var t=TENSES[k];
+ var m=document.createElement('div');
+ m.className='tt-mark';m.dataset.tense=k;
+ m.style.left=t.pos+'%';
+ m.innerHTML=''+t.label+' ';
+ m.onclick=function(){document.getElementById('stense').value=k;update();};
+ marks.appendChild(m);
+ });
+ function update(){
+ var v=document.getElementById('sverb').value;
+ var tn=document.getElementById('stense').value;
+ var V=VERBS[v]||VERBS.work,T=TENSES[tn]||TENSES.present;
+ document.getElementById('vvb').textContent=V.base+' ('+V.mean+')';
+ document.getElementById('vtn').textContent=T.label;
+ var formMap={third:V.third,past:V.past,base:V.base,ing:V.ing,pastp:V.pastp};
+ var form=formMap[T.form];
+ var sentences={
+ present:'He '+V.third+' every day.',
+ past:'He '+V.past+' yesterday.',
+ future:'He will '+V.base+' tomorrow.',
+ progressive:'He is '+V.ing+' now.',
+ perfect:'He has '+V.pastp+' already.'
+ };
+ var sen=sentences[tn]||sentences.present;
+ var hi=''+form+' ';
+ document.getElementById('sen').innerHTML=sen.split(form).join(hi);
+ document.getElementById('frm').innerHTML='动词变形:'+V.base+' → '+form+' ';
+ document.getElementById('zhx').textContent=T.label+' · '+T.zh;
+ var all=marks.querySelectorAll('.tt-mark');
+ for(var i=0;i { const cs = ['永','水','火','山','木']; return '笔顺 · ' + (cs[Number(p.char)] || '永'); },
+ render: (p) => {
+ const ci = Number(p.char ?? '0');
+ const sp = num(p, 'speed', 1);
+ return SHELL('汉字笔顺', '', `
+ (function(){
+ var CHARS=[
+ {c:'永',strokes:[['点','M30,15 Q40,5 50,18'],['横','M10,40 Q40,38 70,40'],['撇','M55,28 Q40,50 20,72'],['捺','M45,28 Q60,50 75,72'],['撇','M40,45 Q30,58 18,68'],['撇','M48,48 Q56,60 66,70']]},
+ {c:'水',strokes:[['竖钩','M40,8 L40,70 Q40,76 34,76'],['横撇','M15,30 Q40,28 65,30 Q60,40 50,48'],['撇','M40,40 Q28,55 15,68'],['捺','M42,42 Q56,58 70,72']]},
+ {c:'火',strokes:[['点','M38,8 Q44,4 50,10'],['撇','M42,12 Q30,30 18,48'],['撇','M30,30 Q20,45 12,60'],['捺','M46,18 Q58,38 70,60'],['长撇','M44,20 Q36,40 24,66'],['短捺','M40,30 L40,72']]},
+ {c:'山',strokes:[['竖','M40,10 L40,72'],['竖折','M12,55 Q12,68 25,68 L55,68 Q60,68 60,55'],['竖','M60,30 L60,68']]},
+ {c:'木',strokes:[['横','M10,38 Q40,36 70,38'],['竖','M40,15 L40,72'],['撇','M40,40 Q28,55 15,68'],['捺','M40,40 Q52,55 65,68']]}
+ ];
+ var stage=document.getElementById('stage');
+ var init={ci:'${ci}',sp:${sp}};
+ var idx=0,playing=false,timer=null;
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
'+
+ '
'+
+ '
'+'
'+'
'+
+ '▶ 顺序播放 '+'下一笔 '+'重写 '+''+
+ CHARS.map(function(c,i){return ''+c.c+' ';}).join('')+' '+'速度 '+
+ '
'+'
米字格内大字示范 · 点击「下一笔」逐笔学习 · 调整速度后点「顺序播放」
'+
+ '
';
+ function cur(){return CHARS[parseInt(document.getElementById('sel').value,10)||0]||CHARS[0];}
+ function render(){var c=cur();document.getElementById('big').textContent=c.c;document.getElementById('nm').textContent='「'+c.c+'」字';document.getElementById('ct').textContent='共 '+c.strokes.length+' 笔';var l=document.getElementById('list');l.innerHTML='';for(var i=0;i=c.strokes.length&&timer){clearInterval(timer);timer=null;playing=false;document.getElementById('play').textContent='▶ 顺序播放';}}
+ function togglePlay(){playing=!playing;document.getElementById('play').textContent=playing?'⏸ 暂停':'▶ 顺序播放';if(playing){if(idx>=cur().strokes.length)reset();var sp=parseFloat(document.getElementById('spd').value)||1;timer=setInterval(step,900/sp);}else if(timer){clearInterval(timer);timer=null;}}
+ document.getElementById('play').onclick=togglePlay;document.getElementById('step').onclick=step;document.getElementById('reset').onclick=reset;document.getElementById('sel').onchange=function(){reset();if(playing){clearInterval(timer);timer=null;playing=false;document.getElementById('play').textContent='▶ 顺序播放';}};document.getElementById('spd').oninput=function(){if(playing){clearInterval(timer);var sp=parseFloat(this.value)||1;timer=setInterval(step,900/sp);}};
+ render();
+ })();
+ `);
+ },
+}
+
+// ===== 钢琴键盘与简谱对照(音乐) =====
+const pianoNotes: AnimationTemplate = {
+ id: 'piano-notes',
+ name: '钢琴键盘与简谱',
+ category: 'music',
+ description: '展示一个八度钢琴键盘,点击琴键发声并显示对应简谱/音名,支持示范音阶播放',
+ icon: 'Microphone',
+ accent: 'linear-gradient(135deg, #db2777, #9d174d)',
+ params: [
+ { key: 'startNote', label: '起始音', type: 'select', default: 'C4', options: [
+ { label: 'C4(中央C)', value: 'C4' },
+ { label: 'C3(低八度)', value: 'C3' },
+ { label: 'C5(高八度)', value: 'C5' },
+ ] },
+ { key: 'showNames', label: '显示音名', type: 'select', default: 'both', options: [
+ { label: '简谱+音名', value: 'both' },
+ { label: '仅简谱', value: 'jianpu' },
+ { label: '仅音名', value: 'name' },
+ ] },
+ { key: 'volume', label: '音量', type: 'number', default: 0.4, min: 0, max: 1, step: 0.05 },
+ ],
+ defaultTitle: (p) => '钢琴键盘 · 起始 ' + (p.startNote || 'C4'),
+ render: (p) => {
+ const sn = String(p.startNote || 'C4');
+ const nm = String(p.showNames || 'both');
+ const vol = num(p, 'volume', 0.4);
+ return SHELL('钢琴键盘与简谱', '', `
+ (function(){
+ var START='${sn}',SHOW='${nm}',VOL=${vol};
+ var baseOct=parseInt(START.slice(1),10)||4;
+ // 12 semitones names + jianpu (relative to C of starting octave)
+ var NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
+ var JIAN=['1','1#','2','2#','3','4','4#','5','5#','6','6#','7'];
+ var WHITE_IDX=[0,2,4,5,7,9,11]; // C D E F G A B
+ var BLACK_POS={0:1,1:2,3:3,4:4,6:5,8:6,10:7}; // semitone -> after which white key
+ var stage=document.getElementById('stage');
+ var actx=null;
+ function ensureAudio(){if(!actx){try{actx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){actx=null;}}return actx;}
+ function freq(semiFromC4){return 261.63*Math.pow(2,semiFromC4/12);}
+ function playTone(semi,dur){var a=ensureAudio();if(!a)return;var o=a.createOscillator();var g=a.createGain();o.type='triangle';o.frequency.value=freq(semi);g.gain.value=VOL;o.connect(g);g.connect(a.destination);var t=a.currentTime;o.start(t);g.gain.setValueAtTime(VOL,t);g.gain.exponentialRampToValueAtTime(0.001,t+dur);o.stop(t+dur+0.02);}
+ // semi offset from C4: baseOct relative
+ var baseSemi=(baseOct-4)*12;
+ // build 7 white + 5 black in one octave (12 semitones from start C)
+ var css='';
+ stage.innerHTML=css+'点击琴键发声 · 高亮显示当前音
▶ 播放音阶 清除高亮
音量/起始音可在生成时设置;浏览器 Web Audio 合成示范音
';
+ var piano=document.getElementById('piano'),info=document.getElementById('info');
+ var whiteEls={},blackEls={};
+ // build 7 white keys
+ WHITE_IDX.forEach(function(sRel,i){var semi=baseSemi+sRel;var div=document.createElement('div');div.className='pn-white';div.dataset.semi=semi;var main='',sub='';if(SHOW!=='jianpu'){main=NAMES[sRel];}if(SHOW!=='name'){sub=''+JIAN[sRel]+' ';}if(SHOW==='both'){main=''+JIAN[sRel]+' ';sub=''+NAMES[sRel]+(baseOct)+' ';}div.innerHTML=main+sub;div.onclick=function(){hit(semi,div);};piano.appendChild(div);whiteEls[semi]=div;});
+ // build 5 black keys positioned over white gaps
+ [1,3,6,8,10].forEach(function(sRel){var semi=baseSemi+sRel;var div=document.createElement('div');div.className='pn-black';div.dataset.semi=semi;var pos=BLACK_POS[sRel];var left=(pos)*(62)-19;div.style.left='calc(50% - 217px + '+left+'px)';var lab='';if(SHOW!=='jianpu'){lab=''+NAMES[sRel]+' ';}if(SHOW==='jianpu'){lab=''+JIAN[sRel]+' ';}div.innerHTML=lab;div.onclick=function(){hit(semi,div);};piano.appendChild(div);blackEls[semi]=div;});
+ function hit(semi,el){document.querySelectorAll('.pn-white.active,.pn-black.active').forEach(function(e){e.classList.remove('active');});if(el)el.classList.add('active');playTone(semi-baseSemi,0.6);var idx=((semi-baseSemi)%12+12)%12;info.textContent='当前音:'+NAMES[idx]+' · 简谱 '+JIAN[idx]+' · 频率 '+Math.round(freq(semi-baseSemi))+'Hz';}
+ document.getElementById('clear').onclick=function(){document.querySelectorAll('.pn-white.active,.pn-black.active').forEach(function(e){e.classList.remove('active');});info.textContent='点击琴键发声 · 高亮显示当前音';};
+ document.getElementById('scale').onclick=function(){var seq=[0,2,4,5,7,9,11,12];seq.forEach(function(s,i){setTimeout(function(){var semi=baseSemi+s;var el=whiteEls[semi];hit(semi,el);},i*420);});};
+ })();
+ `);
+ },
+}
+
+// ===== 三原色混色实验(美术) =====
+const colorMixer: AnimationTemplate = {
+ id: 'color-mixer',
+ name: '三原色混色实验',
+ category: 'art',
+ description: '调节红/绿/蓝三色滑块,实时叠加混合,展示 RGB 加色法与颜色属性(HEX/RGB/HSL)',
+ icon: 'Brush',
+ accent: 'linear-gradient(135deg, #ea580c, #c2410c)',
+ params: [
+ { key: 'mode', label: '混合模式', type: 'select', default: 'add', options: [
+ { label: '加色法(光照RGB)', value: 'add' },
+ { label: '减色法(颜料CMY)', value: 'sub' },
+ ] },
+ { key: 'r', label: '红 R', type: 'number', default: 200, min: 0, max: 255, step: 1 },
+ { key: 'g', label: '绿 G', type: 'number', default: 80, min: 0, max: 255, step: 1 },
+ { key: 'b', label: '蓝 B', type: 'number', default: 120, min: 0, max: 255, step: 1 },
+ ],
+ defaultTitle: (p) => '三原色混色 · ' + (p.mode === 'sub' ? '减色法 CMY' : '加色法 RGB'),
+ render: (p) => {
+ const mode = String(p.mode || 'add');
+ const r0 = num(p, 'r', 200), g0 = num(p, 'g', 80), b0 = num(p, 'b', 120);
+ return SHELL('三原色混色实验', '', `
+ (function(){
+ var MODE='${mode}',R0=${r0},G0=${g0},B0=${b0};
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
混色模式
'+
+ '
R 红
'+
+ '
G 绿
'+
+ '
B 蓝
'+
+ '
'+
+ '
';
+ var big=document.getElementById('big'),info=document.getElementById('info'),modeBadge=document.getElementById('modeBadge');
+ var rEl=document.getElementById('r'),gEl=document.getElementById('g'),bEl=document.getElementById('b');
+ modeBadge.textContent=MODE==='sub'?'减色法 CMY':'加色法 RGB';
+ if(MODE==='sub'){['chR','chG','chB'].forEach(function(id,i){var el=document.getElementById(id);el.style.mixBlendMode='multiply';el.style.opacity='.85';el.style.background=['#00ffff','##ff00ff','#ffff00'][i];});}
+ function toHex(n){var h=Math.max(0,Math.min(255,Math.round(n))).toString(16);return h.length<2?'0'+h:h;}
+ function rgbToHsl(r,g,b){r/=255;g/=255;b/=255;var max=Math.max(r,g,b),min=Math.min(r,g,b);var h,s,l=(max+min)/2;if(max===min){h=s=0;}else{var d=max-min;s=l>0.5?d/(2-max-min):d/(max+min);switch(max){case r:h=(g-b)/d+(gHEX '+hex+'RGB '+R+', '+G+', '+B+'HSL '+hsl[0]+'°, '+hsl[1]+'%, '+hsl[2]+'%';}
+ rEl.oninput=update;gEl.oninput=update;bEl.oninput=update;
+ update();
+ })();
+ `);
+ },
+}
+
+// ===== 二进制拨码开关(信息技术) =====
+const binaryCounter: AnimationTemplate = {
+ id: 'binary-counter',
+ name: '二进制拨码开关',
+ category: 'it',
+ description: '8 位二进制拨码开关,实时显示十进制、十六进制,配合位权教学,支持点击/键盘切换',
+ icon: 'Cpu',
+ accent: 'linear-gradient(135deg, #0891b2, #155e75)',
+ params: [
+ { key: 'bits', label: '位数', type: 'select', default: '8', options: [
+ { label: '4 位', value: '4' },
+ { label: '8 位', value: '8' },
+ { label: '12 位', value: '12' },
+ ] },
+ { key: 'init', label: '初始值', type: 'number', default: 0, min: 0, max: 4095, step: 1 },
+ ],
+ defaultTitle: (p) => '二进制拨码 · ' + p.bits + ' 位',
+ render: (p) => {
+ const bits = Number(p.bits || 8);
+ const init = num(p, 'init', 0);
+ return SHELL('二进制拨码开关', '', `
+ (function(){
+ var BITS=${bits},INIT=${init};
+ BITS=Math.max(1,Math.min(12,BITS));
+ INIT=Math.max(0,Math.min(Math.pow(2,BITS)-1,INIT));
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '
点击开关切换 0/1 · 从右到左位权依次 ×2 递增
';
+ var bitsEl=document.getElementById('bits');
+ var arr=[];
+ for(var i=0;i'+(i===0?'位权 1':'×2^'+i+' = '+Math.pow(2,i))+' ';bitsEl.appendChild(bit);var sw=bit.querySelector('.bc-switch');sw.onclick=function(){var idx=+this.dataset.i;arr[idx]=arr[idx]?0:1;render();};arr.push(0);}
+ // init
+ var v=INIT;for(var k=BITS-1;k>=0;k--){if(v>=Math.pow(2,k)){arr[k]=1;v-=Math.pow(2,k);}else{arr[k]=0;}}
+ function render(){var switches=bitsEl.querySelectorAll('.bc-switch');var dec=0;for(var i=0;i '拼音四声 · ' + (p.syllable || 'ma'),
+ render: (p) => {
+ const syl = String(p.syllable || 'ma');
+ return SHELL('拼音四声调对比', '', `
+ (function(){
+ var SYL='${syl}';
+ var DATA={ma:['mā','má','mǎ','mà'],ba:['bā','bá','bǎ','bà'],pa:['pā','pá','pǎ','pà'],da:['dā','dá','dǎ','dà'],fu:['fū','fú','fǔ','fù']};
+ var EXAMPLES={ma:['妈','麻','马','骂'],ba:['吧','拔','把','爸'],pa:['啪','爬','怕','帕'],da:['搭','达','打','大'],fu:['夫','福','府','父']};
+ var NAMES=['阴平(第一声)','阳平(第二声)','上声(第三声)','去声(第四声)'];
+ // pitch contour: 1=high, 5=low (5-level pinyin scale)
+ var CURVE=[[1,1],[3,1],[2,4],[1,5]];
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var grid=document.getElementById('grid');
+ var list=DATA[SYL]||DATA.ma;var exs=EXAMPLES[SYL]||EXAMPLES.ma;
+ var cards=[];
+ list.forEach(function(syl,i){var c=document.createElement('div');c.className='pt-card';c.innerHTML=''+syl+' '+NAMES[i]+'
'+exs[i]+'
';c.dataset.i=i;c.onclick=function(){activate(i);};grid.appendChild(c);cards.push(c);drawCurve(c.querySelector('canvas'),CURVE[i],false);});
+ function drawCurve(cv,curve,active){var ctx=cv.getContext('2d');ctx.clearRect(0,0,260,60);ctx.lineWidth=active?4:2.5;ctx.strokeStyle=active?'#16a34a':'#86efac';ctx.lineCap='round';var x1=40,x2=220;var y1=(curve[0]-1)*12+6;var y2=(curve[1]-1)*12+6;ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();ctx.fillStyle=active?'#15803d':'#a7f3d0';ctx.beginPath();ctx.arc(x2,y2,6,0,Math.PI*2);ctx.fill();ctx.beginPath();ctx.arc(x1,y1,6,0,Math.PI*2);ctx.fill();}
+ var cur=-1;
+ function activate(i){cards.forEach(function(c,j){c.classList.toggle('active',i===j);drawCurve(c.querySelector('canvas'),CURVE[j],i===j);});cur=i;}
+ })();
+ `);
+ },
+}
+
+
+// ===== 元素周期表交互(化学) =====
+const periodicTable: AnimationTemplate = {
+ id: 'periodic-table',
+ name: '元素周期表交互',
+ category: 'chemistry',
+ description: '前20号元素周期表,按族/周期分类色块标注,点击查看元素详情(电子构型/用途)',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #0891b2, #0e7490)',
+ params: [
+ { key: 'showCategory', label: '分类高亮', type: 'select', default: 'category', options: [
+ { label: '按族分类', value: 'category' },
+ { label: '按状态分类', value: 'state' },
+ { label: '无高亮', value: 'none' },
+ ] },
+ ],
+ defaultTitle: () => '元素周期表 · 前20号元素',
+ render: (p) => {
+ const mode = String(p.showCategory || 'category')
+ return SHELL('元素周期表', '', `
+ (function(){
+ var MODE='${mode}';
+ var ELS=[
+ [1,'H','氢','非金属','气态','1','燃料/化工原料'],
+ [2,'He','氦','稀有气体','气态','2','气球/深海呼吸'],
+ [3,'Li','锂','碱金属','固态','2,1','电池/合金'],
+ [4,'Be','铍','碱土金属','固态','2,2','航天合金'],
+ [5,'B','硼','类金属','固态','2,3','玻璃/半导体'],
+ [6,'C','碳','非金属','固态','2,4','有机物基础/钢铁'],
+ [7,'N','氮','非金属','气态','2,5','氮肥/保护气'],
+ [8,'O','氧','非金属','气态','2,6','呼吸/燃烧'],
+ [9,'F','氟','卤素','气态','2,7','氟化物/制冷'],
+ [10,'Ne','氖','稀有气体','气态','2,8','霓虹灯'],
+ [11,'Na','钠','碱金属','固态','2,8,1','食盐/化工'],
+ [12,'Mg','镁','碱土金属','固态','2,8,2','合金/烟花'],
+ [13,'Al','铝','后过渡金属','固态','2,8,3','航空/包装'],
+ [14,'Si','硅','类金属','固态','2,8,4','芯片/玻璃'],
+ [15,'P','磷','非金属','固态','2,8,5','化肥/火柴'],
+ [16,'S','硫','非金属','固态','2,8,6','硫酸/橡胶'],
+ [17,'Cl','氯','卤素','气态','2,8,7','消毒/塑料'],
+ [18,'Ar','氩','稀有气体','气态','2,8,8','保护气/灯'],
+ [19,'K','钾','碱金属','固态','2,8,8,1','化肥/生物'],
+ [20,'Ca','钙','碱土金属','固态','2,8,8,2','骨骼/建材']
+ ];
+ var POS={1:[1,1],2:[1,18],3:[2,1],4:[2,2],5:[2,13],6:[2,14],7:[2,15],8:[2,16],9:[2,17],10:[2,18],11:[3,1],12:[3,2],13:[3,13],14:[3,14],15:[3,15],16:[3,16],17:[3,17],18:[3,18],19:[4,1],20:[4,2]};
+ var CAT_COLOR={'碱金属':'#fecaca','碱土金属':'#fed7aa','后过渡金属':'#fef3c7','类金属':'#d1fae5','非金属':'#dbeafe','卤素':'#cffafe','稀有气体':'#e9d5ff'};
+ var STATE_COLOR={'气态':'#dbeafe','固态':'#d1fae5','液态':'#fef3c7'};
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var grid=document.getElementById('grid'),legend=document.getElementById('legend'),detail=document.getElementById('detail');
+ if(MODE==='category'){Object.keys(CAT_COLOR).forEach(function(cat){legend.innerHTML+='';});}
+ else if(MODE==='state'){Object.keys(STATE_COLOR).forEach(function(s){legend.innerHTML+='';});}
+ var cells={};
+ for(var row=1;row<=4;row++){for(var col=1;col<=18;col++){var cell=document.createElement('div');cell.className='pt-cell pt-empty';grid.appendChild(cell);cells[row+'-'+col]=cell;}}
+ ELS.forEach(function(el){var z=el[0];var pos=POS[z];if(!pos)return;var cell=cells[pos[0]+'-'+pos[1]];cell.classList.remove('pt-empty');var sym=el[1],name=el[2],cat=el[3],state=el[4];cell.innerHTML=''+z+'
'+sym+'
'+name+'
';var bg=MODE==='category'?CAT_COLOR[cat]:MODE==='state'?STATE_COLOR[state]:'#fff';cell.style.background=bg;cell.dataset.z=z;cell.onclick=function(){selectEl(z);};});
+ function selectEl(z){var el=ELS[z-1];document.querySelectorAll('.pt-cell.sel').forEach(function(c){c.classList.remove('sel');});cells[POS[z][0]+'-'+POS[z][1]].classList.add('sel');detail.innerHTML=''+el[2]+' ('+el[1]+') 原子序数 '+el[0]+'
族类 '+el[3]+'
常温状态 '+el[4]+'
电子构型 '+el[5]+'
主要用途 '+el[6]+'
';}
+ })();
+ `);
+ },
+}
+
+
+// ===== 化学键与分子结构(化学) =====
+const chemicalBonds: AnimationTemplate = {
+ id: 'chemical-bonds',
+ name: '化学键与分子结构',
+ category: 'chemistry',
+ description: '对比离子键/共价键/金属键的形成过程与代表物质,点击切换查看电子转移示意',
+ icon: 'Connection',
+ accent: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ params: [
+ { key: 'bondType', label: '化学键类型', type: 'select', default: 'ionic', options: [
+ { label: '离子键(NaCl)', value: 'ionic' },
+ { label: '共价键(H₂O)', value: 'covalent' },
+ { label: '金属键(Fe)', value: 'metallic' },
+ ] },
+ ],
+ defaultTitle: (p) => { const m: Record = {ionic:'离子键 NaCl', covalent:'共价键 H₂O', metallic:'金属键 Fe'}; return '化学键 · ' + (m[String(p.bondType)] || '离子键'); },
+ render: (p) => {
+ const bt = String(p.bondType || 'ionic')
+ return SHELL('化学键与分子结构', '', `
+ (function(){
+ var BT='${bt}';
+ var DATA={
+ ionic:{name:'离子键',formula:'NaCl',desc:'钠原子失去1个电子给氯原子,形成带正电的Na+和带负电的Cl-,静电吸引结合',examples:['NaCl 食盐','KCl','MgO','CaF2'],steps:['Na (2,8,1) 失去1个电子','Na+ (2,8) 正离子','Cl (2,8,7) 得到1个电子','Cl- (2,8,8) 负离子','正负离子静电吸引形成 NaCl']},
+ covalent:{name:'共价键',formula:'H2O',desc:'原子间通过共用电子对结合。氧原子分别与两个氢原子共用1对电子',examples:['H2O 水','CO2','O2','CH4 甲烷'],steps:['O (2,6) 需要2个电子达稳定','H (1) 需要1个电子达稳定','O 与 H 共用1对电子','形成 O-H 共价键 x2','分子呈V形(极性分子)']},
+ metallic:{name:'金属键',formula:'Fe',desc:'金属原子释放外层电子形成金属阳离子,自由电子在阳离子间自由移动(电子海模型)',examples:['Fe 铁','Cu 铜','Al 铝','Au 金'],steps:['Fe 原子释放外层电子','Fe2+/Fe3+ 金属阳离子','释放的电子形成自由电子海','阳离子沉浸在电子海中','金属键 = 阳离子与电子海的吸引']}
+ };
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var tabs=document.querySelectorAll('.cb-tab');
+ tabs.forEach(function(t){t.onclick=function(){render(this.dataset.bt);};});
+ function render(bt){var d=DATA[bt];tabs.forEach(function(t){t.classList.toggle('active',t.dataset.bt===bt);});var card=document.getElementById('card');card.innerHTML=''+d.formula+'
'+d.desc+'
'+d.steps.map(function(s,i){return '
';}).join('')+'
代表物质
'+d.examples.map(function(e){return '
'+e+'
';}).join('')+'
';}
+ render(BT);
+ })();
+ `);
+ },
+}
+
+
+// ===== pH酸碱指示剂色阶(化学) =====
+const phScale: AnimationTemplate = {
+ id: 'ph-scale',
+ name: 'pH酸碱指示剂',
+ category: 'chemistry',
+ description: '0-14 pH色阶可视化,标注常见物质的酸碱度,点击查看对应pH位置',
+ icon: 'Histogram',
+ accent: 'linear-gradient(135deg, #dc2626, #7c2d12)',
+ params: [
+ { key: 'indicator', label: '指示剂', type: 'select', default: 'universal', options: [
+ { label: '广泛pH试纸', value: 'universal' },
+ { label: '紫色石蕊', value: 'litmus' },
+ { label: '无色酚酞', value: 'phenol' },
+ ] },
+ ],
+ defaultTitle: (p) => { const m: Record={universal:'广泛pH试纸',litmus:'紫色石蕊',phenol:'无色酚酞'}; return 'pH酸碱指示剂 · '+(m[String(p.indicator)]||'广泛pH试纸'); },
+ render: (p) => {
+ const ind = String(p.indicator || 'universal')
+ return SHELL('pH酸碱指示剂', '', `
+ (function(){
+ var IND='${ind}';
+ var UNI=['#dc2626','#ea580c','#f59e0b','#eab308','#84cc16','#22c55e','#10b981','#06b6d4','#3b82f6','#6366f1','#8b5cf6','#a855f7','#d946ef','#ec4899','#7c3aed'];
+ var SUBSTANCES=[['胃酸',1],['柠檬汁',2],['食醋',3],['番茄',4],['黑咖啡',5],['雨水',6],['纯水',7],['血液',7.4],['海水',8],['小苏打水',9],['肥皂水',10],['氨水',11],['石灰水',12],['漂白液',13],['氢氧化钠',14]];
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var scaleEl=document.getElementById('scale'),subsEl=document.getElementById('subs');
+ function colorFor(ph,indicator){if(indicator==='universal'){return UNI[Math.round(ph)];}if(indicator==='litmus'){return ph<7?'#dc2626':ph>7?'#3b82f6':'#8b5cf6';}return ph<8.2?'#f3f4f6':ph<10?'#f9a8d4':'#ec4899';}
+ for(var i=0;i<=14;i++){var bar=document.createElement('div');bar.className='ph-bar';bar.style.background=colorFor(i,IND);bar.innerHTML=''+i+'
';bar.dataset.ph=i;scaleEl.appendChild(bar);}
+ SUBSTANCES.forEach(function(s){var div=document.createElement('div');div.className='ph-sub';var c=colorFor(s[1],IND);div.innerHTML='
'+s[0]+'
'+s[1]+'
';div.onclick=function(){var bars=scaleEl.querySelectorAll('.ph-bar');bars.forEach(function(b){b.style.outline='';});bars[Math.round(s[1])].style.outline='3px solid #1f2937';};subsEl.appendChild(div);});
+ })();
+ `);
+ },
+}
+
+
+// ===== 勾股定理可视化证明(数学) =====
+const pythagorean: AnimationTemplate = {
+ id: 'pythagorean',
+ name: '勾股定理可视化',
+ category: 'math',
+ description: '直角三角形三边 a^2+b^2=c^2 的交互证明:调节两直角边,实时显示三个正方形面积关系',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #6366f1, #4338ca)',
+ params: [
+ { key: 'legA', label: '直角边 a', type: 'number', default: 3, min: 1, max: 8, step: 0.5 },
+ { key: 'legB', label: '直角边 b', type: 'number', default: 4, min: 1, max: 8, step: 0.5 },
+ ],
+ defaultTitle: (p) => `勾股定理 a=${p.legA}, b=${p.legB}`,
+ render: (p) => {
+ const a = num(p, 'legA', 3), b = num(p, 'legB', 4)
+ return SHELL('勾股定理可视化', '', `
+ (function(){
+ var A=${a},B=${b};
+ var a2=A*A,b2=B*B,c2=a2+b2,C=Math.sqrt(c2);
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ ''+
+ '
a
'+A+'
a^2='+a2.toFixed(0)+'
'+
+ '
'+
+ '
b
'+B+'
b^2='+b2.toFixed(0)+'
'+
+ '
'+
+ '
c(斜边)
'+C.toFixed(3)+'
c^2='+c2.toFixed(1)+'
'+
+ '
'+a2.toFixed(0)+' + '+b2.toFixed(0)+' = '+c2.toFixed(1)+' OK
';
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ function resize(){cv.width=cv.offsetWidth;cv.height=cv.offsetHeight;draw();}
+ function draw(){
+ ctx.clearRect(0,0,cv.width,cv.height);
+ var cx=cv.width/2,cy=cv.height/2+40;
+ var sc=Math.min((cv.width-80)/(A+B),(cv.height-120)/C)*0.8;
+ var P={x:cx-(A+B)*sc/2,y:cy};
+ var Q={x:P.x+A*sc,y:P.y};
+ var R={x:P.x,y:P.y-B*sc};
+ ctx.fillStyle='rgba(99,102,241,.25)';ctx.strokeStyle='#6366f1';ctx.lineWidth=2;
+ ctx.beginPath();ctx.moveTo(P.x,P.y);ctx.lineTo(Q.x,Q.y);ctx.lineTo(Q.x,Q.y+A*sc);ctx.lineTo(P.x,P.y+A*sc);ctx.closePath();ctx.fill();ctx.stroke();
+ ctx.fillStyle='#4338ca';ctx.font='bold 13px sans-serif';ctx.textAlign='center';
+ ctx.fillText('a^2='+a2.toFixed(0),(P.x+Q.x)/2,P.y+A*sc/2+4);
+ ctx.fillStyle='rgba(34,197,94,.25)';ctx.strokeStyle='#22c55e';
+ ctx.beginPath();ctx.moveTo(P.x,P.y);ctx.lineTo(R.x,R.y);ctx.lineTo(R.x-B*sc,R.y);ctx.lineTo(P.x-B*sc,P.y);ctx.closePath();ctx.fill();ctx.stroke();
+ ctx.fillStyle='#15803d';ctx.fillText('b^2='+b2.toFixed(0),P.x-B*sc/2,P.y-B*sc/2);
+ ctx.fillStyle='rgba(245,158,11,.22)';ctx.strokeStyle='#f59e0b';
+ var dx=Q.x-R.x,dy=Q.y-R.y,len=Math.hypot(dx,dy),nx=-dy/len,ny=dx/len;
+ ctx.beginPath();ctx.moveTo(Q.x,Q.y);ctx.lineTo(R.x,R.y);ctx.lineTo(R.x+nx*len,R.y+ny*len);ctx.lineTo(Q.x+nx*len,Q.y+ny*len);ctx.closePath();ctx.fill();ctx.stroke();
+ ctx.fillStyle='#b45309';
+ ctx.fillText('c^2='+c2.toFixed(1),(Q.x+R.x)/2+nx*len/2,(Q.y+R.y)/2+ny*len/2);
+ ctx.fillStyle='rgba(79,70,229,.15)';ctx.strokeStyle='#4f46e5';ctx.lineWidth=2.5;
+ ctx.beginPath();ctx.moveTo(P.x,P.y);ctx.lineTo(Q.x,Q.y);ctx.lineTo(R.x,R.y);ctx.closePath();ctx.fill();ctx.stroke();
+ ctx.strokeStyle='#4f46e5';ctx.lineWidth=1.5;
+ ctx.beginPath();ctx.moveTo(P.x+12,P.y);ctx.lineTo(P.x+12,P.y-12);ctx.lineTo(P.x,P.y-12);ctx.stroke();
+ }
+ resize();window.addEventListener('resize',resize);
+ })();
+ `);
+ },
+}
+
+// ===== 三角函数单位圆(数学) =====
+const unitCircle: AnimationTemplate = {
+ id: 'unit-circle',
+ name: '三角函数单位圆',
+ category: 'math',
+ description: '单位圆上动点旋转,实时投影显示 sin、cos、tan 及对应角度,可拖动调节',
+ icon: 'Refresh',
+ accent: 'linear-gradient(135deg, #8b5cf6, #6d28d9)',
+ params: [
+ { key: 'initAngle', label: '初始角度(°)', type: 'number', default: 45, min: 0, max: 360, step: 5 },
+ { key: 'auto', label: '自动旋转', type: 'select', default: '1', options: [
+ { label: '开启', value: '1' },
+ { label: '关闭', value: '0' },
+ ] },
+ ],
+ defaultTitle: (p) => '三角函数单位圆',
+ render: (p) => {
+ const initA = num(p, 'initAngle', 45), auto = String(p.auto || '1')
+ return SHELL('三角函数单位圆', '', `
+ (function(){
+ var ANGLE=${initA},AUTO=${auto}==='1';
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
'+
+ '
'+
+ '
'+
+ '
'+
+ '
';
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d'),R=120,cx,cy,drag=false;
+ function resize(){cv.width=cv.offsetWidth;cv.height=cv.offsetHeight;cx=cv.width/2;cy=cv.height/2;R=Math.min(cv.width,cv.height)/2-30;}
+ function draw(){
+ ctx.clearRect(0,0,cv.width,cv.height);
+ ctx.strokeStyle='#cbd5e1';ctx.lineWidth=1;
+ ctx.beginPath();ctx.moveTo(20,cy);ctx.lineTo(cv.width-20,cy);ctx.moveTo(cx,20);ctx.lineTo(cx,cv.height-20);ctx.stroke();
+ ctx.strokeStyle='#a5b4fc';ctx.lineWidth=2;ctx.beginPath();ctx.arc(cx,cy,R,0,Math.PI*2);ctx.stroke();
+ var rad=ANGLE*Math.PI/180;
+ var px=cx+Math.cos(rad)*R,py=cy-Math.sin(rad)*R;
+ ctx.strokeStyle='#fbbf24';ctx.lineWidth=2;ctx.beginPath();ctx.arc(cx,cy,26,0,-rad,true);ctx.stroke();
+ ctx.strokeStyle='#4f46e5';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(px,py);ctx.stroke();
+ ctx.strokeStyle='#22c55e';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(px,cy);ctx.stroke();
+ ctx.strokeStyle='#6366f1';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(px,cy);ctx.lineTo(px,py);ctx.stroke();
+ ctx.fillStyle='#4f46e5';ctx.beginPath();ctx.arc(px,py,7,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(px,py,3,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#22c55e';ctx.font='bold 12px sans-serif';ctx.textAlign='center';ctx.fillText('cos',(cx+px)/2,cy-6);
+ ctx.fillStyle='#6366f1';ctx.textAlign='left';ctx.fillText('sin',px+8,(cy+py)/2);
+ }
+ function update(){
+ var rad=ANGLE*Math.PI/180;
+ var s=Math.sin(rad),co=Math.cos(rad),t=Math.abs(co)<0.001?NaN:Math.tan(rad);
+ document.getElementById('ang').textContent=Math.round(ANGLE)+'deg';
+ document.getElementById('sv').textContent=s.toFixed(3);
+ document.getElementById('cvv').textContent=co.toFixed(3);
+ document.getElementById('tv').textContent=isNaN(t)?'INF':t.toFixed(3);
+ document.getElementById('sb').style.width=(Math.abs(s)*50)+'%';
+ document.getElementById('cb').style.width=(Math.abs(co)*50)+'%';
+ document.getElementById('tb').style.width=Math.min(100,Math.abs(isNaN(t)?0:t)*30)+'%';
+ draw();
+ }
+ resize();update();
+ window.addEventListener('resize',function(){resize();update();});
+ function angleFromEvt(e){var r=cv.getBoundingClientRect();var x=(e.clientX-r.left)-cx;var y=cy-(e.clientY-r.top);var a=Math.atan2(y,x)*180/Math.PI;if(a<0)a+=360;return a;}
+ cv.addEventListener('mousedown',function(e){drag=true;AUTO=false;ANGLE=angleFromEvt(e);update();});
+ window.addEventListener('mousemove',function(e){if(drag){ANGLE=angleFromEvt(e);update();}});
+ window.addEventListener('mouseup',function(){drag=false;});
+ if(AUTO){setInterval(function(){if(!drag){ANGLE=(ANGLE+1.5)%360;update();}},33);}
+ })();
+ `);
+ },
+}
+
+// ===== 排序算法可视化(信息技术) =====
+const sortingAlgo: AnimationTemplate = {
+ id: 'sorting-algo',
+ name: '排序算法可视化',
+ category: 'it',
+ description: '冒泡 / 选择 / 插入排序的逐步可视化,对比移动次数与时间复杂度,支持调速与重置',
+ icon: 'Sort',
+ accent: 'linear-gradient(135deg, #0891b2, #0e7490)',
+ params: [
+ { key: 'algo', label: '算法', type: 'select', default: 'bubble', options: [
+ { label: '冒泡排序', value: 'bubble' },
+ { label: '选择排序', value: 'selection' },
+ { label: '插入排序', value: 'insertion' },
+ ] },
+ { key: 'count', label: '数据个数', type: 'number', default: 16, min: 6, max: 30, step: 1 },
+ { key: 'speed', label: '速度(ms/步)', type: 'number', default: 120, min: 20, max: 600, step: 10 },
+ ],
+ defaultTitle: (p) => '排序算法 · ' + ({bubble:'冒泡',selection:'选择',insertion:'插入'}[String(p.algo)]||'冒泡'),
+ render: (p) => {
+ const n = num(p, 'count', 16)
+ return SHELL('排序算法可视化', '', `
+ (function(){
+ var N=${n},ALGO='${String(p.algo||'bubble')}',SPEED=${num(p,'speed',120)};
+ N=Math.max(6,Math.min(30,N));
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ var arr=[],hi=-1,hj=-1,sortedFrom=9999,cmp=0,swp=0,running=false,done=false;
+ function gen(){arr=[];for(var i=0;i=sortedFrom)c='#22c55e';
+ if(i===hi||i===hj)c='#f59e0b';
+ ctx.fillStyle=c;ctx.fillRect(x,y,w,h);
+ ctx.fillStyle='#94a3b8';ctx.font='10px sans-serif';ctx.textAlign='center';
+ ctx.fillText(arr[i],x+w/2,cv.height-2);
+ }
+ }
+ function sleep(ms){return new Promise(function(r){setTimeout(r,ms);});}
+ async function bubble(){for(var i=0;iarr[j+1]){var t=arr[j];arr[j]=arr[j+1];arr[j+1]=t;swp++;document.getElementById('swp').textContent=swp;draw();await sleep(SPEED);}}sortedFrom=N-1-i;}sortedFrom=0;}
+ async function selection(){for(var i=0;i=0&&arr[j]>key){if(!running)return;hj=j;cmp++;document.getElementById('cmp').textContent=cmp;arr[j+1]=arr[j];swp++;document.getElementById('swp').textContent=swp;draw();await sleep(SPEED);j--;}arr[j+1]=key;sortedFrom=i+1;draw();}sortedFrom=0;}
+ document.getElementById('go').onclick=async function(){
+ if(running){running=false;document.getElementById('st').textContent='已暂停';this.textContent='继续';return;}
+ if(done){gen();}
+ running=true;document.getElementById('st').textContent='排序中';this.textContent='暂停';
+ if(ALGO==='bubble')await bubble();else if(ALGO==='selection')await selection();else await insertion();
+ running=false;done=true;hi=hj=-1;document.getElementById('st').textContent='完成';document.getElementById('go').textContent='完成';
+ draw();
+ };
+ document.getElementById('rst').onclick=function(){running=false;gen();document.getElementById('go').textContent='开始排序';};
+ gen();window.addEventListener('resize',resize);
+ })();
+ `);
+ },
+}
+
+// ===== DNA 双螺旋结构(生物) =====
+const dnaHelix: AnimationTemplate = {
+ id: 'dna-helix',
+ name: 'DNA 双螺旋结构',
+ category: 'biology',
+ description: '旋转的 DNA 双螺旋三维结构,标注碱基互补配对(A-T、C-G),可调速与暂停',
+ icon: 'Link',
+ accent: 'linear-gradient(135deg, #ec4899, #be185d)',
+ params: [
+ { key: 'speed', label: '旋转速度', type: 'number', default: 2, min: 0, max: 8, step: 0.5 },
+ { key: 'pairs', label: '碱基对数', type: 'number', default: 12, min: 6, max: 20, step: 1 },
+ ],
+ defaultTitle: (p) => 'DNA 双螺旋结构',
+ render: (p) => {
+ const pairs = num(p, 'pairs', 12)
+ return SHELL('DNA 双螺旋结构', '', `
+ (function(){
+ var SPEED=${num(p,'speed',2)},PAIRS=${pairs};
+ PAIRS=Math.max(6,Math.min(20,PAIRS));
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d'),angle=0,running=true;
+ var BASES=['AT','TA','GC','CG'];var COL={A:'#22c55e',T:'#f59e0b',G:'#3b82f6',C:'#ef4444'};
+ var seq=[];for(var i=0;i `牛顿第二定律 F=${p.force}N, m=${p.mass}kg`,
+ render: (p) => {
+ const f = num(p, 'force', 10), m = num(p, 'mass', 5), mu = num(p, 'friction', 0.1)
+ return SHELL('牛顿第二定律 F=ma', '', `
+ (function(){
+ var F=${f},M=${m},MU=${mu},g=9.8;
+ var a=F/M, fmax=MU*M*g, areal=Math.max(0,(F-fmax)/M);
+ var stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
'+
+ '
加速度 a=F/m
'+a.toFixed(2)+'
'+
+ '
实际 a(含摩擦)
'+areal.toFixed(2)+'
'+
+ '
理论 a=F/m='+F+'/'+M+'='+a.toFixed(2)+' m/s2 | 最大摩擦 f='+fmax.toFixed(1)+' N
'+
+ '
开始施力 重置
';
+ var bx=document.getElementById('bx'),sc=bx.parentElement;
+ var pos=0,vel=0,t=null,running=false,sceneW=0;
+ function measure(){sceneW=sc.offsetWidth-86;}
+ function reset(){if(t){clearInterval(t);t=null;}running=false;pos=0;vel=0;bx.style.left='30px';document.getElementById('go').textContent='开始施力';}
+ function step(){vel+=areal*0.05;pos+=vel*0.05*30;if(pos>sceneW){pos=0;}bx.style.left=(30+pos)+'px';}
+ document.getElementById('go').onclick=function(){
+ if(running){running=false;if(t){clearInterval(t);t=null;}this.textContent='继续';return;}
+ running=true;this.textContent='暂停';measure();t=setInterval(step,50);
+ };
+ document.getElementById('rs').onclick=reset;
+ window.addEventListener('resize',measure);measure();
+ })();
+ `);
+ },
+}
+
+// ===== 板块构造运动(地理) =====
+const plateTectonics: AnimationTemplate = {
+ id: 'plate-tectonics',
+ name: '板块构造运动',
+ category: 'geography',
+ description: '三大板块边界(汇聚/张裂/转换)的运动示意与典型地貌形成,点击切换边界类型',
+ icon: 'Position',
+ accent: 'linear-gradient(135deg, #0d9488, #0f766e)',
+ params: [
+ { key: 'type', label: '边界类型', type: 'select', default: 'convergent', options: [
+ { label: '汇聚型(碰撞造山/海沟)', value: 'convergent' },
+ { label: '张裂型(海岭/裂谷)', value: 'divergent' },
+ { label: '转换型(走滑断层)', value: 'transform' },
+ ] },
+ ],
+ defaultTitle: (p) => '板块构造 · ' + ({convergent:'汇聚型边界',divergent:'张裂型边界',transform:'转换型边界'}[String(p.type)]||'汇聚型'),
+ render: (p) => {
+ const tp = String(p.type || 'convergent')
+ return SHELL('板块构造运动', '', `
+ (function(){
+ var TYPE='${tp}';
+ var stage=document.getElementById('stage');
+ var INFO={
+ convergent:{title:'汇聚型边界(板块相撞)',desc:'两个板块相互挤压。大洋-大陆板块碰撞形成海沟与岛弧;大陆-大陆碰撞形成高大褶皱山脉(如喜马拉雅山)。',ex:['喜马拉雅山','马里亚纳海沟','安第斯山脉']},
+ divergent:{title:'张裂型边界(板块分离)',desc:'两个板块相互远离。岩浆上涌形成新洋壳,海底扩张形成大洋中脊;陆地张裂形成裂谷(如东非大裂谷)。',ex:['大西洋中脊','东非大裂谷','红海']},
+ transform:{title:'转换型边界(板块平错)',desc:'两个板块沿断层水平滑动,既不增生也不消亡。摩擦积累应力突然释放时引发地震(如圣安德烈亚斯断层)。',ex:['圣安德烈亚斯断层','北安纳托利亚断层','阿尔派恩断层']}
+ };
+ stage.innerHTML=''+
+ '
'+
+ '
'+INFO[TYPE].title+' '+INFO[TYPE].desc+'
'+
+ '
典型实例: '+INFO[TYPE].ex.join('、')+'
';
+ var cv=document.getElementById('cv'),ctx=cv.getContext('2d'),frame=0;
+ function resize(){cv.width=cv.offsetWidth;cv.height=cv.offsetHeight;}
+ function drawPlate(x,y,w,h,color){
+ ctx.fillStyle=color;ctx.beginPath();ctx.moveTo(x,y);var segs=8;
+ for(var i=0;i<=segs;i++){ctx.lineTo(x+w*i/segs,y+(i%2===0?0:8));}
+ ctx.lineTo(x+w,cv.height);ctx.lineTo(x,cv.height);ctx.closePath();ctx.fill();ctx.strokeStyle='rgba(0,0,0,.15)';ctx.lineWidth=1;ctx.stroke();
+ }
+ function drawArrow(x,y,dx,dy,color){
+ ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+dx,y+dy);ctx.stroke();
+ var ang=Math.atan2(dy,dx);ctx.beginPath();ctx.moveTo(x+dx,y+dy);ctx.lineTo(x+dx-10*Math.cos(ang-0.4),y+dy-10*Math.sin(ang-0.4));ctx.lineTo(x+dx-10*Math.cos(ang+0.4),y+dy-10*Math.sin(ang+0.4));ctx.closePath();ctx.fill();
+ }
+ function draw(){
+ ctx.clearRect(0,0,cv.width,cv.height);
+ var H=cv.height,W=cv.width,groundY=H*0.45;
+ var grd=ctx.createLinearGradient(0,H-25,0,H);grd.addColorStop(0,'rgba(245,158,11,.5)');grd.addColorStop(1,'rgba(220,38,38,.8)');ctx.fillStyle=grd;ctx.fillRect(0,H-25,W,25);
+ var off=Math.sin(frame*0.03)*4;
+ if(TYPE==='convergent'){
+ var cw=W*0.5-30+off;drawPlate(0,groundY,cw,H-groundY,'#a16207');drawPlate(cw+60,groundY,W-cw-60,H-groundY,'#92400e');
+ ctx.fillStyle='#78716c';ctx.beginPath();ctx.moveTo(cw,groundY+20);ctx.lineTo(cw+30,groundY-50-Math.abs(off)*2);ctx.lineTo(cw+60,groundY+20);ctx.closePath();ctx.fill();
+ drawArrow(20,groundY-15,40,0,'#dc2626');drawArrow(W-20,groundY-15,-40,0,'#dc2626');
+ }else if(TYPE==='divergent'){
+ var gap=40+Math.abs(off)*3,lw=(W-gap)/2;drawPlate(0,groundY,lw,H-groundY,'#a16207');drawPlate(lw+gap,groundY,lw,H-groundY,'#92400e');
+ ctx.fillStyle='rgba(220,38,38,.6)';ctx.fillRect(lw,groundY+10,gap,H-groundY-30);
+ drawArrow(W/2-10,groundY-15,-40,0,'#dc2626');drawArrow(W/2+10,groundY-15,40,0,'#dc2626');
+ }else{
+ drawPlate(0,groundY-20,W,H-groundY+20,'#a16207');ctx.strokeStyle='#dc2626';ctx.lineWidth=3;ctx.setLineDash([8,4]);var sh=Math.sin(frame*0.05)*15;ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2+sh,H);ctx.stroke();ctx.setLineDash([]);
+ drawArrow(40,groundY*0.5,50,0,'#dc2626');drawArrow(W-40,groundY*1.3,-50,0,'#dc2626');
+ }
+ frame++;
+ }
+ resize();draw();setInterval(draw,50);window.addEventListener('resize',resize);
+ })();
+ `);
+ },
+}
+
+export const animationTemplates: AnimationTemplate[] = [
+ quadratic,
+ sineWave,
+ projectile,
+ harmonic,
+ molecule,
+ cellDivision,
+ solarSystem,
+ dayNight,
+ geometricTransform,
+ circuit,
+ foodChain,
+ waterCycle,
+ timeline,
+ dynastyShift,
+ poetryScene,
+ fractions,
+ lightRefraction,
+ acidBase,
+ photosynthesis,
+ probability,
+ wordCards,
+ tenseTimeline,
+ strokeOrder,
+ pianoNotes,
+ colorMixer,
+ binaryCounter,
+ pinyinTones,
+ periodicTable,
+ chemicalBonds,
+ phScale,
+ pythagorean,
+ unitCircle,
+ sortingAlgo,
+ dnaHelix,
+ newtonLaw,
+ plateTectonics,
+]
+
+export function getAnimationTemplate(id: string): AnimationTemplate | undefined {
+ return animationTemplates.find((t) => t.id === id)
+}
+
+export function defaultParams(t: AnimationTemplate): Record {
+ const p: Record = {}
+ t.params.forEach((param) => {
+ p[param.key] = param.default
+ })
+ return p
+}
+
+export const animationCategoryLabels: Record = {
+ math: '数学',
+ physics: '物理',
+ chemistry: '化学',
+ biology: '生物',
+ geography: '地理',
+ history: '历史',
+ chinese: '语文',
+ english: '英语',
+ music: '音乐',
+ art: '美术',
+ it: '信息技术',
+ general: '通用',
+}
diff --git a/frontend/src/utils/coursewareThemes.ts b/frontend/src/utils/coursewareThemes.ts
new file mode 100644
index 0000000..7e969bd
--- /dev/null
+++ b/frontend/src/utils/coursewareThemes.ts
@@ -0,0 +1,131 @@
+// 课件主题模板 —— 通过注入 CSS 变量与背景为每一页换肤
+// 每个主题返回一段可插入到页面 HTML 开头的 )
+function stripTheme(html: string): string {
+ const re = new RegExp('')
+ }
+ return base
+ }
+ // 片段:直接前置
+ return marker + '' + base
+}
+
+/** 检测页面当前主题 id */
+export function detectPageTheme(html: string): string | null {
+ if (!html) return null
+ const m = html.match(new RegExp(THEME_MARKER.replace(/[/*]/g, '\\$&') + '(\\w+)'))
+ return m ? m[1] : null
+}
+
+export const coursewareThemes: CoursewareTheme[] = [
+ {
+ id: 'clean',
+ name: '简洁明亮',
+ description: '干净的白色背景,深色正文',
+ swatch: 'linear-gradient(135deg,#ffffff,#f8fafc)',
+ accent: '#4f46e5',
+ styleBlock: () => `:root{--tk-bg:#ffffff;--tk-fg:#1f2937;--tk-muted:#6b7280;--tk-accent:#4f46e5;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body *{color:inherit;}
+h1,h2,h3,h4{color:var(--tk-fg)!important;}`,
+ },
+ {
+ id: 'warm',
+ name: '暖橙课堂',
+ description: '暖色调,适合低年级与人文课堂',
+ swatch: 'linear-gradient(135deg,#fff7ed,#fed7aa)',
+ accent: '#ea580c',
+ styleBlock: () => `:root{--tk-bg:#fff7ed;--tk-fg:#7c2d12;--tk-muted:#9a3412;--tk-accent:#ea580c;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body{background-image:radial-gradient(circle at 90% 10%,rgba(251,191,36,0.15),transparent 40%)!important;}
+h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
+ },
+ {
+ id: 'ocean',
+ name: '海洋蓝',
+ description: '冷静的蓝色系,适合理科与综合课程',
+ swatch: 'linear-gradient(135deg,#eff6ff,#bfdbfe)',
+ accent: '#2563eb',
+ styleBlock: () => `:root{--tk-bg:#eff6ff;--tk-fg:#1e3a8a;--tk-muted:#1e40af;--tk-accent:#2563eb;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body{background-image:linear-gradient(135deg,rgba(59,130,246,0.08),rgba(14,165,233,0.05))!important;}
+h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
+ },
+ {
+ id: 'forest',
+ name: '森林绿',
+ description: '自然绿色调,适合生物/地理/环保主题',
+ swatch: 'linear-gradient(135deg,#f0fdf4,#bbf7d0)',
+ accent: '#059669',
+ styleBlock: () => `:root{--tk-bg:#f0fdf4;--tk-fg:#14532d;--tk-muted:#166534;--tk-accent:#059669;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body{background-image:radial-gradient(circle at 10% 90%,rgba(34,197,94,0.12),transparent 40%)!important;}
+h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
+ },
+ {
+ id: 'dark',
+ name: '深夜模式',
+ description: '深色背景,高对比投影展示',
+ swatch: 'linear-gradient(135deg,#1e293b,#0f172a)',
+ accent: '#a78bfa',
+ styleBlock: () => `:root{--tk-bg:#0f172a;--tk-fg:#f1f5f9;--tk-muted:#cbd5e1;--tk-accent:#a78bfa;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body{background-image:radial-gradient(circle at 80% 20%,rgba(167,139,250,0.15),transparent 50%)!important;}
+h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
+ },
+ {
+ id: 'sunset',
+ name: '日落渐变',
+ description: '粉紫渐变,适合艺术与展示型课件',
+ swatch: 'linear-gradient(135deg,#fdf2f8,#fbcfe8)',
+ accent: '#db2777',
+ styleBlock: () => `:root{--tk-bg:#fdf2f8;--tk-fg:#831843;--tk-muted:#9d174d;--tk-accent:#db2777;}
+html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
+body{background-image:linear-gradient(135deg,rgba(244,114,182,0.18),rgba(168,85,247,0.1))!important;}
+h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
+ },
+]
+
+export function getTheme(id: string): CoursewareTheme | undefined {
+ return coursewareThemes.find((t) => t.id === id)
+}
diff --git a/frontend/src/utils/demoDrafts.ts b/frontend/src/utils/demoDrafts.ts
new file mode 100644
index 0000000..9a5d8a6
--- /dev/null
+++ b/frontend/src/utils/demoDrafts.ts
@@ -0,0 +1,387 @@
+import { createAnimation } from '@/api/animation'
+import { createCourseware } from '@/api/courseware'
+import { createExam } from '@/api/exam'
+import { createExercise } from '@/api/exercise'
+import { createLessonPlan } from '@/api/lessonPlan'
+
+type DemoDraftType = 'courseware' | 'animation' | 'exercise' | 'lesson_plan' | 'exam'
+
+type DemoDraftSpec = {
+ type: DemoDraftType
+ title: string
+ subject: string
+ grade: string
+ description: string
+ tags: string[]
+ payload: () => any
+}
+
+export type DemoDraftResult = {
+ type: DemoDraftType
+ draft: any
+ openPath: string
+}
+
+const aliases: Record = {
+ 'demo-cyl': 'cylinder',
+ cylinder: 'cylinder',
+ 'demo-guide': 'guide',
+ guide: 'guide',
+ 'demo-tips': 'tips',
+ tips: 'tips',
+ 'demo-data': 'data',
+ data: 'data',
+ 'demo-food': 'food',
+ food: 'food',
+ 'english-food': 'food',
+ 'demo-case': 'case',
+ case: 'case',
+ 'demo-creator': 'creator',
+ creator: 'creator',
+ 'demo-ai': 'vision',
+ vision: 'vision',
+ 'demo-exam': 'exam',
+ exam: 'exam',
+}
+
+const specs: Record = {
+ cylinder: {
+ type: 'courseware',
+ title: '小学数学六年级圆柱的体积(示例改编)',
+ subject: '数学',
+ grade: '六年级下册',
+ description: '围绕圆柱体积公式推导组织课堂,包含旧知回顾、操作探究、公式推导、巩固练习和拓展应用。',
+ tags: ['六年级', '圆柱体积', '公式推导'],
+ payload: () => coursewarePayload('小学数学六年级圆柱的体积(示例改编)', '数学', '六年级下册', [
+ ['封面', '小学数学六年级圆柱的体积', '从已有体积经验出发,理解圆柱体积公式。', 'title'],
+ ['知识回顾', '长方体、正方体体积公式', '复习 V = 底面积 x 高,为转化思想做准备。', 'content'],
+ ['操作探究', '把圆柱切拼成近似长方体', '观察底面积不变、高不变,体积也不变。', 'interaction'],
+ ['公式推导', 'V = S x h', '用底面积乘高计算圆柱体积,并解释每个量的意义。', 'summary'],
+ ['巩固练习', '分层计算与应用', '完成基础计算、逆向求高和生活情境题。', 'exercise'],
+ ], ['六年级', '圆柱体积', '公式推导']),
+ },
+ guide: {
+ type: 'courseware',
+ title: '智教助手新手指引(示例改编)',
+ subject: '课堂工具',
+ grade: '通用',
+ description: '展示从输入需求、上传材料到生成、预览、改编和发布的完整创作流程。',
+ tags: ['快速入门', '生成课件', '发布资源'],
+ payload: () => coursewarePayload('智教助手新手指引(示例改编)', '课堂工具', '通用', [
+ ['封面', '智教助手新手指引', '用一个主题完成从生成到发布的完整流程。', 'title'],
+ ['输入教学需求', '描述课题、学段和课堂目标', '把教材版本、知识点、互动要求写清楚。', 'content'],
+ ['选择生成类型', '课件、动画、练习、教案、试卷', '按课堂任务选择最合适的 AI 工具。', 'interaction'],
+ ['预览与微调', '检查页面结构与教学节奏', '根据课堂实际调整标题、图片、互动和练习。', 'content'],
+ ['发布与改编', '沉淀为可复用资源', '发布到资源广场后支持收藏、下载和二次改编。', 'summary'],
+ ], ['快速入门', '生成课件', '发布资源']),
+ },
+ tips: {
+ type: 'courseware',
+ title: '玩转互动课件必看小技巧(示例改编)',
+ subject: '课堂工具',
+ grade: '通用',
+ description: '整理课件编辑、图片替换、动画插入和课堂展示的高频技巧。',
+ tags: ['互动课件', '图片替换', '课堂展示'],
+ payload: () => coursewarePayload('玩转互动课件必看小技巧(示例改编)', '课堂工具', '通用', [
+ ['封面', '互动课件小技巧', '快速掌握高频编辑与课堂展示方法。', 'title'],
+ ['替换图片素材', '选中图片后替换或调整大小', '保持图片和文字的教学信息一致。', 'content'],
+ ['插入动画片段', '把动画放到关键解释处', '用动画承接抽象概念和课堂提问。', 'interaction'],
+ ['复用优秀结构', '从热门资源改编为原创课件', '保留结构,替换主题、例题和教师引导语。', 'summary'],
+ ], ['互动课件', '图片替换', '课堂展示']),
+ },
+ data: {
+ type: 'animation',
+ title: '课堂数据回收新手指引(示例改编)',
+ subject: '课堂工具',
+ grade: '通用',
+ description: '演示问答收集、投票统计和学生反馈汇总流程。',
+ tags: ['数据回收', '问答收集', '课堂反馈'],
+ payload: () => ({
+ title: '课堂数据回收新手指引(示例改编)',
+ anim_type: 'general',
+ description: '演示问答收集、投票统计和学生反馈汇总流程。',
+ config: {
+ title: '课堂数据回收新手指引',
+ description: '从创建任务到统计反馈的课堂闭环。',
+ html: animationHtml('课堂数据回收', ['创建收集任务', '学生扫码提交', '实时统计分布', '生成复盘建议'], '#063a2d', '#0f8a5f'),
+ scenes: [
+ { id: 1, name: '创建任务', duration: 8, narration: '选择问答、投票或打卡模板。' },
+ { id: 2, name: '学生提交', duration: 10, narration: '学生扫码后实时提交答案。' },
+ { id: 3, name: '数据统计', duration: 12, narration: '教师端显示选项分布和关键词。' },
+ { id: 4, name: '复盘建议', duration: 8, narration: '根据数据生成讲评重点。' },
+ ],
+ totalDuration: 38,
+ interactive: true,
+ },
+ }),
+ },
+ food: {
+ type: 'exercise',
+ title: 'Unit4 Healthy Food 第一课时(示例改编)',
+ subject: '英语',
+ grade: '七年级',
+ description: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。',
+ tags: ['Healthy Food', '词汇认读', '句型操练'],
+ payload: () => ({
+ title: 'Unit4 Healthy Food 第一课时(示例改编)',
+ exercise_type: 'choice',
+ subject: '英语',
+ knowledge_points: ['Healthy Food', 'food vocabulary', 'would like'],
+ questions: [
+ question('Which one is a healthy drink?', ['Cola', 'Water', 'Candy', 'Cake'], 'Water', 'Water is a healthy drink.'),
+ question('What would you like?', ['I like play.', 'I would like an apple.', 'I am seven.', 'It is blue.'], 'I would like an apple.', 'Use “would like” to express what you want.'),
+ question('Choose the fruit.', ['Carrot', 'Milk', 'Banana', 'Bread'], 'Banana', 'Banana is a fruit.'),
+ question('Which food should we eat more often?', ['Vegetables', 'Chips', 'Candy', 'Ice cream'], 'Vegetables', 'Vegetables are healthier for daily meals.'),
+ ],
+ settings: { show_analysis: true, shuffle: false, mode: 'class_game' },
+ }),
+ },
+ case: {
+ type: 'lesson_plan',
+ title: '智教助手经典案例解析合集(示例改编)',
+ subject: '课堂工具',
+ grade: '通用',
+ description: '拆解优秀课件的结构、互动设计和课堂使用场景,适合备课复盘。',
+ tags: ['优秀案例', '结构拆解', '教学设计'],
+ payload: () => lessonPlanPayload('智教助手经典案例解析合集(示例改编)', '课堂工具', '通用', [
+ ['案例导入', 8, ['展示优秀互动课件封面和学习目标。'], ['引导教师观察结构。'], ['记录课件亮点。']],
+ ['结构拆解', 18, ['拆分封面、导入、探究、练习、总结五类页面。'], ['讲解每类页面的作用。'], ['对照自己的课题迁移。']],
+ ['互动分析', 14, ['分析点击、拖拽、投票和即时反馈的使用位置。'], ['指出互动与目标的关系。'], ['判断互动是否服务学习。']],
+ ['改编实践', 15, ['选择一个课题完成原创改编方案。'], ['提供改编检查表。'], ['完成小组互评。']],
+ ]),
+ },
+ creator: {
+ type: 'courseware',
+ title: 'AI好课晒一晒创作者激励活动(示例改编)',
+ subject: '课堂工具',
+ grade: '通用',
+ description: '沉淀教师作品、活动说明和优秀模板推荐,支持发布后持续改编。',
+ tags: ['发布作品', '资源复用', '教学案例'],
+ payload: () => coursewarePayload('AI好课晒一晒创作者激励活动(示例改编)', '课堂工具', '通用', [
+ ['封面', 'AI好课晒一晒', '把好课发布出来,让更多老师复用和改编。', 'title'],
+ ['活动目标', '沉淀优秀课堂资源', '鼓励教师发布可预览、可改编、可复用的教学作品。', 'content'],
+ ['发布流程', '保存作品后发布到资源广场', '补充标题、学科、年级、标签和使用说明。', 'interaction'],
+ ['反馈迭代', '查看收藏、下载和改编反馈', '根据使用数据持续优化资源。', 'summary'],
+ ], ['发布作品', '资源复用', '教学案例']),
+ },
+ vision: {
+ type: 'animation',
+ title: 'AI如何看世界:图像识别(示例改编)',
+ subject: '信息科技',
+ grade: '人工智能体验课',
+ description: '用流程动画解释图像识别的基本工作过程。',
+ tags: ['人工智能', '图像识别', '流程动画'],
+ payload: () => ({
+ title: 'AI如何看世界:图像识别(示例改编)',
+ anim_type: 'general',
+ description: '用流程动画解释图像识别的基本工作过程。',
+ config: {
+ title: 'AI如何看世界',
+ description: '图像输入、特征提取、分类判断和反思边界。',
+ html: animationHtml('图像识别流程', ['输入图像', '拆分像素', '提取特征', '分类预测', '讨论偏差'], '#080b12', '#38bdf8'),
+ scenes: [
+ { id: 1, name: '输入图像', duration: 8, narration: '图像被拆成像素和颜色信息。' },
+ { id: 2, name: '提取特征', duration: 12, narration: '模型寻找边缘、纹理和形状。' },
+ { id: 3, name: '分类预测', duration: 10, narration: '输出类别和置信度。' },
+ { id: 4, name: '讨论边界', duration: 8, narration: '分析 AI 可能出错的场景。' },
+ ],
+ totalDuration: 38,
+ interactive: true,
+ },
+ }),
+ },
+ exam: {
+ type: 'exam',
+ title: '七年级数学有理数运算测试(示例改编)',
+ subject: '数学',
+ grade: '七年级',
+ description: '围绕有理数加减乘除组织基础检测,含答案与解析。',
+ tags: ['智能命题', '有理数', '基础检测'],
+ payload: () => ({
+ title: '七年级数学有理数运算测试(示例改编)',
+ subject: '数学',
+ grade: '七年级',
+ questions: [
+ examQuestion('计算:-3 + 8 = ?', ['-11', '-5', '5', '11'], '5', '异号两数相加,取绝对值较大的符号,用 8 - 3 = 5。', 5),
+ examQuestion('计算:(-2) x (-6) = ?', ['-12', '12', '8', '-8'], '12', '两个负数相乘结果为正。', 5),
+ examQuestion('下列数中最小的是?', ['-5', '-1', '0', '2'], '-5', '数轴上越靠左的数越小。', 5),
+ ],
+ answers: ['5', '12', '-5'],
+ duration: 45,
+ total_score: 100,
+ difficulty: 'medium',
+ knowledge_points: ['有理数运算'],
+ }),
+ },
+}
+
+export function normalizeDemoDraftKey(input: any) {
+ const raw = typeof input === 'string' ? input : String(input?.id || input?.demoId || '')
+ return aliases[raw] || ''
+}
+
+export function isDemoDraftAvailable(input: any) {
+ const key = normalizeDemoDraftKey(input)
+ return Boolean(key && specs[key])
+}
+
+export function getDemoDraftType(input: any): DemoDraftType | '' {
+ const key = normalizeDemoDraftKey(input)
+ return key && specs[key] ? specs[key].type : ''
+}
+
+export async function createDemoDraft(input: any): Promise {
+ const key = normalizeDemoDraftKey(input)
+ const spec = key ? specs[key] : null
+ if (!spec) throw new Error('示例资源暂不支持直接改编')
+
+ const payload = spec.payload()
+ let draft: any
+ if (spec.type === 'courseware') draft = unwrap(await createCourseware(payload))
+ if (spec.type === 'animation') draft = unwrap(await createAnimation(payload))
+ if (spec.type === 'exercise') draft = unwrap(await createExercise(payload))
+ if (spec.type === 'lesson_plan') draft = unwrap(await createLessonPlan(payload))
+ if (spec.type === 'exam') draft = unwrap(await createExam(payload))
+ return { type: spec.type, draft, openPath: openPath(spec.type, draft.id) }
+}
+
+function unwrap(res: any) {
+ return res?.data || res
+}
+
+function openPath(type: DemoDraftType, id: number) {
+ if (type === 'courseware') return `/courseware/${id}`
+ if (type === 'animation') return `/animation?open=${id}`
+ if (type === 'exercise') return `/exercise?open=${id}`
+ if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
+ return `/exam?open=${id}`
+}
+
+function coursewarePayload(title: string, subject: string, grade: string, pages: Array<[string, string, string, string]>, tags: string[]) {
+ return {
+ title,
+ subject,
+ grade,
+ description: `${subject}${grade ? ` · ${grade}` : ''}示例课件,可继续编辑、授课预览和发布分享。`,
+ content: pages.map(([pageTitle, heading, subtitle, type], index) => ({
+ title: pageTitle,
+ type,
+ content: slideHtml(heading, subtitle, pageTitle, index),
+ notes: subtitle,
+ })),
+ tags,
+ status: 'draft',
+ }
+}
+
+function lessonPlanPayload(title: string, subject: string, grade: string, phases: any[]) {
+ const normalizedPhases = phases.map(([name, duration, activities, teacher_actions, student_actions]) => ({
+ name,
+ duration,
+ activities,
+ teacher_actions,
+ student_actions,
+ resources: ['互动课件', '学习单'],
+ }))
+ return {
+ title,
+ subject,
+ grade,
+ objectives: ['理解优秀教学资源的结构特征', '能根据本班学情完成原创改编', '形成资源复盘与迭代意识'],
+ key_points: ['结构拆解', '互动设计', '改编实践'],
+ difficulties: ['让互动服务教学目标', '从案例迁移到真实课题'],
+ content: { title, phases: normalizedPhases },
+ duration: normalizedPhases.reduce((sum, item) => sum + Number(item.duration || 0), 0),
+ materials: ['案例资源', '改编检查表', '小组评价表'],
+ homework: ['选择一个个人课题,完成一份资源改编提纲。'],
+ }
+}
+
+function question(stem: string, options: string[], answer: string, explanation: string) {
+ return {
+ type: 'choice',
+ question: stem,
+ stem,
+ options,
+ answer,
+ explanation,
+ difficulty: 'medium',
+ html: questionHtml(stem, options),
+ }
+}
+
+function examQuestion(content: string, options: string[], answer: string, analysis: string, score: number) {
+ return {
+ type: 'choice',
+ content,
+ options,
+ answer,
+ analysis,
+ score,
+ }
+}
+
+function slideHtml(title: string, subtitle: string, badge: string, index: number) {
+ const themes = [
+ ['#fff3c4', '#7a5600', '#f7c948'],
+ ['#e8f7ef', '#00543d', '#8ecf9a'],
+ ['#dbe8ff', '#003f68', '#7fb3ff'],
+ ['#fff1a1', '#654100', '#ffd84d'],
+ ]
+ const [bg, fg, accent] = themes[index % themes.length]
+ return `
+
+
${escapeHtml(badge)}
+
${escapeHtml(title)}
+
${escapeHtml(subtitle)}
+
+
+
+
+
+
`
+}
+
+function animationHtml(title: string, steps: string[], bg: string, accent: string) {
+ return `
+
+
${escapeHtml(title)}
+
点击演示时,按流程观察课堂任务如何推进。
+
+ ${steps.map((step, index) => {
+ const left = 58 + index * Math.max(118, 640 / Math.max(steps.length, 1))
+ return `
+ ${index + 1}
+ ${escapeHtml(step)}
+
`
+ }).join('')}
+
+
`
+}
+
+function questionHtml(stem: string, options: string[]) {
+ return `
+
+
${escapeHtml(stem)}
+
+ ${options.map((option, index) => `
+ ${String.fromCharCode(65 + index)}
+ ${escapeHtml(option)}
+
`).join('')}
+
+
`
+}
+
+function escapeHtml(value: string) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
diff --git a/frontend/src/utils/download.ts b/frontend/src/utils/download.ts
new file mode 100644
index 0000000..13cd41c
--- /dev/null
+++ b/frontend/src/utils/download.ts
@@ -0,0 +1,10 @@
+export function downloadBlob(blob: Blob, filename: string) {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+}
diff --git a/frontend/src/utils/gameTemplates.ts b/frontend/src/utils/gameTemplates.ts
new file mode 100644
index 0000000..8dedaa3
--- /dev/null
+++ b/frontend/src/utils/gameTemplates.ts
@@ -0,0 +1,1538 @@
+// 教学游戏模板库 —— 参数化的可玩游戏模板,无需消耗 AI 积分
+// render() 返回一个可独立运行的 HTML 字符串,玩家可直接在页面交互
+
+export interface GameTemplateParam {
+ key: string
+ label: string
+ type: 'number' | 'text' | 'select'
+ default: string | number
+ min?: number
+ max?: number
+ step?: number
+ options?: { label: string; value: string }[]
+}
+
+export interface GameTemplate {
+ id: string
+ name: string
+ category: 'game_snake' | 'game_match' | 'game_adventure' | 'drag_sort' | 'matching' | 'memory' | 'quiz_word_guess' | 'quiz_true_false' | 'quiz_cloze' | 'quiz_math_sprint' | 'quiz_wheel' | 'word_search' | 'quiz_board' | 'merge_terms' | 'typing_race' | 'categorize' | 'sequence_memory' | 'idiom_chain' | 'bomb_quiz' | 'sudoku'
+ description: string
+ icon: string
+ accent: string
+ params: GameTemplateParam[]
+ defaultTitle: (p: Record) => string
+ render: (p: Record) => string
+}
+
+const SHELL = (title: string, body: string, script: string) => `
+
+
+
+
+${title}
+
+
+
+
+
+
+
+`;
+
+function num(p: Record, key: string, fallback = 0): number {
+ const v = p[key]
+ const n = typeof v === 'number' ? v : parseFloat(v)
+ return Number.isFinite(n) ? n : fallback
+}
+function str(p: Record, key: string, fallback = ''): string {
+ const v = p[key]
+ return v == null ? fallback : String(v)
+}
+
+
+// ===== 1. 知识贪吃蛇 =====
+const knowledgeSnake: GameTemplate = {
+ id: 'snake-knowledge',
+ name: '知识贪吃蛇',
+ category: 'game_snake',
+ description: '控制蛇吃到知识方块,回答随机题目获得加分',
+ icon: 'Cpu',
+ accent: 'linear-gradient(135deg, #84cc16, #65a30d)',
+ params: [
+ { key: 'questions', label: '题目(每行一题,格式:问题|答案)', type: 'text', default: '中国的首都是哪里?|北京\n2+3等于几?|5\n水的化学式是?|H2O\n光的速度约为多少万千米每秒?|30' },
+ { key: 'speed', label: '移动速度', type: 'number', default: 6, min: 3, max: 14, step: 1 },
+ ],
+ defaultTitle: () => '知识贪吃蛇',
+ render: (p) => {
+ const speed = num(p, 'speed', 6)
+ const qs = String(p.questions || '').split('\n').map(s => s.trim()).filter(Boolean)
+ .map(line => { const i = line.indexOf('|'); return i > 0 ? { q: line.slice(0, i), a: line.slice(i + 1) } : null })
+ .filter(Boolean)
+ const dataStr = JSON.stringify(qs)
+ return SHELL('知识贪吃蛇', '', `
+ (function(){
+ const speed=${speed};
+ const questions=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='知识贪吃蛇 方向键或 WASD 控制移动,吃方块答题目
开始游戏 '+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ const cols=22, rows=18;
+ function fit(){const w=Math.min(660,stage.clientWidth-32);cv.width=w;cv.height=w*rows/cols;}
+ fit();
+ let cell=cv.width/cols;
+ let snake,dir,nextDir,food,score,curQ,running,questionsLeft;
+ function reset(){
+ snake=[{x:10,y:9},{x:9,y:9},{x:8,y:9}];dir={x:1,y:0};nextDir=dir;
+ score=0;running=false;questionsLeft=[...questions];
+ placeFood();update();
+ }
+ function placeFood(){
+ if(questionsLeft.length===0)questionsLeft=[...questions];
+ const idx=Math.floor(Math.random()*questionsLeft.length);
+ curQ=questionsLeft.splice(idx,1)[0];
+ food={x:Math.floor(Math.random()*cols),y:Math.floor(Math.random()*rows)};
+ while(snake.some(s=>s.x===food.x&&s.y===food.y)){food.x=(food.x+1)%cols;}
+ }
+ function update(){document.getElementById('score').textContent=score;document.getElementById('len').textContent=snake.length;}
+ function step(){
+ dir=nextDir;
+ const head={x:snake[0].x+dir.x,y:snake[0].y+dir.y};
+ if(head.x<0||head.x>=cols||head.y<0||head.y>=rows||snake.some(s=>s.x===head.x&&s.y===head.y)){
+ gameOver();return;
+ }
+ snake.unshift(head);
+ if(head.x===food.x&&head.y===food.y){
+ const ans=prompt('题目:'+curQ.q+'\\n请输入答案:');
+ if(ans&&ans.trim()===curQ.a.trim()){score+=10;}
+ else if(ans){score+=3;}
+ placeFood();
+ } else snake.pop();
+ update();
+ draw();
+ }
+ function draw(){
+ cell=cv.width/cols;
+ ctx.fillStyle='#0f172a';ctx.fillRect(0,0,cv.width,cv.height);
+ // grid
+ ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1;
+ for(let i=0;i<=cols;i++){ctx.beginPath();ctx.moveTo(i*cell,0);ctx.lineTo(i*cell,cv.height);ctx.stroke();}
+ for(let i=0;i<=rows;i++){ctx.beginPath();ctx.moveTo(0,i*cell);ctx.lineTo(cv.width,i*cell);ctx.stroke();}
+ // food
+ ctx.fillStyle='#fbbf24';ctx.beginPath();
+ ctx.arc(food.x*cell+cell/2,food.y*cell+cell/2,cell*0.32,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#fef3c7';ctx.font='bold '+(cell*0.4)+'px sans-serif';ctx.textAlign='center';
+ ctx.fillText('?',food.x*cell+cell/2,food.y*cell+cell*0.65);
+ // snake
+ snake.forEach((s,i)=>{
+ ctx.fillStyle=i===0?'#a3e635':'#65a30d';
+ const r=cell*0.15;
+ roundRect(s.x*cell+1,s.y*cell+1,cell-2,cell-2,r);ctx.fill();
+ });
+ ctx.textAlign='left';
+ }
+ function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
+ let timer=null;
+ function start(){if(timer)clearInterval(timer);reset();running=true;document.getElementById('ov').style.display='none';timer=setInterval(step,1000/speed);}
+ function gameOver(){running=false;clearInterval(timer);
+ const ov=document.getElementById('ov');ov.style.display='flex';
+ ov.innerHTML='游戏结束 最终分数:'+score+' 分 · 长度 '+snake.length+'
再来一次 ';
+ document.getElementById('startBtn').onclick=start;
+ }
+ document.getElementById('startBtn').onclick=start;
+ document.getElementById('restartBtn').onclick=start;
+ window.addEventListener('keydown',e=>{
+ const k=e.key.toLowerCase();
+ if(k==='arrowup'||k==='w'){if(dir.y!==1)nextDir={x:0,y:-1};}
+ else if(k==='arrowdown'||k==='s'){if(dir.y!==-1)nextDir={x:0,y:1};}
+ else if(k==='arrowleft'||k==='a'){if(dir.x!==1)nextDir={x:-1,y:0};}
+ else if(k==='arrowright'||k==='d'){if(dir.x!==-1)nextDir={x:1,y:0};}
+ if(['arrowup','arrowdown','arrowleft','arrowright'].includes(k))e.preventDefault();
+ });
+ // touch
+ let tx,ty;
+ cv.addEventListener('touchstart',e=>{const t=e.touches[0];tx=t.clientX;ty=t.clientY;});
+ cv.addEventListener('touchend',e=>{
+ const t=e.changedTouches[0];const dx=t.clientX-tx,dy=t.clientY-ty;
+ if(Math.abs(dx)>Math.abs(dy)){if(dx>0&&dir.x!==-1)nextDir={x:1,y:0};else if(dx<0&&dir.x!==1)nextDir={x:-1,y:0};}
+ else{if(dy>0&&dir.y!==-1)nextDir={x:0,y:1};else if(dy<0&&dir.y!==1)nextDir={x:0,y:-1};}
+ });
+ reset();draw();
+ window.addEventListener('resize',fit);
+ })();
+ `);
+ },
+}
+
+
+// ===== 2. 翻牌记忆配对 =====
+const memoryMatch: GameTemplate = {
+ id: 'memory-match',
+ name: '翻牌记忆配对',
+ category: 'memory',
+ description: '翻开卡片找出匹配的概念对,训练记忆力',
+ icon: 'CopyDocument',
+ accent: 'linear-gradient(135deg, #ec4899, #db2777)',
+ params: [
+ { key: 'pairs', label: '配对组(每行 概念|配对)', type: 'text', default: '北京|中国首都\nH2O|水\n2+3|5\n地球|行星\n红色|暖色\n春天|季节' },
+ { key: 'cols', label: '列数', type: 'number', default: 4, min: 3, max: 6, step: 1 },
+ ],
+ defaultTitle: () => '翻牌记忆配对',
+ render: (p) => {
+ const cols = Math.round(num(p, 'cols', 4))
+ const pairs = String(p.pairs || '').split('\n').map(s => s.trim()).filter(Boolean)
+ .map(line => { const i = line.indexOf('|'); return i > 0 ? [line.slice(0, i), line.slice(i + 1)] : null }).filter(Boolean)
+ const dataStr = JSON.stringify(pairs)
+ return SHELL('翻牌配对', '', `
+ (function(){
+ const cols=${cols};
+ const pairsData=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '已配对:0 / '+pairsData.length+' · 翻牌:0
重新开始 ';
+ const grid=document.getElementById('grid');
+ grid.style.gridTemplateColumns='repeat('+cols+', 1fr)';
+ let cards=[],flipped=[],matched=0,flips=0,locked=false;
+ function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}
+ function build(){
+ grid.innerHTML='';cards=[];flipped=[];matched=0;flips=0;locked=false;
+ const deck=[];
+ pairsData.forEach((pair,idx)=>{deck.push({pairIdx:idx,text:pair[0]});deck.push({pairIdx:idx,text:pair[1]});});
+ shuffle(deck);
+ deck.forEach((c,i)=>{
+ const el=document.createElement('div');
+ el.style.cssText='aspect-ratio:1;background:#fff7ed;border-radius:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-weight:600;color:#c2410c;font-size:14px;padding:8px;text-align:center;user-select:none;transition:all 0.2s;box-shadow:0 2px 8px rgba(0,0,0,0.05);';
+ el.dataset.idx=i;
+ el.textContent='?';
+ el.onclick=()=>flip(i,el);
+ cards.push({data:c,el});
+ grid.appendChild(el);
+ });
+ document.getElementById('matched').textContent='0';
+ document.getElementById('flips').textContent='0';
+ }
+ function flip(i,el){
+ if(locked||flipped.includes(i)||cards[i].el.textContent!=='?')return;
+ el.textContent=cards[i].data.text;
+ el.style.background='#fff';
+ flipped.push(i);flips++;document.getElementById('flips').textContent=flips;
+ if(flipped.length===2){
+ locked=true;
+ const [a,b]=flipped;
+ if(cards[a].data.pairIdx===cards[b].data.pairIdx){
+ cards[a].el.style.background='#dcfce7';cards[b].el.style.background='#dcfce7';
+ cards[a].el.style.color='#166534';cards[b].el.style.color='#166534';
+ matched++;document.getElementById('matched').textContent=matched;
+ flipped=[];locked=false;
+ if(matched===pairsData.length){
+ setTimeout(()=>{
+ const ov=document.createElement('div');ov.className='game-overlay';
+ ov.innerHTML='全部配对成功 共翻 '+flips+' 次,记住了 '+pairsData.length+' 组概念
完成 ';
+ stage.querySelector('.game-board').appendChild(ov);
+ },300);
+ }
+ } else {
+ setTimeout(()=>{
+ cards[a].el.textContent='?';cards[a].el.style.background='#fff7ed';
+ cards[b].el.textContent='?';cards[b].el.style.background='#fff7ed';
+ flipped=[];locked=false;
+ },800);
+ }
+ }
+ }
+ document.getElementById('restartBtn').onclick=build;
+ build();
+ })();
+ `);
+ },
+}
+
+
+// ===== 3. 排序拖拽 =====
+const dragSort: GameTemplate = {
+ id: 'drag-sort-items',
+ name: '排序拖拽',
+ category: 'drag_sort',
+ description: '将打乱的条目按正确顺序拖动排列',
+ icon: 'Sort',
+ accent: 'linear-gradient(135deg, #3b82f6, #2563eb)',
+ params: [
+ { key: 'items', label: '条目(按正确顺序,每行一项)', type: 'text', default: '种子发芽\n长出幼苗\n开花\n结果\n果实成熟' },
+ { key: 'direction', label: '排列方向', type: 'select', default: 'desc', options: [{label:'从小到大 / 从早到晚',value:'asc'},{label:'从大到小 / 从晚到早',value:'desc'}] },
+ ],
+ defaultTitle: () => '排序拖拽',
+ render: (p) => {
+ const items = String(p.items || '').split('\n').map(s => s.trim()).filter(Boolean)
+ const direction = String(p.direction || 'desc')
+ const dataStr = JSON.stringify(items)
+ return SHELL('排序拖拽', '', `
+ (function(){
+ const correctOrder=${dataStr};
+ const direction='${direction}';
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '';
+ const list=document.getElementById('list');
+ const status=document.getElementById('status');
+ let els=[];
+ function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}
+ function build(){
+ list.innerHTML='';els=[];
+ const arr=shuffle([...correctOrder]);
+ if(direction==='asc')arr.reverse(); // start shuffled
+ arr.forEach((text,i)=>{
+ const row=document.createElement('div');
+ row.draggable=true;
+ row.style.cssText='background:#fff;border:2px solid #bfdbfe;border-radius:10px;padding:14px 16px;font-size:15px;color:#1e3a8a;cursor:grab;display:flex;align-items:center;gap:12px;font-weight:500;transition:border-color 0.2s;';
+ row.innerHTML=''+(i+1)+' '+text+' ';
+ row.addEventListener('dragstart',e=>{row.style.opacity='0.4';e.dataTransfer.setData('text/plain',i);});
+ row.addEventListener('dragend',()=>{row.style.opacity='1';});
+ row.addEventListener('dragover',e=>{e.preventDefault();row.style.borderColor='#3b82f6';});
+ row.addEventListener('dragleave',()=>{row.style.borderColor='#bfdbfe';});
+ row.addEventListener('drop',e=>{
+ e.preventDefault();row.style.borderColor='#bfdbfe';
+ const from=+e.dataTransfer.getData('text/plain');
+ const to=i;
+ if(from===to)return;
+ const moved=els.splice(from,1)[0];
+ els.splice(to,0,moved);
+ rerender();
+ });
+ els.push({text,row});
+ list.appendChild(row);
+ });
+ status.textContent='将条目拖动到正确顺序';status.style.color='#c2410c';
+ }
+ function rerender(){
+ list.innerHTML='';
+ els.forEach((e,i)=>{
+ e.row.querySelector('span').textContent=(i+1);
+ list.appendChild(e.row);
+ });
+ }
+ document.getElementById('checkBtn').onclick=()=>{
+ const userArr=els.map(e=>e.text);
+ const expected=direction==='asc'?correctOrder:[...correctOrder].reverse();
+ let allRight=true;
+ els.forEach((e,i)=>{
+ if(e.text===expected[i]){e.row.style.borderColor='#86efac';e.row.style.background='#f0fdf4';}
+ else{e.row.style.borderColor='#fca5a5';e.row.style.background='#fef2f2';allRight=false;}
+ });
+ const right=userArr.filter((t,i)=>t===expected[i]).length;
+ status.textContent=allRight?'全部正确!'+correctOrder.length+' 项都对':'正确 '+right+'/'+correctOrder.length+',红色项需调整';
+ status.style.color=allRight?'#16a34a':'#dc2626';
+ };
+ document.getElementById('restartBtn').onclick=build;
+ build();
+ })();
+ `);
+ },
+}
+
+
+// ===== 4. 冒险闯关问答 =====
+const adventureQuiz: GameTemplate = {
+ id: 'adventure-quiz',
+ name: '冒险闯关问答',
+ category: 'game_adventure',
+ description: '角色逐关前进,每关回答一道选择题,闯关成功抵达终点',
+ icon: 'TrophyBase',
+ accent: 'linear-gradient(135deg, #f59e0b, #d97706)',
+ params: [
+ { key: 'questions', label: '题目(格式:题目|正确答案|选项A|选项B|选项C|选项D 每行一题)', type: 'text', default: '中国的首都是?|A|北京|上海|广州|深圳\n2+3等于几?|B|3|5|6|8\n一年有多少个月?|C|10|11|12|13\n彩虹有几种颜色?|D|5|6|6\n水的化学式?|A|H2O|CO2|O2|NaCl' },
+ ],
+ defaultTitle: () => '冒险闯关问答',
+ render: (p) => {
+ const raw = String(p.questions || '').split('\n').map(s => s.trim()).filter(Boolean)
+ const qs = raw.map(line => {
+ const parts = line.split('|')
+ if (parts.length < 6) return null
+ const [q, ans, A, B, C, D] = parts
+ const opts = { A: A.trim(), B: B.trim(), C: C.trim(), D: D.trim() }
+ if (!['A', 'B', 'C', 'D'].includes((ans || '').trim().toUpperCase())) return null
+ return { q: q.trim(), ans: ans.trim().toUpperCase(), opts }
+ }).filter(Boolean)
+ const dataStr = JSON.stringify(qs)
+ return SHELL('冒险闯关问答', '', `
+ (function(){
+ const questions=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='冒险闯关 共 '+questions.length+' 关,全部答对抵达终点
开始闯关 '+
+ '关卡:0 /'+questions.length+' · 生命:3
';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ function fit(){const w=Math.min(660,stage.clientWidth-32);cv.width=w;cv.height=160;}
+ fit();
+ let cur=0,hp=3,selected=null,locked=false;
+ function drawMap(){
+ const w=cv.width,h=cv.height;
+ ctx.fillStyle='#1f2937';ctx.fillRect(0,0,w,h);
+ const pad=30,pathY=h/2;
+ // path
+ ctx.strokeStyle='#475569';ctx.lineWidth=4;
+ ctx.beginPath();ctx.moveTo(pad,pathY);ctx.lineTo(w-pad,pathY);ctx.stroke();
+ // checkpoints
+ const n=questions.length+1;
+ for(let i=0;i=questions.length){win();return;}
+ const q=questions[cur];
+ const qbox=document.getElementById('qbox');
+ qbox.style.display='flex';
+ qbox.innerHTML='第 '+(cur+1)+' 关:'+q.q+'
'+
+ ''+
+ ['A','B','C','D'].map(k=>''+k+'. '+q.opts[k]+' ').join('')+
+ '
';
+ qbox.querySelectorAll('.opt-btn').forEach(btn=>{
+ btn.onmouseenter=()=>{if(!locked)btn.style.borderColor='#ea580c';};
+ btn.onmouseleave=()=>{if(!locked)btn.style.borderColor='#fed7aa';};
+ btn.onclick=()=>{
+ if(locked)return;locked=true;
+ const k=btn.dataset.k;
+ if(k===q.ans){
+ btn.style.background='#dcfce7';btn.style.borderColor='#86efac';btn.style.color='#166534';
+ cur++;document.getElementById('lvl').textContent=cur;
+ setTimeout(()=>{locked=false;showQ();},700);
+ } else {
+ btn.style.background='#fee2e2';btn.style.borderColor='#fca5a5';btn.style.color='#991b1b';
+ hp--;document.getElementById('hp').textContent=hp;
+ if(hp<=0){lose();}else{setTimeout(()=>{locked=false;},700);}
+ }
+ };
+ });
+ drawMap();
+ }
+ function win(){
+ document.getElementById('qbox').style.display='none';
+ const ov=document.getElementById('ov');ov.style.display='flex';
+ ov.innerHTML='🏆 闯关成功! 共答对 '+questions.length+' 题 · 剩余生命 '+hp+'
再次挑战 ';
+ document.getElementById('startBtn').onclick=start;drawMap();
+ }
+ function lose(){
+ document.getElementById('qbox').style.display='none';
+ const ov=document.getElementById('ov');ov.style.display='flex';
+ ov.innerHTML='挑战失败 已通过 '+(cur)+' 关,生命耗尽
重新开始 ';
+ document.getElementById('startBtn').onclick=start;
+ }
+ function start(){
+ cur=0;hp=3;document.getElementById('lvl').textContent=0;document.getElementById('hp').textContent=3;
+ document.getElementById('ov').style.display='none';
+ showQ();
+ }
+ document.getElementById('startBtn').onclick=start;
+ drawMap();
+ window.addEventListener('resize',fit);
+ })();
+ `);
+ },
+}
+
+
+
+// ===== 5. 抢答打地鼠 =====
+const whackQuiz: GameTemplate = {
+ id: 'whack-quiz',
+ name: '抢答打地鼠',
+ category: 'matching',
+ description: '地鼠洞冒出选项,快速点击正确答案得分',
+ icon: 'Aim',
+ accent: 'linear-gradient(135deg, #84cc16, #ca8a04)',
+ params: [
+ { key: 'questions', label: '题目(每行:问题|正确答案|干扰项1|干扰项2|干扰项3)', type: 'text', default: '中国的首都是?|北京|上海|广州|南京\n2+3等于几?|5|3|6|8\n一年有多少个月?|12|10|11|13\n光速约为多少万千米每秒?|30|20|40|50\n水的化学式?|H2O|CO2|O2|NaCl' },
+ { key: 'duration', label: '每题时长(秒)', type: 'number', default: 8, min: 3, max: 20, step: 1 },
+ ],
+ defaultTitle: () => '抢答打地鼠',
+ render: (p) => {
+ const dur = num(p, 'duration', 8)
+ const raw = String(p.questions || '').split('\n').map(s => s.trim()).filter(Boolean)
+ const qs = raw.map(line => {
+ const parts = line.split('|')
+ if (parts.length < 5) return null
+ return { q: parts[0].trim(), ans: parts[1].trim(), wrong: [parts[2].trim(), parts[3].trim(), parts[4].trim()] }
+ }).filter(Boolean)
+ const dataStr = JSON.stringify(qs)
+ return SHELL('抢答打地鼠', '', `
+ (function(){
+ const duration=${dur};
+ const questions=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='抢答打地鼠 地鼠洞冒出选项,点击正确答案得分
开始游戏 '+
+ '分数:0 · 题目:0 /'+questions.length+' · 剩余:'+duration+'s
重新开始 ';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ function fit(){const w=Math.min(660,stage.clientWidth-32),h=Math.max(360,w*0.62);cv.width=w;cv.height=h;}
+ fit();
+ let score=0,qIdx=0,running=false,timeLeft=duration,holes=[],timerInt=null;
+ const HOLES_PER_Q=4;
+ function buildHoles(){
+ const w=cv.width,h=cv.height;
+ holes=[];
+ const q=questions[qIdx];
+ const opts=[{text:q.ans,correct:true},...q.wrong.map(t=>({text:t,correct:false}))];
+ // shuffle
+ for(let i=opts.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[opts[i],opts[j]]=[opts[j],opts[i]];}
+ const cols=2,rows=2;
+ for(let i=0;i{
+ // mound
+ ctx.fillStyle='#78350f';ctx.beginPath();ctx.ellipse(hole.x,hole.y,hole.rx,hole.ry,0,0,Math.PI*2);ctx.fill();
+ ctx.fillStyle='#451a03';ctx.beginPath();ctx.ellipse(hole.x,hole.y,hole.rx*0.8,hole.ry*0.8,0,0,Math.PI*2);ctx.fill();
+ // mole/option rising
+ if(hole.showT>0){
+ const rise=hole.state==='hit'?Math.max(0,hole.showT-0.3):hole.showT;
+ const off=rise*hole.ry*1.2;
+ ctx.fillStyle=hole.opt.correct?'#fef3c7':'#fed7aa';
+ ctx.beginPath();ctx.arc(hole.x,hole.y-off,hole.rx*0.7,0,Math.PI*2);ctx.fill();
+ ctx.strokeStyle='#92400e';ctx.lineWidth=2;ctx.stroke();
+ ctx.fillStyle='#1f2937';ctx.font='bold 13px sans-serif';
+ // wrap text
+ const t=hole.opt.text;
+ ctx.fillText(t.length>8?t.slice(0,8):t,hole.x,hole.y-off+4);
+ }
+ });
+ ctx.textAlign='left';
+ }
+ function nextQ(){
+ qIdx++;
+ if(qIdx>=questions.length){end(true);return;}
+ document.getElementById('qi').textContent=qIdx;
+ buildHoles();
+ timeLeft=duration;document.getElementById('timer').textContent=timeLeft+'s';
+ // pop options staggered
+ holes.forEach((h,i)=>{setTimeout(()=>{if(running){h.showT=1;h.state='shown';}},i*150);});
+ }
+ function end(done){
+ running=false;clearInterval(timerInt);
+ const ov=document.getElementById('ov');ov.style.display='flex';
+ ov.innerHTML=''+(done?'完成全部题目':'时间到')+' 最终分数:'+score+' / '+(questions.length*10)+' 分
再来一次 ';
+ document.getElementById('startBtn').onclick=start;
+ }
+ function start(){
+ score=0;qIdx=0;running=true;
+ document.getElementById('score').textContent=0;
+ document.getElementById('qi').textContent=0;
+ document.getElementById('ov').style.display='none';
+ buildHoles();
+ timeLeft=duration;document.getElementById('timer').textContent=timeLeft+'s';
+ holes.forEach((h,i)=>{setTimeout(()=>{if(running){h.showT=1;h.state='shown';}},i*150);});
+ clearInterval(timerInt);
+ timerInt=setInterval(()=>{
+ if(!running)return;
+ timeLeft--;document.getElementById('timer').textContent=timeLeft+'s';
+ if(timeLeft<=0){nextQ();}
+ },1000);
+ }
+ cv.onclick=e=>{
+ if(!running)return;
+ const rect=cv.getBoundingClientRect();
+ const sx=cv.width/rect.width,sy=cv.height/rect.height;
+ const x=(e.clientX-rect.left)*sx,y=(e.clientY-rect.top)*sy;
+ for(const hole of holes){
+ if(hole.state==='shown'&&hole.showT>0&&Math.hypot(x-hole.x,y-hole.y){fit();buildHoles();});
+ })();
+ `);
+ },
+}
+
+
+// ===== 6. 填词连连看 =====
+const connectMatch: GameTemplate = {
+ id: 'connect-match',
+ name: '填词连连看',
+ category: 'matching',
+ description: '左侧概念与右侧释义,画线连接匹配的概念对',
+ icon: 'Share',
+ accent: 'linear-gradient(135deg, #06b6d4, #0891b2)',
+ params: [
+ { key: 'pairs', label: '概念对(每行:概念|释义)', type: 'text', default: '光合作用|植物制造有机物\n呼吸作用|生物释放能量\n蒸腾作用|水分从叶片散失\n传粉|花粉到达柱头\n受精|精子与卵细胞融合' },
+ ],
+ defaultTitle: () => '填词连连看',
+ render: (p) => {
+ const pairs = String(p.pairs || '').split('\n').map(s => s.trim()).filter(Boolean)
+ .map(line => { const i = line.indexOf('|'); return i > 0 ? [line.slice(0, i), line.slice(i + 1)] : null }).filter(Boolean)
+ const dataStr = JSON.stringify(pairs)
+ return SHELL('填词连连看', '', `
+ (function(){
+ const pairsData=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+
+ '';
+ const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
+ const status=document.getElementById('status');
+ function fit(){const w=Math.min(640,stage.clientWidth-32);cv.width=w;cv.height=Math.max(300,pairsData.length*60+40);}
+ fit();
+ let lefts=[],rights=[],selLeft=null,lines=[],matched=new Set();
+ function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}
+ function build(){
+ lefts=pairsData.map((p,i)=>({idx:i,text:p[0]}));
+ rights=shuffle(pairsData.map((p,i)=>({idx:i,text:p[1]})));
+ selLeft=null;lines=[];matched.clear();status.textContent='点击左侧概念,再点击右侧匹配的释义';status.style.color='#0891b2';
+ }
+ function layout(){
+ const w=cv.width,h=cv.height;
+ const boxW=w*0.4,boxH=44,padY=(h-boxH*pairsData.length)/(pairsData.length+1);
+ lefts.forEach((l,i)=>{l.x=10;l.y=padY+i*(boxH+padY);l.w=boxW;l.h=boxH;});
+ rights.forEach((r,i)=>{r.x=w-boxW-10;r.y=padY+i*(boxH+padY);r.w=boxW;r.h=boxH;});
+ }
+ function draw(){
+ const w=cv.width,h=cv.height;
+ ctx.clearRect(0,0,w,h);ctx.fillStyle='#fff';ctx.fillRect(0,0,w,h);
+ layout();
+ // existing lines
+ ctx.strokeStyle='#0891b2';ctx.lineWidth=3;
+ lines.forEach(ln=>{
+ ctx.beginPath();ctx.moveTo(ln.x1,ln.y1);ctx.lineTo(ln.x2,ln.y2);ctx.stroke();
+ });
+ // pending line to mouse? skip
+ // left boxes
+ lefts.forEach(l=>{
+ const isMatched=matched.has(l.idx);
+ const isSel=selLeft===l;
+ ctx.fillStyle=isMatched?'#d1fae5':isSel?'#cffafe':'#f0fdfa';
+ ctx.strokeStyle=isSel?'#06b6d4':'#a5f3fc';ctx.lineWidth=isSel?3:1.5;
+ ctx.fillRect(l.x,l.y,l.w,l.h);ctx.strokeRect(l.x,l.y,l.w,l.h);
+ ctx.fillStyle='#0e7490';ctx.font='14px sans-serif';ctx.textAlign='left';
+ ctx.fillText(l.text,l.x+12,l.y+l.h/2+5);
+ });
+ // right boxes
+ rights.forEach(r=>{
+ const isMatched=matched.has(r.idx);
+ ctx.fillStyle=isMatched?'#d1fae5':'#fef9c3';
+ ctx.strokeStyle=isMatched?'#86efac':'#fde68a';ctx.lineWidth=1.5;
+ ctx.fillRect(r.x,r.y,r.w,r.h);ctx.strokeRect(r.x,r.y,r.w,r.h);
+ ctx.fillStyle='#854d0e';ctx.font='14px sans-serif';
+ ctx.fillText(r.text,r.x+12,r.y+r.h/2+5);
+ });
+ ctx.textAlign='left';
+ }
+ cv.onclick=e=>{
+ const rect=cv.getBoundingClientRect();
+ const sx=cv.width/rect.width,sy=cv.height/rect.height;
+ const x=(e.clientX-rect.left)*sx,y=(e.clientY-rect.top)*sy;
+ // left click
+ const lhit=lefts.find(l=>x>=l.x&&x<=l.x+l.w&&y>=l.y&&y<=l.y+l.h);
+ if(lhit&&!matched.has(lhit.idx)){selLeft=lhit;draw();return;}
+ // right click
+ if(selLeft){
+ const rhit=rights.find(r=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h);
+ if(rhit&&!matched.has(rhit.idx)){
+ lines.push({x1:selLeft.x+selLeft.w,y1:selLeft.y+selLeft.h/2,x2:rhit.x,y2:rhit.y+rhit.h/2,lIdx:selLeft.idx,rIdx:rhit.idx});
+ if(selLeft.idx===rhit.idx){matched.add(selLeft.idx);status.textContent='匹配正确!已配 '+matched.size+'/'+pairsData.length;status.style.color='#16a34a';
+ if(matched.size===pairsData.length){status.textContent='全部配对完成!';}}
+ else{status.textContent='配对错误,可继续调整(检查时会标红)';status.style.color='#dc2626';}
+ selLeft=null;
+ }
+ }
+ draw();
+ };
+ document.getElementById('checkBtn').onclick=()=>{
+ const expected={};lines.forEach(ln=>{expected[ln.lIdx]=ln.rIdx;});
+ let correct=0,wrong=[];
+ pairsData.forEach((_,i)=>{
+ const ln=lines.find(l=>l.lIdx===i);
+ if(ln&&ln.lIdx===ln.rIdx){correct++;}else{wrong.push(i+1);}
+ });
+ status.textContent='正确 '+correct+'/'+pairsData.length+(wrong.length?',错误项:'+wrong.join(','):' 全部正确!');
+ status.style.color=wrong.length?'#dc2626':'#16a34a';
+ draw();
+ };
+ document.getElementById('restartBtn').onclick=()=>{build();draw();};
+ window.addEventListener('resize',()=>{fit();draw();});
+ build();draw();
+ })();
+ `);
+ },
+}
+
+
+
+const quizWheel: GameTemplate = {
+ id: 'quiz-wheel',
+ name: '转盘答题',
+ category: 'quiz_wheel',
+ description: '旋转转盘随机选题,答对加分答错扣分,课堂抢答利器',
+ icon: 'Aim',
+ accent: 'linear-gradient(135deg, #7c3aed, #6366f1)',
+ params: [
+ { key: 'questions', label: '题目(每行:问题|答案A,答案B,答案C,答案D|正确序号1-4)', type: 'text', default: '中国的首都是哪里?|北京,上海,广州,深圳|1\n世界上最高的山峰是?|珠穆朗玛峰,乔戈里峰,干城章嘉峰,马卡鲁峰|1\n太阳系最大的行星是?|木星,土星,海王星,天王星|1\n一年有多少个月?|10,11,12,13|3\n光的速度大约是?|30万公里每秒,3万公里每秒,300公里每秒,3公里每秒|1' },
+ ],
+ defaultTitle: () => '转盘答题',
+ render: (p) => {
+ const lines = String(p.questions || '').split('\n').map(s => s.trim()).filter(Boolean)
+ const qs = lines.map(line => {
+ const parts = line.split('|')
+ if (parts.length >= 3) {
+ return { q: parts[0], opts: parts[1].split(','), answer: parseInt(parts[2]) - 1 }
+ }
+ return null
+ }).filter(Boolean)
+ const valid = qs.filter(Boolean) as {q:string,opts:string[],answer:number}[]
+ const dataStr = JSON.stringify(valid.map(x => ({ q: x.q, opts: x.opts, a: x.answer })))
+ return SHELL('转盘答题', '', `
+ (function(){
+ const questions = ${dataStr};
+ if(!questions.length){ document.getElementById('stage').innerHTML='请配置至少一道题目
'; return; }
+ let score=0, current=null, spinning=false, angle=0, used=new Set();
+ const stage=document.getElementById('stage');
+ stage.innerHTML=' '+
+ ''+
+ '
';
+ const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
+ const segColors=['#7c3aed','#6366f1','#8b5cf6','#a78bfa','#818cf8','#c4b5fd'];
+ function fit(){const w=Math.min(400,stage.clientWidth-20);cv.width=w;cv.height=w;cv.style.height=w+'px';}
+ function draw(){
+ const w=cv.width,cx=w/2,cy=w/2,R=w/2-8,n=questions.length;
+ ctx.clearRect(0,0,w,w);
+ for(let i=0;i{
+ if(spinning)return;
+ const available=questions.map((_,i)=>i).filter(i=>!used.has(i));
+ if(!available.length){alert('所有题目已完成!最终得分:'+score);return;}
+ spinning=true;
+ const targetIdx=available[Math.floor(Math.random()*available.length)];
+ const n=questions.length,seg=Math.PI*2/n;
+ const targetA=-(targetIdx*seg)-seg/2;
+ const spins=4*Math.PI*2+targetA-angle;
+ let progress=0;
+ function animate(){
+ progress+=0.02;
+ if(progress>=1){progress=1;spinning=false;angle=targetA;used.add(targetIdx);draw();showQ(targetIdx);return;}
+ const eased=1-Math.pow(1-progress,3);
+ angle=angle+spins*eased*0.02*5;
+ draw();requestAnimationFrame(animate);
+ }
+ animate();
+ };
+ function showQ(idx){
+ current=idx;const q=questions[idx];const box=document.getElementById('qbox');
+ box.style.display='block';
+ box.innerHTML=''+q.q+' '+
+ q.opts.map((o,i)=>''+String.fromCharCode(65+i)+'. '+o+' ').join('');
+ box.querySelectorAll('button').forEach(btn=>{
+ btn.onclick=()=>{
+ const choice=parseInt(btn.dataset.i);
+ if(choice===q.a){score+=10;btn.style.background='#86efac';btn.style.color='#166534';}
+ else{score-=5;btn.style.background='#fca5a5';btn.style.color='#991b1b';
+ box.querySelectorAll('button')[q.a].style.background='#86efac';}
+ document.getElementById('sc').textContent=score;
+ setTimeout(()=>{box.style.display='none';},1200);
+ };
+ });
+ }
+ window.addEventListener('resize',fit);fit();draw();
+ })();
+ `);
+ },
+}
+
+const wordSearch: GameTemplate = {
+ id: 'word-search',
+ name: '找词游戏',
+ category: 'word_search',
+ description: '在字母网格中找出隐藏的词语,拖动选择横竖斜方向',
+ icon: 'Search',
+ accent: 'linear-gradient(135deg, #0891b2, #0e7490)',
+ params: [
+ { key: 'words', label: '隐藏词语(逗号分隔,2-4字)', type: 'text', default: '光合,呼吸,根系,叶片,花朵' },
+ { key: 'size', label: '网格大小', type: 'select', default: '10', options: [{label:'8x8',value:'8'},{label:'10x10',value:'10'},{label:'12x12',value:'12'}] },
+ ],
+ defaultTitle: () => '找词游戏',
+ render: (p) => {
+ const words = String(p.words || '').split(/[,,]/).map(s => s.trim()).filter(s => s.length >= 2 && s.length <= 5)
+ const size = parseInt(String(p.size || '10'))
+ return SHELL('找词游戏', '', `
+ (function(){
+ const words=${JSON.stringify(words)};
+ const N=${size};
+ if(!words.length){document.getElementById('stage').innerHTML='请输入至少一个词语
';return;}
+ let grid=[],placed=[],found=new Set();
+ function makeGrid(){
+ grid=[];
+ for(let r=0;r{
+ for(let tries=0;tries<80;tries++){
+ const d=dirs[Math.floor(Math.random()*dirs.length)];
+ const r0=Math.floor(Math.random()*N),c0=Math.floor(Math.random()*N);
+ const r1=r0+(w.length-1)*d[0],c1=c0+(w.length-1)*d[1];
+ if(r1<0||r1>=N||c1<0||c1>=N)continue;
+ let ok=true;
+ for(let i=0;i重新生成 '+
+ '
'+
+ '
';
+ function render(){
+ const el=document.getElementById('grid');
+ el.style.display='grid';
+ el.style.gridTemplateColumns='repeat('+N+',1fr)';
+ el.style.gap='2px';
+ el.innerHTML='';
+ for(let r=0;r''+w+' ').join('');
+ }
+ let selecting=[],startCell=null;
+ document.getElementById('grid').addEventListener('mousedown',e=>{
+ const t=e.target.closest('[data-r]');if(!t)return;
+ startCell=[parseInt(t.dataset.r),parseInt(t.dataset.c)];selecting=[...startCell];
+ });
+ document.getElementById('grid').addEventListener('mouseover',e=>{
+ if(!startCell)return;const t=e.target.closest('[data-r]');if(!t)return;
+ const r=parseInt(t.dataset.r),c=parseInt(t.dataset.c);
+ clearHL();
+ const dr=Math.sign(r-startCell[0]),dc=Math.sign(c-startCell[1]);
+ if(dr===0&&dc===0){hl(startCell[0],startCell[1]);return;}
+ const steps=Math.max(Math.abs(r-startCell[0]),Math.abs(c-startCell[1]));
+ if(Math.abs(r-startCell[0])!==Math.abs(c-startCell[1])&&r!==startCell[0]&&c!==startCell[1]){hl(startCell[0],startCell[1]);return;}
+ for(let i=0;i<=steps;i++)hl(startCell[0]+i*dr,startCell[1]+i*dc);
+ });
+ window.addEventListener('mouseup',()=>{
+ if(!startCell)return;checkWord();startCell=null;clearHL();
+ });
+ function hl(r,c){const el=document.querySelector('[data-r="'+r+'"][data-c="'+c+'"]');if(el)el.style.background='#fde68a';}
+ function clearHL(){document.querySelectorAll('#grid > div').forEach(d=>d.style.background='#e0f2fe');reapplyFound();}
+ const foundCells=new Set();
+ function reapplyFound(){foundCells.forEach(function(key){var p=key.split(',');var el=document.querySelector('[data-r="'+p[0]+'"][data-c="'+p[1]+'"]');if(el){el.style.background='#86efac';el.style.color='#166534';}});}
+ function checkWord(){
+ const cells=Array.from(document.querySelectorAll('#grid > div')).filter(d=>d.style.background.includes('fde68a'));
+ if(cells.length<2)return;
+ const positions=cells.map(d=>[parseInt(d.dataset.r),parseInt(d.dataset.c)]);
+ const text=cells.map(d=>d.textContent).join('');
+ const rev=text.split('').reverse().join('');
+ for(let i=0;i{foundCells.add(r+','+c);
+ const el=document.querySelector('[data-r="'+r+'"][data-c="'+c+'"]');if(el){el.style.background='#86efac';el.style.color='#166534';}
+ });
+ const wEl=document.getElementById('w'+i);if(wEl){wEl.style.background='#bbf7d0';wEl.style.color='#166534';wEl.style.textDecoration='line-through';}
+ document.getElementById('fc').textContent=found.size;
+ if(found.size===words.length)setTimeout(()=>alert('恭喜,全部找到了!'),300);
+ return;
+ }
+ }
+ }
+ document.getElementById('reset').onclick=()=>{found.clear();foundCells.clear();makeGrid();render();document.getElementById('fc').textContent='0';};
+ makeGrid();render();
+ })();
+ `);
+ },
+}
+
+// ===== 扩展游戏模板(第二批):知识飞行棋 / 2048 合成 / 打字竞速 =====
+const quizBoard: GameTemplate = {
+ id: 'quiz-board',
+ name: '知识飞行棋',
+ category: 'quiz_board',
+ description: '掷骰子前进,答对留步、答错后退的棋盘闯关,单人多题',
+ icon: 'Flag',
+ accent: 'linear-gradient(135deg, #6366f1, #4338ca)',
+ params: [
+ { key: 'questions', label: '题目(每行一题,格式:问题|答案)', type: 'text', default: '中国的首都是哪里?|北京\n一年有多少个月?|12\n太阳从哪个方向升起?|东\n三角形的内角和是多少度?|180\n水的沸点是多少摄氏度?|100\n2的5次方等于多少?|32' },
+ { key: 'cells', label: '棋盘格数', type: 'number', default: 15, min: 8, max: 25, step: 1 },
+ ],
+ defaultTitle: () => '知识飞行棋',
+ render: (p) => {
+ const cells = num(p, 'cells', 15)
+ const qs = String(p.questions || '').split('\n').map(s => s.trim()).filter(Boolean)
+ .map(line => { const i = line.indexOf('|'); return i > 0 ? { q: line.slice(0, i), a: line.slice(i + 1) } : null }).filter(Boolean)
+ const dataStr = JSON.stringify(qs)
+ return SHELL('知识飞行棋', '', `
+ (function(){
+ const cells=${cells};
+ const qs=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '
位置:0 / '+(cells-1)+' 骰子:-
'+
+ '
'+
+ '
掷骰子前进 重新开始
'+
+ '
';
+ let pos=0,rolledTo=0,curQ=null;
+ function render(){const board=document.getElementById('board');board.innerHTML='';for(let i=0;i=cells-1){win();}}
+ function win(){document.getElementById('qtext').textContent='闯关成功!';document.getElementById('qfb').textContent='已到达终点';document.getElementById('qans').style.display='none';document.getElementById('qsubmit').textContent='再玩一次';document.getElementById('overlay').style.display='flex';curQ=null;}
+ document.getElementById('roll').onclick=function(){if(pos>=cells-1)return;var die=1+Math.floor(Math.random()*6);document.getElementById('dice').textContent=die;rolledTo=Math.min(pos+die,cells-1);showQ();};
+ document.getElementById('qsubmit').onclick=function(){if(curQ===null){pos=0;rolledTo=0;document.getElementById('dice').textContent='-';render();document.getElementById('overlay').style.display='none';return;}var ans=document.getElementById('qans').value.trim();var ok=ans&&curQ.a&&ans.toLowerCase()===String(curQ.a).trim().toLowerCase();document.getElementById('overlay').style.display='none';commit(ok);};
+ document.getElementById('qans').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('qsubmit').click();});
+ document.getElementById('restart').onclick=function(){pos=0;rolledTo=0;document.getElementById('dice').textContent='-';render();};
+ render();
+ })();
+ `);
+ },
+}
+
+const mergeTerms: GameTemplate = {
+ id: 'merge-2048',
+ name: '知识合成 2048',
+ category: 'merge_terms',
+ description: '2048 玩法:相同概念合并升级,把基础术语一路合成到核心概念',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #f59e0b, #ea580c)',
+ params: [
+ { key: 'terms', label: '概念阶梯(逗号分隔,11 个,从低到高)', type: 'text', default: '词,短语,句子,段落,篇章,结构,修辞,文体,表达,写作,文学' },
+ ],
+ defaultTitle: () => '知识合成 2048',
+ render: (p) => {
+ const terms = String(p.terms || '').split(/[,,]/).map(s => s.trim()).filter(Boolean)
+ const dataStr = JSON.stringify(terms)
+ return SHELL('知识合成 2048', '', `
+ (function(){
+ const terms=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '
分数:0 最高:-
'+
+ '
'+
+ '
左 上 下 右 新游戏
'+
+ '
';
+ const N=4;let grid,score;
+ const valIdx={2:0,4:1,8:2,16:3,32:4,64:5,128:6,256:7,512:8,1024:9,2048:10};
+ function term(v){var i=valIdx[v];return (i!=null&&terms[i])?terms[i]:String(v);}
+ function empty(){var e=[];for(var r=0;r=1024?11:v>=100?13:15)+'px;color:#1f2937;background:'+(colors[Math.min(tier-1,colors.length-1)]||'#ddd')+';left:'+(8+c*72)+'px;top:'+(8+r*72)+'px';t.textContent=term(v);wrap.appendChild(t);}document.getElementById('sc').textContent=score;var mx=0;for(r=0;rmx)mx=grid[r][c];document.getElementById('best').textContent=mx>=2?term(mx):'-';}
+ function slide(row){var a=row.filter(function(x){return x;});for(var i=0;i=2048)won=true;if(won){overlay('合成成功!','你合成了最高级概念','继续');return;}if(empty().length)return;for(r=0;r '打字竞速',
+ render: (p) => {
+ const words = String(p.words || '').split(/[\s,,、]+/).map(s => s.trim()).filter(Boolean)
+ const dataStr = JSON.stringify(words)
+ return SHELL('打字竞速', '', `
+ (function(){
+ const words=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ '
进度:0 /'+words.length+' 用时:0 s 速度:0 字/分
'+
+ '
'+
+ '
'+
+ '
重玩
'+
+ '
';
+ var idx=0,start=0,timer=null,done=0,running=false;
+ var inp=document.getElementById('inp'),target=document.getElementById('target');
+ function setTarget(){target.innerHTML='';var w=words[idx];for(var i=0;i=w.length&&val===w){idx++;done++;inp.value='';if(idx>=words.length){finish();}else setTarget();}}
+ function tick(){var el=Math.floor((Date.now()-start)/1000);document.getElementById('tm').textContent=el;var chars=words.slice(0,done).join('').length;document.getElementById('wpm').textContent=el>0?Math.round(chars/el*60):0;}
+ function finish(){clearInterval(timer);running=false;var el=Math.floor((Date.now()-start)/1000);var chars=words.join('').length;var wpm=el>0?Math.round(chars/el*60):0;document.getElementById('ot').textContent='完成!';document.getElementById('omsg').textContent='用时 '+el+' 秒,速度约 '+wpm+' 字/分';document.getElementById('overlay').style.display='flex';inp.disabled=true;}
+ function startGame(){idx=0;done=0;inp.disabled=false;inp.value='';inp.focus();setTarget();document.getElementById('overlay').style.display='none';start=Date.now();running=true;if(timer)clearInterval(timer);timer=setInterval(tick,200);}
+ inp.addEventListener('input',update);
+ document.getElementById('restart').onclick=startGame;document.getElementById('oagain').onclick=startGame;
+ startGame();
+ })();
+ `);
+ },
+}
+
+// ===== 猜词游戏(猜字母拼单词) =====
+const wordGuess: GameTemplate = {
+ id: 'word-guess',
+ name: '猜词游戏',
+ category: 'quiz_word_guess',
+ description: '看提示猜单词,点击字母填空,错误次数用完则失败,适合词汇拼写练习',
+ icon: 'Key',
+ accent: 'linear-gradient(135deg, #7c3aed, #5b21b6)',
+ params: [
+ { key: 'words', label: '词条(每行:单词|提示)', type: 'text', default: 'APPLE|红色的水果\\nWATER|无色无味的液体\\nLIGHT|让我们看见东西的能量\\nEARTH|我们居住的星球\\nMusic|七个音符组成的艺术' },
+ { key: 'maxWrong', label: '最多错误次数', type: 'number', default: 6, min: 3, max: 10, step: 1 },
+ ],
+ defaultTitle: () => '猜词游戏',
+ render: (p) => {
+ const maxW = num(p, 'maxWrong', 6)
+ const raw = String(p.words || '').split('\\n').map(s => s.trim()).filter(Boolean)
+ const ws = raw.map(line => {
+ const parts = line.split('|')
+ return { word: (parts[0]||'').trim().toUpperCase(), hint: (parts[1]||'').trim() }
+ }).filter(w => w.word.length > 0)
+ const dataStr = JSON.stringify(ws)
+ return SHELL('猜词游戏', '', `
+ (function(){
+ const WORDS=${dataStr};
+ const MAX_WRONG=${maxW};
+ const stage=document.getElementById('stage');
+ stage.innerHTML=''+
+ ''+
+ '
'+
+ '
剩余机会: / '+MAX_WRONG+'
'+
+ '
';
+ let idx=0,wrong=0,guessed=new Set(),cur=WORDS[0]||{word:'WORD',hint:''};
+ function pickWord(){idx=Math.floor(Math.random()*WORDS.length);cur=WORDS[idx];wrong=0;guessed=new Set();render();}
+ function render(){document.getElementById('hint').textContent=cur.hint||'(无提示)';var slots=document.getElementById('slots');slots.innerHTML='';for(var i=0;i=MAX_WRONG){showResult(false);return;}}var done=true;for(var i=0;i'+(win?'🎉 猜对了!':'💀 没机会了')+'答案是:'+cur.word+'
'+(win?'下一题':'再来一题')+' ';stage.querySelector('.game-board')||(stage.firstChild);stage.appendChild(ov);document.getElementById('nextBtn').onclick=function(){ov.remove();pickWord();};}
+ // need a game-board wrapper; recreate stage structure
+ stage.innerHTML='
';var board=document.getElementById('board');board.innerHTML=''+
+ ''+
+ '
'+
+ '
剩余机会: / '+MAX_WRONG+'
'+
+ '
';
+ pickWord();
+ })();
+ `);
+ },
+}
+
+
+// ===== 判断对错速答 =====
+const trueFalseRush: GameTemplate = {
+ id: 'true-false-rush',
+ name: '判断对错速答',
+ category: 'quiz_true_false',
+ description: '快速判断每条陈述的对错,限时作答,答对加分答错扣分,连对有额外奖励',
+ icon: 'CircleCheck',
+ accent: 'linear-gradient(135deg, #0d9488, #0f766e)',
+ params: [
+ { key: 'statements', label: '陈述(每行:陈述文字|对或错)', type: 'text', default: '太阳从东方升起|对\\n水在100摄氏度沸腾|对\\n地球是太阳系最大的行星|错\\n光速比声速慢|错\\n植物通过光合作用吸收二氧化碳|对\\n一年有13个月|错' },
+ { key: 'timePer', label: '每题限时(秒)', type: 'number', default: 6, min: 3, max: 15, step: 1 },
+ ],
+ defaultTitle: () => '判断对错速答',
+ render: (p) => {
+ const tp = num(p, 'timePer', 6)
+ const raw = String(p.statements || '').split('\\n').map(s => s.trim()).filter(Boolean)
+ const ss = raw.map(line => {
+ const parts = line.split('|')
+ return { text: (parts[0]||'').trim(), answer: (parts[1]||'').trim() === '错' ? false : true }
+ }).filter(s => s.text.length > 0)
+ const dataStr = JSON.stringify(ss)
+ return SHELL('判断对错速答', '', `
+ (function(){
+ const ITEMS=${dataStr};
+ const TIME_PER=${tp};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+'分数:0 · 连对:0 · 第 0 /'+ITEMS.length+' 题
重新开始 ';
+ const board=document.getElementById('board');
+ let idx=0,score=0,streak=0,running=false,timeLeft=0,timerInt=null,answered=false;
+ function shuffle(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}return a;}
+ var queue=shuffle(ITEMS.slice());
+ function start(){idx=0;score=0;streak=0;running=true;queue=shuffle(ITEMS.slice());next();}
+ function next(){if(idx>=queue.length){showEnd();return;}answered=false;timeLeft=TIME_PER;renderQ();clearInterval(timerInt);timerInt=setInterval(tick,1000);}
+ function tick(){if(!running||answered)return;timeLeft--;document.getElementById('tl').textContent=timeLeft;if(timeLeft<=0){answer(null);}}
+ function renderQ(){var item=queue[idx];document.getElementById('qi').textContent=idx+1;document.getElementById('score').textContent=score;document.getElementById('streak').textContent=streak;board.innerHTML='第 '+(idx+1)+' / '+queue.length+' 题 · 剩余 '+timeLeft+' 秒
'+item.text+'
✓ 对 ✗ 错
';document.getElementById('btnT').onclick=function(){answer(true);};document.getElementById('btnF').onclick=function(){answer(false);};}
+ function syncScore(){document.getElementById('score').textContent=score;document.getElementById('streak').textContent=streak;}
+ function answer(val){if(answered)return;answered=true;clearInterval(timerInt);var item=queue[idx];var fb=document.getElementById('fb');if(val===null){streak=0;fb.innerHTML='⏰ 超时!正确答案:'+(item.answer?'对':'错')+' ';syncScore();}else if(val===item.answer){streak++;var bonus=Math.floor(streak/3)*2;var gain=10+bonus;score+=gain;fb.innerHTML='✓ 正确!+'+gain+'分'+(streak>=3?'(连对'+streak+'奖励)':'')+' ';syncScore();}else{streak=0;score=Math.max(0,score-5);fb.innerHTML='✗ 错误!正确答案:'+(item.answer?'对':'错')+' -5分 ';syncScore();}setTimeout(function(){idx++;next();},1200);}
+ function showEnd(){running=false;clearInterval(timerInt);board.innerHTML='答题完毕!
'+score+'
共 '+queue.length+' 题
再玩一次 ';document.getElementById('againBtn').onclick=start;}
+ document.getElementById('restartBtn').onclick=start;
+ start();
+ })();
+ `);
+ },
+}
+
+// ===== 选词填空(Cloze 拖放) =====
+const clozeFill: GameTemplate = {
+ id: 'cloze-fill',
+ name: '选词填空',
+ category: 'quiz_cloze',
+ description: '从词库中拖动词语填入句子的空缺处,适合语文英语阅读理解与语法练习',
+ icon: 'EditPen',
+ accent: 'linear-gradient(135deg, #2563eb, #1d4ed8)',
+ params: [
+ { key: 'passage', label: '文章(用【】标记填空处,按顺序)', type: 'text', default: '春天来了,【】开了,【】飞回来了,农民开始【】。这是一年中最【】的季节。' },
+ { key: 'wordBank', label: '词库(逗号分隔,含正确答案+干扰项)', type: 'text', default: '花,燕子,播种,美丽,寒冷,雪花' },
+ ],
+ defaultTitle: () => '选词填空',
+ render: (p) => {
+ const passage = String(p.passage || '')
+ const bank = String(p.wordBank || '').split(',').concat(String(p.wordBank || '').split(',')).map(s=>s.trim()).filter(Boolean)
+ // Extract blanks in order from passage
+ const blanks = []
+ let tmp = passage
+ while (tmp.includes('【')) {
+ const s = tmp.indexOf('【'), e = tmp.indexOf('】', s)
+ if (e < 0) break
+ blanks.push(tmp.slice(s+1, e))
+ tmp = tmp.slice(e+1)
+ }
+ const dataStr = JSON.stringify({ passage, blanks, bank })
+ return SHELL('选词填空', '', `
+ (function(){
+ const DATA=${dataStr};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+'已填:0 / '+DATA.blanks.length+'
检查答案 重置 ';
+ const board=document.getElementById('board');
+ var shuffled=DATA.bank.slice();for(var i=shuffled.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=shuffled[i];shuffled[i]=shuffled[j];shuffled[j]=t;}
+ var filled=[];
+ function render(){var parts=DATA.passage.split('【');var html='';var bi=0;parts.forEach(function(seg,pi){if(pi>0){var ce=seg.indexOf('】');var beforeBlank=seg.slice(0,0);var after=seg.slice(ce+1);html+=''+(filled[bi]?''+filled[bi]+' ':'_____')+' ';bi++;html+=after;}else{html+=seg;}});html+='
';html+='';html+='提示:点击空缺处选中,再点击词库中的词填入。点击已填词可取出。
';board.innerHTML=html;var bankEl=document.getElementById('bank');shuffled.forEach(function(w){var used=filled.indexOf(w)>=0;var btn=document.createElement('button');btn.className='game-btn '+(used?'ghost':'primary');btn.style.cssText='padding:6px 14px;font-size:14px;'+(used?'opacity:.4;':'');btn.textContent=w;btn.disabled=used;btn.onclick=function(){placeWord(w);};bankEl.appendChild(btn);});board.querySelectorAll('.cz-blank').forEach(function(el){el.onclick=function(){var bi2=+el.dataset.bi;if(filled[bi2]){filled[bi2]=null;render();}};});updateDone();}
+ var selBlank=0;
+ function placeWord(w){if(filled.indexOf(w)>=0)return;for(var i=0;i'+(correct===DATA.blanks.length?'🎉 全部正确!':'答对 '+correct+'/'+DATA.blanks.length)+''+(correct===DATA.blanks.length?'太棒了!':'看看哪些可以改进')+'
关闭 ';board.appendChild(ov);document.getElementById('closeOv').onclick=function(){ov.remove();};};
+ document.getElementById('resetBtn').onclick=function(){filled=[];selBlank=0;render();};
+ render();
+ })();
+ `);
+ },
+}
+
+// ===== 算术竞速 =====
+const mathSprint: GameTemplate = {
+ id: 'math-sprint',
+ name: '算术竞速',
+ category: 'quiz_math_sprint',
+ description: '限时内尽可能多地解答算术题,支持加减乘除混合,实时统计正确率与速度',
+ icon: 'Timer',
+ accent: 'linear-gradient(135deg, #e11d48, #be123c)',
+ params: [
+ { key: 'ops', label: '运算类型', type: 'select', default: 'mixed', options: [
+ { label: '加法', value: 'add' },
+ { label: '减法', value: 'sub' },
+ { label: '乘法', value: 'mul' },
+ { label: '加减混合', value: 'addsub' },
+ { label: '四则混合', value: 'mixed' },
+ ] },
+ { key: 'maxNum', label: '最大数字', type: 'number', default: 12, min: 5, max: 99, step: 1 },
+ { key: 'duration', label: '总时长(秒)', type: 'number', default: 60, min: 20, max: 180, step: 10 },
+ ],
+ defaultTitle: (p) => '算术竞速 · ' + (p.ops === 'mul' ? '乘法' : p.ops === 'mixed' ? '四则混合' : '计算') + ' ' + p.duration + '秒',
+ render: (p) => {
+ const ops = String(p.ops || 'mixed'), mx = num(p, 'maxNum', 12), dur = num(p, 'duration', 60)
+ return SHELL('算术竞速', '', `
+ (function(){
+ const OPS='${ops}',MAXN=${mx},DURATION=${dur};
+ const stage=document.getElementById('stage');
+ stage.innerHTML='
'+'得分:0 · 正确率:- · 剩余 '+DURATION+' 秒
重新开始 ';
+ const board=document.getElementById('board');
+ let score=0,total=0,correct=0,timeLeft=DURATION,running=false,cur=null,timerInt=null;
+ function rnd(n){return Math.floor(Math.random()*n)+1;}
+ function gen(){var pool=[];if(OPS==='add'||OPS==='addsub'||OPS==='mixed')pool.push('+');if(OPS==='sub'||OPS==='addsub'||OPS==='mixed')pool.push('-');if(OPS==='mul'||OPS==='mixed')pool.push('×');if(OPS==='mixed')pool.push('÷');var op=pool[Math.floor(Math.random()*pool.length)];var a,b,ans;if(op==='+'){a=rnd(MAXN);b=rnd(MAXN);ans=a+b;}else if(op==='-'){a=rnd(MAXN);b=rnd(a);ans=a-b;}else if(op==='×'){a=rnd(Math.min(MAXN,12));b=rnd(Math.min(MAXN,12));ans=a*b;}else{b=rnd(MAXN);ans=rnd(MAXN);a=b*ans;}return{q:a+' '+op+' '+b+' = ?',ans:ans};}
+ function start(){score=0;total=0;correct=0;timeLeft=DURATION;running=true;cur=gen();renderQ();clearInterval(timerInt);timerInt=setInterval(tick,1000);}
+ function tick(){if(!running)return;timeLeft--;document.getElementById('tl').textContent=timeLeft;if(timeLeft<=0){end();}}
+ function renderQ(){board.innerHTML='第 '+(total+1)+' 题
'+cur.q+'
确认
';var inp=document.getElementById('inp');inp.focus();inp.onkeydown=function(e){if(e.key==='Enter')submit();};document.getElementById('submitBtn').onclick=submit;}
+ function submit(){if(!running)return;var inp=document.getElementById('inp');var val=parseInt(inp.value,10);total++;if(val===cur.ans){correct++;score+=10;document.getElementById('fb').innerHTML='✓ 正确!+10 ';}else{score=Math.max(0,score-3);document.getElementById('fb').innerHTML='✗ 答案是 '+cur.ans+' ';}document.getElementById('score').textContent=score;document.getElementById('acc').textContent=total>0?Math.round(correct/total*100)+'%':'-';setTimeout(function(){if(running){cur=gen();renderQ();}},500);}
+ function end(){running=false;clearInterval(timerInt);var acc=total>0?Math.round(correct/total*100):0;board.innerHTML='时间到!
'+score+'
答题 '+total+' 道 · 正确 '+correct+' · 正确率 '+acc+'%
再玩一次 ';document.getElementById('againBtn').onclick=start;}
+ document.getElementById('restartBtn').onclick=start;
+ board.innerHTML='算术竞速
'+DURATION+'秒内尽可能多答题
▶ 开始 ';document.getElementById('startBtn').onclick=start;
+ })();
+ `);
+ },
+}
+
+
+// ===== 知识分类闯关(将词条归入正确分类) =====
+const categorizeGame: GameTemplate = {
+ id: 'categorize-sort',
+ name: '知识分类闯关',
+ category: 'categorize',
+ description: '将词条拖入正确的分类框,训练概念归类能力,适合理科概念、词性、历史事件等分类',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #7c3aed, #5b21b6)',
+ params: [
+ { key: 'cats', label: '分类(每行:分类名|词条1,词条2,...)', type: 'text', default: '哺乳动物|老虎,鲸鱼,蝙蝠,人类\n鸟类|麻雀,企鹅,鹰,鸽子\n爬行动物|蛇,鳄鱼,蜥蜴,乌龟' },
+ ],
+ defaultTitle: () => '知识分类闯关',
+ render: (p) => {
+ const raw = str(p, 'cats', '').split('\n').map(s => s.trim()).filter(Boolean)
+ return SHELL('知识分类闯关', '', `
+ (function(){
+ var RAW=${JSON.stringify(raw)};
+ var cats=[]; var allItems=[];
+ RAW.forEach(function(line){
+ var parts=line.split('|');
+ var name=(parts[0]||'').trim();
+ var items=(parts[1]||'').split(',').map(function(x){return x.trim();}).filter(Boolean);
+ items.forEach(function(it){allItems.push({text:it,cat:name});});
+ cats.push({name:name,count:items.length});
+ });
+ var stage=document.getElementById('stage');
+ stage.innerHTML='将下方词条拖入正确的分类框(或点击词条再点分类)
'+
+ '
'+
+ '
'+
+ '已分类:0 / '+allItems.length+'
检查答案 重新开始 ';
+ function shuffle(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}return a;}
+ var pool=shuffle(allItems.slice());
+ var placed={}; var selItem=null;
+ function renderPool(){var el=document.getElementById('pool');el.innerHTML='';pool.forEach(function(it){if(placed[it.text])return;var b=document.createElement('div');b.textContent=it.text;b.style.cssText='padding:8px 14px;background:#fff;border:2px solid #c4b5fd;border-radius:8px;cursor:pointer;font-weight:600;font-size:14px;color:#5b21b6;';b.onclick=function(){selItem=it;b.style.background='#7c3aed';b.style.color='#fff';};b.draggable=true;b.ondragstart=function(e){e.dataTransfer.setData('text',it.text);selItem=it;};el.appendChild(b);});if(pool.filter(function(x){return !placed[x.text];}).length===0&&Object.keys(placed).length===allItems.length){el.innerHTML='全部已分类
';}}
+ function renderBoxes(){var el=document.getElementById('boxes');el.innerHTML='';cats.forEach(function(c){var box=document.createElement('div');box.style.cssText='padding:12px;border:2px dashed #a78bfa;border-radius:10px;min-height:80px;background:#faf5ff;';var h=document.createElement('div');h.textContent=c.name+' ('+c.count+')';h.style.cssText='font-weight:700;color:#5b21b6;font-size:14px;margin-bottom:8px;';box.appendChild(h);var cont=document.createElement('div');cont.style.cssText='display:flex;flex-wrap:wrap;gap:6px;';cont.id='box_'+c.name;box.appendChild(cont);box.ondragover=function(e){e.preventDefault();};box.ondrop=function(e){e.preventDefault();place(selItem,c.name);};box.onclick=function(){if(selItem)place(selItem,c.name);};el.appendChild(box);});}
+ function place(it,catName){if(!it||placed[it.text])return;placed[it.text]=catName;selItem=null;document.getElementById('done').textContent=Object.keys(placed).length;var cont=document.getElementById('box_'+catName);var chip=document.createElement('div');chip.textContent=it.text;chip.style.cssText='padding:6px 10px;background:#ede9fe;border-radius:6px;font-size:13px;font-weight:600;color:#5b21b6;';chip.onclick=function(){delete placed[it.text];chip.remove();document.getElementById('done').textContent=Object.keys(placed).length;renderPool();};cont.appendChild(chip);renderPool();}
+ function check(){var correct=0;allItems.forEach(function(it){if(placed[it.text]===it.cat)correct++;});var total=allItems.length;var box=document.getElementById('stage');var pct=Math.round(correct/total*100);var msg=pct===100?'全部正确!太棒了':pct>=80?'很好!'+correct+'/'+total+' 正确':'再试试,'+correct+'/'+total+' 正确';alert(msg+'('+pct+'%)');}
+ document.getElementById('checkBtn').onclick=check;
+ document.getElementById('resetBtn').onclick=function(){placed={};selItem=null;document.getElementById('done').textContent='0';renderPool();renderBoxes();};
+ renderPool();renderBoxes();
+ })();
+ `);
+ },
+}
+
+// ===== 序列记忆(Simon Says 记忆复现) =====
+const sequenceMemory: GameTemplate = {
+ id: 'sequence-memory',
+ name: '序列记忆挑战',
+ category: 'sequence_memory',
+ description: '依次亮起的颜色序列需按顺序复现,每关序列加长,训练短时记忆与专注力',
+ icon: 'Coin',
+ accent: 'linear-gradient(135deg, #db2777, #9d174d)',
+ params: [
+ { key: 'startLen', label: '起始序列长度', type: 'number', default: 2, min: 1, max: 5, step: 1 },
+ ],
+ defaultTitle: () => '序列记忆挑战',
+ render: (p) => {
+ const sl = num(p, 'startLen', 2)
+ return SHELL('序列记忆挑战', '', `
+ (function(){
+ var START=${sl};
+ var stage=document.getElementById('stage');
+ var COLORS=[{id:0,bg:'#ef4444',lit:'#fca5a5'},{id:1,bg:'#3b82f6',lit:'#93c5fd'},{id:2,bg:'#22c55e',lit:'#86efac'},{id:3,bg:'#f59e0b',lit:'#fcd34d'}];
+ var seq=[],userIdx=0,level=0,playing=false,score=0,best=0;
+ stage.innerHTML='
'+
+ '';
+ var board=document.getElementById('board');
+ board.innerHTML=''+
+ COLORS.map(function(c,i){return '
';}).join('')+
+ '
点击"开始游戏"
';
+ var pads=board.querySelectorAll('.pad');
+ function flash(id){var el=pads[id];var orig=COLORS[id].bg;el.style.background=COLORS[id].lit;el.style.opacity='1';setTimeout(function(){el.style.background=orig;el.style.opacity='.7';},350);}
+ function gen(){level++;seq=[];for(var i=0;i=seq.length){playing=true;document.getElementById('msg').textContent='轮到你了!按顺序点击';return;}flash(seq[i]);i++;setTimeout(step,600);}step();}
+ pads.forEach(function(pad){pad.onclick=function(){if(!playing)return;var id=+pad.dataset.id;flash(id);if(seq[userIdx]===id){userIdx++;if(userIdx>=seq.length){playing=false;score+=level*10;best=Math.max(best,score);document.getElementById('sc').textContent=score;document.getElementById('bs').textContent=best;document.getElementById('msg').textContent='正确!进入下一关';setTimeout(gen,900);}}else{playing=false;document.getElementById('msg').textContent='游戏结束!达到第 '+level+' 关';board.style.background='#7f1d1d';setTimeout(function(){board.style.background='#1f2937';},800);}};});
+ document.getElementById('startBtn').onclick=function(){level=0;score=0;document.getElementById('sc').textContent=0;gen();};
+ })();
+ `);
+ },
+}
+
+// ===== 成语接龙(首尾字衔接) =====
+const idiomChain: GameTemplate = {
+ id: 'idiom-chain',
+ name: '成语接龙',
+ category: 'idiom_chain',
+ description: '按首尾字衔接规则进行成语接龙,可选词库提示,训练词汇量与反应速度',
+ icon: 'EditPen',
+ accent: 'linear-gradient(135deg, #be123c, #881337)',
+ params: [
+ { key: 'bank', label: '成语词库(逗号分隔)', type: 'text', default: '一心一意,意气风发,发愤图强,强词夺理,理直气壮,壮志凌云,云开见日,日新月异,异口同声,声东击西,西窗剪烛,烛影斧声,声泪俱下,下笔成章,章台杨柳,柳暗花明,明察秋毫,毫不犹豫,豫章故郡' },
+ { key: 'hint', label: '提示模式', type: 'select', default: '0', options: [{label:'关闭',value:'0'},{label:'开启',value:'1'}] },
+ ],
+ defaultTitle: () => '成语接龙',
+ render: (p) => {
+ const bank = str(p, 'bank', '').split(',').map(s => s.trim()).filter(Boolean)
+ const hint = str(p, 'hint', '0')
+ return SHELL('成语接龙', '', `
+ (function(){
+ var BANK=${JSON.stringify(bank)}; var HINT=${hint}==='1';
+ var stage=document.getElementById('stage');
+ var chain=[],current='',used={},score=0;
+ stage.innerHTML='
'+
+ '';
+ var board=document.getElementById('board');
+ function start(){chain=[];used={};score=0;current='';board.innerHTML='
';document.getElementById('goBtn').onclick=submit;document.getElementById('inp').focus();document.getElementById('inp').onkeydown=function(e){if(e.key==='Enter')submit();};}
+ function submit(){var inp=document.getElementById('inp');var w=inp.value.trim();if(w.length<2){inp.value='';return;}if(used[w]){alert('这个成语已经用过了!');return;}addWord(w,'player');var next=findNext(w);if(!next){showWin();return;}setTimeout(function(){addWord(next,'ai');var aiLast=next.charAt(next.length-1);suggest(aiLast);},700);}
+ function addWord(w,who){used[w]=1;chain.push({w:w,who:who});current=w.charAt(w.length-1);score+=10;document.getElementById('cnt').textContent=chain.length;document.getElementById('sc').textContent=score;var list=document.getElementById('list');var item=document.createElement('div');item.style.cssText='padding:6px 10px;margin:4px 0;border-radius:6px;font-size:15px;font-weight:600;'+(who==='ai'?'background:#fce7f3;color:#9d174d;text-align:right;':'background:#fff;color:#1f2937;');item.textContent=(who==='player'?'我:':'AI:')+w+(chain.length>1?(' ← 衔接 "'+current+'"'):'');list.appendChild(item);list.scrollTop=list.scrollHeight;document.getElementById('inp').value='';}
+ function findNext(prevWord){var last=prevWord.charAt(prevWord.length-1);var cands=BANK.filter(function(x){return !used[x]&&x.charAt(0)===last;});return cands.length?cands[Math.floor(Math.random()*cands.length)]:null;}
+ function suggest(last){if(!HINT)return;var cands=BANK.filter(function(x){return !used[x]&&x.charAt(0)===last;});var s=document.getElementById('sugg');s.innerHTML='';if(!cands.length)return;var lbl=document.createElement('div');lbl.style.cssText='font-size:12px;color:#9ca3af;margin-bottom:4px;';lbl.textContent='提示(可选用):';s.appendChild(lbl);cands.slice(0,4).forEach(function(x){var sp=document.createElement('span');sp.style.cssText='display:inline-block;padding:4px 10px;margin:3px;background:#fff7ed;border:1px solid #fed7aa;border-radius:6px;font-size:13px;cursor:pointer;color:#9a3412;';sp.textContent=x;sp.onclick=function(){var inp=document.getElementById('inp');if(inp)inp.value=x;};s.appendChild(sp);});}
+ function showWin(){var item=document.createElement('div');item.style.cssText='padding:12px;text-align:center;background:#fef3c7;color:#92400e;border-radius:8px;font-weight:700;margin-top:10px;';item.textContent='AI 接不上啦!你赢了!得分:'+score;document.getElementById('list').appendChild(item);document.getElementById('inp').disabled=true;}
+ document.getElementById('resetBtn').onclick=start;
+ start();
+ })();
+ `);
+ },
+}
+
+// ===== 定时炸弹答题(倒计时引信) =====
+const bombQuiz: GameTemplate = {
+ id: 'bomb-quiz',
+ name: '定时炸弹答题',
+ category: 'bomb_quiz',
+ description: '炸弹引信不断燃烧,必须在爆炸前连续答对题目来拆除,紧张刺激的限时挑战',
+ icon: 'Aim',
+ accent: 'linear-gradient(135deg, #dc2626, #991b1b)',
+ params: [
+ { key: 'questions', label: '题目(每行:问题|正确答案|干扰1,干扰2)', type: 'text', default: '中国的首都是哪里?|北京|上海,广州\n一年有多少个月?|12|10,11\n太阳从哪个方向升起?|东方|西方,南方\n水的化学式是什么?|H2O|CO2,O2\n地球有几大洲?|7|5,6\n光的速度比声音快还是慢?|快|慢,一样' },
+ { key: 'fuse', label: '引信时长(秒)', type: 'number', default: 45, min: 20, max: 90, step: 5 },
+ ],
+ defaultTitle: () => '定时炸弹答题',
+ render: (p) => {
+ const fuse = num(p, 'fuse', 45)
+ const raw = str(p, 'questions', '').split('\n').map(s => s.trim()).filter(Boolean)
+ return SHELL('定时炸弹答题', '', `
+ (function(){
+ var FUSE=${fuse};
+ var QS=${JSON.stringify(raw)}.map(function(l){var p=l.split('|');return{q:(p[0]||'').trim(),a:(p[1]||'').trim(),wrong:(p[2]||'').split(',').map(function(x){return x.trim();}).filter(Boolean)};}).filter(function(x){return x.q&&x.a;});
+ var stage=document.getElementById('stage');
+ var timeLeft=FUSE,score=0,solved=0,running=false,cur=null,timer=null;
+ stage.innerHTML='
'+
+ '';
+ var board=document.getElementById('board');
+ function start(){timeLeft=FUSE;score=0;solved=0;running=true;document.getElementById('startBtn').textContent='重新开始';renderFuse();nextQ();clearInterval(timer);timer=setInterval(tick,1000);}
+ function tick(){if(!running)return;timeLeft--;renderFuse();if(timeLeft<=0){boom();}}
+ function renderFuse(){var pct=timeLeft/FUSE*100;var col=pct>50?'#16a34a':pct>25?'#f59e0b':'#dc2626';var fuseBar=''+timeLeft+' 秒
';var bomb=document.getElementById('fuseZone');if(bomb)bomb.innerHTML=fuseBar;}
+ function shuffle(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}return a;}
+ function nextQ(){cur=QS[Math.floor(Math.random()*QS.length)];var opts=shuffle([cur.a].concat(cur.wrong.slice(0,3)));var html='
'+cur.q+'
'+opts.map(function(o){return ''+o+' ';}).join('')+'
';board.innerHTML=html;renderFuse();board.querySelectorAll('[data-ans]').forEach(function(b){b.onclick=function(){check(b.dataset.ans,b);};});}
+ function check(val,btn){if(val===cur.a){btn.style.background='#16a34a';btn.style.color='#fff';solved++;score+=15;timeLeft=Math.min(FUSE,timeLeft+8);document.getElementById('sv').textContent=solved;document.getElementById('sc').textContent=score;setTimeout(nextQ,500);}else{btn.style.background='#dc2626';btn.style.color='#fff';timeLeft=Math.max(0,timeLeft-5);renderFuse();}}
+ function boom(){running=false;clearInterval(timer);board.innerHTML='💥
炸弹爆炸!
你拆除了 '+solved+' 颗炸弹,得分 '+score+'
再来一次 ';document.getElementById('againBtn').onclick=start;}
+ document.getElementById('startBtn').onclick=start;
+ })();
+ `);
+ },
+}
+
+// ===== 数独逻辑(4x4/6x6 入门数独) =====
+const sudokuGame: GameTemplate = {
+ id: 'sudoku-mini',
+ name: '入门数独',
+ category: 'sudoku',
+ description: '4x4 或 6x6 入门数独,每行每列每宫不重复,训练逻辑推理与数字思维',
+ icon: 'Grid',
+ accent: 'linear-gradient(135deg, #0369a1, #075985)',
+ params: [
+ { key: 'size', label: '规格', type: 'select', default: '4', options: [{label:'4x4(2x2宫)',value:'4'},{label:'6x6(2x3宫)',value:'6'}] },
+ ],
+ defaultTitle: (p) => '' + p.size + 'x' + p.size + ' 数独',
+ render: (p) => {
+ const size = num(p, 'size', 4)
+ return SHELL('入门数独', '', `
+ (function(){
+ var N=${size}; var BOXR=N===6?2:2, BOXC=N===6?3:2;
+ if(N===4){BOXR=2;BOXC=2;}
+ function genSolution(){var g=[];for(var i=0;i0;i--){var j=Math.floor(Math.random()*(i+1));var t=nums[i];nums[i]=nums[j];nums[j]=t;}for(var k=0;k空格:'+countEmpty()+'
检查 提示 新题目 '+
+ '
';
+ function countEmpty(){var c=0;puzzle.forEach(function(r){r.forEach(function(v){if(v===0)c++;});});return c;}
+ function renderGrid(){var el=document.getElementById('grid');el.style.gridTemplateColumns='repeat('+N+',44px)';el.innerHTML='';puzzle.forEach(function(row,r){row.forEach(function(v,c){var cell=document.createElement('div');var given=solution[r][c]&&puzzle[r][c]!==0&&true;var isFixed=puzzle[r][c]!==0&&v!==0;cell.textContent=v||'';var borderR=(c+1)%BOXC===0&&c g.id === id)
+}
+
+export function defaultGameParams(g: GameTemplate): Record {
+ const p: Record = {}
+ g.params.forEach((param) => {
+ p[param.key] = param.default
+ })
+ return p
+}
+
+export const gameCategoryLabels: Record = {
+ game_snake: '贪吃蛇闯关',
+ game_match: '翻牌配对',
+ game_adventure: '冒险闯关',
+ drag_sort: '排序拖拽',
+ matching: '匹配',
+ memory: '记忆翻牌',
+ quiz_wheel: '转盘答题',
+ word_search: '找词游戏',
+ quiz_board: '飞行棋闯关',
+ merge_terms: '合成 2048',
+ typing_race: '打字竞速',
+ quiz_word_guess: '猜词游戏',
+ quiz_true_false: '判断速答',
+ quiz_cloze: '选词填空',
+ quiz_math_sprint: '算术竞速',
+ categorize: '分类闯关',
+ sequence_memory: '序列记忆',
+ idiom_chain: '成语接龙',
+ bomb_quiz: '定时炸弹',
+ sudoku: '数独逻辑',
+}
diff --git a/frontend/src/utils/materialContext.ts b/frontend/src/utils/materialContext.ts
new file mode 100644
index 0000000..be6aeae
--- /dev/null
+++ b/frontend/src/utils/materialContext.ts
@@ -0,0 +1,59 @@
+export type MaterialContextItem = {
+ id: string
+ name: string
+ icon: string
+ typeLabel: string
+ sizeLabel: string
+ excerpt: string
+}
+
+export function formatMaterialFileSize(size: number) {
+ const n = Number(size || 0)
+ if (n < 1024) return `${n}B`
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
+ return `${(n / 1024 / 1024).toFixed(1)}MB`
+}
+
+export function materialTypeLabel(type: string, file?: { type?: string; name?: string }) {
+ if (file?.type?.startsWith('image/') || type === 'image') return '图片'
+ const map: Record = {
+ docx: 'Word材料',
+ pptx: 'PPT材料',
+ csv: '表格材料',
+ json: '结构化材料',
+ md: 'Markdown材料',
+ pdf: 'PDF材料',
+ xlsx: '表格材料',
+ xls: '表格材料',
+ txt: '文本材料',
+ text: '文本材料',
+ }
+ return map[type] || '教学材料'
+}
+
+export function materialToContext(item: any): MaterialContextItem {
+ return {
+ id: `library-${item.id}`,
+ name: item.title || item.filename || '素材',
+ icon: item.material_type === 'image' ? 'Picture' : 'Document',
+ typeLabel: materialTypeLabel(item.material_type || 'text', { type: item.material_type || '', name: item.filename || '' }),
+ sizeLabel: formatMaterialFileSize(item.size || 0),
+ excerpt: item.summary || '',
+ }
+}
+
+export function fileParseToContext(file: File, data: any): MaterialContextItem {
+ return {
+ id: `${Date.now()}-${file.name}`,
+ name: file.name,
+ icon: file.type.startsWith('image/') ? 'Picture' : 'Document',
+ typeLabel: materialTypeLabel(data.material_type, file),
+ sizeLabel: formatMaterialFileSize(data.size || file.size),
+ excerpt: data.summary || '',
+ }
+}
+
+export function buildMaterialPromptBlock(materials: MaterialContextItem[], title = '已选素材') {
+ if (!materials.length) return ''
+ return `\n\n【${title}】\n${materials.map(item => `- ${item.name}(${item.typeLabel}):${item.excerpt || '暂无摘要,请结合素材标题和类型进行创作。'}`).join('\n')}`
+}
diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts
new file mode 100644
index 0000000..4ada13c
--- /dev/null
+++ b/frontend/src/utils/request.ts
@@ -0,0 +1,97 @@
+import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
+import { ElMessage } from 'element-plus'
+import router from '@/router'
+import { useUserStore } from '@/stores/user'
+
+
+const request = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL || '',
+ timeout: 300000,
+})
+
+request.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem('access_token')
+ if (token) {
+ config.headers.set('Authorization', `Bearer ${token}`)
+ }
+ return config
+ },
+ (error) => Promise.reject(error),
+)
+
+// 单飞刷新:并发 401 时只发起一次刷新,其他请求等待结果
+let refreshPromise: Promise | null = null
+
+async function doRefresh(): Promise {
+ if (refreshPromise) return refreshPromise
+ const refreshToken = localStorage.getItem('refresh_token')
+ if (!refreshToken) return null
+ refreshPromise = (async () => {
+ try {
+ const res: any = await axios.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
+ const data = res?.data || res
+ if (data?.access_token) {
+ localStorage.setItem('access_token', data.access_token)
+ if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token)
+ return data.access_token as string
+ }
+ return null
+ } catch {
+ return null
+ } finally {
+ refreshPromise = null
+ }
+ })()
+ return refreshPromise
+}
+
+request.interceptors.response.use(
+ (response) => response.data,
+ async (error: AxiosError) => {
+ const detail = (error.response?.data as any)?.detail
+ let msg = '请求失败,请稍后重试'
+ if (typeof detail === 'string') {
+ msg = detail
+ } else if (Array.isArray(detail)) {
+ msg = detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ')
+ }
+ console.error('API Error:', error.response?.status, detail)
+
+ const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean }
+ const status = error.response?.status
+ const isAuthError =
+ status === 401 ||
+ (status === 403 && detail === 'Not authenticated')
+
+ // 自动刷新:401 且未重试过 且不是刷新接口本身
+ if (status === 401 && originalRequest && !originalRequest._retried && !originalRequest.url?.includes('/auth/refresh')) {
+ const newToken = await doRefresh()
+ if (newToken) {
+ originalRequest._retried = true
+ originalRequest.headers.set('Authorization', `Bearer ${newToken}`)
+ return request(originalRequest)
+ }
+ // 刷新失败:清理并跳转登录
+ try { useUserStore().clearToken() } catch { localStorage.removeItem('access_token') }
+ if (router.currentRoute.value.path !== '/login') router.push('/login')
+ return Promise.reject(error)
+ }
+
+ if (isAuthError) {
+ try {
+ useUserStore().clearToken()
+ } catch {
+ localStorage.removeItem('access_token')
+ }
+ if (router.currentRoute.value.path !== '/login') {
+ router.push('/login')
+ }
+ } else {
+ ElMessage.error(msg)
+ }
+ return Promise.reject(error)
+ },
+)
+
+export default request
diff --git a/frontend/src/utils/speech.ts b/frontend/src/utils/speech.ts
new file mode 100644
index 0000000..3cea430
--- /dev/null
+++ b/frontend/src/utils/speech.ts
@@ -0,0 +1,51 @@
+export type SpeechController = {
+ start: () => void
+ stop: () => void
+ isSupported: () => boolean
+}
+
+type SpeechOptions = {
+ onStart?: () => void
+ onText: (text: string) => void
+ onError?: () => void
+ onEnd?: () => void
+}
+
+export function createSpeechController(options: SpeechOptions): SpeechController {
+ let recognition: any = null
+
+ function getSpeechRecognition() {
+ return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
+ }
+
+ function stop() {
+ recognition?.stop()
+ }
+
+ function start() {
+ const SpeechRecognition = getSpeechRecognition()
+ if (!SpeechRecognition) {
+ options.onError?.()
+ return
+ }
+ stop()
+ recognition = new SpeechRecognition()
+ recognition.lang = 'zh-CN'
+ recognition.interimResults = false
+ recognition.continuous = false
+ recognition.onstart = () => options.onStart?.()
+ recognition.onresult = (event: any) => {
+ const text = Array.from(event.results).map((result: any) => result[0]?.transcript || '').join('')
+ if (text.trim()) options.onText(text.trim())
+ }
+ recognition.onerror = () => options.onError?.()
+ recognition.onend = () => options.onEnd?.()
+ recognition.start()
+ }
+
+ return {
+ start,
+ stop,
+ isSupported: () => !!getSpeechRecognition(),
+ }
+}
diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue
new file mode 100644
index 0000000..ab9b0cf
--- /dev/null
+++ b/frontend/src/views/Admin.vue
@@ -0,0 +1,447 @@
+
+
+
+
+
管理后台
+
运营数据、用户管理与资源审核
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+ {{ card.value }}
+ {{ card.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+
+ {}">
+
+
+ {{ formatTime(row.created_at) }}
+
+
+
+ {{ actionLabel(row.action) }}
+
+
+
+ {{ statusLabel(row.status) }}
+
+
+
+
+
+
+ 暂无审计日志,切换筛选或进行登录/上传操作后再查询
+
+
+
+
+
+
+
+
+
+
+ 查询
+
+
+
+
+
+
+
+ {{ row.role === 'admin' ? '管理员' : '教师' }}
+
+
+
+
+
+
+ {{ row.is_active ? '正常' : '禁用' }}
+
+
+
+ {{ formatDate(row.created_at) }}
+
+
+
+ {{ row.is_active ? '禁用' : '启用' }}
+ {{ row.role === 'admin' ? '降为教师' : '升为管理员' }}
+ 发积分
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+
+
+
+
+
+ {{ typeLabel(row.resource_type) }}
+
+
+
+
+
+
+
+ {{ row.status === 'active' ? '上架' : '下架' }}
+
+
+
+
+
+
+ {{ row.status === 'active' ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Animation.vue b/frontend/src/views/Animation.vue
new file mode 100644
index 0000000..7699faa
--- /dev/null
+++ b/frontend/src/views/Animation.vue
@@ -0,0 +1,733 @@
+
+
+
+
+
+
+
+
教学动画
+
描述教学场景,AI生成可交互的动画页面
+
+
+
+
+
+
+
+
AI正在生成动画页面...
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+ {{ item.title }}
+ {{ item.desc }}
+
+
+
+
+
+
+
+
+
{{ g.title }}
+
{{ g.prompt }}
+
+
试试 →
+
+
+
+
+
+
+
+
+
+
+
+
+
动画场景
+
+
+
{{ scene.id }}
+
+
{{ scene.name }}
+
+ 时长:{{ scene.duration }}秒
+ {{ scene.narration }}
+
+
+
+
+
+
+
+ 总时长:{{ result.totalDuration || '--' }}秒 · {{ result.interactive ? '支持交互控制' : '自动播放' }}
+
+
+
+
+
+
+ 正在加载已保存动画...
+
+
+ 暂无已保存动画
+
+
+
+
+
+ {{ item.title }}
+ {{ typeLabel(item.anim_type) }}
+
+
+
{{ item.title }}
+
{{ item.description || '暂无描述' }}
+
+
+
+ 预览
+
+
+
+ 发布
+
+
+
+ 下载
+
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Assistant.vue b/frontend/src/views/Assistant.vue
new file mode 100644
index 0000000..cea0824
--- /dev/null
+++ b/frontend/src/views/Assistant.vue
@@ -0,0 +1,585 @@
+
+
+
+
+
+ 新建对话
+
+
+
+
+
+
+
+
加载中…
+
还没有对话,点上方“新建对话”开始吧。
+
+
+
+
onConvMenu(c, conv)" @click.stop>
+
+
+
+ 重命名
+ {{ conv.pinned ? '取消置顶' : '置顶' }}
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ msg.content }}
+
+
+
+
+
+
+
+
+
+
+
你好,我是你的AI教学助手
+
告诉我你正在教的学科与年级,或者直接抛出一个教学问题,我会给你可落地的方案。
+
+
+ {{ s.tag }}
+ {{ s.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Classroom.vue b/frontend/src/views/Classroom.vue
new file mode 100644
index 0000000..10e94ac
--- /dev/null
+++ b/frontend/src/views/Classroom.vue
@@ -0,0 +1,2176 @@
+
+
+
+
+
+
+
+
我的课堂活动
+
学生提交后会同步到统计条、最近提交和导出数据中。
+
+
+ 更新于 {{ lastUpdatedAt }}
+
+
+ 自动刷新
+
+
+
+ 刷新
+
+
+
+
+
+
+
+ {{ getToolLabel(item.activity_type) }}
+ {{ statusText[item.status] || item.status }}
+
+ {{ item.title }}
+ {{ item.description || '暂无说明' }}
+
+ 提交 {{ item.responses?.length || 0 }}
+ 学生 {{ participantCount(item) }}
+ 活动ID {{ item.id }}
+
+
+
+ 投放码
+ {{ item.id }}
+
+
+ 学生入口
+ {{ joinEntryLink }}
+ {{ activityLink(item) }}
+
+
+
+ 复制码
+
+
+
+ 复制链接
+
+
+
+ 打开
+
+
+
+ 投屏
+
+
+ 打印
+
+
+
+ {{ option }}
+
+
+
+
+ {{ material.name }}
+
+
+
+ 正确答案:{{ item.config.correct_answer }}
+
+
+
+
+ {{ option }}
+ {{ optionCount(item, option) }} · {{ optionPercent(item, option) }}%
+
+
+
+
+
+
+
+ {{ word }}
+
+
+
+ 最近提交
+
+
+ 导出
+
+
+
+ {{ res.student_name || '匿名' }}:{{ formatAnswer(res.answer) }}
+
+
最近一次 {{ latestSubmittedAt(item) }}
+
+
+
+ 编辑
+
+
+ 复制
+
+
+ 发布
+
+
+ 结束
+
+
+ 导出
+
+
+ 诊断
+
+
+ 投屏
+
+
+ 打印
+
+
+ 删除
+
+
+
+
+
+
+
+
+ 模拟提交
+
+
+ {{ item.status === 'draft' ? '草稿活动发布后才能投放给学生。' : '活动已结束,学生端只保留查看状态。' }}
+
+
+
+
+
+
+ 还没有课堂活动
+ 从上方选择一个工具类型并创建。
+
+
+
+
+ 正在分析课堂数据...
+
+
+
+
{{ getToolLabel(analysis.activity_type) }}
+
{{ analysis.title }}
+
{{ analysis.question }}
+
+
{{ analysis.mastery_level }}
+
+
+
{{ analysis.response_count }} 提交
+
{{ analysis.participant_count }} 学生
+
{{ analysis.accuracy === null || analysis.accuracy === undefined ? '-' : `${analysis.accuracy}%` }} 正确率
+
+
+
选项分布
+
+
+
{{ item.option }} {{ item.count }} · {{ item.percent }}%
+
+
+
+
+
+
高频关键词
+
+ {{ item.keyword }} · {{ item.count }}
+
+
+
+
+
+
+
+ {{ analysisExporting ? '导出中' : '导出报告' }}
+
+
+
+ 复制二次练习提示词
+
+
+
+ 生成二次练习
+
+
+
+
+
+
+
+
+
+
{{ getToolLabel(launchActivity.activity_type) }}
+
{{ launchActivity.title }}
+
{{ activityQuestion(launchActivity) || '请学生输入投放码进入活动。' }}
+
+
+ 投放码
+ {{ launchActivity.id }}
+
+
+
+
+
+ 学生入口
+ {{ joinEntryLink }}
+ {{ activityLink(launchActivity) }}
+
+
+
+ 复制投放码
+
+
+
+ 复制链接
+
+
+
+ 学生页
+
+
+ 打印投放卡
+
+
+
+
+ 参考素材
+
+
+
+ {{ material.name }}
+
+
+
+
+
+
+ {{ launchActivity.responses?.length || 0 }}
+ 提交数
+
+
+ {{ participantCount(launchActivity) }}
+ 参与学生
+
+
+ {{ latestSubmittedAt(launchActivity) || '等待提交' }}
+ 最近提交
+
+
+
+
+
+
{{ launchActivity.config?.options?.length ? '实时选项分布' : '实时关键词' }}
+
+
+
+ {{ option }}
+ {{ optionCount(launchActivity, option) }} · {{ optionPercent(launchActivity, option) }}%
+
+
+
+
+
+ {{ word }}
+
+
等待学生提交后展示实时分布。
+
+
+
最近提交
+
+
+ {{ res.student_name || '匿名' }}
+ {{ formatAnswer(res.answer) }}
+
+
+
还没有学生提交。
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/ClassroomJoin.vue b/frontend/src/views/ClassroomJoin.vue
new file mode 100644
index 0000000..6e14d6c
--- /dev/null
+++ b/frontend/src/views/ClassroomJoin.vue
@@ -0,0 +1,529 @@
+
+
+
+
+
+
+
+
+
+
投放码
+
输入老师给出的活动 ID
+
也可以让老师复制完整投放链接,打开后会直接进入提交页。
+
+
+
+
+
+
+ 活动不存在或已关闭
+ 请核对投放码,或让老师重新发布活动。
+ 重新输入投放码
+
+
+
+
+ {{ activity.status === 'closed' ? '活动已结束' : '活动暂未发布' }}
+ 请等待老师重新发布或切换到新的投放链接。
+ 重新加载
+
+
+
+
+ 提交成功
+ 你的回答已进入课堂数据统计。
+ 继续提交
+
+
+
+
+ {{ activityTypeLabel(activity.activity_type) }}
+ {{ statusText[activity.status] || activity.status }}
+
+ {{ activity.title }}
+ {{ activity.description || '请根据课堂要求完成提交。' }}
+
+ {{ displayQuestion }}
+
+
+ 姓名
+
+
+
+ 选择答案
+
+
+ {{ option }}
+
+
+
+
+
+ 我的回答
+
+
+
+
+
+ {{ submitting ? '提交中' : '提交回答' }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Community.vue b/frontend/src/views/Community.vue
new file mode 100644
index 0000000..e1b0c2b
--- /dev/null
+++ b/frontend/src/views/Community.vue
@@ -0,0 +1,493 @@
+
+
+
+
+
+
+ {{ scope.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ {{ editingPost ? '保存修改' : '发布' }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/CommunityDetail.vue b/frontend/src/views/CommunityDetail.vue
new file mode 100644
index 0000000..bfcf9d9
--- /dev/null
+++ b/frontend/src/views/CommunityDetail.vue
@@ -0,0 +1,461 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/views/CoursewareCreate.vue b/frontend/src/views/CoursewareCreate.vue
new file mode 100644
index 0000000..2e377ec
--- /dev/null
+++ b/frontend/src/views/CoursewareCreate.vue
@@ -0,0 +1,1130 @@
+
+
+
+
+
+ 返回
+
+
+
+ AI互动课件
+
+
+
+
+ 全屏预览
+
+
+
+ {{ exporting ? '导出中' : '下载HTML' }}
+
+
+
+ {{ saving ? '保存中' : '保存' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
AI正在创作课件
+
分析教学内容 · 设计课件结构 · 生成互动页面 · 优化排版效果
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+
+
+ {{ step.num }}
+ {{ step.title }}
+ {{ step.desc }}
+
+
+
快速开始
+
+
+
+
+
+
+
{{ g.title }}
+
{{ g.desc }}
+
+
+
+
+
+
+
+
+
+
+
{{ result.title }}
+
+
+
+
+
+
+ {{ form.subject || '未设置学科' }}
+
+ {{ form.grade || '未设置年级' }}
+
+ {{ result.pages?.length || 0 }} 页
+
+
{{ result.summary }}
+
+
+
+
+
+
+
{{ savedCoursewareId ? '已保存为作品 #' + savedCoursewareId : '生成结果尚未保存' }}
+
{{ publishHint }}
+
+
+
+
+
+ 授课预览
+
+
+
+ 保存草稿
+
+
+
+ {{ publishing ? '发布中' : '发布到资源广场' }}
+
+
+
+ 离线HTML
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+ {{ idx + 1 }}
+ {{ page.title || getPageTypeLabel(page.type) }}
+
+
+
+
+
+
+
+
+
+
+ 标题
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingPage ? '完成编辑' : '编辑内容' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/CoursewareDetail.vue b/frontend/src/views/CoursewareDetail.vue
new file mode 100644
index 0000000..00a9a41
--- /dev/null
+++ b/frontend/src/views/CoursewareDetail.vue
@@ -0,0 +1,621 @@
+
+
+
+
+
+
+ 返回
+
+
+
{{ courseware?.title || '课件详情' }}
+
+
+
+
+
+
+
+ {{ courseware.status === 'published' ? '已发布' : '草稿' }}
+
+
+
+ 全屏预览
+
+
+
+ 下载HTML
+
+
+
+ {{ exportingDocx ? '导出中' : '下载Word' }}
+
+
+
+ {{ sharing ? '生成中' : '分享链接' }}
+
+
+
+ {{ publishing ? '发布中' : '发布资源' }}
+
+
+
+ 保存
+
+
+
+
+
+
+
+
+
+
+
+
{{ idx + 1 }}
+
{{ page.title || getPageTypeLabel(page.type) }}
+
+
+
+
+
+
+
+
+
+ 添加页
+
+
+
+
+
+
+
+
+ 标题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 主题
+
+
+
+
+
+ {{ t.name }}
+
+
+
+ 清除主题
+
+
+
+
+
+
+ 插入模板
+
+
+
+ {{ editingPage ? '完成编辑' : '编辑内容' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 第 {{ currentPage + 1 }} 页
+ {{ getPageTypeLabel(courseware.content[currentPage].type) }} · {{ editingPage ? '正在编辑' : '预览模式' }}
+
+
+ 复制此页
+ 删除此页
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/CoursewareList.vue b/frontend/src/views/CoursewareList.vue
new file mode 100644
index 0000000..4b59b19
--- /dev/null
+++ b/frontend/src/views/CoursewareList.vue
@@ -0,0 +1,812 @@
+
+
+
+
+
+
+ {{ works.length }}
+ 全部作品
+
+
+ {{ publishedCount }}
+ 已发布
+
+
+ {{ draftCount }}
+ 草稿
+
+
+ {{ totalUnits }}
+ 内容单元
+
+
+
+
+
+
+
+
+
{{ typeLabel(work.type) }}
+
+ {{ work.status === 'published' ? '已发布' : '草稿' }}
+
+
{{ unitText(work) }}
+
{{ work.subject || typeLabel(work.type) }}
+
{{ work.title }}
+
{{ work.grade || metaText(work) }}
+
+
+
+
+
{{ work.title }}
+
{{ work.description || defaultDescription(work) }}
+
{{ metaLine(work) }}
+
+ {{ tag }}
+
+
+
+ 用它生成
+
+ {{ downloadingKey === work.key ? '下载中' : '下载' }}
+
+ 查看资源
+ 预览
+
+ {{ publishingKey === work.key ? '发布中' : work.resource_id ? '更新发布' : '发布资源' }}
+
+
+
+
+
+
+
+
+
{{ works.length ? '没有匹配的作品' : '暂无作品' }}
+
创建第一个作品
+
+
+
+
+
+
+ {{ index + 1 }}
+ {{ page.title || getPageTypeLabel(page.type) }}
+
+
+
+
+ 暂无页面内容
+
+
+
+
+
+
+
+ {{ typeLabel(previewWorkItem.type) }}
+ {{ previewWorkItem.title }}
+ {{ metaLine(previewWorkItem) }}
+
+
+
+
{{ index + 1 }}
+
{{ line }}
+
+
+
+
+
+ 编辑
+ 用它生成
+ 发布到资源广场
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/CoursewarePreview.vue b/frontend/src/views/CoursewarePreview.vue
new file mode 100644
index 0000000..7eee50b
--- /dev/null
+++ b/frontend/src/views/CoursewarePreview.vue
@@ -0,0 +1,1234 @@
+
+
+
+
+
+
+
+
+
+
+
+ 返回
+
+
+ 智教助手
+ zhijiao.local
+
+
+
+
+
+
+ 分享
+
+
+
+ 一键改编
+
+
+
+ 下载
+
+
+
+ 全屏
+
+ 立即体验
+
+
+
+
+
+
+
+ AI互动课件
+
{{ coursewareTitle || '课件预览' }}
+
+
{{ currentPage + 1 }} / {{ pages.length }} · {{ aspectRatio }}
+
+
+
+
+
+
{{ currentPage + 1 }} / {{ pages.length }}
+
+
+
+ {{ showNotes ? pages[currentPage].notes : '教师备注' }}
+
+
+
+
+
+
智
+
+ 智教资源库
+ {{ pageCountLabel }} · 可预览、改编、下载和课堂展示
+
+
+
+ 关注
+
+
+
+ 6.5万
+ 5503
+ 分享
+ 一键改编
+ 下载
+
+
+
+
+
+
+
相关推荐
+ 更多
+
+
+
+ 热门
+
+
+
{{ item.title }}
+
{{ item.author }}
+
{{ item.views }} {{ item.likes }}
+
+
+
+
+
+
+
+
+
+
+
{{ coursewareTitle }}
+
{{ currentPage + 1 }} / {{ pages.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
暂无课件内容
+
请先返回创建课件
+
返回
+
+
+
+
+
+
+
diff --git a/frontend/src/views/DemoResource.vue b/frontend/src/views/DemoResource.vue
new file mode 100644
index 0000000..e4ea1e0
--- /dev/null
+++ b/frontend/src/views/DemoResource.vue
@@ -0,0 +1,886 @@
+
+
+
+
+
+
+
+
+
+
+ 全屏
+
+
+
+
+ {{ currentPage + 1 }} / {{ pages.length }}
+ {{ current.badge }}
+
+
+
{{ current.title }}
+
{{ current.subtitle }}
+
{{ current.badge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isPlaying ? '暂停' : '演示' }}
+
+
+
+
+
+
+
+ {{ resource.author }}
+ {{ resource.school }} · {{ resource.type }}
+
+
关注
+
+
+ {{ resource.views }}
+ {{ resource.likes }}
+ 分享
+ 一键改编
+ 下载
+
+
+
+
{{ tag }}
+
{{ resource.summary }}
+
+
+
+
+
+
相关推荐
+ 更多
+
+
+
+ 🔥热门
+
+
+
{{ item.title }}
+
{{ item.author }}
+
{{ item.views }} {{ item.likes }}
+
+
+
+
+
+
+
退出
+
+
+
{{ current.title }}
+
{{ current.subtitle }}
+
{{ current.badge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/EssayGrade.vue b/frontend/src/views/EssayGrade.vue
new file mode 100644
index 0000000..2ac58bf
--- /dev/null
+++ b/frontend/src/views/EssayGrade.vue
@@ -0,0 +1,518 @@
+
+
+
+
+
+
+
+
作文智能批改
+
AI多维度智能批改,即时反馈作文质量
+
+
+
+
+
+ {{ sample.title }}
+ {{ sample.desc }}
+
+
+
+
+
+
+
+
+
+
AI正在多维度批改作文...
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+ 下载报告
+
+
+
+
+ {{ result.total_score }}
+ / {{ form.total_score }}
+
+
{{ result.level }}
+
+
+
+
+
+
+
+
+
+
{{ item.score }}/{{ item.max }}
+
+
{{ getScoreLabel(String(key)) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
输入作文内容后点击"开始批改"
+
+ 总分档位
+ 分项得分
+ 亮点不足
+ 逐句批注
+
+
+
+
+
+
+
+ 正在加载批改记录...
+
+
+ 暂无批改记录
+
+
+
+
+ {{ item.grade_level }} · {{ item.essay_type }}
+ {{ item.title }}
+ {{ item.result?.total_score ?? '-' }}/{{ item.total_score }} · {{ item.result?.level || '已批改' }}
+
+ {{ item.essay_text }}
+
+ 打开
+ 下载
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Exam.vue b/frontend/src/views/Exam.vue
new file mode 100644
index 0000000..7ba10fd
--- /dev/null
+++ b/frontend/src/views/Exam.vue
@@ -0,0 +1,647 @@
+
+
+
+
+
+
+
+
智能命题
+
按学科、年级、知识点和难度自动生成结构化试卷
+
+
+
+
+
+
+
+
AI正在生成试卷...
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+ {{ item.title }}
+ {{ item.desc }}
+
+
+
+
+
+
+
+
+
{{ g.title }}
+
{{ g.desc }}
+
+
试试 →
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ String.fromCharCode(65 + oi) }}
+ {{ opt }}
+
+
+
+
+
+
+
+
+
+ 正在加载已保存试卷...
+
+
+ 暂无已保存试卷
+
+
+
+
+
+ {{ item.title }}
+ {{ item.subject || '未设置学科' }} · {{ item.questions?.length || 0 }}题
+
+
+
{{ item.title }}
+
{{ item.grade || '未设置年级' }} · {{ difficultyLabel(item.difficulty) }} · {{ item.total_score || 100 }}分
+
+ 预览
+ 发布
+ 下载
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Exercise.vue b/frontend/src/views/Exercise.vue
new file mode 100644
index 0000000..0fe038f
--- /dev/null
+++ b/frontend/src/views/Exercise.vue
@@ -0,0 +1,873 @@
+
+
+
+
+
+
+
+
教学游戏
+
AI生成闯关、选择、填空等互动练习,学生可以直接在页面作答
+
+
+
+
+
+
+
+
AI正在生成练习题...
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+ {{ item.title }}
+ {{ item.desc }}
+
+
+
+
+
+
+
+
+
{{ g.title }}
+
{{ g.prompt }}
+
+
试试 →
+
+
+
+
+
+
+
+
+
+
+
+ 第 {{ currentQIdx + 1 }} / {{ result.questions.length }} 题
+ 已答 {{ answeredCount }} · 正确 {{ correctCount }} · 正确率 {{ scorePercent }}%
+ 已有 {{ attemptSummary.count }} 人完成 · 平均正确率 {{ attemptSummary.avgScore }}%
+ 重新作答
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ String.fromCharCode(65 + oi) }}
+ {{ opt }}
+
+
+
+
+
+
+
+
+ 提交答案
+
+
+
+
+
+
+
+ {{ isCurrentCorrect ? '回答正确!' : '回答错误' }}
+ 正确答案:{{ currentQuestion.answer }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ idx + 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ String.fromCharCode(65 + oi) }}
+ {{ opt }}
+
+
+
+
+ 答案:{{ q.answer }}
+
+
+
+
+
+
+
+
+ 正在加载已保存练习...
+
+
+ 暂无已保存练习
+
+
+
+
+
+ {{ item.title }}
+ {{ typeLabel(item.exercise_type) }} · {{ item.questions?.length || 0 }}题
+
+
+
{{ item.title }}
+
{{ item.subject || '未设置学科' }} · {{ item.knowledge_points?.join('、') || '未设置知识点' }}
+
+ 预览
+ 发布
+ 下载
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Favorites.vue b/frontend/src/views/Favorites.vue
new file mode 100644
index 0000000..4700397
--- /dev/null
+++ b/frontend/src/views/Favorites.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
个人收藏
+
我的收藏
+
统一管理你收藏的教学资源和社区帖子,随时回顾、改编或取消收藏。
+
+
+ {{ resourceTotal }} 个资源
+ {{ postTotal }} 篇帖子
+
+
+
+
+
+
+ 教学资源
+
+
+ 社区帖子
+
+
+
+
+
+
+
+
+
+
{{ typeLabel(res.resource_type) }}
+
+
+ {{ res.subject || '通用' }}
+ {{ res.title }}
+ {{ res.grade || res.resource_type || 'AI教学资源' }}
+
+
+
+
{{ res.title }}
+
{{ res.description || 'AI 生成的教学资源,可直接预览或改编复用。' }}
+
+ {{ res.name || '匿名教师' }}
+ {{ formatCount(res.views) }}
+ {{ formatCount(res.downloads) }}
+
+
+ 预览
+ 改编
+ 取消收藏
+
+
+
+
+
+
+
+ 还没有收藏任何资源
+ 去资源广场逛逛,点击星标即可收藏到这里。
+ 浏览资源广场
+
+
+
+
+
+
+
{{ (post.name || '匿')[0] }}
+
+ {{ post.name || '匿名教师' }}
+ {{ post.subject || '通用' }}{{ post.school ? ' · ' + post.school : '' }}
+
+
{{ postTypeLabel(post.post_type) }}
+
+ {{ post.title }}
+ {{ post.content }}
+
+ #{{ tag }}
+
+
+
+
+
+
+
+ 还没有收藏任何帖子
+ 在模板社区找到感兴趣的内容,点击星标收藏。
+ 逛逛社区
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Help.vue b/frontend/src/views/Help.vue
new file mode 100644
index 0000000..2871439
--- /dev/null
+++ b/frontend/src/views/Help.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
使用帮助
+
快速上手智教助手
+
从一句话生成互动课件,到命题组卷、教案设计——几分钟掌握全部核心功能。
+
+
+
+
+
+
+
+
+
+ 三步生成你的第一份课件
+
+
+
1
+
描述需求
+
在首页输入框用一句话描述教学想法,例如「六年级分数的意义与性质」。可上传教材材料辅助 AI 理解。
+
+
+
2
+
选择类型
+
在左侧导航选择创作模式:互动课件、教学动画、AI组题、教案、思维导图等,不同模式产出不同格式。
+
+
+
3
+
生成与保存
+
点击生成按钮,AI 在数秒内完成创作。预览满意后保存到「我的作品」,随时编辑、导出或分享给学生。
+
+
+
+
+
+ 核心功能模块
+
+
+
+
+
{{ m.title }}
+
{{ m.desc }}
+
+
+
+
+
+
+ 积分与会员
+
+
+
每日签到
+
每天可在个人中心签到领取积分,连续签到奖励递增。
+
+
+
生成消耗
+
AI 生成课件、动画、教案等内容时会消耗对应积分,余额实时同步。
+
+
+
积分流水
+
个人中心可查看完整的积分收支记录,包括签到、生成、充值等。
+
+
+
+
+
+ 常见问题
+
+
+ {{ item.q }}
+ {{ item.a }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
new file mode 100644
index 0000000..5f6e033
--- /dev/null
+++ b/frontend/src/views/Home.vue
@@ -0,0 +1,1601 @@
+
+
+
+
+
+ ☻
+
智教助手,一句话生成专业级互动课件
+
+
+
+
+
+
+
+ {{ item.name }}
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
🔥热门
+
精选
+
{{ item.views }}
+
+ {{ item.kicker }}
+ {{ item.coverTitle }}
+ {{ item.coverSub }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.title }}
+
+
+ {{ item.author }}
+ {{ item.likes }}
+
+
+
+
+
+
+
+
+
+
+
+ ☻
+
Hi, 我是智教助手
+
+
+
+
+
+ {{ activeConfig.label }}
+ 小提示
+
+ {{ activeConfig.tip }}
+
+
+ {{ item.label }}
+
+
+ ChinaDaily.doc
+ File · 54KB
+
+
{{ activeConfig.previewText }}
+
+
+ 好的
+ 试一试
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ 大家都在用
+
+
+ {{ item.prompt }}
+ 热门
+ {{ item.heat }} 热度
+
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+ {{ item.title }}
+ {{ item.file }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/LessonPlan.vue b/frontend/src/views/LessonPlan.vue
new file mode 100644
index 0000000..5f5812f
--- /dev/null
+++ b/frontend/src/views/LessonPlan.vue
@@ -0,0 +1,650 @@
+
+
+
+
+
+
+
+
大单元教案
+
AI生成结构化教学设计,支持目标、重难点、活动、作业和评价环节
+
+
+
+
+
+
+
+
AI正在生成教案...
+
模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+ {{ item.title }}
+ {{ item.desc }}
+
+
+
+
+
+
+
+
+
{{ g.title }}
+
{{ g.desc }}
+
+
试试 →
+
+
+
+
+
+
+
+
+ 正在加载已保存教案...
+
+
+ 暂无已保存教案
+
+
+
+
+
+ {{ item.title }}
+ {{ item.subject || '未设置学科' }} · {{ item.grade || '未设置年级' }}
+
+
+
{{ item.title }}
+
{{ item.objectives?.join('、') || '暂无教学目标' }}
+
+ 预览
+ 发布
+ 下载
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
new file mode 100644
index 0000000..88b60c9
--- /dev/null
+++ b/frontend/src/views/Login.vue
@@ -0,0 +1,631 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
智教助手
+
面向教师的AI创作平台 一句话生成互动课件、教学动画、课堂游戏和智能测练
+
+
+
+
+
+
+
+
+ {{ f.text }}
+ {{ f.desc }}
+
+
+
+
+
+
+
+ {{ item.value }}
+ {{ item.label }}
+
+
+
+ 支持 全学科 备课、授课、练习与评价
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue
new file mode 100644
index 0000000..225baa5
--- /dev/null
+++ b/frontend/src/views/Materials.vue
@@ -0,0 +1,392 @@
+
+
+
+
+
我的素材库
+
沉淀教材、课件、试题和课堂资料
+
上传解析后的材料会自动保存,可按学科、类型和关键词检索,并一键带入课件、组题、教案、命题或数据回收。
+
+
+
+
+ {{ uploadingMode === 'image' ? '识别中' : '拍照/图片' }}
+
+
+
+ {{ uploadingMode === 'file' ? '解析中' : '上传解析' }}
+
+
+
+
+
+
+
+
+ {{ type.label }}
+
+
+
+
+
+
+
+
+ {{ typeLabel(item.material_type) }}
+ {{ item.title }}
+ {{ item.subject || '通用' }} · {{ item.grade || formatFileSize(item.size) }}
+
+
+
+
{{ item.title }}
+ reuseMaterial(item, cmd)">
+
+
+
+ 生成课件
+ 生成动画
+ 生成练习
+ 生成教案
+ 智能命题
+ 数据回收
+
+
+
+
+
{{ item.summary || '暂无摘要' }}
+
+ {{ item.filename }}
+ {{ formatCount(item.char_count) }}字
+
+
+ {{ tag }}
+
+
+ 编辑
+ 带入课件
+ 生成动画
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+ 还没有素材
+ 上传教材、课件、试题或图片,解析后会自动进入素材库。
+ 上传第一个素材
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存修改
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/MindMap.vue b/frontend/src/views/MindMap.vue
new file mode 100644
index 0000000..7e7d367
--- /dev/null
+++ b/frontend/src/views/MindMap.vue
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+
AI 思维导图
+
输入主题,一键生成层次清晰的知识思维导图,支持折叠、导出与保存
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI 正在梳理知识结构……
+ 模型深度推理中,通常需要 1-2 分钟,请耐心等待
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/NotFound.vue b/frontend/src/views/NotFound.vue
new file mode 100644
index 0000000..c1fa62a
--- /dev/null
+++ b/frontend/src/views/NotFound.vue
@@ -0,0 +1,204 @@
+
+
+
+
404
+
页面走丢了
+
你访问的页面不存在或已被移动,可能是链接输入有误。
+
+
+
+ 返回首页
+
+
+
+ 返回上一页
+
+
+
+ 或前往:
+ AI课件
+ 教学动画
+ 资源广场
+ 模板社区
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue
new file mode 100644
index 0000000..8ff7651
--- /dev/null
+++ b/frontend/src/views/Profile.vue
@@ -0,0 +1,1286 @@
+
+
+
+ {{ avatarText }}
+
+
账号中心
+
{{ form.name || '未命名教师' }}
+
{{ profileLine }}
+
+
+
+ {{ saving ? '保存中' : '保存资料' }}
+
+
+
+
+
+
+ 基础资料
+ 用于生成更贴合学段、学科和校本场景的教学内容。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ avatarText }}
+
+
+
+
+
+ {{ uploadingAvatar ? '上传中' : '上传图片' }}
+
+
+
+
+
+
+
+
+
+
+ 账号安全
+ 定期修改密码,保障账号安全。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ changingPwd ? '修改中' : '修改密码' }}
+
+
+
+
+
+
+
+
+
+
+
+ 最近作品
+ 全部作品
+
+
+
+ {{ typeLabel(item.item_type) }}
+ {{ item.title }}
+ {{ item.status ? statusLabel(item.status) : '已保存' }} · {{ formatDate(item.updated_at) }}
+
+
+
+ 还没有作品
+ 从课件、动画、练习、教案或试卷开始创建,数据会自动汇总到这里。
+
+
+
+
+
+ 最近发布
+ 我的发布
+
+
+
+ {{ item.subtitle || '资源' }}
+ {{ item.title }}
+ {{ typeLabel(item.item_type) }} · {{ formatDate(item.updated_at) }}
+
+
+
+ 还没有发布资源
+ 在“我的作品”中发布后,可进入资源广场复用、编辑或下架。
+
+
+
+
+
+ 最近互动
+ 课堂工具
+
+
+
+ {{ typeLabel(item.item_type) }}
+ {{ item.title }}
+ {{ item.subtitle || (item.status ? statusLabel(item.status) : '已更新') }} · {{ formatDate(item.updated_at) }}
+
+
+
+ 还没有互动数据
+ 课堂投票、随堂测验和社区模板发布后,会在这里显示最近动态。
+
+
+
+
+
+ 积分流水
+ 生成与解析消耗
+
+
+
+ {{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
+ {{ creditActionLabel(item.action) }}
+ {{ item.description || '创作权益' }} · 余额 {{ item.balance_after }} · {{ formatDate(item.created_at) }}
+
+
+
+ 暂无积分流水
+ 生成课件、动画、练习、教案、试卷或解析材料后会记录在这里。
+
+
+
+
+
+ 课件分享
+ 管理作品
+
+
+
+
{{ item.views || 0 }} 次查看
+
{{ item.title }}
+
{{ item.subject || '通用' }} · {{ formatDate(item.updated_at) }}
+
+ 打开
+ 复制
+ 撤销
+
+
+
+
+ 暂无公开分享
+ 在课件详情中点击“分享链接”后,会在这里管理公开访问链接。
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/ResourceDetail.vue b/frontend/src/views/ResourceDetail.vue
new file mode 100644
index 0000000..f40e20f
--- /dev/null
+++ b/frontend/src/views/ResourceDetail.vue
@@ -0,0 +1,1161 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ typeLabel(resource.resource_type) }}
+
{{ resource.title }}
+
{{ resource.subject || '通用' }} · {{ resource.grade || '教学资源' }}
+
+
+
+ 打开完整预览
+
+
+
+
+
+ {{ currentPage + 1 }} / {{ previewPages.length }}
+ {{ currentPreview.badge }}
+
+
+
+
+
{{ currentPreview.badge }}
+
{{ currentPreview.title }}
+
{{ currentPreview.subtitle }}
+
+
+
+
+
+ {{ currentPage + 1 >= previewPages.length ? '回到封面' : '演示' }}
+
+
+
+
+
+ {{ tag }}
+
+
{{ resource.description || fallbackDescription(resource) }}
+
+
+
+
+
{{ authorInitial }}
+
+ {{ authorName }}
+ {{ authorMeta }}
+
+
+
+ {{ isOwner ? '我的资源' : '关注' }}
+
+
+
+ {{ formatCount(resource.views) }}
+ {{ formatCount(resource.likes) }}
+
+ {{ resource.is_favorited ? '已收藏' : '收藏' }}
+
+ 分享
+ 编辑
+ 一键改编
+ 下载
+
+
+
+
+
+
+
{{ authorInitial }}
+
+ {{ authorName }}
+ {{ authorMeta }}
+
+
+
+
+
{{ formatCount(resource.views) }} 浏览
+
{{ formatCount(resource.downloads) }} 下载
+
{{ formatCount(resource.likes) }} 收藏
+
+
+
+
+
+ 打开预览
+
+
+
+ 用它生成
+
+
+
+ {{ resource.is_favorited ? '已收藏,点击取消' : '收藏资源' }}
+
+
+
+ {{ resourceDownloadLabel }}
+
+
+
+ 编辑资源信息
+
+
+
+ 下架资源
+
+
+
+
+
+
+
+ 相关推荐
+ 更多
+
+
+
+ 热门
+
+
+
{{ item.title }}
+
{{ item.desc }}
+
{{ item.views }} {{ item.likes }}
+
+
+
+
+
+
+
+
+
+
+
+ 资源不存在或无权访问
+ 返回资源广场
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存修改
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Resources.vue b/frontend/src/views/Resources.vue
new file mode 100644
index 0000000..49d261f
--- /dev/null
+++ b/frontend/src/views/Resources.vue
@@ -0,0 +1,778 @@
+
+
+
+
+
资源中心
+
查找、收藏和改编教学资源
+
按学科、类型和关键词筛选课件、动画、练习、教案与试卷,快速进入预览或二次创作。
+
+
+
+ 上传素材
+
+
+
+
+
+
+ {{ type.label }}
+
+
+
+
+
+
+
+
+
+ 清除
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+
{{ typeLabel(res.resource_type) }}
+
已收藏
+
{{ formatCount(res.views) }}
+
+ {{ res.subject || '通用' }}
+ {{ res.title }}
+ {{ res.grade || res.resource_type || 'AI教学资源' }}
+
+
+
+
+
{{ res.title }}
+
{{ res.description || fallbackDescription(res) }}
+
+ {{ res.subject || '未设置学科' }}
+ {{ resourceAuthorName(res) }}
+ {{ formatCount(res.downloads) }} 下载
+
+
+ {{ formatCount(res.likes) }}
+
+
+
+ 预览
+ 改编
+ 下架
+ 下载
+
+
+
+
+
+
+
+ 没有找到相关资源
+ 换个筛选条件,或回到推荐资源。
+ 查看推荐
+
+
+
+
+
+ {{ typeLabel(currentResource.resource_type) }}
+ {{ currentResource.title }}
+ {{ currentResource.subject || '通用' }} · {{ currentResource.grade || '教学资源' }}
+
+
{{ currentResource.description || fallbackDescription(currentResource) }}
+
+ {{ formatCount(currentResource.views) }} 浏览
+ {{ formatCount(currentResource.downloads) }} 下载
+ {{ formatCount(currentResource.likes) }} 收藏
+
+
+ 打开预览
+ 用它生成
+ 编辑资源
+ {{ currentResource.is_favorited ? '取消收藏' : '收藏资源' }}
+ 下架资源
+ {{ resourceDownloadLabel(currentResource) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 发布资源
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存修改
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Search.vue b/frontend/src/views/Search.vue
new file mode 100644
index 0000000..6a60c74
--- /dev/null
+++ b/frontend/src/views/Search.vue
@@ -0,0 +1,428 @@
+
+
+
+
+
全局搜索
+
{{ keyword ? `搜索:${keyword}` : '搜索资源、模板和我的内容' }}
+
一次检索资源广场、模板社区、我的作品、素材库和课堂活动,也可以直接把关键词变成创作任务。
+
+
+
+
+ 搜索
+
+
+
+
+
+ 快速创作
+ {{ keyword ? `用“${keyword}”开始生成新的教学内容` : '输入关键词后可直接进入创作' }}
+
+
+ 课件
+ 动画
+ 练习
+ 教案
+ 试卷
+
+
+
+
+
+
+
+ 没有找到“{{ keyword }}”
+ 可以直接把这个关键词作为创作提示词,生成新的课件、动画、练习、教案或试卷。
+ 用它生成课件
+
+
+
+
+
+
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1e2d961
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": { "@/*": ["src/*"] }
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..20e4e4b
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,46 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import { resolve } from 'path'
+
+export default defineConfig({
+ root: __dirname,
+ plugins: [
+ vue(),
+ AutoImport({
+ resolvers: [ElementPlusResolver()],
+ imports: ['vue', 'vue-router', 'pinia'],
+ dts: 'src/auto-imports.d.ts',
+ }),
+ Components({
+ resolvers: [ElementPlusResolver()],
+ dts: 'src/components.d.ts',
+ }),
+ ],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ vue: ['vue', 'vue-router', 'pinia'],
+ element: ['element-plus', '@element-plus/icons-vue'],
+ },
+ },
+ },
+ },
+})
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..1913ea2
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,32 @@
+@echo off
+chcp 65001 >nul 2>&1
+title 智教助手
+
+echo =========================================
+echo 智教助手
+echo =========================================
+echo.
+
+cd /d "%~dp0"
+
+echo [INFO] 启动后端...
+start "智教助手-后端" cmd /k "cd /d %~dp0backend && F:\anaconda3\envs\dmts\python.exe -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
+
+ping -n 3 127.0.0.1 >nul
+
+echo [INFO] 启动前端...
+start "智教助手-前端" cmd /k "cd /d %~dp0frontend && npx vite --host 0.0.0.0 --port 5173"
+
+ping -n 3 127.0.0.1 >nul
+
+echo.
+echo 前端: http://localhost:5173
+echo 后端: http://localhost:8000
+echo API: http://localhost:8000/docs
+echo.
+echo 关闭此窗口或按任意键停止服务...
+echo.
+pause >nul
+
+taskkill /FI "WINDOWTITLE eq 智教助手-后端*" /F >nul 2>&1
+taskkill /FI "WINDOWTITLE eq 智教助手-前端*" /F >nul 2>&1
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000..b2c72b1
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+# =============================================
+# 智教助手 - 单终端启动(前后端日志合并显示)
+# =============================================
+
+set -e
+ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "========================================="
+echo " 智教助手 启动中..."
+echo "========================================="
+
+# Kill existing processes on ports
+for port in 8000 5173; do
+ for pid in $(netstat -ano 2>/dev/null | grep ":$port .*LISTEN" | awk '{print $5}' | sort -u); do
+ [ "$pid" != "0" ] && [ -n "$pid" ] && taskkill //PID "$pid" //F 2>/dev/null || true
+ done
+done
+sleep 1
+
+cleanup() {
+ echo ""
+ echo "[STOP] 停止所有服务..."
+ # Kill backend and frontend python/node processes we spawned
+ kill $(jobs -p) 2>/dev/null || true
+ wait 2>/dev/null
+ echo "[STOP] 已停止"
+ exit 0
+}
+trap cleanup INT TERM
+
+# Backend - log with prefix
+(
+ cd "$ROOT_DIR/backend"
+ python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload 2>&1 | while IFS= read -r line; do
+ printf "\033[36m%-8s\033[0m %s\n" "后端" "$line"
+ done
+) &
+BACK_PID=$!
+
+# Frontend - log with prefix
+(
+ cd "$ROOT_DIR/frontend"
+ npx vite --host 0.0.0.0 --port 5173 2>&1 | while IFS= read -r line; do
+ printf "\033[32m%-8s\033[0m %s\n" "前端" "$line"
+ done
+) &
+FRONT_PID=$!
+
+sleep 4
+
+echo ""
+echo "========================================="
+echo " ✓ 服务已启动"
+echo " 前端: http://localhost:5173"
+echo " 后端: http://localhost:8000"
+echo " API: http://localhost:8000/docs"
+echo "========================================="
+echo " Ctrl+C 停止所有服务"
+echo ""
+
+wait
{{ comment.content }}
+ {{ comment.created_at || '刚刚' }} +