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"{body}
" + + +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]*?", " ", text, flags=re.IGNORECASE) + text = re.sub(r"", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"", "\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
+

长方体体积公式

+

体积和底面积、高有关。先看“底面铺了多少”,再看“竖起来有多高”。

+
+ + +
+
+""", + "点击卡片切换旧知", + ), + "notes": "知识回顾", + }, + { + "type": "interactive", + "title": "引入新知", + "content": self._courseware_shell_clean( + "引入新知", + "先看形状和高度,再让学生猜一猜体积和什么有关", + """ +
+
+
情境导入
+
+
+
+
+
+
课堂提问
+

你觉得圆柱体积和什么有关?

+
+
A. 只和底面形状有关
+
B. 和底面积、高都有关系
+
C. 只和颜色有关
+
+
课堂结论:体积计算要抓住“底面积 × 高”这条主线。
+
+
+""", + "学生先猜想,再看图验证", + ), + "notes": "引入", + }, + { + "type": "content", + "title": "公式探究", + "content": self._courseware_shell_clean( + "公式探究", + "用切、拼、比的方式,把圆柱体积想清楚", + """ +
+
+ + + +
+
+
+
切分观察
+

把圆柱切成很多薄片

+

圆柱越像被分成很多很薄的小层,每一层越接近一个圆形小面。层数越多,拼出的形状就越接近长方体。

+
+ + +
+
+""", + "切、拼、比,逐步导出公式", + ), + "notes": "公式探究", + }, + { + "type": "interactive", + "title": "例题讲解", + "content": self._courseware_shell_clean( + "例题讲解", + "把公式落到计算里,按步骤说清楚", + """ +
+
+
例题
+

一个圆柱的底面积是 28.26 cm²,高是 10 cm,体积是多少?

+
+ + + +
+
+
+
已知
+
S = 28.26 cm²,h = 10 cm
+
+ + +
+
+
+
解题提醒
+
圆柱体积公式中的 S 是底面积,不是底面周长。单位也要对应写成立方单位。
+
写答语时要带上单位,例如 cm³、m³。
+
+
+""", + "按步骤理解计算过程", + ), + "notes": "例题", + }, + { + "type": "exercise", + "title": "巩固练习", + "content": self._courseware_shell_clean( + "巩固练习", + "点击选项,立即查看判断结果", + """ +
+
+
选择题
+

圆柱体积公式正确的是哪一个?

+
+ + + +
+
+
+
+
反馈
+
A 不是正确答案。圆柱体积不是把周长直接相乘。
+
+ + +
+
+""", + "选择答案后直接反馈", + ), + "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 不完整。课堂结构不能只靠一个结论支撑。
+
+ + +
+
+""", + "选择后看反馈", + ), + "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备课工具 + + + + + + + + + + + + + + + + + + + +
+ + + + 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课件生成 - 调试工具

+ + +
+ + + 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

+
+ +
+ + + + + + \ 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 @@ +