feat: 新增汇报PPT与生成脚本,修复tsconfig弃用告警
- 新增医科高校智能学习中心汇报PPT(27页,面向高校管理者与广电领导) - 新增 pptxgenjs 生成脚本 scripts/gen-pptx.js - 新增 SYSTEM-OVERVIEW.md 系统功能全景文档 - 新增 deploy.sh 部署脚本 - 补充 rate-limit guard、ai-stream controller、exam-prep 仓储等后端模块 - 补充 exam-recommendations、markdown、mock-exam、offline-indicator 前端组件 - tsconfig.json: 显式设置 rootDir=./src,加 ignoreDeprecations=6.0 静音 baseUrl 弃用告警 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
# 医科高校 AI 学习中心 · 系统功能全景图
|
||||
|
||||
## 系统定位
|
||||
|
||||
**医科类高校 AI 学习中心**是面向医学院校的智能学习平台,以"能力"为核心,贯穿基础医学→临床医学→见习实习→住院医师培养的完整成长链。系统将学生在校的所有学习活动(课程、对练、科研、协作)统一汇入能力图谱,形成数据贯通的闭环成长体系。
|
||||
|
||||
---
|
||||
|
||||
## 用户角色与核心价值
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 医科高校 AI 学习中心 │
|
||||
├────────────────┬───────────────────┬────────────────────────────────┤
|
||||
│ 🎓 学生 │ 👨🏫 导师 │ 🔧 管理员 │
|
||||
│ │ │ │
|
||||
│ • 全程学习汇聚 │ • 题目质量把关 │ • AI 技能定义与治理 │
|
||||
│ • AI 智能训练 │ • 成果点评指导 │ • 权限与合规管理 │
|
||||
│ • 能力画像可视化│ • 学生画像监测 │ • 审计日志与安全 │
|
||||
│ • 职业规划闭环 │ • 教学问责 │ │
|
||||
│ • 科研能力培养 │ │ │
|
||||
│ • 负责任AI协作 │ │ │
|
||||
└────────────────┴───────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 功能模块全景
|
||||
|
||||
### 一、学生端功能矩阵
|
||||
|
||||
#### 1. 个人学习空间
|
||||
**价值**:一站式汇聚成长链上所有学习内容,告别分散记录
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 成果记录 | 支持 9 种类型:课程记录、作业、实验报告、临床见习/实习、OSCE 考核、文献阅读、科研成果、证书、执业资格备考 |
|
||||
| 自动归档 | 按学年、学期、临床轮转科室三维度自动归类 |
|
||||
| 里程碑标记 | 标记阶段性成长节点,关联该阶段内学习成果 |
|
||||
| 智能检索 | 按类型/学年/学期/科室筛选,时间倒序,分页浏览 |
|
||||
| 能力关联 | 每条成果自动映射到能力图谱中的能力标签 |
|
||||
|
||||
#### 2. AI 评估与反馈
|
||||
**价值**:实时获得 AI 对学习进度的过程性与阶段性评价
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 形成性评价 | 每新增一条学习成果,30 秒内获得 AI 过程性反馈 |
|
||||
| 终结性评价 | 完成里程碑时,基于该阶段全部成果生成综合画像 |
|
||||
| 发展建议 | 每条建议精确指向能力图谱中的具体能力维度 |
|
||||
| 可信标注 | 所有 AI 评价附带来源依据与置信度,可追溯核验 |
|
||||
|
||||
#### 3. 课程对练
|
||||
**价值**:AI 生成高质量练习题,导师把关,模拟真实考试场景
|
||||
|
||||
```
|
||||
课程内容 ──AI生成题目──→ 导师审核 ──通过──→ 学生对练 ──→ 结果诊断
|
||||
│
|
||||
└─退回(附原因)
|
||||
```
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 智能出题 | 支持 A1-A4 型选择题、病例分析题、临床决策题 |
|
||||
| 权威保障 | 每题绑定权威知识源(教材/指南/官方题库) |
|
||||
| 导师审核 | 题目经导师通过后才可使用,退回需注明原因 |
|
||||
| 模拟对练 | 逐题呈现、限时作答(120秒/题)、即时判定 |
|
||||
| 诊断报告 | 正确率、用时、薄弱环节分析(<60% 能力标签) |
|
||||
| 改进建议 | 针对每个薄弱环节生成具体建议 |
|
||||
| 间隔重复 | SM-2 算法优先复现错题与即将遗忘的题 |
|
||||
| 模拟考试 | 限时批量答题,交卷后统一批阅 |
|
||||
|
||||
#### 4. 临床情景对话对练
|
||||
**价值**:模拟真实问诊场景,训练临床沟通与决策能力
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 情景呈现 | 基于权威知识源的临床情景(含难度、时长、考核重点) |
|
||||
| 多轮对话 | 与 AI 模拟患者进行最多 50 轮对话 |
|
||||
| 角色扮演 | AI 以设定角色应答,保持情景连贯 |
|
||||
| 三维评估 | 问诊完整性 + 临床推理 + 医患沟通 三方面评分 |
|
||||
| 上下文记忆 | 携带最近 10 轮历史,保持对话连贯 |
|
||||
|
||||
#### 5. 能力画像
|
||||
**价值**:全方位量化呈现个人能力分布,辅助就业与发展决策
|
||||
|
||||
```
|
||||
知识掌握
|
||||
▲
|
||||
职业倾向 / \ 临床技能
|
||||
/ \
|
||||
AI协同素养───科研能力
|
||||
\
|
||||
人文素养
|
||||
```
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 六维画像 | 知识掌握、临床技能、科研能力、人文素养、AI协同素养、职业倾向 |
|
||||
| 量化评分 | 每个维度 0-100 分值 |
|
||||
| 数据溯源 | 每项结论可追溯到具体学习成果或对练记录 |
|
||||
| 动态更新 | 能力图谱数据更新后自动重算 |
|
||||
| 成长趋势 | 能力维度时间序列,可视化成长曲线 |
|
||||
|
||||
#### 6. 职业规划
|
||||
**价值**:明确职业目标与能力差距,获得可执行的发展计划
|
||||
|
||||
```
|
||||
设定职业目标 → 匹配岗位胜任力模型 → 能力差距分析 → 生成发展规划
|
||||
↑ │
|
||||
└── 能力更新后重算 ←──┘
|
||||
(动态闭环)
|
||||
```
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 目标设定 | 选择或自定义职业目标 |
|
||||
| 胜任力模型 | 参照权威医学框架(科学与学术、临床能力、健康与社会、职业素养) |
|
||||
| 差距分析 | 逐项对照当前能力水平与岗位要求 |
|
||||
| 行动建议 | 每个差距项提供具体行动与目标水平 |
|
||||
| 资源推荐 | 为差距项推荐学习资源或对练任务 |
|
||||
| 动态闭环 | 能力提升后重算差距,规划自动更新 |
|
||||
|
||||
#### 7. 研究资料查询
|
||||
**价值**:高效检索医学文献,AI辅助总结与引用管理,支撑科研与论文写作
|
||||
|
||||
```
|
||||
自然语言描述 → PICO/MeSH检索式 → 多源检索 → 证据分级总结 → 引用导出
|
||||
```
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 检索式生成 | 自然语言自动转为 PICO 要素 + MeSH 主题词的专业检索式 |
|
||||
| 多源检索 | 支持 PubMed、CNKI、万方、UpToDate、Cochrane 等 |
|
||||
| 结果筛选 | 按发表时间、来源类型、相关度筛选 |
|
||||
| 证据分级 | 对纳入总结的每条资料标注证据强度 |
|
||||
| AI 总结 | 生成带引用的结论,每条可追溯来源 |
|
||||
| 引用管理 | 支持 Vancouver / GB-T 7714 格式,导出 EndNote / NoteExpress |
|
||||
| 版权安全 | 仅提供摘要与链接,不存储或分发受版权保护全文 |
|
||||
|
||||
#### 8. AI 协同训练
|
||||
**价值**:系统性培养负责任使用AI的能力——这在医学领域尤为关键
|
||||
|
||||
| 评估维度 | 说明 |
|
||||
|----------|------|
|
||||
| 提问能力 | 能否向AI提出精准、有效的问题 |
|
||||
| 批判性验证 | 能否对AI输出进行来源核验与批判性思考 |
|
||||
| 责任边界意识 | 是否清楚AI辅助与人工决策的边界 |
|
||||
| 协同工作流 | 能否有效组织人机协作流程 |
|
||||
|
||||
- 每维度各有真实任务载体的训练任务
|
||||
- 直接采用未核验AI输出时会被要求来源核验
|
||||
- 评估结果计入能力画像与岗位胜任力对照
|
||||
- 支持导师对协同质量进行点评
|
||||
|
||||
#### 9. 补充模块(v0.2+)
|
||||
|
||||
| 模块 | 价值 |
|
||||
|------|------|
|
||||
| 轮转见习 | 记录临床轮转经历 |
|
||||
| 技能视频 | 搜索与学习操作技能视频 |
|
||||
| 执业医师备考 | AI出题 + 间隔重复 + 模拟考试 + 个性化推荐 |
|
||||
| 学术交流 | 记录学术交流与会议参与 |
|
||||
| 医学问答 | AI流式多轮医学知识对话 |
|
||||
| 病例推演 | 逐步推演临床病例,保持全程上下文 |
|
||||
| 用药安全 | 用药安全性检查与学习 |
|
||||
|
||||
---
|
||||
|
||||
### 二、导师端功能矩阵
|
||||
|
||||
| 功能 | 价值 |
|
||||
|------|------|
|
||||
| **题目审核** | 审核AI生成题目的准确性,通过或退回(附原因),保障教学内容质量 |
|
||||
| **成果点评** | 对学生学习成果进行教学点评,点评对学生可见,促进学生反思与改进 |
|
||||
| **带教学生画像** | 查看所带学生的能力画像(脱敏),精准把握学生发展状况,针对性指导 |
|
||||
|
||||
---
|
||||
|
||||
### 三、管理端功能矩阵
|
||||
|
||||
| 功能 | 价值 |
|
||||
|------|------|
|
||||
| **技能治理** | 定义、配置、启用AI技能;每个技能含输入规格、处理逻辑、知识源、输出格式、可信度规则五要素 |
|
||||
| **审计日志** | 追踪所有技能配置变更(操作者、时间、内容) |
|
||||
| **权限管理** | 按角色分级定义访问权限范围 |
|
||||
| **合规监控** | 查看越权拒绝事件,确保数据安全 |
|
||||
|
||||
---
|
||||
|
||||
## 系统架构示意
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 用户界面层 │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 学生端 │ │ 导师端 │ │ 管理端 │ Next.js + Tailwind │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
└───────┼───────────────┼───────────────┼──────────────────────────────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 接入层 · 认证鉴权 │
|
||||
│ JWT 认证 · 角色路由 · API 网关 │
|
||||
└────────────────────────────┬─────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌───────────────────┐
|
||||
│ 横切关注点 │ │ 业务模块层 │ │ 底座层 │
|
||||
│ │ │ │ │ │
|
||||
│ • 合规模块 │ │ • 学习空间 │ │ • 能力图谱 │
|
||||
│ 权限分级 │ │ • AI评估引擎 │ │ 统一能力标签 │
|
||||
│ 数据脱敏 │ │ • 课程对练引擎 │ │ 跨模块数据打通 │
|
||||
│ 授权管理 │ │ • 临床对话对练 │ │ │
|
||||
│ 审计日志 │ │ • 画像引擎 │ │ • 技能能力中心 │
|
||||
│ │ │ • 职业规划 │ │ 统一AI技能框架 │
|
||||
│ • AI 可信层 │ │ • 研究资料查询 │ │ 8+可复用技能 │
|
||||
│ 可信度标注 │ │ • AI协同培养 │ │ │
|
||||
│ 来源溯源 │ │ • 医学问答 │ │ │
|
||||
│ 未核验标记 │ │ • 病例推演 │ │ │
|
||||
│ │ │ • 执业备考 │ │ │
|
||||
└───────────────┘ │ • 轮转/学术/… │ │ │
|
||||
└────────┬────────┘ └─────────┬─────────┘
|
||||
│ │
|
||||
└──────────┬──────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 基础设施层 │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │PostgreSQL │ │ AI 网关 │ │ 资料来源适配器 │ │
|
||||
│ │ 数据库 │ │ (通义千问等) │ │ PubMed/CNKI/万方/UpToDate/… │ │
|
||||
│ └──────────┘ └──────────────┘ └───────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心设计理念
|
||||
|
||||
### 1. 能力贯通 —— 打破数据孤岛
|
||||
|
||||
所有学习活动通过统一的**能力图谱**连接。学习空间新增一条成果、课程对练完成一次测验、临床对话获得评分——这些数据都映射到相同的能力标签体系上,使得:
|
||||
|
||||
- 学生画像基于全量行为数据生成,而非单一模块的局部视图
|
||||
- 职业规划能精确对照当前能力与目标岗位的差距
|
||||
- 各模块的使用数据相互增强,形成正反馈循环
|
||||
|
||||
### 2. AI 可信优先 —— 医学场景零容忍幻觉
|
||||
|
||||
在医学教育场景下,AI 幻觉(生成不实信息)的风险极高。系统采用:
|
||||
|
||||
- **所有 AI 输出必须附带可信度标注**(来源 + 置信度)
|
||||
- **无法溯源的内容标注为"未经核验"**
|
||||
- **题目必须绑定权威知识源**,否则不进入使用流程
|
||||
- **总结中无法追溯的结论标注"未验证"**,不作为引用输出
|
||||
|
||||
### 3. 合规默认 —— 敏感数据天然受保护
|
||||
|
||||
医学生画像包含敏感的健康、心理数据,系统采用:
|
||||
|
||||
- **默认脱敏**:非本人访问画像,敏感字段自动脱敏
|
||||
- **知情同意授权**:跨用途使用必须有学生有效授权
|
||||
- **即时撤销**:学生撤销授权后立即失效
|
||||
- **全审计**:所有敏感操作留痕(保留≥12个月)
|
||||
- **最小采集**:仅采集与画像维度直接相关的数据
|
||||
|
||||
### 4. 人机协同 —— AI 辅助而非替代
|
||||
|
||||
- AI 生成题目 → **导师审核后才可使用**
|
||||
- AI 文献总结 → **明确提示"不替代阅读原文"**
|
||||
- AI 协同训练 → **直接采用未核验输出会被要求核验**
|
||||
- 所有 AI 建议 → **可追溯依据,用户可核验**
|
||||
|
||||
---
|
||||
|
||||
## 用户使用流程示例
|
||||
|
||||
### 典型学生日常
|
||||
|
||||
```
|
||||
1. 登录 → 进入学习空间
|
||||
2. 记录今日临床见习成果 → 系统自动归档 + 能力标签映射
|
||||
3. 30秒后收到 AI 形成性评价与发展建议
|
||||
4. 进入课程对练 → AI 出题(已过导师审核)→ 模拟答题 → 查看诊断报告
|
||||
5. 进入临床对话对练 → 模拟问诊 → 获得三维评估
|
||||
6. 查看能力画像 → 六维度分数 + 成长趋势
|
||||
7. 进入职业规划 → 查看差距分析 → 按推荐资源学习
|
||||
8. 能力提升后 → 画像自动更新 → 发展规划动态调整(闭环)
|
||||
```
|
||||
|
||||
### 典型导师工作
|
||||
|
||||
```
|
||||
1. 登录 → 进入导师工作台
|
||||
2. 审核 AI 生成题目 → 通过准确的 / 退回有问题的
|
||||
3. 对学生学习成果进行点评
|
||||
4. 查看所带学生画像 → 了解学生能力分布 → 针对性指导
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术规格摘要
|
||||
|
||||
| 维度 | 规格 |
|
||||
|------|------|
|
||||
| 前端 | Next.js 14 + React 18 + TypeScript + Tailwind CSS |
|
||||
| 后端 | NestJS 10.4 + TypeScript + TypeORM |
|
||||
| 数据库 | PostgreSQL(可选,开发环境支持内存模式) |
|
||||
| AI 引擎 | OpenAI兼容接口(默认通义千问),支持 SSE 流式输出 |
|
||||
| 认证 | JWT + bcrypt |
|
||||
| 外部数据源 | PubMed、CNKI、万方、UpToDate、Cochrane |
|
||||
| 速率保护 | AI调用 20次/分钟、500次/天 |
|
||||
| 后端模块数 | 21 个业务模块 |
|
||||
|
||||
---
|
||||
|
||||
## 系统差异化优势
|
||||
|
||||
| 维度 | 传统教学平台 | 本系统 |
|
||||
|------|-------------|--------|
|
||||
| 数据关联 | 各模块数据孤立 | 统一能力图谱贯通所有模块 |
|
||||
| AI 输出信任 | 缺乏验证机制 | 全链路可信度标注 + 来源溯源 |
|
||||
| 学习反馈 | 周期性人工评价 | 实时AI形成性评价 + 阶段终结性评价 |
|
||||
| 职业发展 | 静态指导 | 动态闭环:能力变化→规划自动更新 |
|
||||
| AI 素养 | 无系统培养 | 四维度协同能力训练+评估 |
|
||||
| 数据隐私 | 粗放管理 | 知情同意+即时撤销+默认脱敏+全审计 |
|
||||
| 教学质量 | 缺乏把控 | AI生成+人工审核双层保障 |
|
||||
|
||||
---
|
||||
|
||||
*本文档描述系统面向用户的功能与价值。技术架构与实现细节参见 `.kiro/specs/college-ai-center/design.md`。*
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# College AI Center — 一键部署脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./deploy.sh
|
||||
#
|
||||
# 作用:把本地代码同步到远程服务器,在服务器上安装依赖、构建前后端、
|
||||
# 配置 PostgreSQL 数据库、systemd 服务与 nginx(域名 mc.all8ai.top),并启动。
|
||||
#
|
||||
# 与服务器上已有的 k12 应用互不影响(使用独立端口 3100/4100、独立目录、
|
||||
# 名称匹配的 nginx server_name,不占用 default_server)。
|
||||
#
|
||||
# 依赖(本机):sshpass、rsync、ssh
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
# ====== 可配置参数 ======
|
||||
SERVER_IP="${SERVER_IP:-101.96.196.218}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PASS="${SERVER_PASS:-Why_701208}"
|
||||
|
||||
REMOTE_DIR="/opt/college-ai-center"
|
||||
BACKEND_PORT=3100
|
||||
FRONTEND_PORT=4100
|
||||
|
||||
# 数据库
|
||||
DB_NAME="college_ai_center"
|
||||
DB_USER="cac_user"
|
||||
DB_PASS="${DB_PASS:-Cac_$(echo "$SERVER_PASS" | md5sum 2>/dev/null | cut -c1-12 || echo Pg88dev)}"
|
||||
|
||||
# 大模型配置(如需启用,部署后在服务器 .env 中填写 LLM_API_KEY)
|
||||
LLM_API_KEY="${LLM_API_KEY:-}"
|
||||
LLM_BASE_URL="${LLM_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}"
|
||||
LLM_MODEL="${LLM_MODEL:-qwen-plus}"
|
||||
|
||||
JWT_SECRET="${JWT_SECRET:-cac-prod-$(openssl rand -hex 16 2>/dev/null || echo change-me-in-prod)}"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ssh_run() { sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$SERVER_IP" "$@"; }
|
||||
|
||||
echo "==> [1/6] 检查本机依赖"
|
||||
for c in sshpass rsync ssh; do
|
||||
command -v "$c" >/dev/null 2>&1 || { echo "缺少命令:$c,请先安装(mac: brew install $c)"; exit 1; }
|
||||
done
|
||||
|
||||
echo "==> [2/6] 同步代码到 $SERVER_IP:$REMOTE_DIR"
|
||||
ssh_run "mkdir -p $REMOTE_DIR"
|
||||
sshpass -p "$SERVER_PASS" rsync -az --delete -e "ssh $SSH_OPTS" \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'web/node_modules' \
|
||||
--exclude '.next' \
|
||||
--exclude 'web/.next' \
|
||||
--exclude 'dist' \
|
||||
--exclude '.git' \
|
||||
--exclude 'coverage' \
|
||||
--exclude '*.log' \
|
||||
"$SCRIPT_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_DIR/"
|
||||
|
||||
echo "==> [3/6] 在服务器上准备数据库"
|
||||
ssh_run "sudo -u postgres psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\" | grep -q 1 || sudo -u postgres psql -c \"CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';\""
|
||||
ssh_run "sudo -u postgres psql -tc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\" | grep -q 1 || sudo -u postgres psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
|
||||
ssh_run "sudo -u postgres psql -c \"ALTER DATABASE $DB_NAME OWNER TO $DB_USER;\" >/dev/null 2>&1 || true"
|
||||
|
||||
echo "==> [4/6] 写入后端 .env"
|
||||
ssh_run "cat > $REMOTE_DIR/.env <<EOF
|
||||
APP_PORT=$BACKEND_PORT
|
||||
NODE_ENV=production
|
||||
CORS_ORIGIN=*
|
||||
|
||||
DB_ENABLED=true
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=$DB_USER
|
||||
DB_PASSWORD=$DB_PASS
|
||||
DB_DATABASE=$DB_NAME
|
||||
DB_SYNCHRONIZE=true
|
||||
DB_LOGGING=false
|
||||
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
JWT_EXPIRES_IN_SECONDS=7200
|
||||
|
||||
LLM_API_KEY=$LLM_API_KEY
|
||||
LLM_BASE_URL=$LLM_BASE_URL
|
||||
LLM_MODEL=$LLM_MODEL
|
||||
LLM_TIMEOUT_MS=30000
|
||||
|
||||
AI_RATE_LIMIT_PER_MINUTE=20
|
||||
AI_RATE_LIMIT_PER_DAY=500
|
||||
EOF"
|
||||
|
||||
echo "==> [5/6] 安装依赖并构建(后端 + 前端)"
|
||||
ssh_run "set -e; cd $REMOTE_DIR && npm install --no-audit --no-fund && npm run build"
|
||||
ssh_run "set -e; cd $REMOTE_DIR/web && npm install --no-audit --no-fund && BACKEND_URL=http://127.0.0.1:$BACKEND_PORT npm run build"
|
||||
|
||||
echo "==> [6/6] 配置 systemd 服务"
|
||||
|
||||
# 后端 systemd
|
||||
ssh_run "cat > /etc/systemd/system/cac-backend.service <<EOF
|
||||
[Unit]
|
||||
Description=College AI Center Backend (NestJS)
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$REMOTE_DIR
|
||||
ExecStart=/usr/bin/node dist/main
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF"
|
||||
|
||||
# 前端 systemd(监听 0.0.0.0:$FRONTEND_PORT,直接对外提供访问)
|
||||
ssh_run "cat > /etc/systemd/system/cac-frontend.service <<EOF
|
||||
[Unit]
|
||||
Description=College AI Center Frontend (Next.js)
|
||||
After=network.target cac-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$REMOTE_DIR/web
|
||||
ExecStart=/usr/bin/npm run start
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=NODE_ENV=production
|
||||
Environment=BACKEND_URL=http://127.0.0.1:$BACKEND_PORT
|
||||
Environment=PORT=$FRONTEND_PORT
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF"
|
||||
|
||||
# 若曾配置过本应用的 nginx 站点,移除(改为直接端口访问)
|
||||
ssh_run "rm -f /etc/nginx/sites-enabled/college-ai-center /etc/nginx/sites-available/college-ai-center; (nginx -t && systemctl reload nginx) >/dev/null 2>&1 || true"
|
||||
|
||||
# 若 ufw 启用,则放行端口
|
||||
ssh_run "if ufw status 2>/dev/null | grep -q 'Status: active'; then ufw allow $BACKEND_PORT/tcp; ufw allow $FRONTEND_PORT/tcp; fi || true"
|
||||
|
||||
echo "==> 启动服务"
|
||||
ssh_run "systemctl daemon-reload && systemctl enable cac-backend cac-frontend >/dev/null 2>&1 && systemctl restart cac-backend cac-frontend"
|
||||
|
||||
echo "==> 等待服务就绪"
|
||||
sleep 6
|
||||
ssh_run "systemctl is-active cac-backend cac-frontend; echo '--- backend health ---'; curl -s -o /dev/null -w 'backend(api/auth/login probe): %{http_code}\n' -X POST http://127.0.0.1:$BACKEND_PORT/api/auth/login -H 'Content-Type: application/json' -d '{}'; echo '--- frontend ---'; curl -s -o /dev/null -w 'frontend: %{http_code}\n' http://127.0.0.1:$FRONTEND_PORT"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 部署完成!"
|
||||
echo " 前端访问: http://${SERVER_IP}:$FRONTEND_PORT"
|
||||
echo " 后端 API: http://${SERVER_IP}:$BACKEND_PORT/api"
|
||||
echo " 数据库 : $DB_NAME (用户 $DB_USER)"
|
||||
echo ""
|
||||
echo " 测试账户:student / mentor / admin(密码同 *123,如 student123)"
|
||||
echo ""
|
||||
echo " 常用运维:"
|
||||
echo " systemctl status cac-backend cac-frontend"
|
||||
echo " journalctl -u cac-backend -f"
|
||||
echo " journalctl -u cac-frontend -f"
|
||||
echo "============================================================"
|
||||
+1395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@modules/auth';
|
||||
|
||||
/**
|
||||
* AI 调用速率限制守卫。
|
||||
*
|
||||
* 基于内存滑动窗口,按用户限制 AI 密集型端点的调用频率,
|
||||
* 防止滥用导致的成本失控。
|
||||
*
|
||||
* 配置(环境变量):
|
||||
* - `AI_RATE_LIMIT_PER_MINUTE`:每分钟最大调用次数(默认 20)
|
||||
* - `AI_RATE_LIMIT_PER_DAY`:每日最大调用次数(默认 500)
|
||||
*
|
||||
* 注意:内存实现仅适用于单实例部署;多实例需替换为 Redis 等共享存储。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AIRateLimitGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AIRateLimitGuard.name);
|
||||
|
||||
private readonly perMinute = parseInt(
|
||||
process.env.AI_RATE_LIMIT_PER_MINUTE ?? '20',
|
||||
10,
|
||||
);
|
||||
private readonly perDay = parseInt(
|
||||
process.env.AI_RATE_LIMIT_PER_DAY ?? '500',
|
||||
10,
|
||||
);
|
||||
|
||||
/** 每个用户的调用时间戳记录(毫秒)。 */
|
||||
private readonly callLog = new Map<string, number[]>();
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthenticatedUser;
|
||||
}>();
|
||||
const userId = request.user?.id ?? 'anonymous';
|
||||
|
||||
const now = Date.now();
|
||||
const minuteAgo = now - 60_000;
|
||||
const dayAgo = now - 86_400_000;
|
||||
|
||||
const log = this.callLog.get(userId) ?? [];
|
||||
|
||||
// 清理超过一天的记录
|
||||
const recentLog = log.filter((ts) => ts > dayAgo);
|
||||
|
||||
const callsLastMinute = recentLog.filter((ts) => ts > minuteAgo).length;
|
||||
const callsLastDay = recentLog.length;
|
||||
|
||||
if (callsLastMinute >= this.perMinute) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: `AI 调用过于频繁,请稍后再试(每分钟上限 ${this.perMinute} 次)`,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
if (callsLastDay >= this.perDay) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: 'DAILY_LIMIT_EXCEEDED',
|
||||
message: `今日 AI 调用次数已达上限(${this.perDay} 次),请明日再来`,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
recentLog.push(now);
|
||||
this.callLog.set(userId, recentLog);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Inject,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AuthenticatedUser, CurrentUser } from '@modules/auth';
|
||||
import { AIRateLimitGuard } from '@common/rate-limit/ai-rate-limit.guard';
|
||||
|
||||
import { AI_GATEWAY, AIGateway, AIStreamRequest } from './ai-gateway.types';
|
||||
|
||||
/**
|
||||
* AI 流式生成端点。
|
||||
*
|
||||
* 通过 Server-Sent Events 逐 chunk 返回 AI 生成内容,
|
||||
* 前端可实时显示生成过程(打字机效果)。
|
||||
*
|
||||
* 受 `AIRateLimitGuard` 速率限制保护,防止 AI 调用滥用。
|
||||
*/
|
||||
@Controller('api/ai')
|
||||
@UseGuards(AIRateLimitGuard)
|
||||
export class AIStreamController {
|
||||
constructor(
|
||||
@Inject(AI_GATEWAY) private readonly ai: AIGateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/ai/stream
|
||||
*
|
||||
* Body: { prompt, systemPrompt?, messages?, temperature?, maxTokens? }
|
||||
*
|
||||
* 返回 SSE 流:
|
||||
* - data: {"chunk":"..."} — 每个生成 chunk
|
||||
* - data: {"done":true,"content":"完整内容","annotation":{...}} — 生成结束
|
||||
* - data: {"error":"错误信息"} — 出错
|
||||
*/
|
||||
@Post('stream')
|
||||
async stream(
|
||||
@Body() body: {
|
||||
prompt: string;
|
||||
systemPrompt?: string;
|
||||
messages?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
},
|
||||
@Res() res: Response,
|
||||
@CurrentUser() _user: AuthenticatedUser,
|
||||
) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
// no-transform 阻止下游代理(Next.js dev proxy)对 SSE 流进行 gzip 压缩,
|
||||
// 压缩会缓冲整个响应导致前端无法实时收到 chunk(打字机效果失效)。
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Content-Encoding', 'identity');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const request: AIStreamRequest = {
|
||||
prompt: body.prompt,
|
||||
systemPrompt: body.systemPrompt,
|
||||
messages: body.messages,
|
||||
temperature: body.temperature,
|
||||
maxTokens: body.maxTokens,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.ai.generateStream(request, (chunk) => {
|
||||
res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
|
||||
});
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
done: true,
|
||||
content: result.content,
|
||||
annotation: result.annotation,
|
||||
})}\n\n`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '生成失败';
|
||||
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 执业医师备考内置题库实体。
|
||||
* 用于持久化存储 AI 生成或人工录入的题库题目,支持自动查重。
|
||||
*/
|
||||
@Entity({ name: 'question_bank' })
|
||||
export class QuestionBankEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 128 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
subject!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content!: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'[]'" })
|
||||
options!: string[];
|
||||
|
||||
@Column({ type: 'varchar', length: 8 })
|
||||
correctAnswer!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
explanation!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type { QuestionBankEntry, QuestionBankRepository } from '../exam-prep.types';
|
||||
|
||||
const DATA_DIR = join(process.cwd(), '.data');
|
||||
const DATA_FILE = join(DATA_DIR, 'question-bank.json');
|
||||
|
||||
/**
|
||||
* 内存题库仓库,附带 JSON 文件持久化。
|
||||
* 适用于 DB_ENABLED=false 的本地开发/测试场景,重启后可恢复数据。
|
||||
*/
|
||||
@Injectable()
|
||||
export class InMemoryQuestionBankRepository implements QuestionBankRepository, OnModuleInit {
|
||||
private readonly logger = new Logger(InMemoryQuestionBankRepository.name);
|
||||
private store = new Map<string, QuestionBankEntry>();
|
||||
|
||||
onModuleInit() {
|
||||
this.loadFromDisk();
|
||||
}
|
||||
|
||||
async save(entry: QuestionBankEntry): Promise<void> {
|
||||
this.store.set(entry.id, entry);
|
||||
this.flushToDisk();
|
||||
}
|
||||
|
||||
async list(subject?: string): Promise<QuestionBankEntry[]> {
|
||||
const all = Array.from(this.store.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
if (subject) {
|
||||
return all.filter((q) => q.subject === subject);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<QuestionBankEntry | undefined> {
|
||||
return this.store.get(id);
|
||||
}
|
||||
|
||||
private loadFromDisk(): void {
|
||||
if (!existsSync(DATA_FILE)) {
|
||||
this.logger.log(`题库持久化文件不存在,等待首次写入: ${DATA_FILE}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(DATA_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as QuestionBankEntry[];
|
||||
if (Array.isArray(parsed)) {
|
||||
this.store = new Map(parsed.map((q) => [q.id, q]));
|
||||
this.logger.log(`已从 ${DATA_FILE} 加载 ${this.store.size} 条题库记录`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`题库持久化文件读取失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private flushToDisk(): void {
|
||||
try {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const data = Array.from(this.store.values());
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`题库持久化文件写入失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { QuestionBankEntity } from '../entities/question-bank.entity';
|
||||
import type { QuestionBankEntry, QuestionBankRepository } from '../exam-prep.types';
|
||||
|
||||
/**
|
||||
* 基于 TypeORM / PostgreSQL 的题库持久化实现。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TypeOrmQuestionBankRepository implements QuestionBankRepository {
|
||||
constructor(
|
||||
@InjectRepository(QuestionBankEntity)
|
||||
private readonly repo: Repository<QuestionBankEntity>,
|
||||
) {}
|
||||
|
||||
async save(entry: QuestionBankEntry): Promise<void> {
|
||||
const entity = new QuestionBankEntity();
|
||||
entity.id = entry.id;
|
||||
entity.subject = entry.subject;
|
||||
entity.content = entry.content;
|
||||
entity.options = entry.options;
|
||||
entity.correctAnswer = entry.correctAnswer;
|
||||
entity.explanation = entry.explanation;
|
||||
entity.createdAt = new Date(entry.createdAt);
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
|
||||
async list(subject?: string): Promise<QuestionBankEntry[]> {
|
||||
const where = subject ? { subject } : {};
|
||||
const rows = await this.repo.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((r) => this.toEntry(r));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<QuestionBankEntry | undefined> {
|
||||
const row = await this.repo.findOne({ where: { id } });
|
||||
return row ? this.toEntry(row) : undefined;
|
||||
}
|
||||
|
||||
private toEntry(r: QuestionBankEntity): QuestionBankEntry {
|
||||
return {
|
||||
id: r.id,
|
||||
subject: r.subject,
|
||||
content: r.content,
|
||||
options: r.options,
|
||||
correctAnswer: r.correctAnswer,
|
||||
explanation: r.explanation,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Lightbulb, TrendingDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { examPrepApi } from "@/lib/services";
|
||||
|
||||
interface Recommendation {
|
||||
subject: string;
|
||||
label: string;
|
||||
priority: number;
|
||||
reason: string;
|
||||
correctRate: number | null;
|
||||
totalAnswered: number;
|
||||
daysSinceLastPractice: number;
|
||||
}
|
||||
|
||||
const PRIORITY_VARIANT: Record<number, "destructive" | "warning" | "info" | "success"> = {
|
||||
1: "destructive",
|
||||
2: "info",
|
||||
3: "warning",
|
||||
4: "success",
|
||||
};
|
||||
|
||||
/**
|
||||
* 个性化备考推荐横幅:展示薄弱科目与今日复习建议。
|
||||
*/
|
||||
export function ExamRecommendations({
|
||||
onPickSubject,
|
||||
}: {
|
||||
onPickSubject?: (subject: string) => void;
|
||||
}) {
|
||||
const [recs, setRecs] = useState<Recommendation[]>([]);
|
||||
const [summary, setSummary] = useState<string>("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
examPrepApi
|
||||
.getRecommendations()
|
||||
.then((res) => {
|
||||
setRecs(res.recommendations ?? []);
|
||||
setSummary(res.summary ?? "");
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!loaded || recs.length === 0) return null;
|
||||
|
||||
// 只展示优先级最高的前 3 个
|
||||
const top = recs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Lightbulb className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-foreground">智能备考推荐</h3>
|
||||
<p className="text-xs text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{top.map((r) => (
|
||||
<button
|
||||
key={r.subject}
|
||||
onClick={() => onPickSubject?.(r.subject)}
|
||||
className="flex flex-col gap-1 rounded-xl border border-border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">{r.label}</span>
|
||||
<Badge variant={PRIORITY_VARIANT[r.priority] ?? "info"}>
|
||||
{r.priority === 1 ? "薄弱" : r.priority === 2 ? "新科目" : r.priority === 3 ? "待复习" : "巩固"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{r.correctRate !== null && r.priority === 1 && <TrendingDown className="size-3" />}
|
||||
{r.reason}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 统一的 Markdown 渲染组件。
|
||||
*
|
||||
* 用于渲染 AI 生成的富文本内容(标题、列表、加粗、代码、表格等),
|
||||
* 样式与应用主题一致,支持暗色模式。可在流式输出过程中实时渲染。
|
||||
*/
|
||||
export function Markdown({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-relaxed text-foreground",
|
||||
// 标题
|
||||
"[&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-bold",
|
||||
"[&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-bold",
|
||||
"[&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold",
|
||||
// 段落
|
||||
"[&_p]:my-1.5 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
||||
// 列表
|
||||
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5",
|
||||
"[&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5",
|
||||
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
|
||||
// 强调
|
||||
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||
"[&_em]:italic",
|
||||
// 引用
|
||||
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||
// 行内代码
|
||||
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
|
||||
// 代码块
|
||||
"[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
|
||||
// 链接
|
||||
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||
// 表格
|
||||
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||
// 分割线
|
||||
"[&_hr]:my-3 [&_hr]:border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Clock, FileText, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { examPrepApi } from "@/lib/services";
|
||||
|
||||
const SUBJECTS = [
|
||||
{ value: "internal", label: "内科学" },
|
||||
{ value: "surgery", label: "外科学" },
|
||||
{ value: "pediatrics", label: "儿科学" },
|
||||
{ value: "obgyn", label: "妇产科学" },
|
||||
{ value: "pharmacology", label: "药理学" },
|
||||
{ value: "pathology", label: "病理学" },
|
||||
{ value: "diagnostics", label: "诊断学" },
|
||||
{ value: "ethics", label: "医学伦理" },
|
||||
];
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type Phase = "config" | "exam" | "result";
|
||||
|
||||
/**
|
||||
* 模拟考试组件:限时批量答题 → 交卷统一批阅。
|
||||
*/
|
||||
export function MockExam() {
|
||||
const [phase, setPhase] = useState<Phase>("config");
|
||||
const [count, setCount] = useState(10);
|
||||
const [timeLimit, setTimeLimit] = useState(20);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [exam, setExam] = useState<any>(null);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [remainingSec, setRemainingSec] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startExam() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const e = await examPrepApi.startMockExam({ count, timeLimitMinutes: timeLimit });
|
||||
setExam(e);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setPhase("exam");
|
||||
setRemainingSec(timeLimit * 60);
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingSec((s) => {
|
||||
if (s <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
void submitExam(e, true);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "无法发起模拟考试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExam(examData = exam, auto = false) {
|
||||
if (!examData) return;
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
examId: examData.examId,
|
||||
answers: examData.questions.map((q: any) => ({
|
||||
questionId: q.id,
|
||||
answer: answers[q.id] ?? "",
|
||||
})),
|
||||
};
|
||||
const r = await examPrepApi.submitMockExam(payload);
|
||||
setResult(r);
|
||||
setPhase("result");
|
||||
if (auto) toast.warning("考试时间到,已自动交卷。");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "交卷失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setPhase("config");
|
||||
setExam(null);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
const answeredCount = exam ? Object.keys(answers).filter((k) => answers[k]).length : 0;
|
||||
|
||||
// ── 配置阶段 ──
|
||||
if (phase === "config") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" /> 模拟考试
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
限时批量答题,模拟真实考场环境。交卷后统一批阅并生成成绩单。
|
||||
</p>
|
||||
{error && <ErrorText message={error} />}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">题目数量</label>
|
||||
<Select value={String(count)} onChange={(e) => setCount(Number(e.target.value))} className="w-28">
|
||||
<option value="10">10 题</option>
|
||||
<option value="20">20 题</option>
|
||||
<option value="30">30 题</option>
|
||||
<option value="50">50 题</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">时间限制</label>
|
||||
<Select value={String(timeLimit)} onChange={(e) => setTimeLimit(Number(e.target.value))} className="w-28">
|
||||
<option value="10">10 分钟</option>
|
||||
<option value="20">20 分钟</option>
|
||||
<option value="40">40 分钟</option>
|
||||
<option value="60">60 分钟</option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={startExam} disabled={busy}>
|
||||
{busy ? "准备中…" : "开始考试"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 考试阶段 ──
|
||||
if (phase === "exam" && exam) {
|
||||
const mm = String(Math.floor(remainingSec / 60)).padStart(2, "0");
|
||||
const ss = String(remainingSec % 60).padStart(2, "0");
|
||||
const lowTime = remainingSec <= 60;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="sticky top-0 z-10 border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>模拟考试进行中</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已答 {answeredCount} / {exam.totalQuestions}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1 text-sm font-bold tabular-nums ${
|
||||
lowTime ? "bg-destructive/12 text-destructive" : "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Clock className="size-4" /> {mm}:{ss}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{error && <ErrorText message={error} />}
|
||||
{exam.questions.map((q: any) => (
|
||||
<div key={q.id} className="rounded-lg border border-border p-4">
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<span className="text-sm font-bold text-primary">{q.index}.</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm leading-relaxed text-foreground">{q.content}</p>
|
||||
<Badge variant="info" className="mt-2">
|
||||
{q.subjectLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{q.options && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{q.options.map((opt: string, i: number) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const selected = answers[q.id] === letter;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAnswers((a) => ({ ...a, [q.id]: letter }))}
|
||||
className={`flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground/80 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${
|
||||
selected ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => submitExam()} disabled={busy}>
|
||||
{busy ? "交卷中…" : "交卷"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={reset} disabled={busy}>
|
||||
放弃考试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 成绩阶段 ──
|
||||
if (phase === "result" && result) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>考试成绩单</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl bg-muted/40 p-6">
|
||||
<div
|
||||
className={`text-5xl font-bold ${
|
||||
result.score >= 80 ? "text-success" : result.score >= 60 ? "text-warning" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.score}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={result.passed ? "success" : "destructive"}>
|
||||
{result.passed ? "及格" : "未及格"}
|
||||
</Badge>
|
||||
{result.overtime && <Badge variant="warning">超时</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.correctCount}/{result.totalQuestions} 题正确 · 用时 {result.elapsedMinutes} 分钟
|
||||
</p>
|
||||
<p className="text-center text-sm text-foreground/80">{result.comment}</p>
|
||||
</div>
|
||||
|
||||
{/* 科目分布 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">各科目表现</p>
|
||||
{Object.entries(result.bySubject).map(([key, val]: [string, any]) => {
|
||||
const label = SUBJECTS.find((s) => s.value === key)?.label ?? key;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
val.rate >= 80 ? "text-success" : val.rate >= 60 ? "text-warning" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{val.correct}/{val.total}({val.rate}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 逐题解析 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">逐题解析</p>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{result.results.map((r: any, i: number) => (
|
||||
<div key={r.questionId} className="rounded-lg border border-border p-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{r.correct ? (
|
||||
<CheckCircle2 className="size-4 text-success" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-destructive" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">第 {i + 1} 题</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/90">{r.content}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>你的答案:{r.userAnswer}</span>
|
||||
<span>正确答案:{r.correctAnswer}</span>
|
||||
</div>
|
||||
{r.explanation && (
|
||||
<p className="mt-2 rounded bg-muted/50 p-2 text-xs text-foreground/70">{r.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={reset}>再考一次</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ErrorText({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{message}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 离线状态指示器:检测网络断开,提示用户当前展示的是缓存数据。
|
||||
*/
|
||||
export function OfflineIndicator() {
|
||||
const [offline, setOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator === "undefined") return;
|
||||
setOffline(!navigator.onLine);
|
||||
|
||||
const onOffline = () => setOffline(true);
|
||||
const onOnline = () => setOffline(false);
|
||||
window.addEventListener("offline", onOffline);
|
||||
window.addEventListener("online", onOnline);
|
||||
return () => {
|
||||
window.removeEventListener("offline", onOffline);
|
||||
window.removeEventListener("online", onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!offline) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-amber-500/90 px-2.5 py-0.5 text-[11px] font-medium text-white">
|
||||
<WifiOff className="size-3" />
|
||||
<span className="hidden sm:inline">离线模式(显示缓存数据)</span>
|
||||
<span className="sm:hidden">离线</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user