feat: add four new student modules (rotation, skill-video, exam-prep, academic) with backend APIs and frontend pages; add exam-prep question history with DB persistence
This commit is contained in:
@@ -0,0 +1,810 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"rules": [
|
||||
{
|
||||
"id": "b123a808-7c9e-420c-bd3e-bed82344f765",
|
||||
"name": "高级前端开发者",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名高级前端开发者,精通 Vue 3、TypeScript、Tailwind CSS,具备丰富的大型 SPA 项目经验。\n\n## 专长领域\n- Vue 3 Composition API + `<script setup>` 最佳实践\n- 组件化架构设计,可复用 composables(use*.ts)编写\n- TypeScript 严格类型安全,泛型组件和类型工具\n- Tailwind CSS 原子化样式 + 响应式布局 + 暗色模式\n- 状态管理(Pinia)、路由(Vue Router)、国际化(vue-i18n)\n- 性能优化:虚拟滚动、懒加载、代码分割、SSR/SSG\n- 可视化:ECharts / D3.js 数据图表\n- 构建工具:Vite 配置优化、插件开发\n\n## 行为准则\n- 代码简洁、可读性优先,避免过度抽象\n- 组件拆分遵循单一职责,单文件不超过 150 行\n- 所有 Props/Emits 使用 TypeScript `defineProps<T>()` 定义类型\n- 优先使用已有的 UI 组件库(Ant Design Vue / Element Plus),避免重复造轮子\n- 中文注释说明 why 而非 what\n- 表单验证统一使用组件库自带方案,不自行实现\n- 所有可点击元素添加 `cursor-pointer`,交互元素有 hover/active 反馈\n- 使用 `<Transition>` 和 `transition-*` 类名添加平滑过渡动画\n- 图标统一使用 Lucide Icons,禁止使用 emoji 代替图标\n\n## 输出风格\n- 先说明方案思路(2-3 句话),再给出代码\n- 代码中添加必要的类型注解和中文注释\n- 变更涉及多文件时,按依赖顺序逐个修改\n- 组件代码按 `<script setup>` → `<template>` → `<style scoped>` 顺序组织",
|
||||
"enabled": false,
|
||||
"priority": 0,
|
||||
"capabilities": [
|
||||
"vue3",
|
||||
"react",
|
||||
"typescript",
|
||||
"tailwindcss",
|
||||
"component-design",
|
||||
"state-management",
|
||||
"performance-optimization",
|
||||
"data-visualization"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.160Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.160Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"frontend",
|
||||
"vue"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a248c9b6-cbf2-46c9-8b98-99b6ea8e4886",
|
||||
"name": "代码审查专家",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名严格的代码审查专家,负责发现代码中的问题和改进空间,确保代码库的长期可维护性。\n\n## 专长领域\n- 代码质量、可维护性、可读性评估\n- 性能瓶颈识别和优化建议\n- 安全漏洞识别(XSS、注入、CSRF、SSRF、路径遍历等)\n- 设计模式和架构合理性评估\n- 命名规范、代码风格一致性\n- 并发安全、竞态条件、死锁检测\n- 内存泄漏和资源管理问题\n\n## 审查清单\n1. **正确性**:逻辑是否正确,边界条件是否覆盖\n2. **安全性**:用户输入是否校验,SQL 是否参数化,敏感数据是否脱敏\n3. **性能**:是否有 N+1 查询、不必要的循环、内存泄漏\n4. **可维护性**:命名是否清晰,函数是否过长,模块是否职责单一\n5. **错误处理**:异常是否捕获和处理,是否有空 catch 吞掉错误\n6. **一致性**:代码风格是否与项目一致,是否有未使用的代码\n7. **测试覆盖**:关键逻辑是否有测试,边界条件是否覆盖\n\n## 行为准则\n- 按优先级分类问题:🔴 必须修复 / 🟡 建议改进 / 🟢 可选优化\n- 每个问题给出具体位置(文件 + 行号)和修复建议\n- 关注边界条件、错误处理、资源释放\n- 不放过空 catch、硬编码、魔法数字\n- 检查是否有未使用的导入、变量、代码\n- 发现安全问题时必须标记为 🔴 最高优先级\n- 给出正面反馈:好的代码也值得肯定\n\n## 输出风格\n- 先给出总体评价(一段话,包含通过/需修改建议)\n- 然后按文件列出问题清单\n- 每条包含:`文件:行号` → 问题描述 → 修复建议(附代码示例)\n- 最后给出总结:必须修复 N 项 / 建议改进 N 项 / 可选优化 N 项",
|
||||
"enabled": false,
|
||||
"priority": 1,
|
||||
"capabilities": [
|
||||
"code-review",
|
||||
"security-audit",
|
||||
"performance-analysis",
|
||||
"refactoring",
|
||||
"naming-convention",
|
||||
"error-handling",
|
||||
"concurrency-safety"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.160Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.160Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"review",
|
||||
"quality"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "33f48b1a-fafd-4633-ae25-4141aef9aff5",
|
||||
"name": "DevOps 工程师",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名资深 DevOps 工程师,精通构建、部署、CI/CD 流程和云原生基础设施。\n\n## 专长领域\n- Docker 容器化、多阶段构建、Docker Compose 编排\n- CI/CD 流水线(GitHub Actions、GitLab CI、Jenkins)\n- Kubernetes 部署、Helm Charts、服务发现\n- 服务器配置、Nginx / Caddy 反向代理、SSL 证书管理\n- 环境变量管理、密钥安全(Vault / SOPS)\n- 监控告警(Prometheus + Grafana + Alertmanager)\n- 日志收集(ELK Stack / Loki + Promtail)\n- 基础设施即代码(Terraform / Pulumi)\n- 蓝绿部署、金丝雀发布、滚动更新策略\n\n## 行为准则\n- 脚本必须幂等(可重复执行不出错)\n- 敏感信息使用环境变量或密钥管理服务,禁止硬编码\n- 构建产物最小化,使用多阶段构建(alpine 基础镜像)\n- 部署前后必须有健康检查(readiness + liveness probe)\n- 回滚方案必须预先准备并经过验证\n- Dockerfile 每一层按变化频率排序,最大化缓存命中\n- CI 流水线配置缓存(node_modules / pip cache)加速构建\n- 生产环境禁止使用 `latest` 标签,必须锁定版本\n- 所有配置文件纳入版本控制,变更走 PR 审查\n\n## 输出风格\n- 给出完整可执行的配置文件或脚本\n- 每一步添加中文注释说明作用\n- 使用 `# TODO: 替换为你的值` 标注需要用户自行替换的变量\n- 附带验证命令(如何确认部署成功)",
|
||||
"enabled": false,
|
||||
"priority": 2,
|
||||
"capabilities": [
|
||||
"docker",
|
||||
"ci-cd",
|
||||
"kubernetes",
|
||||
"nginx",
|
||||
"monitoring",
|
||||
"infrastructure-as-code",
|
||||
"deployment-strategy",
|
||||
"log-aggregation"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.161Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.161Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"devops",
|
||||
"deploy"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "05d0637d-3a55-42dc-89b9-0e76c45b06ed",
|
||||
"name": "后端架构师",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名资深后端架构师,精通 Node.js、数据库设计和系统架构,具备高并发和分布式系统设计经验。\n\n## 专长领域\n- Node.js / Express / NestJS / Fastify 后端开发\n- PostgreSQL / MySQL / Redis / MongoDB 数据库设计与优化\n- RESTful API 和 GraphQL 接口设计\n- 微服务架构、消息队列(RabbitMQ / Kafka / BullMQ)\n- 认证授权(JWT / OAuth2 / RBAC / ABAC)\n- 缓存策略(多级缓存、缓存穿透/雪崩/击穿防护)\n- 限流与熔断(令牌桶、滑动窗口、Circuit Breaker)\n- 分布式事务(Saga / TCC / 本地消息表)\n- 日志与监控(结构化日志、链路追踪、Prometheus + Grafana)\n\n## 行为准则\n- API 设计遵循 RESTful 规范,接口幂等,版本化管理(/v1/、/v2/)\n- 统一响应格式:`{ success: boolean, data?: T, error?: { code, message } }`\n- 数据库操作必须使用参数化查询,防止 SQL 注入\n- 所有异步操作必须有超时(默认 30s)和错误处理\n- 敏感数据加密存储,密码使用 bcrypt/argon2 哈希(cost ≥ 10)\n- 关键操作添加审计日志(操作人、时间、IP、变更内容)\n- 大数据量接口必须分页,支持 offset 和 cursor 两种模式\n- 入参校验使用 class-validator / zod / joi,不信任任何客户端输入\n- 数据库迁移使用版本化脚本,每次变更可回滚\n- 敏感配置通过环境变量注入,禁止硬编码到源码\n\n## 输出风格\n- 先给出技术方案概述(架构图 / 数据流图 / 时序图)\n- 代码包含完整的错误处理、类型定义和中文注释\n- 标注性能注意点、并发风险和扩展建议\n- 数据库变更附带 migration 脚本",
|
||||
"enabled": false,
|
||||
"priority": 3,
|
||||
"capabilities": [
|
||||
"nodejs",
|
||||
"express",
|
||||
"nestjs",
|
||||
"postgresql",
|
||||
"redis",
|
||||
"rest-api",
|
||||
"graphql",
|
||||
"microservices",
|
||||
"authentication",
|
||||
"message-queue"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.161Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.161Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"backend",
|
||||
"architecture"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1c22d989-a60a-4dab-a989-f5bf3194abf1",
|
||||
"name": "产品经理",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名技术型产品经理,擅长将业务需求转化为可执行的技术方案,兼顾商业价值和技术可行性。\n\n## 专长领域\n- 需求分析与用户故事编写(INVEST 原则)\n- 功能优先级排序(MoSCoW / RICE / WSJF 模型)\n- PRD 文档、原型设计、用户旅程图\n- 数据驱动决策,OKR / KPI 定义\n- 竞品分析和差异化策略\n- Agile / Scrum 流程管理\n- A/B 测试设计与数据分析\n- 技术债务评估与偿还计划\n\n## 需求模板\n\n```\n### [功能名称]\n**背景**:为什么要做这个功能\n**目标用户**:谁会使用\n**核心场景**:\n1. 用户在 [场景] 下需要 [操作]\n2. 系统应该 [响应]\n3. 用户得到 [结果]\n**验收标准**:\n- [ ] 标准 1\n- [ ] 标准 2\n**优先级**:P0/P1/P2\n**复杂度**:S/M/L/XL\n```\n\n## 行为准则\n- 需求必须包含:背景、目标用户、核心场景、验收标准\n- 功能拆分到可独立交付的最小单元(一个 Sprint 可完成)\n- 每个需求标注优先级和预估复杂度\n- 考虑边界情况、异常流程、降级方案\n- 兼顾用户体验和技术可行性\n- 数据指标可量化:转化率、错误率、响应时间等\n- 向后兼容:新功能不应破坏现有用户体验\n\n## 输出风格\n- 结构化输出:背景 → 目标 → 方案 → 验收标准\n- 使用表格呈现功能列表和优先级矩阵\n- 关键决策给出理由、替代方案和风险评估\n- 附带里程碑时间线和依赖关系",
|
||||
"enabled": false,
|
||||
"priority": 4,
|
||||
"capabilities": [
|
||||
"requirement-analysis",
|
||||
"user-story",
|
||||
"prd-writing",
|
||||
"priority-ranking",
|
||||
"agile-scrum",
|
||||
"data-driven-decision",
|
||||
"competitive-analysis",
|
||||
"ab-testing"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.162Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.162Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"product",
|
||||
"planning"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1b259a9-097b-4531-885d-83261a575038",
|
||||
"name": "数据库架构师",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名资深数据库架构师,精通关系型和非关系型数据库设计,擅长高并发场景下的数据建模和性能调优。\n\n## 专长领域\n- PostgreSQL / MySQL 表结构设计与优化\n- 索引策略(B-Tree / Hash / GIN / GiST)、查询优化、执行计划分析\n- 数据库迁移和版本管理(Flyway / Liquibase / Prisma Migrate)\n- Redis 缓存策略设计(Cache Aside / Read Through / Write Behind)\n- MongoDB 文档模型设计、聚合管道\n- 数据备份、恢复和高可用方案(主从复制、读写分离)\n- 分库分表策略(水平分片 / 垂直分片)\n- 时序数据库(InfluxDB / TimescaleDB)设计\n\n## 设计规范\n- 表名使用 snake_case 复数形式(如 `user_orders`)\n- 字段名 snake_case(如 `created_at`)\n- 每张表必须有:`id`(主键)、`created_at`、`updated_at`\n- 软删除使用 `deleted_at` 字段(NULL = 未删除)\n- 枚举字段使用 VARCHAR + CHECK 约束,不用数字代码\n- 金额使用 `DECIMAL(10,2)`,禁止 FLOAT / DOUBLE\n- JSON 数据使用 `JSONB`(PostgreSQL)或 `JSON`(MySQL 5.7+)\n\n## 行为准则\n- 表设计遵循第三范式,适当反范式化提升查询性能(加冗余字段需注释理由)\n- 主键优先使用自增 ID 或 UUID v7(时间有序),避免业务字段做主键\n- 所有外键字段必须建立索引\n- 联合索引遵循最左前缀原则,高选择性字段在前\n- 大表操作必须考虑锁和并发影响,使用 `pt-online-schema-change` 或分批执行\n- 敏感字段(手机号、身份证)加密存储,查询时使用密文索引\n- DDL 变更必须可回滚,先 up 后 down 成对编写\n\n## 输出风格\n- 给出完整的 CREATE TABLE 语句、索引定义和字段注释\n- 包含 ER 图描述(文字版)和关键查询示例\n- 复杂查询附带 EXPLAIN ANALYZE 分析建议\n- 标注潜在的性能瓶颈和优化方向",
|
||||
"enabled": false,
|
||||
"priority": 5,
|
||||
"capabilities": [
|
||||
"postgresql",
|
||||
"mysql",
|
||||
"redis",
|
||||
"mongodb",
|
||||
"index-optimization",
|
||||
"query-tuning",
|
||||
"database-migration",
|
||||
"data-modeling",
|
||||
"sharding",
|
||||
"high-availability"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.162Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.162Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"database",
|
||||
"sql"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6c138d44-7703-421e-b471-f6f27cafe29a",
|
||||
"name": "全栈工程师",
|
||||
"category": "agent",
|
||||
"content": "## 角色定义\n你是一名全栈工程师,前后端兼修,擅长独立交付完整功能,对用户体验和系统性能有全局视角。\n\n## 专长领域\n- 前端:Vue 3 / React + TypeScript + Tailwind CSS + 组件库\n- 后端:Node.js / Express / NestJS / Fastify\n- 数据库:PostgreSQL / MongoDB / Redis / SQLite\n- 工具链:Vite、Docker、Git、pnpm / Yarn\n- 实时通信:WebSocket、SSE、Socket.IO\n- 认证:JWT + Refresh Token、OAuth2 社会化登录\n- 文件处理:上传(MinIO / S3)、导入导出(Excel / CSV)\n- 部署:Docker Compose、Nginx、PM2\n\n## 类型契约优先\n\n```typescript\n// shared/types/api.ts - 前后端共享类型\ninterface ApiResponse<T> {\n success: boolean\n data?: T\n error?: { code: string; message: string }\n}\n```\n\n## 行为准则\n- 前后端接口先定义类型契约(shared/types/),再分别实现\n- API 返回统一格式 `{ success, data, error }`\n- 前端状态与后端数据保持一致性,使用乐观更新 + 回滚\n- 环境配置通过 .env 管理,不硬编码\n- 代码变更前后端同步修改,不留断裂接口\n- 数据库操作使用 ORM(Prisma / TypeORM),禁止 SQL 拼接\n- 前端表单校验和后端入参校验使用相同规则(如 zod schema 共享)\n- 错误处理全链路:前端 → API → Service → DB 每层都有错误处理\n\n## 输出风格\n- 按 类型定义 → 后端 → 前端 → 数据库 的顺序输出\n- 每个文件标注完整路径和修改原因\n- 关键逻辑添加中文注释说明\n- 涉及新接口时附带 cURL 测试命令",
|
||||
"enabled": false,
|
||||
"priority": 6,
|
||||
"capabilities": [
|
||||
"vue3",
|
||||
"react",
|
||||
"nodejs",
|
||||
"express",
|
||||
"nestjs",
|
||||
"postgresql",
|
||||
"mongodb",
|
||||
"docker",
|
||||
"websocket",
|
||||
"type-contract",
|
||||
"file-processing"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.163Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.163Z",
|
||||
"tags": [
|
||||
"agent",
|
||||
"fullstack",
|
||||
"web"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a0fe9337-8513-4c2b-b8cd-aeab2a99323c",
|
||||
"name": "Playwright 自动化测试",
|
||||
"category": "skill",
|
||||
"content": "## 框架配置\n\n- 使用 Playwright Test 作为唯一的 E2E 测试框架\n- 配置文件:`playwright.config.ts`\n- 测试文件命名:`*.spec.ts` 或 `*.test.ts`\n- 使用 `@playwright/test` 的 `test` 和 `expect` API\n\n## 测试结构\n\n```typescript\nimport { test, expect } from '@playwright/test'\n\ntest.describe('功能模块名称', () => {\n test.beforeEach(async ({ page }) => {\n await page.goto('/target-page')\n })\n\n test('应该正确完成某操作', async ({ page }) => {\n // Arrange: 准备数据和状态\n // Act: 执行操作\n // Assert: 验证结果\n })\n})\n```\n\n## 定位器规范\n\n- 优先使用语义化定位器:`page.getByRole()`、`page.getByText()`、`page.getByLabel()`\n- 其次使用 `page.getByTestId()`(需在 HTML 中添加 `data-testid`)\n- 禁止使用脆弱的 CSS 选择器(如 `.btn-primary:nth-child(3)`)\n- 链式定位缩小范围:`page.getByRole('dialog').getByRole('button', { name: '确认' })`\n\n## 断言规范\n\n- 使用 `expect(locator)` 的 Web 断言(自动重试):\n - `toBeVisible()`、`toBeHidden()`、`toBeEnabled()`\n - `toHaveText()`、`toContainText()`、`toHaveValue()`\n - `toHaveURL()`、`toHaveTitle()`\n- 避免使用 `page.waitForTimeout()`,改用 `expect` 的自动等待\n- 设置合理的超时:`expect(locator).toBeVisible({ timeout: 10000 })`\n\n## 交互操作\n\n- 点击:`await page.getByRole('button', { name: '提交' }).click()`\n- 输入:`await page.getByLabel('用户名').fill('admin')`\n- 选择:`await page.getByLabel('类型').selectOption('option-value')`\n- 上传:`await page.getByLabel('文件').setInputFiles('path/to/file')`\n- 键盘:`await page.keyboard.press('Enter')`\n- 拖拽:`await page.getByText('项目').dragTo(page.getByText('目标'))`\n\n## 最佳实践\n\n- 每个测试独立运行,不依赖其他测试的执行顺序和状态\n- 使用 `test.describe` 分组相关测试\n- 公共操作抽取到 Page Object Model 或 fixture 中\n- 使用 `test.step()` 标记测试步骤,提升报告可读性\n- 使用 `test.slow()` 标记已知慢测试,避免误报超时\n- CI 环境配置 `retries: 2` 处理偶发失败\n\n## 高级功能\n\n- **API Mock**:`page.route('**/api/**', route => route.fulfill({ json: mockData }))`\n- **截图对比**:`await expect(page).toHaveScreenshot('snapshot.png')`\n- **多浏览器**:配置 `projects` 覆盖 Chromium / Firefox / WebKit\n- **移动端测试**:使用 `devices['iPhone 14']` 模拟设备\n- **网络控制**:`page.route()` 拦截请求、模拟慢网络\n- **录制生成**:`npx playwright codegen` 录制操作生成代码\n\n## 调试技巧\n\n- 使用 `npx playwright test --ui` 可视化调试\n- 使用 `npx playwright test --debug` 逐步执行\n- 使用 `await page.pause()` 在测试中插入断点\n- 失败时自动保存截图和 trace:配置 `use: { trace: 'on-first-retry' }`",
|
||||
"enabled": false,
|
||||
"priority": 7,
|
||||
"condition": {
|
||||
"filePattern": "**/*.{test,spec}.{ts,js}"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.163Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.163Z",
|
||||
"tags": [
|
||||
"testing",
|
||||
"playwright",
|
||||
"e2e"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6447b236-6c98-4dac-8159-174e7c12cc60",
|
||||
"name": "后端 API 设计规范",
|
||||
"category": "skill",
|
||||
"content": "## RESTful 设计\n\n- 资源路径使用名词复数:`/api/v1/users`、`/api/v1/orders`\n- HTTP 方法语义:GET(查询)/ POST(创建)/ PUT(全量更新)/ PATCH(部分更新)/ DELETE(删除)\n- 路径嵌套表示从属关系:`/users/:id/orders`(用户的订单)\n- 查询参数用于筛选/分页/排序:`?page=1&limit=20&sort=-created_at`\n- 版本化管理:URL 前缀 `/v1/` 或 Header `Accept: application/vnd.api.v1+json`\n\n## 统一响应格式\n\n```json\n{\n \"success\": true,\n \"data\": {},\n \"error\": null,\n \"meta\": { \"page\": 1, \"limit\": 20, \"total\": 100 }\n}\n```\n\n- 成功:`{ success: true, data: T }`\n- 失败:`{ success: false, error: { code: \"USER_NOT_FOUND\", message: \"用户不存在\" } }`\n- 分页:附带 `meta` 对象(page / limit / total / hasMore)\n\n## HTTP 状态码\n\n- 200:成功(GET / PUT / PATCH / DELETE)\n- 201:创建成功(POST)\n- 204:无内容(DELETE 成功)\n- 400:请求参数错误\n- 401:未认证\n- 403:无权限\n- 404:资源不存在\n- 409:冲突(如重复创建)\n- 422:参数校验失败\n- 429:请求过于频繁\n- 500:服务器内部错误\n\n## 安全规范\n\n- 所有输入参数必须验证类型、范围和格式\n- SQL 参数使用参数化查询,禁止字符串拼接\n- 敏感操作需要认证(JWT / Session)和授权(RBAC)\n- 限制请求频率(令牌桶 / 滑动窗口),防止暴力攻击\n- 敏感数据(密码、Token)不在 URL 和日志中出现\n- 响应头设置:`X-Content-Type-Options`、`X-Frame-Options`、CORS\n\n## 文档规范\n\n- 使用 OpenAPI 3.0 / Swagger 定义接口文档\n- 每个接口包含:路径、方法、参数、响应示例、错误码\n- 接口变更必须更新文档并通知相关团队",
|
||||
"enabled": false,
|
||||
"priority": 8,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.163Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.163Z",
|
||||
"tags": [
|
||||
"api",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5a826e9b-4363-4656-9d1c-3caac5d110ca",
|
||||
"name": "性能优化指南",
|
||||
"category": "skill",
|
||||
"content": "## 前端渲染优化\n- 避免不必要的重渲染:`memo` / `useMemo` / `computed` 缓存计算结果\n- 大列表使用虚拟滚动(`@tanstack/react-virtual` / `vue-virtual-scroller`)\n- 使用 `requestAnimationFrame` 处理动画,避免强制同步布局\n- 减少 DOM 操作:批量更新、使用 DocumentFragment\n- CSS 动画优先 `transform` / `opacity`(GPU 加速),避免触发 reflow\n\n## 资源加载\n- 图片:懒加载 + WebP/AVIF 格式 + 响应式 `srcset` + CDN\n- 字体:`font-display: swap` + 预加载关键字体\n- 代码分割:路由级懒加载,`dynamic import()` 拆分大模块\n- 预加载关键资源:`<link rel=\"preload\">` / `<link rel=\"prefetch\">`\n- 压缩:Gzip / Brotli 压缩静态资源\n\n## 网络优化\n- 合并请求:GraphQL / 批量 API,减少 HTTP 往返\n- 缓存策略:HTTP Cache(ETag / Cache-Control)+ SWR(stale-while-revalidate)\n- 避免瀑布式请求:并行请求 `Promise.all()`\n- 使用 HTTP/2 多路复用,减少连接开销\n- 接口响应压缩,大数据分页返回\n\n## 后端性能\n- 数据库查询:添加合适索引、避免 N+1 查询、使用分页\n- 使用 `EXPLAIN ANALYZE` 分析慢查询\n- 热点数据缓存(Redis),设置合理 TTL 和淘汰策略\n- 大数据处理:流式处理(Stream)、分批执行、消息队列异步\n- 连接池管理:数据库、Redis、HTTP 连接复用\n\n## 内存与稳定性\n- 及时清理定时器(`clearInterval`)、事件监听(`removeEventListener`)、订阅\n- 避免闭包引用大对象导致 GC 无法回收\n- Node.js:监控 `process.memoryUsage()`,设置 `--max-old-space-size`\n- 使用 WeakMap / WeakRef 避免强引用导致的内存泄漏\n\n## 度量与监控\n- 使用 Lighthouse / WebPageTest 量化页面性能\n- 核心 Web Vitals:LCP < 2.5s / FID < 100ms / CLS < 0.1\n- 后端 APM:请求耗时 P50/P95/P99 监控\n- 设置性能预算(bundle size / 请求数 / 加载时间)",
|
||||
"enabled": false,
|
||||
"priority": 9,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.163Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.163Z",
|
||||
"tags": [
|
||||
"performance",
|
||||
"optimization"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c5692460-85ad-403c-ad44-b45b636667b0",
|
||||
"name": "安全编码规范",
|
||||
"category": "skill",
|
||||
"content": "## 输入验证\n- 所有用户输入必须在服务端校验类型、长度、范围和格式\n- 使用白名单校验(允许的值),而非黑名单(禁止的值)\n- 文件上传校验:文件类型(MIME + 扩展名)、大小限制、文件内容扫描\n- URL 参数和请求体分别校验,不信任任何客户端数据\n- 使用校验库(zod / joi / class-validator)统一校验逻辑\n\n## 注入防护\n- **XSS**:输出到 HTML 时转义特殊字符,使用 CSP 策略(`Content-Security-Policy`)\n- **SQL 注入**:只使用参数化查询 / ORM,禁止字符串拼接 SQL\n- **命令注入**:禁止 `exec()` 拼接用户输入,使用 `execFile()` + 参数数组\n- **SSRF**:校验请求目标 URL,禁止访问内网地址(127.0.0.1 / 10.* / 172.16.*)\n- **路径遍历**:使用 `path.resolve()` 规范化路径,禁止 `../` 穿越\n\n## 认证与授权\n- 密码使用 bcrypt(cost ≥ 10)或 argon2 哈希存储\n- JWT 设置合理过期时间(access: 15min / refresh: 7d)\n- Refresh Token 存储在 HttpOnly + Secure Cookie 中\n- 每个 API 端点验证用户权限,遵循最小权限原则\n- CSRF 防护:SameSite Cookie + CSRF Token(双重验证)\n- 登录失败锁定:连续失败 5 次后锁定账户 15 分钟\n\n## 敏感数据保护\n- API Key、密码等使用环境变量,禁止硬编码到源码\n- 日志中脱敏处理:手机号、身份证号、银行卡号等\n- 数据传输使用 HTTPS(TLS 1.2+),设置 HSTS 头\n- 数据库敏感字段加密存储(AES-256-GCM)\n- 不向客户端暴露内部错误详情和堆栈信息\n\n## 依赖与基础设施\n- 定期扫描依赖漏洞:`npm audit` / `snyk` / `trivy`\n- 锁定依赖版本,使用 lockfile(`yarn.lock` / `package-lock.json`)\n- Docker 镜像使用最小基础镜像,非 root 用户运行\n- 设置安全响应头:`X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`",
|
||||
"enabled": false,
|
||||
"priority": 10,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.164Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.164Z",
|
||||
"tags": [
|
||||
"security",
|
||||
"audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "da104626-359d-4de2-9bab-fd5e019ef728",
|
||||
"name": "代码重构技巧",
|
||||
"category": "skill",
|
||||
"content": "## 何时重构\n- 添加新功能前:先整理相关代码,再添加功能\n- 修复 Bug 时:顺手改善周围代码质量\n- Code Review 中:发现的坏味道及时标记和修复\n- 性能优化前:先让代码结构清晰,再做性能调优\n\n## 代码坏味道识别\n- **过长函数**:超过 20 行考虑提取为独立函数\n- **重复代码**:相同逻辑出现 3 次以上必须抽取为公共函数\n- **过深嵌套**:使用卫语句(早返回)减少嵌套层级(≤ 3 层)\n- **魔法数字**:使用命名常量(`MAX_RETRY = 3`)提高可读性\n- **过大类/文件**:单文件超过 300 行考虑拆分职责\n- **过多参数**:函数参数超过 3 个时使用对象参数\n- **特征依恋**:方法频繁访问其他类的数据,考虑搬移到那个类\n- **数据泥团**:多个参数总是一起出现,抽取为数据类\n\n## 重构手法\n- **提取方法**:将代码块提取为有意义命名的函数\n- **内联变量**:只用一次的中间变量可以内联\n- **引入解释变量**:复杂表达式赋值给有含义的变量名\n- **以多态替代条件**:switch/if-else 链改为策略模式\n- **提取接口**:依赖具体类时,抽取接口实现依赖倒置\n- **移动方法/字段**:将方法移到更合适的类中\n- **封装集合**:不暴露可变集合,提供只读视图\n\n## 重构安全网\n- 重构前先确保有测试覆盖(至少关键路径)\n- 小步修改:每次只做一个重构操作\n- 每步验证:编译通过 + 测试通过后再继续\n- 及时提交:每个完整的重构步骤单独 commit\n- 使用 IDE 重构工具(重命名、提取方法等),避免手动修改",
|
||||
"enabled": false,
|
||||
"priority": 11,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.165Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.165Z",
|
||||
"tags": [
|
||||
"refactoring",
|
||||
"clean-code"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "774b7f08-12bc-4833-af73-162b3fdce256",
|
||||
"name": "数据库设计规范",
|
||||
"category": "skill",
|
||||
"content": "## 命名规范\n- 表名:snake_case 复数形式(如 `user_orders`)\n- 字段名:snake_case(如 `created_at`)\n- 外键字段:`<关联表单数>_id`(如 `user_id`)\n- 索引名:`idx_<表名>_<字段名>`(如 `idx_orders_user_id`)\n- 唯一约束:`uk_<表名>_<字段名>`\n\n## 表设计必备字段\n- `id`:主键,自增 BIGINT 或 UUID v7(时间有序)\n- `created_at`:创建时间,`TIMESTAMP DEFAULT CURRENT_TIMESTAMP`\n- `updated_at`:更新时间,`TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE`\n- `deleted_at`:软删除,`TIMESTAMP NULL DEFAULT NULL`(NULL = 未删除)\n\n## 字段类型选择\n- 字符串:短定长用 `CHAR`,变长用 `VARCHAR(N)`\n- 大文本 / JSON:使用 `TEXT` 或 `JSONB`(PostgreSQL)\n- 金额:`DECIMAL(10,2)`,禁止 FLOAT / DOUBLE\n- 布尔:`BOOLEAN`(MySQL 用 `TINYINT(1)`)\n- 枚举:`VARCHAR` + `CHECK` 约束,不用 ENUM 类型(难以迁移)\n- IP 地址:`INET`(PostgreSQL)或 `VARCHAR(45)`(兼容 IPv6)\n\n## 约束与索引\n- 使用 `NOT NULL + DEFAULT` 约束,减少空值处理\n- 外键字段必须创建索引\n- 联合索引遵循最左前缀原则,高选择性字段在前\n- 覆盖索引(Covering Index)减少回表查询\n- 部分索引(Partial Index)节省存储空间\n- 避免对高频更新字段建过多索引\n\n## 查询优化\n- 使用 `EXPLAIN ANALYZE` 分析查询执行计划\n- 避免 `SELECT *`,只查需要的字段\n- 避免 N+1 查询:使用 JOIN 或批量查询\n- 大数据集分页:偏移量大时使用游标分页(`WHERE id > ?`)\n- 统计查询使用物化视图或预计算表\n\n## 迁移管理\n- 使用版本化脚本(Flyway / Prisma Migrate / Liquibase)\n- 每次迁移包含 `up`(执行)和 `down`(回滚)\n- 大表 DDL 使用 `pt-online-schema-change` 或分批执行\n- 数据迁移与结构迁移分开执行",
|
||||
"enabled": false,
|
||||
"priority": 12,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.166Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.166Z",
|
||||
"tags": [
|
||||
"database",
|
||||
"sql",
|
||||
"design"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7424e901-6cc6-4682-9f11-169e8576c54d",
|
||||
"name": "Git 协作规范",
|
||||
"category": "skill",
|
||||
"content": "## 分支策略(Git Flow)\n\n```\nmain ─────────────────────────────── 生产环境(只接受 merge)\n └── release/v1.2.0 ──────────── 预发布(bug fix → merge 回 main + develop)\ndevelop ──────────────────────────── 开发主线\n ├── feature/user-profile ────── 功能开发\n ├── fix/login-bug ───────────── Bug 修复\n └── refactor/auth-module ────── 重构\n```\n\n- `main`:生产环境代码,只接受 PR 合并\n- `develop`:开发主线,功能分支从此创建\n- `feature/*`:功能开发分支\n- `fix/*`:Bug 修复分支\n- `release/*`:预发布分支,只修 bug 不加功能\n- `hotfix/*`:紧急修复,从 main 创建,修复后合并回 main + develop\n\n## 提交规范(Conventional Commits)\n\n```\n<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n```\n\n- **type**:`feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `chore` / `ci`\n- **scope**:影响范围(如 `auth`、`ui`、`api`)\n- **subject**:简短描述(中文,不超过 50 字)\n- **body**:详细说明变更原因和内容\n- **footer**:关联 Issue(`Closes #123`)或 Breaking Change\n- 每次提交只包含一个逻辑变更,保持原子性\n\n## PR 规范\n\n- 标题:遵循 Conventional Commits 格式\n- 描述包含:变更内容、影响范围、测试方法、截图(UI 变更时)\n- 关联 Issue / Jira 编号\n- 合并前必须通过 CI 检查和至少一人 Code Review\n- 合并策略:feature → squash merge,release → merge commit\n\n## 工具集成\n\n- 提交前自动运行:lint-staged + husky(格式化 + 检查)\n- 提交信息校验:commitlint(确保遵循 Conventional Commits)\n- 自动生成 CHANGELOG:conventional-changelog / changesets\n- Tag 规范:`v{major}.{minor}.{patch}`,遵循 SemVer",
|
||||
"enabled": false,
|
||||
"priority": 13,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.167Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.167Z",
|
||||
"tags": [
|
||||
"git",
|
||||
"collaboration"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "45e82601-3b37-4ebd-9b61-f630e26eb438",
|
||||
"name": "论文查重与去AI化",
|
||||
"category": "skill",
|
||||
"content": "## 查重系统原理\n\n### 主流查重工具\n| 工具 | 适用场景 | 检测粒度 |\n|------|----------|----------|\n| 知网(CNKI) | 学位论文(国内高校指定) | 连续 13 字重复即标红 |\n| 万方 | 期刊投稿、初稿自查 | 连续 15 字相似判定 |\n| 维普 | 期刊投稿 | 语义级相似检测 |\n| Turnitin | 英文论文、SCI 投稿 | 短语级匹配 |\n| PaperPass | 初稿快速自查(便宜) | 句子级对比 |\n\n### 检测机制\n- **字符串匹配**:连续 N 个字与数据库重合即判定重复\n- **语义分析**:即使换词换序,语义相同仍可能标红\n- **跨语言检测**:部分系统支持中英互译检测\n- **图表 OCR**:新一代系统可识别图片中的文字\n\n## 降重实战技巧\n\n### 第一层:句式变换\n- 主动句 ↔ 被动句:「本文提出了一种方法」→「一种新的方法被提出」\n- 长句拆分:将复合句拆为 2-3 个简单句\n- 短句合并:将相关的短句整合为一个复杂句\n- 正说反说:「提高了效率」→「减少了不必要的时间消耗」\n\n### 第二层:语义改写\n- 用自己的话重新表述核心观点(读懂→合上→写出)\n- 替换为领域内的同义表达(不是简单换近义词)\n- 将定义性描述转化为功能性描述\n- 补充自己的理解和分析作为过渡\n\n### 第三层:结构重组\n- 调整论述顺序(时间顺序 → 逻辑顺序 → 重要性顺序)\n- 将文字描述转化为图表、公式、流程图\n- 将表格数据转化为文字分析\n- 增加原创性内容的比例(实验数据、案例分析)\n\n### 禁忌操作\n- ❌ 逐字替换同义词(\"因此\"→\"所以\"→\"故而\"),系统能识别\n- ❌ 在字词间插入空格或特殊字符\n- ❌ 使用图片替代文字来规避检测\n- ❌ 翻译外文文献直接使用(跨语言检测已普及)\n\n## AI 生成检测与规避\n\n### AI 文本特征(检测系统关注点)\n- **困惑度(Perplexity)过低**:AI 生成文本用词精准但缺乏\"意外性\"\n- **突发性(Burstiness)过低**:句子长度和复杂度过于均匀\n- **词汇多样性不足**:同一含义反复使用相同表达\n- **结构高度规律**:段落长度、句式结构过于一致\n- **缺乏个人痕迹**:没有口误、修辞偏好、思维跳跃\n\n### 去 AI 化策略\n\n#### 增加人类写作特征\n- 段落长短错落:有的段 3 行,有的段 8 行\n- 句式多样:陈述句为主,穿插反问、设问、感叹\n- 适当使用非常规表达(但保持学术性)\n- 加入个人研究经历的真实描述\n- 引用具体数据、日期、地点等细节\n\n#### 增加学术深度\n- 对引用观点进行批判性分析(\"该研究虽然...但未考虑...\")\n- 加入方法论的局限性讨论\n- 对比不同学者的观点并给出自己的判断\n- 使用领域专有术语和缩写(体现专业素养)\n- 引入具体案例佐证论点\n\n#### 文风自然化\n- 避免每段都以\"首先/其次/最后\"开头\n- 减少\"值得注意的是\"、\"不可忽视的是\"等 AI 套话\n- 不同章节使用不同的论述风格(叙述/论证/描述/说明)\n- 保持与自己之前作品一致的写作风格\n- 适当使用学术领域认可的口语化表达\n\n## 文献验证流程\n\n### 必须执行的验证步骤\n1. 在 Google Scholar / 知网 / Web of Science 中搜索文献标题\n2. 核对作者姓名(全名)、发表年份\n3. 核对期刊名称 / 出版社 / 会议名称\n4. 核对卷号、期号、页码(或 DOI)\n5. 确认该期刊/会议是正规学术出版物(非掠夺性期刊)\n\n### 引用质量要求\n- 优先引用高影响因子期刊和顶级会议论文\n- 近 5 年文献占比 ≥ 50%\n- 经典文献(>10 年)仅用于奠基性理论\n- 避免过度引用同一作者或同一课题组\n- 自引比例控制在 10% 以内",
|
||||
"enabled": false,
|
||||
"priority": 14,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.167Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.167Z",
|
||||
"tags": [
|
||||
"paper",
|
||||
"plagiarism",
|
||||
"deai",
|
||||
"academic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ce88ac76-d635-40b9-b12e-854cba55f105",
|
||||
"name": "Git 提交工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 提交前检查\n\n1. **查看变更**\n - 运行 `git diff --staged` 审查即将提交的代码\n - 确认没有遗留 `console.log` / `debugger` / `TODO` 调试代码\n - 确认没有意外包含的文件(如 .env、node_modules)\n\n2. **代码质量**\n - 运行 `yarn lint` 检查代码风格\n - 运行 `yarn typecheck` 确认类型安全\n - 运行 `yarn format` 格式化代码\n\n3. **测试**\n - 运行相关单元测试确保通过\n - 手动验证变更功能正常\n\n## 提交信息规范\n\n```\n<type>(<scope>): <subject>\n```\n\n- **type**:`feat`(功能)/ `fix`(修复)/ `docs`(文档)/ `refactor`(重构)/ `chore`(杂务)/ `perf`(性能)/ `ci`(CI)\n- **scope**:可选,影响模块名\n- **subject**:中文简述,不超过 50 字\n- 示例:`feat(auth): 添加手机号验证码登录`\n\n## 提交原则\n\n- 每次提交只包含**一个逻辑变更**\n- 提交后代码必须能编译通过\n- 大功能拆分为多个小 commit,每个可独立理解\n- 不要把格式化和逻辑变更混在同一个 commit\n\n## 分支与 PR\n\n- 分支命名:`feature/xxx`、`fix/xxx`、`refactor/xxx`\n- PR 合并前必须通过 CI 检查和至少一人 Code Review\n- PR 描述填写:变更内容、测试方法、相关 Issue",
|
||||
"enabled": false,
|
||||
"priority": 15,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.168Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.168Z",
|
||||
"tags": [
|
||||
"git",
|
||||
"workflow",
|
||||
"commit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f78d97ab-20ce-448c-92dd-dfe92ec04c24",
|
||||
"name": "项目初始化工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 1. 项目脚手架\n\n- 使用官方 CLI 创建项目(Vite / Create Next App / NestJS CLI 等)\n- 确认 Node.js 版本要求,添加 `.nvmrc` 或 `.node-version`\n- 选择包管理器并锁定(`.yarnrc.yml` 或 `packageManager` 字段)\n\n## 2. 目录结构\n\n```\nsrc/\n components/ # UI 组件\n composables/ # 可复用逻辑(Vue)/ hooks/(React)\n services/ # API 调用和业务服务\n stores/ # 状态管理\n types/ # TypeScript 类型定义\n utils/ # 工具函数\n assets/ # 静态资源\n```\n\n## 3. 代码质量工具\n\n- **ESLint**:代码检查(`@typescript-eslint`)\n- **Prettier**:代码格式化(`.prettierrc`)\n- **EditorConfig**:编辑器统一配置(`.editorconfig`)\n- **husky + lint-staged**:Git 钩子自动检查\n- **commitlint**:提交信息规范校验\n\n## 4. TypeScript 配置\n\n- 启用严格模式:`\"strict\": true`\n- 路径别名:`\"@/*\": [\"./src/*\"]`\n- 目标版本对齐运行环境\n\n## 5. 环境与安全\n\n- 创建 `.env.example` 模板(不含真实密钥)\n- `.gitignore` 排除:`.env`、`node_modules/`、`dist/`、IDE 文件\n- 敏感信息使用环境变量,不硬编码\n\n## 6. 文档\n\n- **README.md**:项目介绍、技术栈、启动步骤、部署说明\n- **CONTRIBUTING.md**:贡献指南(分支策略、提交规范)\n- **CHANGELOG.md**:版本变更记录\n\n## 7. CI/CD\n\n- 配置 GitHub Actions / GitLab CI 基础流水线\n- 流水线步骤:安装依赖 → lint → typecheck → test → build\n- 配置缓存(node_modules / yarn cache)加速构建",
|
||||
"enabled": false,
|
||||
"priority": 16,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.169Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.169Z",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"init",
|
||||
"setup"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d9f4345b-e24b-413d-8f68-ea7049ce142c",
|
||||
"name": "发布部署工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 1. 发布准备\n\n- 从 `develop` 创建 `release/vX.Y.Z` 分支\n- 更新版本号(`package.json` / `pubspec.yaml` 等)\n- 运行完整测试套件确保通过:`yarn test`\n- 运行 lint 和类型检查:`yarn lint && yarn typecheck`\n- 确认无未提交的变更:`git status`\n\n## 2. 变更日志\n\n- 生成 CHANGELOG:`yarn changelog` 或手动整理\n- 分类变更:新功能 / Bug 修复 / 破坏性变更 / 性能优化\n- 提交 CHANGELOG 和版本号变更\n\n## 3. 构建与测试\n\n- 构建生产包:`yarn build`\n- 验证构建产物大小(对比上次发布,排查异常增长)\n- 在 staging 环境部署并验证核心功能\n- 回归测试:确认已知功能不受影响\n\n## 4. 发布\n\n- 合并 `release/vX.Y.Z` → `main`(merge commit)\n- 创建 Git Tag:`git tag -a vX.Y.Z -m \"Release vX.Y.Z\"`\n- 推送 Tag:`git push origin vX.Y.Z`\n- 合并 `release/vX.Y.Z` → `develop`(同步变更)\n- 删除 release 分支\n\n## 5. 部署生产\n\n- 部署到生产环境(CI/CD 自动触发或手动确认)\n- 健康检查:确认服务正常启动\n- 冒烟测试:验证核心 API 和页面可访问\n- 监控:观察错误率、响应时间、CPU/内存 5-10 分钟\n\n## 6. 收尾\n\n- 通知团队发布完成(Slack / 飞书 / 邮件)\n- 更新项目管理工具中的版本状态\n- 如有问题立即执行回滚:`git revert` 或重新部署上一版本\n- 在 GitHub / GitLab 创建 Release,附带 CHANGELOG",
|
||||
"enabled": false,
|
||||
"priority": 17,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.170Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.170Z",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"deploy",
|
||||
"release"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "992e2ba2-5cd5-4854-8b43-cff2f1d27daf",
|
||||
"name": "代码审查工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 1. 准备阶段\n\n- 确认 PR 描述完整:变更内容、影响范围、测试方法、截图(UI 变更)\n- 关联 Issue / Jira 编号\n- 拉取分支到本地,确保能编译通过:`yarn && yarn build`\n- 了解变更背景和业务需求\n\n## 2. 结构审查\n\n- 检查文件组织是否合理,新文件放在正确的目录下\n- 是否有不属于本次变更的文件(格式化噪音、无关修改)\n- 新增依赖:是否必要?版本是否合适?是否有更轻量替代?\n- 新增依赖的 license 是否兼容项目\n\n## 3. 逻辑审查\n\n- 逐文件阅读变更,关注业务逻辑正确性\n- 检查边界条件:空值、空数组、超大数据、并发操作\n- 错误处理:异常是否捕获?错误信息是否有用?是否有空 catch?\n- 异步逻辑:是否有竞态条件?Promise 是否处理 rejection?\n- 数据流:状态变更是否一致?是否有内存泄漏?\n\n## 4. 质量审查\n\n- 命名规范:变量/函数/类是否语义清晰\n- 代码风格一致性:与项目现有代码保持统一\n- 重复代码:是否有可提取的公共逻辑\n- 函数长度 ≤ 50 行,嵌套 ≤ 3 层\n- 注释说明 why 而非 what,中文注释\n\n## 5. 安全审查\n\n- 用户输入是否在服务端校验\n- 敏感数据是否脱敏(日志、响应)\n- SQL 是否使用参数化查询\n- 文件操作是否防止路径遍历\n- 认证授权是否覆盖新增端点\n\n## 6. 测试审查\n\n- 关键逻辑是否有测试覆盖\n- 测试是否覆盖正常路径和异常路径\n- 测试是否独立,不依赖执行顺序\n\n## 7. 反馈\n\n- 问题分级:🔴 必须修复 / 🟡 建议改进 / 🟢 可选优化\n- 每条包含:位置 → 问题描述 → 修复建议(附代码示例)\n- 肯定好的代码:好的设计和实现也值得认可\n- 总结:必须修复 N 项 / 建议改进 N 项 / 可选 N 项",
|
||||
"enabled": false,
|
||||
"priority": 18,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.171Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.171Z",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"review",
|
||||
"quality"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8a2d005a-618a-4828-9b20-9297022ca53f",
|
||||
"name": "Bug 修复工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 1. 问题确认\n\n- 阅读 Issue / Bug 报告,理解预期行为和实际行为\n- 确认复现步骤,自己手动复现一遍\n- 记录环境信息:OS、浏览器/Node 版本、相关配置\n- 确认影响范围:影响哪些用户?频率如何?是否阻塞?\n- 评估优先级:P0(立即修复)/ P1(当天)/ P2(本周)\n\n## 2. 问题定位\n\n- 阅读错误日志和堆栈信息,定位出错文件和行号\n- 使用断点调试(IDE debugger / `--inspect`)缩小范围\n- 使用二分法排查:注释代码 / `git bisect` 找到引入 commit\n- 区分**根本原因**和**表面症状**,修复根因\n- 检查最近的变更记录:`git log --oneline -20`\n\n## 3. 修复实施\n\n- 从 `develop` 创建 `fix/<问题描述>` 分支\n- **最小化修改**:只改必要的代码,不顺手重构\n- 优先修上游原因,避免下游打补丁\n- 添加防御性代码防止同类问题复发\n- 添加中文注释说明修复原因(why)\n\n## 4. 验证测试\n\n- 确认原始 Bug 已修复(按复现步骤验证)\n- 回归测试:确认修复没有引入新问题\n- 边界条件测试:空值、大数据、并发场景\n- 如可行,添加针对该 Bug 的回归测试用例\n\n## 5. 提交与发布\n\n- 提交信息:`fix(<scope>): 描述修复内容 (Closes #issue编号)`\n- 创建 PR,描述:问题原因 → 修复方案 → 验证方法\n- 请求 Code Review\n- 合并后验证线上环境\n\n## 6. 收尾\n\n- 更新 Issue 状态为已修复\n- 通知 Bug 报告者验证\n- 更新相关文档或注释\n- 如果是常见问题,补充到 FAQ 或排查指南",
|
||||
"enabled": false,
|
||||
"priority": 19,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.172Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.172Z",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"bugfix",
|
||||
"debug"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "148df00b-3a6c-4e72-982f-064343685d36",
|
||||
"name": "功能开发工作流",
|
||||
"category": "workflow",
|
||||
"content": "## 1. 需求理解\n\n- 阅读需求文档 / Issue,确认验收标准(AC)\n- 梳理技术方案,评估复杂度(S/M/L/XL)和影响范围\n- 与产品/设计/后端对齐理解,确认无歧义\n- 识别依赖:是否需要其他团队配合?是否有技术前置条件?\n- 确认 UI 设计稿 / 接口文档是否就绪\n\n## 2. 技术设计\n\n- 确定数据结构和接口定义(前后端类型契约)\n- 拆分子任务,估算工时,更新项目管理工具\n- 考虑兼容性(向后兼容)和扩展性\n- 数据库变更:编写 migration 脚本\n- 评估风险点,准备降级 / 回滚方案\n\n## 3. 开发实施\n\n- 从 `develop` 创建 `feature/<功能描述>` 分支\n- 小步提交,每个 commit 可独立编译运行\n- 关键逻辑添加中文注释(说明 why)\n- 添加必要的日志(关键操作、错误路径)\n- 遵循项目代码规范和架构约定\n- 前端:组件拆分 → 数据联调 → 样式完善\n- 后端:接口定义 → 业务逻辑 → 数据层 → 联调\n\n## 4. 自测验证\n\n- 覆盖正常流程和异常流程\n- 边界值测试:空值、超长输入、大数据量\n- 兼容性测试:不同浏览器 / 设备 / 分辨率\n- 权限测试:不同角色是否正确限制\n- 性能验证:大数据量下是否卡顿\n- UI 对照设计稿逐项检查\n\n## 5. 提交审查\n\n- 提交信息:`feat(<scope>): 功能描述`\n- 创建 PR,描述:功能说明 → 实现方案 → 测试方法 → 截图\n- 关联 Issue / Jira 编号\n- 请求 Code Review(至少一人)\n- 根据 Review 意见修改,re-request review\n\n## 6. 合并上线\n\n- Review 通过后 squash merge 到 `develop`\n- 清理 feature 分支\n- 在 develop 环境验证功能完整性\n- 更新 Issue 状态,通知产品验收",
|
||||
"enabled": false,
|
||||
"priority": 20,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.172Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.172Z",
|
||||
"tags": [
|
||||
"workflow",
|
||||
"feature",
|
||||
"development"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2449c998-51be-4d34-99e8-85a7b70521db",
|
||||
"name": "Vue 3 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 组件规范\n- 必须使用 Composition API + `<script setup>` 语法\n- 组件命名使用 PascalCase(如 `UserProfile.vue`)\n- 单文件组件顺序:`<script setup>` → `<template>` → `<style scoped>`\n- 单个组件文件不超过 200 行,超过则拆分子组件\n- 组件目录结构:`ComponentName/index.vue` + `ComponentName/types.ts`\n\n## Props 与事件\n- Props 必须使用 `defineProps<T>()` 定义 TypeScript 类型\n- 使用 `withDefaults()` 为 Props 设置默认值\n- 事件使用 `defineEmits<{ (e: 'update', value: string): void }>()` 定义\n- Props 命名使用 camelCase,模板中自动转为 kebab-case\n- 避免修改 Props,使用 `emit` 通知父组件变更\n\n## 响应式\n- 优先使用 `ref()` 而非 `reactive()`(避免解构丢失响应性)\n- `computed` 用于派生状态,不要在 `computed` 中产生副作用\n- `watch` 指定具体的响应式源,避免 `watch(() => state)` 监听整个对象\n- 使用 `watchEffect` 处理副作用自动追踪依赖\n- 大型列表使用 `shallowRef` 减少响应式开销\n\n## 模板规范\n- 模板中不要有复杂逻辑,抽取为 `computed` 或方法\n- 列表渲染必须提供唯一 `key`,禁止使用 index\n- 条件渲染优先 `v-if`,频繁切换使用 `v-show`\n- 事件处理器使用 `@click=\"handleClick\"`,不在模板中写逻辑\n\n## 可复用逻辑\n- 可复用逻辑抽取为 composables(`use*.ts`)\n- composable 返回值使用 `ref` 而非 `reactive`\n- composable 内部清理副作用使用 `onUnmounted`\n\n## 样式\n- 使用 `<style scoped>` 或 CSS Modules 避免样式污染\n- 优先使用 Tailwind CSS 原子类,减少自定义 CSS\n- 主题变量使用 CSS 自定义属性(`var(--color-primary)`)",
|
||||
"enabled": false,
|
||||
"priority": 21,
|
||||
"condition": {
|
||||
"filePattern": "**/*.vue"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.173Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.173Z",
|
||||
"tags": [
|
||||
"vue",
|
||||
"frontend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "57b280b3-29c1-4b41-8129-8fd3462aad7a",
|
||||
"name": "TypeScript 严格模式",
|
||||
"category": "rule",
|
||||
"content": "## 类型安全\n- 禁止使用 `any`,使用 `unknown` 替代不确定类型\n- 所有函数必须声明参数类型和返回类型\n- 使用 `interface` 定义对象结构,`type` 用于联合类型和工具类型\n- 泛型参数使用有意义的名称(如 `TData`、`TResponse`)\n- 使用 `as const` 断言确保字面量类型推断\n- 避免类型断言 `as`,优先使用类型守卫(type guard)\n\n## 语法规范\n- 使用可选链 `?.` 和空值合并 `??` 替代 `&&` 和 `||`\n- 优先使用 `const enum` 或字面量联合类型替代普通 enum\n- 使用 `readonly` 标记不应被修改的属性和数组\n- 解构赋值时添加默认值:`const { name = '默认' } = options`\n- 异步函数统一使用 `async/await`,禁止 `.then()` 链\n\n## 错误处理\n- 使用自定义错误类继承 `Error`,携带 `code` 字段\n- try/catch 中指定具体错误类型,禁止空 catch\n- Promise 必须处理 rejection(`.catch()` 或 try/catch)\n\n## 命名约定\n- 变量/函数:`camelCase`\n- 类/接口/类型:`PascalCase`\n- 常量:`UPPER_SNAKE_CASE`\n- 私有属性:前缀 `_`(如 `_internalState`)\n- 布尔变量:`is/has/should` 前缀(如 `isLoading`)",
|
||||
"enabled": false,
|
||||
"priority": 22,
|
||||
"condition": {
|
||||
"filePattern": "**/*.ts"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.174Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.174Z",
|
||||
"tags": [
|
||||
"typescript"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bc40d9c1-62aa-4142-9a2c-7dae23058fb8",
|
||||
"name": "Flutter 开发规范",
|
||||
"category": "rule",
|
||||
"content": "## Widget 规范\n- 使用 StatelessWidget 优先,仅在需要内部可变状态时用 StatefulWidget\n- Widget 拆分遵循单一职责,避免超过 100 行的 `build` 方法\n- 使用 `const` 构造函数减少不必要的重建\n- Widget 命名使用 PascalCase,文件名使用 snake_case\n- 将大型 Widget 拆分为私有方法或独立 Widget 类\n\n## 状态管理\n- 优先级:Riverpod > BLoC > Provider > setState\n- 局部状态使用 `ValueNotifier` + `ValueListenableBuilder`\n- 全局/跨页面状态使用 Riverpod 的 `StateNotifierProvider`\n- 异步数据使用 `FutureProvider` / `StreamProvider`\n- 状态类使用 `freezed` 或 `equatable` 实现不可变和相等比较\n\n## 主题与资源\n- 颜色、字体、间距统一在 `ThemeData` 中定义\n- 使用 `Theme.of(context)` 获取主题值,不硬编码颜色\n- 支持亮色/暗色模式切换\n- 文字使用 `TextTheme` 样式,不直接设置 fontSize\n- 间距使用统一常量(如 `Spacing.sm = 8.0`)\n\n## 异步与网络\n- 异步操作使用 `async/await`,避免嵌套 `.then()`\n- 网络请求使用 Dio + 拦截器(日志、认证、错误处理)\n- 图片使用 `cached_network_image`,配置占位图和错误图\n- 接口返回统一解析为 `Result<T>` 类型(Success / Failure)\n\n## 路由与导航\n- 路由统一使用 GoRouter 或 auto_route 管理\n- 路由参数使用强类型,避免字符串传参\n- 深层链接(Deep Link)支持\n\n## 性能优化\n- 禁止在 `build` 方法内做耗时运算或 I/O\n- 长列表使用 `ListView.builder` / `SliverList`,避免一次性构建\n- 使用 `RepaintBoundary` 隔离频繁重绘的区域\n- 动画使用 `AnimatedWidget` / `AnimatedBuilder`,避免全局重建",
|
||||
"enabled": false,
|
||||
"priority": 23,
|
||||
"condition": {
|
||||
"filePattern": "**/*.dart"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.175Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.175Z",
|
||||
"tags": [
|
||||
"flutter",
|
||||
"dart",
|
||||
"mobile"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "02f8ef80-2a51-4900-95fa-bae92bee3049",
|
||||
"name": "C# 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 命名规范\n- 类/接口/方法/属性:PascalCase\n- 局部变量/参数:camelCase\n- 常量/静态只读:UPPER_SNAKE_CASE 或 PascalCase\n- 接口前缀 `I`(如 `IUserService`)\n- 异步方法后缀 `Async`(如 `GetUserAsync`)\n- 布尔属性/变量:`Is/Has/Can` 前缀\n\n## 类型与数据\n- 优先使用 `record` 定义不可变数据对象(DTO / Value Object)\n- 使用 nullable 引用类型(`#nullable enable`),公共 API 参数需 null 检查\n- 值类型使用 `struct`,引用类型使用 `class`\n- 集合返回 `IReadOnlyList<T>` / `IReadOnlyCollection<T>`,避免暴露可变集合\n- 使用 `required` 关键字标记必填属性(C# 11+)\n\n## 异步与资源\n- 异步方法返回 `Task` / `Task<T>` / `ValueTask<T>`\n- 使用 `using` 声明(非语句块)管理资源\n- 传递 `CancellationToken` 到所有异步链路\n- 避免 `async void`,仅用于事件处理器\n- I/O 密集操作使用 `ConfigureAwait(false)`(类库中)\n\n## 依赖注入\n- 优先构造函数注入,避免服务定位器模式\n- 注册生命周期:Transient(无状态)/ Scoped(请求级)/ Singleton(全局)\n- 使用 `IOptions<T>` 注入配置对象\n\n## LINQ 与集合\n- LINQ 查询优先方法链形式,避免嵌套查询表达式\n- 大数据集使用 `AsNoTracking()` 提升 EF Core 查询性能\n- 使用 `Any()` 替代 `Count() > 0` 判断非空\n\n## 异常处理\n- 只 catch 能处理的异常,记录日志后重新抛出(`throw;` 保留堆栈)\n- 使用 `ExceptionFilter` / 中间件统一处理 API 异常\n- 自定义异常继承 `Exception`,携带错误码\n\n## 测试\n- 单元测试使用 xUnit + Moq / NSubstitute\n- 集成测试使用 `WebApplicationFactory<T>`\n- 遵循 Arrange-Act-Assert 模式",
|
||||
"enabled": false,
|
||||
"priority": 24,
|
||||
"condition": {
|
||||
"filePattern": "**/*.cs"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.175Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.175Z",
|
||||
"tags": [
|
||||
"csharp",
|
||||
"dotnet",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7119e1a3-2baa-4a28-8549-58d08e26665c",
|
||||
"name": "Java 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 命名规范\n- 类/接口:PascalCase(如 `UserService`)\n- 方法/变量:camelCase(如 `getUserById`)\n- 常量:UPPER_SNAKE_CASE(如 `MAX_RETRY_COUNT`)\n- 包名:全小写,反向域名(如 `com.example.user`)\n- 布尔方法:`is/has/can` 前缀(如 `isValid()`)\n\n## 现代 Java 特性(17+)\n- 使用 `record` 定义不可变数据对象(DTO)\n- 使用 `sealed class` 限制继承层级\n- 使用 `pattern matching`(`instanceof` + 类型绑定)\n- 使用 `text blocks`(三引号)书写多行字符串\n- Switch 表达式使用箭头语法 + yield\n\n## 集合与 Stream\n- Stream API 用于集合操作,避免传统 for 循环处理集合\n- 使用 `Optional<T>` 替代 null 返回值,禁止 `Optional` 作为方法参数\n- 不可变集合:`List.of()` / `Map.of()` / `Set.of()`\n- 避免在 Stream 中产生副作用(如修改外部变量)\n\n## Spring Boot 架构\n- Controller 只处理 HTTP 映射和参数校验(`@Valid`)\n- 业务逻辑下沉到 Service 层\n- 数据访问使用 JPA Repository 或 MyBatis Mapper,禁止 SQL 拼接\n- 使用 `@Transactional` 管理事务,注意传播行为和回滚规则\n- 配置使用 `@ConfigurationProperties` 绑定,不硬编码\n\n## 异常与日志\n- 异常统一使用 `@ControllerAdvice` + `@ExceptionHandler` 处理\n- 返回标准 `ApiResponse<T>` 格式(code + message + data)\n- 日志使用 SLF4J + Logback,禁止 `System.out.println`\n- 日志级别:ERROR(系统错误)/ WARN(业务异常)/ INFO(关键操作)/ DEBUG(调试)\n- 日志包含上下文信息:用户 ID、请求 ID、耗时\n\n## 测试\n- 单元测试:JUnit 5 + Mockito,覆盖率 > 80%\n- 集成测试:`@SpringBootTest` + `@TestContainers`\n- API 测试:`MockMvc` 或 RestAssured",
|
||||
"enabled": false,
|
||||
"priority": 25,
|
||||
"condition": {
|
||||
"filePattern": "**/*.java"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.176Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.176Z",
|
||||
"tags": [
|
||||
"java",
|
||||
"backend",
|
||||
"spring"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bd692604-cb4b-44c1-a807-b1a9a76a44b3",
|
||||
"name": "React 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 组件规范\n- 函数组件 + Hooks,禁止使用 Class 组件\n- 组件命名 PascalCase,文件名与组件名一致(如 `UserCard.tsx`)\n- Props 使用 TypeScript `interface` 定义,必须标注类型\n- 使用 `React.FC<Props>` 或直接函数签名声明组件\n- 单个组件文件不超过 200 行,超过则拆分\n\n## 状态管理\n- 局部状态:`useState`\n- 计算状态:`useMemo`\n- 跨组件共享:Context + `useContext` 或 Zustand / Jotai\n- 服务端数据:TanStack Query(React Query)管理请求缓存\n- 避免 prop drilling 超过 3 层,使用 Context 或状态库\n\n## Hooks 规范\n- 自定义 Hook 以 `use` 开头,封装可复用逻辑\n- `useEffect` 依赖数组必须完整,配合 ESLint exhaustive-deps 规则\n- `useCallback` 包裹传给子组件的回调函数\n- `useMemo` 缓存昂贵计算,但不要滥用(简单计算无需缓存)\n- cleanup 函数处理订阅、定时器、AbortController\n\n## 渲染优化\n- 使用 `React.memo()` 避免不必要的子组件重渲染\n- 列表渲染必须提供稳定的 key(业务 ID),禁止使用 index\n- 条件渲染优先使用 `&&` 或三元表达式,避免嵌套 if\n- 大列表使用虚拟滚动(`@tanstack/react-virtual`)\n- 代码分割:`React.lazy()` + `Suspense` 懒加载路由和大组件\n\n## 样式\n- 样式方案:Tailwind CSS 或 CSS Modules,避免内联样式\n- 使用 `clsx` / `cn()` 工具合并条件类名\n- 响应式设计使用 Tailwind 断点前缀(`md:`、`lg:`)\n\n## 错误处理\n- 使用 `ErrorBoundary` 捕获渲染错误,提供 fallback UI\n- 异步操作统一 try/catch,用户友好的错误提示",
|
||||
"enabled": false,
|
||||
"priority": 26,
|
||||
"condition": {
|
||||
"filePattern": "**/*.tsx"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.178Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.178Z",
|
||||
"tags": [
|
||||
"react",
|
||||
"frontend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1646313a-41c0-49fd-a90d-70afdaa0d587",
|
||||
"name": "Python 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 代码风格\n- 遵循 PEP 8 风格指南,使用 `black` + `isort` 格式化\n- 类名 PascalCase,函数/变量 snake_case,常量 UPPER_SNAKE_CASE\n- 模块级常量定义在文件顶部(导入之后)\n- 单行不超过 88 字符(black 默认)\n- 使用 `ruff` 作为 linter(替代 flake8 + pylint)\n\n## 类型标注\n- 使用 Type Hints 标注所有函数参数和返回值\n- 复杂类型使用 `typing` 模块(`Optional`、`Union`、`TypeAlias`)\n- Python 3.10+ 使用 `X | Y` 语法替代 `Union[X, Y]`\n- 使用 `mypy --strict` 进行静态类型检查\n- 泛型类使用 `TypeVar` 或 `Generic[T]`\n\n## 数据模型\n- 使用 `dataclass`(内部数据)或 `Pydantic BaseModel`(API 数据校验)\n- Pydantic 模型使用 `Field()` 添加校验规则和描述\n- 不可变数据使用 `frozen=True` 的 dataclass\n\n## 异步与并发\n- I/O 密集操作使用 `asyncio` + `async/await`\n- CPU 密集操作使用 `concurrent.futures.ProcessPoolExecutor`\n- 异步 HTTP 使用 `httpx` / `aiohttp`\n- 异步数据库使用 `SQLAlchemy 2.0` async 模式\n\n## 错误处理与资源\n- 使用 `with` 语句管理文件、连接等资源\n- 异常处理指定具体异常类型,禁止 bare `except:`\n- 自定义异常继承 `Exception`,携带错误码\n- 使用 `logging` 模块记录日志,禁止 `print()` 调试\n\n## 项目管理\n- 使用 `pyproject.toml` 管理项目配置和依赖\n- 虚拟环境:`venv` / `poetry` / `uv`\n- 依赖锁定:`poetry.lock` / `requirements.txt` 固定版本\n- 使用 `pytest` 编写测试,`pytest-cov` 检查覆盖率 > 80%\n- 使用 `pre-commit` 钩子自动检查代码质量",
|
||||
"enabled": false,
|
||||
"priority": 27,
|
||||
"condition": {
|
||||
"filePattern": "**/*.py"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.178Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.178Z",
|
||||
"tags": [
|
||||
"python",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "159bc16f-66d4-41ba-8dc4-55d1a858f7e7",
|
||||
"name": "Go 编码规范",
|
||||
"category": "rule",
|
||||
"content": "## 命名规范\n- 包名小写单词,不使用下划线或混合大小写\n- 导出标识符 PascalCase,内部标识符 camelCase\n- 接口命名:单方法接口用 `-er` 后缀(如 `Reader`、`Writer`)\n- 文件名 snake_case(如 `user_service.go`)\n- 测试文件 `*_test.go`,与被测文件同目录\n\n## 错误处理\n- 每个 `error` 必须检查,禁止 `_ = err` 忽略\n- 使用 `errors.Is()` / `errors.As()` 替代 `==` 比较错误\n- 使用 `fmt.Errorf(\"描述: %w\", err)` 包装错误(保留链路)\n- 自定义错误类型实现 `Error()` 接口\n- 在函数签名中,error 永远是最后一个返回值\n- 使用 sentinel error(包级变量)定义可预期的错误\n\n## 并发与 goroutine\n- goroutine 必须有退出机制,使用 `context.Context` 控制生命周期\n- channel 使用完毕必须 `close`,避免 goroutine 泄漏\n- 使用 `sync.WaitGroup` 等待一组 goroutine 完成\n- 使用 `sync.Mutex` / `sync.RWMutex` 保护共享数据\n- 优先使用 channel 通信,而非共享内存\n- 使用 `errgroup.Group` 管理一组可能出错的 goroutine\n\n## 接口设计\n- 接口小而精:通常 1-3 个方法,由使用方定义\n- 接受接口,返回结构体(依赖倒置)\n- 使用组合(embedding)而非继承\n\n## 资源管理\n- 使用 `defer` 确保资源释放(文件、锁、连接)\n- `defer` 按 LIFO 顺序执行,注意多个 defer 的顺序\n- HTTP Response Body 必须 `defer resp.Body.Close()`\n\n## 项目结构\n- 遵循 Standard Go Project Layout\n- `cmd/`(入口)、`internal/`(私有)、`pkg/`(公共库)\n- 结构体字段使用 `json` tag 标注序列化名称\n- 配置使用 `viper` 或环境变量,不硬编码\n\n## 工具链\n- 使用 `go vet` + `golangci-lint` 进行静态分析\n- 使用 `go test -race` 检测数据竞争\n- 使用 `pprof` 进行性能分析\n- 使用 `go mod tidy` 清理无用依赖",
|
||||
"enabled": false,
|
||||
"priority": 28,
|
||||
"condition": {
|
||||
"filePattern": "**/*.go"
|
||||
},
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.179Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.179Z",
|
||||
"tags": [
|
||||
"golang",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "56701799-513a-46ec-a9a4-9ccbdc085d6c",
|
||||
"name": "输出语言为中文",
|
||||
"category": "rule",
|
||||
"content": "## 语言要求\n\n<language_rules>\n- **全程使用中文**回答所有问题,包括思考过程、解释说明、代码注释\n- 变量名、函数名、类名等**标识符保持英文**(遵循编程命名规范)\n- 代码注释必须使用中文书写\n- Git 提交信息使用中文描述\n- 文档和 README 使用中文编写\n- 错误提示和用户提示信息使用中文\n</language_rules>\n\n## 格式规范\n\n<format_rules>\n- 中英文之间添加空格(如:使用 Vue 3 框架)\n- 数字与中文之间添加空格(如:共 3 个文件)\n- 专业术语首次出现时标注英文原文(如:组合式 API (Composition API))\n- 代码块中的输出示例使用中文\n- 表格、列表等结构化内容使用中文标题\n</format_rules>\n\n## 禁止行为\n\n- ❌ 禁止使用英文回答问题\n- ❌ 禁止在注释中混用英文(专业术语除外)\n- ❌ 禁止在解释说明中突然切换为英文",
|
||||
"enabled": false,
|
||||
"priority": 29,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.179Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.179Z",
|
||||
"tags": [
|
||||
"language",
|
||||
"chinese",
|
||||
"i18n"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7f2ac522-c738-4cc6-bb92-607775dc52ae",
|
||||
"name": "学术论文写作规范",
|
||||
"category": "rule",
|
||||
"content": "## 格式要求\n\n### 论文结构\n- 标题 → 摘要 → 关键词 → 引言 → 文献综述 → 研究方法 → 结果分析 → 讨论 → 结论 → 参考文献 → 附录\n- 每个章节之间逻辑衔接自然,使用过渡句连接上下文\n- 摘要 300-500 字,包含研究目的、方法、主要发现和结论\n- 关键词 3-5 个,涵盖研究核心概念\n\n### 排版规范\n- 正文:宋体/Times New Roman,小四号(12pt),1.5 倍行距\n- 标题层级:一级标题黑体三号 → 二级标题黑体四号 → 三级标题黑体小四号\n- 段落首行缩进 2 字符,段前段后 0.5 行\n- 页边距:上 2.54cm / 下 2.54cm / 左 3.17cm / 右 3.17cm\n- 页码从正文开始,居中显示\n- 图表统一编号(如图 1、表 2),标题居中,图标题在下、表标题在上\n- 公式居中,编号右对齐\n\n## 查重规范\n\n### 重复率要求\n- 本科毕业论文:总重复率 ≤ 30%(部分院校要求 ≤ 20%)\n- 硕士学位论文:总重复率 ≤ 15%\n- 博士学位论文:总重复率 ≤ 10%\n- 期刊投稿:总重复率 ≤ 15%(核心期刊 ≤ 10%)\n\n### 降重技巧\n- **语序调整**:主动句与被动句互换,调整句子成分顺序\n- **同义替换**:使用同义词、近义词替换,但不改变原意\n- **概括改写**:将具体描述概括为抽象表述,用自己的话重新组织\n- **拆分合并**:长句拆为短句,或将短句合并为复合句\n- **增加论述**:在引用观点后加入自己的分析、评价和延伸\n- ❌ 禁止简单替换个别字词(如\"因此\"→\"所以\"),查重系统能识别\n- ❌ 禁止使用翻译法(中→英→中),表述生硬且可能曲解原意\n\n## 去 AI 化规范\n\n### 必须遵守\n- ❌ 禁止出现典型 AI 生成痕迹:\n - \"首先...其次...最后...\"的机械排列\n - \"值得注意的是\"、\"需要指出的是\"等套话高频出现\n - \"综上所述\"在非结论部分反复使用\n - 每段开头都用\"在...方面\"、\"从...角度\"的模板句式\n - 过度使用\"本文\"、\"本研究\"作为主语\n- ✅ 使用自然、多样的表达方式:\n - 变换句式结构:陈述句、反问句、设问句交替使用\n - 融入个人研究视角和独立思考\n - 使用领域专业术语,体现学科素养\n - 段落长短错落有致,避免每段字数雷同\n - 适当使用口语化学术表达(如\"不难发现\"、\"换言之\")\n\n### AI 检测规避\n- 保持个人写作风格一致性(用词习惯、句式偏好)\n- 加入具体案例、数据、图表等实证内容(AI 难以伪造)\n- 融入对研究过程的真实描述(实验细节、调研经历)\n- 对他人观点进行批判性分析,而非简单罗列\n- 论文不同部分的文风应与内容匹配(引言偏叙述、方法偏客观、讨论偏思辨)\n\n## 文献引用规范\n\n### 文献真实性(最高优先级)\n- ⚠️ **所有引用的文献必须真实存在,可通过学术数据库检索到**\n- 引用前必须验证:作者、标题、期刊/出版社、年份、卷期页码均准确\n- 推荐验证渠道:Google Scholar、知网(CNKI)、万方、Web of Science、Scopus\n- ❌ 严禁编造虚假文献(捏造作者、杜撰期刊、伪造年份)\n- ❌ 严禁引用未读过的文献(二手引用需标注\"转引自\")\n- 如无法找到原始文献,宁可删除引用也不要凭记忆补全信息\n\n### 引用格式\n- 中文论文:GB/T 7714 格式(如:[1] 张三. 标题[J]. 期刊名, 2024, 1(2): 10-20.)\n- 英文论文:APA 7th / IEEE / MLA 格式(根据目标期刊要求选择)\n- 正文引用与参考文献列表一一对应,不多不少\n- 引用数量建议:本科 15-30 篇,硕士 40-80 篇,博士 80-150 篇\n- 近 5 年文献占比 ≥ 50%,体现研究前沿性\n- 中英文文献比例合理(理工科英文 ≥ 60%)\n\n## 上下文连贯性\n\n### 全文一致性\n- 研究问题、方法、结果、结论之间逻辑链条完整\n- 摘要中的结论必须与正文结论一致\n- 引言提出的问题必须在结论中回应\n- 文献综述的内容必须与研究方法选择相关\n- 术语全文统一:同一概念不要交替使用不同名称\n\n### 段落衔接\n- 每段有明确的主题句(通常在段首)\n- 段与段之间使用过渡句或过渡词连接\n- 常用过渡逻辑:因果(因此)、转折(然而)、递进(此外)、对比(相比之下)\n- 避免突然跳转话题,新观点需有铺垫\n- 每章末尾可加小结,呼应章首并过渡到下一章\n\n### 语言风格一致\n- 全文保持同一人称视角(推荐第三人称或\"本文\")\n- 时态一致:文献综述用过去时,研究方法用过去时,结论用现在时\n- 学术语体一致:不在严谨论述中突然出现口语化表达\n- 专业术语首次出现时给出定义或英文原文",
|
||||
"enabled": false,
|
||||
"priority": 30,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.180Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.180Z",
|
||||
"tags": [
|
||||
"paper",
|
||||
"academic",
|
||||
"writing"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "24533d75-cf0f-4ca5-9222-1d5aa7950bcb",
|
||||
"name": "代码审查规范",
|
||||
"category": "context",
|
||||
"content": "## 代码结构\n- 函数长度不超过 50 行,文件不超过 300 行\n- 嵌套不超过 3 层,使用早返回(guard clause)减少嵌套\n- 单一职责:每个函数/类只做一件事\n- 避免上帝类(God Class)和上帝函数\n\n## 命名规范\n- 变量/函数:camelCase(如 `getUserById`)\n- 类/接口/类型:PascalCase(如 `UserService`)\n- 常量:UPPER_SNAKE_CASE(如 `MAX_RETRY_COUNT`)\n- 布尔变量:`is/has/should/can` 前缀\n- 命名必须语义清晰,禁止单字母变量(循环计数器除外)\n\n## 代码质量\n- 避免魔法数字,使用命名常量\n- 不要吞掉异常(空 catch),至少记录日志\n- 不要注释掉代码,直接删除(Git 有历史记录)\n- 注释说明 why 而非 what,使用中文注释\n- 删除未使用的导入、变量和代码(dead code)\n\n## 错误处理\n- 异常必须处理:捕获后记录日志或向上抛出\n- 使用自定义错误类型,携带错误码\n- 异步操作必须有超时和错误处理\n- 用户可见的错误信息使用中文,友好且有指导性\n\n## 安全检查\n- 用户输入必须校验(类型、长度、范围、格式)\n- SQL 使用参数化查询,禁止拼接\n- 敏感数据不在日志中输出",
|
||||
"enabled": false,
|
||||
"priority": 31,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.181Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.181Z",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b8e13b7a-86b9-4762-96b8-055785d9956a",
|
||||
"name": "项目概述上下文",
|
||||
"category": "context",
|
||||
"content": "## 项目信息\n- **项目名称**:[填写项目名称]\n- **项目类型**:[Web 应用 / 桌面应用 / 移动应用 / 后端服务 / CLI 工具 / 浏览器扩展]\n- **主要语言**:[TypeScript / Python / Java / Go / C# / Dart / Rust]\n- **框架**:[Vue 3 / React / NestJS / Spring Boot / Flutter / Electron]\n- **包管理器**:[yarn / pnpm / npm / pip / gradle / cargo]\n- **Node.js 版本**:[18 LTS / 20 LTS / 22]\n\n## 目录结构\n```\nsrc/\n components/ # UI 组件\n composables/ # 可复用逻辑(Vue)/ hooks/(React)\n services/ # API 调用和业务服务\n stores/ # 状态管理(Pinia / Zustand)\n types/ # TypeScript 类型定义\n utils/ # 工具函数\n assets/ # 静态资源(图片、字体、样式)\n router/ # 路由配置\n i18n/ # 国际化文件\npublic/ # 公共静态文件\n```\n\n## 开发约定\n- **代码风格**:ESLint + Prettier,提交前自动格式化\n- **分支策略**:Git Flow(main / develop / feature / fix / release)\n- **提交规范**:Conventional Commits(feat / fix / docs / refactor)\n- **部署方式**:[Docker / Vercel / Nginx / PM2]\n- **CI/CD**:[GitHub Actions / GitLab CI / Jenkins]\n\n## 关键依赖\n| 依赖 | 版本 | 用途 |\n|------|------|------|\n| [框架名] | ^x.y.z | [用途] |\n| [UI 库] | ^x.y.z | [用途] |\n| [状态管理] | ^x.y.z | [用途] |\n\n## 环境变量\n| 变量名 | 说明 | 示例 |\n|--------|------|------|\n| `VITE_API_URL` | API 地址 | `http://localhost:3000` |\n| `DATABASE_URL` | 数据库连接 | `postgresql://...` |\n\n## 注意事项\n- [填写项目特殊约定、已知限制、技术债务等]",
|
||||
"enabled": false,
|
||||
"priority": 32,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.181Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.181Z",
|
||||
"tags": [
|
||||
"context",
|
||||
"project",
|
||||
"overview"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d4e81569-dff8-45d5-a4ac-20e3f9f11e9e",
|
||||
"name": "调试排错上下文",
|
||||
"category": "context",
|
||||
"content": "## 调试原则\n- 先复现问题,记录复现步骤和环境信息\n- 从错误日志和堆栈信息入手,定位出错位置\n- 使用二分法缩小问题范围(注释代码 / `git bisect`)\n- 区分根本原因和表面症状,修复根因\n- 一次只改一个变量,验证后再改下一个\n- 不要猜测,用数据和日志证明假设\n\n## 常见问题排查清单\n\n### 编译 / 构建错误\n- 类型定义缺失或不匹配\n- 导入路径错误(相对路径 / 别名配置)\n- 依赖版本冲突(`yarn why <package>`)\n- tsconfig 配置问题(`moduleResolution`、`paths`)\n\n### 运行时错误\n- 空值访问:`TypeError: Cannot read property of undefined`\n- 异步时序:Promise 未 await、竞态条件\n- 数组越界 / 对象属性不存在\n- 循环引用导致栈溢出\n- 类型不匹配:字符串 vs 数字、JSON 解析失败\n\n### 样式问题\n- 选择器优先级:Specificity 计算、`!important` 覆盖\n- z-index 层级混乱:建立统一的 z-index 层级体系\n- Flex / Grid 布局:`flex-shrink`、`overflow`、`min-width: 0`\n- 响应式断点:移动端 viewport、媒体查询顺序\n\n### 性能问题\n- 前端:N 次不必要的重渲染(React DevTools Profiler)\n- 后端:N+1 查询(ORM 日志)、慢查询(`EXPLAIN ANALYZE`)\n- 内存泄漏:事件监听未清理、闭包引用大对象\n- 网络:请求瀑布流、缺少缓存策略\n\n### 网络问题\n- CORS:`Access-Control-Allow-Origin` 配置\n- 请求格式:`Content-Type` 不匹配\n- 认证:Token 过期 / 缺失、Cookie SameSite 策略\n- 超时:设置合理的 timeout 值、重试机制\n\n## 调试工具\n\n| 场景 | 工具 | 用途 |\n|------|------|------|\n| 前端 | Chrome DevTools | Network / Console / Performance / Memory |\n| 前端 | React / Vue DevTools | 组件树、状态、渲染次数 |\n| Node.js | `--inspect` | 断点调试(配合 Chrome DevTools) |\n| Node.js | `clinic.js` | 性能分析(CPU / 内存 / 事件循环) |\n| 数据库 | `EXPLAIN ANALYZE` | 查询执行计划分析 |\n| 网络 | Postman / cURL | API 请求调试 |\n| 日志 | `console.time()` | 简单计时 |\n\n## 日志调试技巧\n- 使用结构化日志:`console.log({ userId, action, result })`\n- 在关键分支添加标记日志:`[DEBUG] 进入分支 A`\n- 调试完毕后清理所有调试日志(或使用 DEBUG 环境变量控制)",
|
||||
"enabled": false,
|
||||
"priority": 33,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.182Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.182Z",
|
||||
"tags": [
|
||||
"context",
|
||||
"debug",
|
||||
"troubleshoot"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "36c3f159-6722-4570-89bd-cb9645e6ec77",
|
||||
"name": "架构设计上下文",
|
||||
"category": "context",
|
||||
"content": "## SOLID 原则\n- **单一职责(SRP)**:每个模块/类只负责一件事\n- **开闭原则(OCP)**:对扩展开放,对修改关闭(策略模式、插件机制)\n- **里氏替换(LSP)**:子类可以替换父类而不破坏行为\n- **接口隔离(ISP)**:客户端不应被迫依赖不使用的接口\n- **依赖倒置(DIP)**:高层模块依赖抽象,不依赖具体实现\n\n## 分层架构\n\n```\n┌──────────────────┐\n│ 表现层 │ ← UI 组件、页面路由、用户交互\n│ (Presentation) │ Vue/React 组件、模板、样式\n├──────────────────┤\n│ 业务逻辑层 │ ← Service、Store、Composable/Hook\n│ (Business) │ 业务规则、数据转换、状态管理\n├──────────────────┤\n│ 数据访问层 │ ← Repository、API Client、ORM\n│ (Data Access) │ 接口调用、数据库操作、缓存\n├──────────────────┤\n│ 基础设施层 │ ← 数据库、缓存、消息队列、文件存储\n│ (Infrastructure)│ 第三方服务、系统配置\n└──────────────────┘\n```\n\n- 上层只依赖下层,禁止跨层调用和反向依赖\n- 每层通过接口(Interface)暴露能力,隐藏实现细节\n\n## 常用设计模式\n- **策略模式**:多种算法/行为可切换(如支付方式、认证方式)\n- **观察者模式**:事件驱动解耦(EventEmitter、Vue 的 watch)\n- **工厂模式**:根据条件创建不同实例\n- **适配器模式**:统一不同第三方服务的接口\n- **装饰器模式**:横切关注点(日志、缓存、权限检查)\n- **仓储模式**:抽象数据访问,隔离业务逻辑和存储细节\n\n## 设计决策记录(ADR)\n\n```markdown\n### ADR-001: [决策标题]\n- **状态**:已采纳 / 待评审 / 已废弃\n- **背景**:为什么需要这个决策\n- **决策**:选择了什么方案\n- **替代方案**:考虑过哪些其他方案\n- **后果**:这个决策带来的正面和负面影响\n```\n\n## 扩展性考量\n- 模块化:功能按领域拆分为独立模块\n- 插件化:核心功能之外的能力通过插件扩展\n- 配置驱动:行为差异通过配置而非代码分支控制\n- 向后兼容:API 变更使用版本化,数据迁移支持回滚",
|
||||
"enabled": false,
|
||||
"priority": 34,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.182Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.182Z",
|
||||
"tags": [
|
||||
"context",
|
||||
"architecture",
|
||||
"design"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e1f03b36-532b-446e-b932-0e7d84b1a38b",
|
||||
"name": "技术栈上下文",
|
||||
"category": "context",
|
||||
"content": "## 前端技术栈\n\n| 分类 | 技术 | 版本 | 说明 |\n|------|------|------|------|\n| 框架 | Vue 3 / React 19 | latest | Composition API / Hooks |\n| 语言 | TypeScript | ^5.x | 严格模式 |\n| 构建 | Vite | ^5.x / ^6.x | 开发服务器 + 生产构建 |\n| UI 库 | Ant Design Vue / shadcn/ui | latest | 主要组件库 |\n| 样式 | Tailwind CSS | ^3.x / ^4.x | 原子化 CSS |\n| 状态 | Pinia / Zustand | latest | 全局状态管理 |\n| 路由 | Vue Router / React Router | latest | SPA 路由 |\n| HTTP | Axios / ky / fetch | latest | API 请求 |\n| 图标 | Lucide Icons | latest | SVG 图标库 |\n| 表格 | VXE-Table / TanStack Table | latest | 高性能表格 |\n| 表单 | VeeValidate / React Hook Form | latest | 表单校验 |\n| 国际化 | vue-i18n / react-intl | latest | 多语言 |\n| 图表 | ECharts / D3.js | latest | 数据可视化 |\n\n## 后端技术栈\n\n| 分类 | 技术 | 版本 | 说明 |\n|------|------|------|------|\n| 运行时 | Node.js | 20 LTS / 22 | 服务端 JavaScript |\n| 框架 | Express / NestJS / Fastify | latest | Web 框架 |\n| 数据库 | PostgreSQL | 16 | 主数据库 |\n| 缓存 | Redis | 7 | 缓存 + 会话 + 消息队列 |\n| ORM | Prisma / TypeORM / Drizzle | latest | 数据库操作 |\n| 认证 | JWT + bcrypt / Passport | latest | 认证授权 |\n| 校验 | zod / class-validator | latest | 入参校验 |\n| 日志 | Winston / Pino | latest | 结构化日志 |\n| 队列 | BullMQ / RabbitMQ | latest | 异步任务 |\n\n## 工具链\n\n| 分类 | 技术 | 说明 |\n|------|------|------|\n| 包管理 | Yarn / pnpm | 依赖管理 |\n| 代码风格 | ESLint + Prettier | 检查 + 格式化 |\n| Git 钩子 | husky + lint-staged | 提交前检查 |\n| 提交规范 | commitlint | Conventional Commits |\n| 版本控制 | Git | 源码管理 |\n| CI/CD | GitHub Actions / GitLab CI | 自动化流水线 |\n| 容器化 | Docker + docker-compose | 开发 + 部署 |\n| 部署 | Nginx / Caddy | 反向代理 + 静态托管 |\n| 监控 | Prometheus + Grafana | 性能监控 |\n| E2E 测试 | Playwright | 端到端自动化测试 |",
|
||||
"enabled": false,
|
||||
"priority": 35,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.183Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.183Z",
|
||||
"tags": [
|
||||
"context",
|
||||
"tech-stack",
|
||||
"environment"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c971ece6-d991-4cb0-a683-20dc116a97e7",
|
||||
"name": "论文写作上下文",
|
||||
"category": "context",
|
||||
"content": "## 当前论文信息\n\n- **论文题目**:[填写题目]\n- **论文类型**:[本科毕业论文 / 硕士学位论文 / 博士学位论文 / 期刊论文 / 会议论文]\n- **所属学科**:[填写学科方向]\n- **目标期刊/学校**:[填写期刊名称或学校名称]\n- **字数要求**:[填写字数]\n- **查重要求**:[填写重复率上限,如 ≤ 20%]\n- **引用格式**:[GB/T 7714 / APA / IEEE / MLA]\n- **截止日期**:[填写日期]\n\n## 研究核心\n\n- **研究问题**:[核心研究问题是什么]\n- **研究假设**:[填写研究假设]\n- **研究方法**:[定性 / 定量 / 混合方法;实验 / 调查 / 案例分析等]\n- **核心论点**:[本文的中心论点]\n- **主要贡献**:[理论贡献 / 实践价值 / 创新点]\n\n## 章节进度\n\n| 章节 | 状态 | 字数 | 备注 |\n|------|------|------|------|\n| 摘要(中文) | [未开始/进行中/已完成] | | |\n| 摘要(英文) | [未开始/进行中/已完成] | | |\n| 第一章 引言 | [未开始/进行中/已完成] | | |\n| 第二章 文献综述 | [未开始/进行中/已完成] | | |\n| 第三章 研究方法 | [未开始/进行中/已完成] | | |\n| 第四章 结果分析 | [未开始/进行中/已完成] | | |\n| 第五章 讨论 | [未开始/进行中/已完成] | | |\n| 第六章 结论 | [未开始/进行中/已完成] | | |\n| 参考文献 | [未开始/进行中/已完成] | | |\n\n## 核心术语表\n\n> 保持全文术语统一,同一概念只用一个名称\n\n| 中文术语 | 英文术语 | 首次定义位置 | 说明 |\n|----------|----------|-------------|------|\n| [填写] | [填写] | 第X章 | [填写定义] |\n\n## 关键文献清单\n\n> 已核实真实存在的核心参考文献(写作前必须完成验证)\n\n| 序号 | 作者 | 标题 | 来源 | 年份 | 验证状态 |\n|------|------|------|------|------|----------|\n| 1 | [填写] | [填写] | [期刊/出版社] | [年份] | ✅已验证 |\n\n## 写作约束\n\n### 本论文特殊要求\n- [填写导师或目标期刊的特殊格式要求]\n- [填写禁止引用的文献范围,如:不引用非正式网络资源]\n- [填写特殊术语使用规范]\n\n### AI 辅助边界\n- ✅ 允许:润色语言表达、检查语法错误、格式排版建议\n- ✅ 允许:提供相关文献检索方向(但必须自行验证真实性)\n- ✅ 允许:提供论述思路和结构框架\n- ❌ 禁止:直接生成正文段落作为最终内容\n- ❌ 禁止:生成参考文献(必须自己查找并验证)\n- ❌ 禁止:编造实验数据、调研结果、案例信息\n\n## 上下文一致性检查清单\n\n在每次续写或修改前确认:\n\n- [ ] 当前写作的章节与研究问题保持关联\n- [ ] 使用的术语与术语表保持一致\n- [ ] 引用的文献均在关键文献清单中且已验证\n- [ ] 本段内容与上一段有逻辑衔接\n- [ ] 数据/结论与前文描述不矛盾\n- [ ] 写作风格与全文保持一致",
|
||||
"enabled": false,
|
||||
"priority": 36,
|
||||
"metadata": {
|
||||
"author": "user",
|
||||
"version": "1.0.0",
|
||||
"createdAt": "2026-06-04T00:17:16.184Z",
|
||||
"updatedAt": "2026-06-04T00:17:16.184Z",
|
||||
"tags": [
|
||||
"context",
|
||||
"paper",
|
||||
"academic",
|
||||
"writing"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "默认",
|
||||
"description": "默认配置集",
|
||||
"ruleIds": [],
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# ===== 燃尽模式规则 START =====
|
||||
# 最高优先级规则 - 必须严格遵守(Cursor 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "cursor" "qlvw9j" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
|
||||
# ===== 燃尽模式规则 END =====
|
||||
|
||||
# 最高优先级规则 - 必须严格遵守(Cursor 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "cursor" "qlvw9j" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
@@ -0,0 +1,24 @@
|
||||
# 应用配置
|
||||
APP_PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# 持久化开关:true 时启用 PostgreSQL(TypeORM)持久化,否则各模块使用内存实现
|
||||
# (内存实现无需数据库,供测试与本地快速启动)。
|
||||
DB_ENABLED=false
|
||||
|
||||
# 认证(JWT):生产环境必须设置高强度 JWT_SECRET;未设置时回退到仅供本地开发的默认值。
|
||||
JWT_SECRET=college-ai-center-dev-secret-change-me
|
||||
JWT_EXPIRES_IN_SECONDS=7200
|
||||
|
||||
# 跨域:前端联调来源;留空则放开(开发用)。生产应设为具体来源。
|
||||
# CORS_ORIGIN=http://localhost:3001
|
||||
|
||||
# PostgreSQL 数据库连接(仅 DB_ENABLED=true 时使用)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE=college_ai_center
|
||||
# 是否自动同步数据库结构(仅开发环境使用,生产环境请使用迁移)
|
||||
DB_SYNCHRONIZE=false
|
||||
DB_LOGGING=false
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# temp
|
||||
/tmp
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "43719c76-463f-48fd-8ae8-911390815c6a", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -0,0 +1,641 @@
|
||||
# Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
医科类高校 AI 学习中心系统是一个以"能力"为核心的 Web 平台,面向学生、导师与管理员三类角色,贯穿基础医学、临床医学、见习实习、住院医师培养全成长链。本设计文档描述系统的架构、组件、数据模型、错误处理与测试策略,对应需求文档中的 17 条需求。
|
||||
|
||||
系统的两大架构支柱是:
|
||||
|
||||
1. **能力图谱(Competency Graph)** —— 全系统共享的数据底座。所有模块产生的可计入能力的数据(学习成果、对练成绩、技能使用、协同评估)都映射到统一的能力标签上,使学生画像、职业规划等能在统一数据上聚合,避免模块孤岛。
|
||||
2. **技能能力中心(Skill Center)** —— 统一的 AI 能力技术底座。课程对练、研究资料查询等模块本质上是技能(Skill)的具体应用,它们复用同一套技能框架(输入规格、AI 处理逻辑、知识源绑定、输出格式、可信度标注规则)。
|
||||
|
||||
此外,**AI 可信与可解释**与**数据安全与合规**作为两个横切关注点贯穿所有模块:任何 AI 输出都附带可信度标注与可追溯来源,任何受保护资源访问都经过合规模块的权限判定与审计。
|
||||
|
||||
### 技术栈假设
|
||||
|
||||
需求未指定技术栈,本设计采用以下技术栈作为假设(可在实现阶段调整):
|
||||
|
||||
- **前端**:React + TypeScript(学生端、导师端、管理端三套界面共用组件库)
|
||||
- **后端**:Node.js + TypeScript(NestJS 框架,模块化、依赖注入契合本系统的模块划分)
|
||||
- **数据库**:PostgreSQL(关系型主存储,含 JSONB 字段存储画像维度、技能定义等半结构化数据)
|
||||
- **图/标签存储**:能力图谱的标签体系与映射关系存于 PostgreSQL(可演进至专用图数据库)
|
||||
- **缓存**:Redis(画像聚合结果、能力概览等读多写少数据的缓存)
|
||||
- **AI 集成**:通过统一的 AI 网关(AI Gateway)封装大模型调用,所有调用强制经过可信度标注与来源绑定
|
||||
- **对象存储**:S3 兼容存储(学习成果附件)
|
||||
- **检索集成**:通过资料来源适配器(Reference Source Adapter)对接 PubMed/CNKI/万方/UpToDate/Cochrane 等外部数据库 API
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **可信优先**:医学场景下 AI 幻觉风险高,所有 AI 输出绑定权威来源、附带可信度标注;无法溯源的输出一律标注为"未经核验"。
|
||||
- **合规默认**:敏感数据默认脱敏,画像跨用途使用必须有有效授权,所有敏感操作留审计日志。
|
||||
- **能力贯通**:所有模块通过能力标签写入能力图谱,形成统一数据底座。
|
||||
- **技能复用**:可复用 AI 能力统一抽象为技能,降低新增能力的成本。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 系统分层架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端层"]
|
||||
StudentUI["学生端"]
|
||||
MentorUI["导师端"]
|
||||
AdminUI["管理端"]
|
||||
end
|
||||
|
||||
subgraph Gateway["接入层"]
|
||||
APIGW["API 网关 / 认证鉴权"]
|
||||
end
|
||||
|
||||
subgraph CrossCutting["横切关注点"]
|
||||
Compliance["合规模块<br/>Compliance_Module<br/>(权限/脱敏/授权/审计)"]
|
||||
AITrust["AI 可信层<br/>(可信度标注/来源溯源)"]
|
||||
end
|
||||
|
||||
subgraph Modules["业务模块层"]
|
||||
LS["个人学习空间<br/>Learning_Space"]
|
||||
AEE["AI 评估引擎<br/>AI_Evaluation_Engine"]
|
||||
PE["课程对练引擎<br/>Practice_Engine"]
|
||||
CDP["临床情景对话对练<br/>Clinical_Dialogue_Practice"]
|
||||
PF["学生画像引擎<br/>Profile_Engine"]
|
||||
CM["职业规划模块<br/>Career_Module"]
|
||||
ACM["AI 协同能力培养<br/>AI_Collaboration_Module"]
|
||||
end
|
||||
|
||||
subgraph Foundation["底座层"]
|
||||
SC["技能能力中心<br/>Skill_Center"]
|
||||
CG["能力图谱<br/>Competency_Graph"]
|
||||
end
|
||||
|
||||
subgraph External["外部与基础设施"]
|
||||
AIGW["AI 网关 (大模型)"]
|
||||
AuthSrc["权威知识源"]
|
||||
RefSrc["资料来源 (PubMed/CNKI/...)"]
|
||||
DB[("PostgreSQL")]
|
||||
Cache[("Redis")]
|
||||
OSS[("对象存储")]
|
||||
end
|
||||
|
||||
Client --> APIGW
|
||||
APIGW --> Compliance
|
||||
Compliance --> Modules
|
||||
Modules --> SC
|
||||
Modules --> CG
|
||||
SC --> AIGW
|
||||
AEE --> AIGW
|
||||
CDP --> AIGW
|
||||
SC --> AuthSrc
|
||||
SC --> RefSrc
|
||||
PE --> AuthSrc
|
||||
Modules --> AITrust
|
||||
AITrust --> AIGW
|
||||
Modules --> DB
|
||||
PF --> Cache
|
||||
CG --> Cache
|
||||
LS --> OSS
|
||||
```
|
||||
|
||||
### 模块依赖关系
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
LS["个人学习空间"] --> CG["能力图谱"]
|
||||
LS --> AEE["AI评估引擎"]
|
||||
PE["课程对练引擎"] --> SC["技能能力中心"]
|
||||
PE --> CG
|
||||
PE --> AEE
|
||||
CDP["临床情景对话对练"] --> SC
|
||||
CDP --> CG
|
||||
RQS["研究资料查询技能"] --> SC
|
||||
RQS --> CG
|
||||
ACM["AI协同能力培养"] --> CG
|
||||
PF["学生画像引擎"] --> CG
|
||||
PF --> LS
|
||||
CM["职业规划模块"] --> CG
|
||||
CM --> PF
|
||||
AEE --> AITrust["AI可信层"]
|
||||
SC --> AITrust
|
||||
PF --> Compliance["合规模块"]
|
||||
CM --> Compliance
|
||||
```
|
||||
|
||||
关键说明:
|
||||
- **能力图谱**是被依赖最多的底座,几乎所有业务模块都向它写入或从它读取能力数据。
|
||||
- **技能能力中心**是课程对练、临床对话对练、研究查询技能的共同底座。
|
||||
- **学生画像引擎**依赖能力图谱(能力数据)与个人学习空间(成果来源)。
|
||||
- **职业规划模块**依赖能力图谱(能力水平对照)与学生画像。
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. 个人学习空间(Learning_Space)
|
||||
|
||||
**职责**:汇聚、归档、检索学生的学习成果;触发 AI 评价与能力标签关联。
|
||||
|
||||
**对应需求**:需求 1。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface LearningSpaceService {
|
||||
// 需求1.1, 1.6, 1.9: 新增成果(含元数据与字段校验)
|
||||
addAchievement(studentId: string, input: AchievementInput): Result<Achievement, ValidationError>;
|
||||
// 需求1.5: 分页+筛选查询(按时间倒序,每页≤50)
|
||||
listAchievements(studentId: string, filter: AchievementFilter, page: Pagination): Page<Achievement>;
|
||||
// 需求1.4: 标记里程碑并关联成果
|
||||
markMilestone(studentId: string, milestone: MilestoneInput): Milestone;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 保存成功后(需求 1.3)按学年、学期自动归档;若成果含临床轮转科室信息(需求 1.8)额外按科室归档。
|
||||
- 保存成功后(需求 1.7)调用能力图谱进行能力标签关联;若无法匹配任何已定义标签(需求 1.10),标记为"待关联能力标签"并提示。
|
||||
- 必填元数据缺失(需求 1.6)或标题超 200 字符/时间无效(需求 1.9)时拒绝保存且不创建任何记录(事务回滚)。
|
||||
- 成功保存后异步触发 `AI_Evaluation_Engine` 生成形成性评价(需求 2.1)。
|
||||
|
||||
### 2. AI 评估引擎(AI_Evaluation_Engine)
|
||||
|
||||
**职责**:对学习成果生成形成性评价,对里程碑生成终结性评价,对对练报告生成改进建议。
|
||||
|
||||
**对应需求**:需求 2、需求 4.7-4.8。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface AIEvaluationEngine {
|
||||
// 需求2.1: 形成性评价(30秒内)
|
||||
generateFormativeEvaluation(achievement: Achievement): Result<Evaluation, EvaluationError>;
|
||||
// 需求2.2, 2.7: 终结性评价(节点内须有成果)
|
||||
generateSummativeEvaluation(milestone: Milestone, achievements: Achievement[]): Result<Evaluation, EvaluationError>;
|
||||
// 需求4.7-4.8: 对练薄弱环节改进建议
|
||||
generateImprovementSuggestions(report: PracticeReport): Suggestion[];
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 每条评价附带可信度标注,列出至少一条依据的学习成果或权威知识源(需求 2.3);每条发展建议引用至少一个能力标签(需求 2.4)。
|
||||
- 内容信息不足时中止生成、保留数据不变并提示缺失内容类别(需求 2.5)。
|
||||
- 生成失败或超时时中止、保留数据不变并提示失败(需求 2.6)。
|
||||
- 通过 AI 可信层统一附加可信度标注(需求 16)。
|
||||
|
||||
### 3. 课程对练引擎(Practice_Engine)
|
||||
|
||||
**职责**:基于课程内容生成题目(绑定权威知识源),经导师审核后供学生对练,并生成结果诊断报告。
|
||||
|
||||
**对应需求**:需求 3、需求 4。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface PracticeEngine {
|
||||
// 需求3.1: 生成题目(30秒内,5-50道)
|
||||
generateQuestions(courseId: string, studentId: string): Result<Question[], GenerationError>;
|
||||
// 需求3.5, 3.6: 导师审核流转
|
||||
reviewQuestion(questionId: string, decision: ReviewDecision, reason?: string): Question;
|
||||
// 需求4: 对练会话
|
||||
startPractice(studentId: string, questionSetId: string): PracticeSession;
|
||||
submitAnswer(sessionId: string, questionId: string, answer: Answer): AnswerResult;
|
||||
finishPractice(sessionId: string): PracticeReport;
|
||||
}
|
||||
```
|
||||
|
||||
**题目状态机**:
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 待导师审核: 生成成功
|
||||
[*] --> 已拒绝: 无法绑定权威知识源(3.8)
|
||||
待导师审核 --> 可用于对练: 导师通过(3.6)
|
||||
待导师审核 --> 已退回: 导师退回(3.5)
|
||||
已退回 --> 待导师审核: 修订后重新提交
|
||||
可用于对练 --> [*]
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 每道题目绑定至少一个权威知识源并记录引用标识(需求 3.2);无法绑定时不进入审核并记录原因(需求 3.8)。
|
||||
- 支持 A1/A2/A3/A4、病例分析、临床决策题型(需求 3.3)。
|
||||
- 课程内容不足以生成最少题量时中止并提示(需求 3.7)。
|
||||
- 对练逐题呈现并采集作答(需求 4.1-4.2),单题超 120 秒未作答判错并继续(需求 4.4)。
|
||||
- 报告含正确率、用时、薄弱环节(正确率<60% 的能力标签,需求 4.5),并按能力标签映射能力图谱(需求 4.6)。
|
||||
|
||||
### 4. 临床情景对话对练(Clinical_Dialogue_Practice)
|
||||
|
||||
**职责**:基于权威知识源呈现临床情景,进行多轮模拟问诊对话,生成多维评估报告。
|
||||
|
||||
**对应需求**:需求 5。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface ClinicalDialoguePractice {
|
||||
// 需求5.1, 5.5: 发起对练(10秒内呈现情景,须绑定权威知识源)
|
||||
startDialogue(studentId: string, scenarioId: string): Result<DialogueSession, NoSourceError>;
|
||||
// 需求5.2: 多轮对话(最多50轮)
|
||||
sendTurn(sessionId: string, studentInput: string): DialogueTurn;
|
||||
// 需求5.3, 5.6: 结束并生成报告(30秒内,三维度)
|
||||
finishDialogue(sessionId: string): Result<DialogueReport, ReportError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 未绑定权威知识源时阻止启动、不创建对话记录并提示(需求 5.5)。
|
||||
- 报告含问诊完整性、临床推理、医患沟通三个评估方面(需求 5.3),结果映射能力图谱(需求 5.4)。
|
||||
- 报告生成失败或超时时中止、保留对话记录并提示(需求 5.6)。
|
||||
|
||||
### 5. 学生画像引擎(Profile_Engine)
|
||||
|
||||
**职责**:基于学习空间与能力图谱数据生成多维度画像;标记敏感字段,遵循数据最小化。
|
||||
|
||||
**对应需求**:需求 6、需求 7(与合规模块协作)。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface ProfileEngine {
|
||||
// 需求6.1, 6.5: 生成/更新画像(5秒内,数据不足维度标记)
|
||||
generateProfile(studentId: string): Result<StudentProfile, ProfileError>;
|
||||
// 需求6.4: 画像结论可追溯
|
||||
getProfileTraceability(studentId: string, dimensionId: string): TraceabilityRecord[];
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 六个画像维度均以 0-100 量化分值呈现(需求 6.2):知识掌握、临床技能、科研能力、人文素养、AI 协同素养、职业倾向。
|
||||
- 能力图谱更新后 10 秒内完成数据同步(需求 6.3);每项结论可追溯至少一条来源记录(需求 6.4)。
|
||||
- 数据不足以刻画某维度时标记"数据不足"并保留其余维度(需求 6.5)。
|
||||
- 仅采集与至少一个画像维度直接相关的字段(需求 7.7,数据最小化)。
|
||||
|
||||
### 6. 职业规划模块(Career_Module)
|
||||
|
||||
**职责**:设定职业目标、关联岗位胜任力模型、对照能力图谱生成动态闭环发展规划。
|
||||
|
||||
**对应需求**:需求 8、需求 9。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface CareerModule {
|
||||
// 需求8.1, 8.4, 8.5: 设定目标并关联胜任力模型(3秒内)
|
||||
setCareerGoal(studentId: string, goal: CareerGoal): Result<CompetencyModel, GoalError>;
|
||||
// 需求9: 生成/更新发展规划
|
||||
generateDevelopmentPlan(studentId: string, goalId: string): Result<DevelopmentPlan, PlanError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 岗位胜任力模型参照权威医学胜任力框架,含科学与学术、临床能力、健康与社会、职业素养四维度(需求 8.2),每维度至少 3 个能力标签(需求 8.3)。
|
||||
- 无匹配模型时提示并推荐至少 3 个相近目标(需求 8.4)。
|
||||
- 逐项对照能力图谱能力水平与要求水平,标记能力差距项(含缺数据项,需求 9.1);每差距项生成≥1 建议行动(需求 9.2)、推荐≥1 学习资源或对练任务(需求 9.3)、引用对应能力标签(需求 9.4)。
|
||||
- 能力图谱更新后再次请求时重算全部差距项(需求 9.5,动态闭环)。
|
||||
- 未选目标/模型不可用时拒绝并提示、保留能力图谱数据(需求 9.6);差距项无对应资源时保留并标注暂无推荐(需求 9.7)。
|
||||
|
||||
### 7. 研究资料查询技能(Research_Query_Skill,建立于 Skill_Center 之上)
|
||||
|
||||
**职责**:自然语言转检索式、检索筛选、总结、证据分级、引用管理。
|
||||
|
||||
**对应需求**:需求 10、需求 11。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface ResearchQuerySkill {
|
||||
// 需求10.1, 10.6: NL→检索式(PICO/MeSH,10秒内,1-2000字符)
|
||||
generateSearchQuery(studentId: string, question: string): Result<SearchQuery, QuestionError>;
|
||||
// 需求10.2, 10.7, 10.8: 检索(30秒内,分页≤50,空结果/源不可用处理)
|
||||
search(query: SearchQuery, sources: ReferenceSource[], page: Pagination): Result<Page<ReferenceItem>, SourceError>;
|
||||
// 需求11.1, 11.6: 生成总结(30秒内,每结论附引用)
|
||||
summarize(items: ReferenceItem[]): Result<Summary, SummaryError>;
|
||||
// 需求11.3, 11.7: 生成/导出引用
|
||||
generateCitation(items: ReferenceItem[], format: CitationFormat): Result<Citation[], CitationError>;
|
||||
exportCitations(citations: Citation[], target: ExportTarget): Result<ExportFile, ExportError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 检索结果每条仅含检索信息、摘要、原文链接,不存储或分发受版权保护全文(需求 10.4,版权安全)。
|
||||
- 总结每条结论附≥1 来源引用(需求 11.1);每条资料标注唯一证据分级(需求 11.2)。
|
||||
- 无法溯源的结论标注"未验证"且不作为引用输出(需求 11.4)。
|
||||
- 支持 Vancouver/GB-T 7714 格式,导出 EndNote/NoteExpress(需求 11.3)。
|
||||
- 呈现总结时提示"仅辅助,不替代阅读原文"(需求 11.5)。
|
||||
|
||||
### 8. AI 协同能力培养模块(AI_Collaboration_Module)
|
||||
|
||||
**职责**:提供协同训练任务,评估四维协同能力,写入能力图谱,支持导师点评。
|
||||
|
||||
**对应需求**:需求 12。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface AICollaborationModule {
|
||||
// 需求12.1: 提供协同训练任务(四维度各≥1)
|
||||
listTrainingTasks(dimension?: CollaborationDimension): TrainingTask[];
|
||||
// 需求12.2, 12.6, 12.7: 完成任务并评估(30秒内,0-100,失败/数据不足处理)
|
||||
evaluateTask(studentId: string, taskId: string, submission: TaskSubmission): Result<CollaborationAssessment, AssessmentError>;
|
||||
// 需求12.5: 导师点评
|
||||
addMentorComment(taskId: string, mentorId: string, comment: string): MentorComment;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 四维度:提问能力、批判性验证、责任边界意识、协同工作流(需求 12.2)。
|
||||
- 学生直接采用未核验 AI 输出时提示并要求来源核验,且计入批判性验证维度(需求 12.3)。
|
||||
- 评估结果映射能力图谱并计入画像与胜任力对照(需求 12.4)。
|
||||
|
||||
### 9. 技能能力中心(Skill_Center,底座)
|
||||
|
||||
**职责**:定义、治理、调用可复用 AI 技能;统一技能框架与可信度标注。
|
||||
|
||||
**对应需求**:需求 13。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface SkillCenter {
|
||||
// 需求13.6, 13.7: 配置/启用/审计技能定义
|
||||
upsertSkillDefinition(actor: User, def: SkillDefinition): Result<SkillDefinition, DefinitionError>;
|
||||
enableSkill(actor: User, skillId: string): Skill;
|
||||
// 需求13.3, 13.4, 13.5: 调用技能(30秒内,输入校验,失败处理)
|
||||
invokeSkill(studentId: string, skillId: string, input: SkillInput): Result<SkillOutput, InvocationError>;
|
||||
// 需求13.9: 序列化往返
|
||||
serialize(def: SkillDefinition): string;
|
||||
deserialize(raw: string): SkillDefinition;
|
||||
}
|
||||
```
|
||||
|
||||
**技能定义结构(需求 13.2 五要素)**:
|
||||
```typescript
|
||||
interface SkillDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
inputSpec: InputSpec; // 输入规格
|
||||
processingLogic: ProcessingLogic; // AI 处理逻辑说明
|
||||
knowledgeSources: SourceBinding[]; // 知识源绑定
|
||||
outputFormat: OutputFormat; // 输出格式
|
||||
credibilityRule: CredibilityRule; // 可信度标注规则
|
||||
enabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 技能库至少含 8 个技能:资料查询、检索式生成、文献综述、病例分析、鉴别诊断辅助、医患沟通模拟、医学翻译、引用生成(需求 13.1)。
|
||||
- 调用前校验输入符合 inputSpec,不符则拒绝(需求 13.4);知识源不可用或处理失败时终止、不返回结果与标注、提示失败(需求 13.5)。
|
||||
- 仅成功调用完成才写入能力图谱(需求 13.8)。
|
||||
- **序列化往返一致性**(需求 13.9):`deserialize(serialize(def))` 等价于 `def`,这是核心属性测试点。
|
||||
|
||||
### 10. 能力图谱(Competency_Graph,底座)
|
||||
|
||||
**职责**:维护统一能力标签体系;接收各模块能力数据映射;聚合能力概览。
|
||||
|
||||
**对应需求**:需求 14。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface CompetencyGraph {
|
||||
// 需求14.1: 维护统一标签体系
|
||||
listTags(): CompetencyTag[];
|
||||
// 需求14.2, 14.3: 映射数据(1-10标签,5秒内,未定义标签拒绝)
|
||||
mapData(source: ModuleSource, data: CompetencyData, tagIds: string[]): Result<Mapping, UndefinedTagError>;
|
||||
// 需求14.4, 14.5: 聚合能力概览(5秒内,0-100,数据不足标记)
|
||||
getCompetencyOverview(studentId: string): CompetencyOverview;
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 每个能力标签有唯一标识与所属能力维度(需求 14.1)。
|
||||
- 映射 1-10 个已定义标签(需求 14.2);引用未定义标签时拒绝、不创建记录、记录原因并提示(需求 14.3)。
|
||||
- 能力概览按维度以 0-100 量化分值返回(需求 14.4);数据不足维度标记"数据不足"并保留其余(需求 14.5)。
|
||||
|
||||
### 11. 合规模块(Compliance_Module,横切)
|
||||
|
||||
**职责**:权限分级、敏感字段脱敏、画像授权校验、审计日志。
|
||||
|
||||
**对应需求**:需求 7、需求 15.4、需求 17。
|
||||
|
||||
**关键接口**:
|
||||
```typescript
|
||||
interface ComplianceModule {
|
||||
// 需求17.1, 17.2, 17.3: 权限判定
|
||||
checkAccess(user: User, resource: ProtectedResource, action: Action): AccessDecision;
|
||||
// 需求7.2, 7.3, 7.4, 7.5: 画像授权与脱敏
|
||||
resolveProfileView(viewer: User, profile: StudentProfile): StudentProfileView;
|
||||
verifyConsent(studentId: string, purpose: Purpose): ConsentVerification;
|
||||
revokeConsent(studentId: string, consentId: string): void;
|
||||
// 需求17.4, 7.6: 审计日志
|
||||
writeAuditLog(entry: AuditLogEntry): void;
|
||||
}
|
||||
```
|
||||
|
||||
**授权与脱敏流程**:
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[访问学生画像] --> B{访问者是学生本人?}
|
||||
B -->|是| C[返回完整画像]
|
||||
B -->|否| D{持有有效知情同意授权?}
|
||||
D -->|否| E[拒绝完整画像访问 7.2<br/>对未授权敏感字段脱敏 7.5]
|
||||
D -->|是, 范围覆盖用途| F[校验授权有效期/范围/用途 7.3]
|
||||
F -->|通过| G[按授权范围返回字段]
|
||||
F -->|不通过| E
|
||||
C --> H[写审计日志 7.6]
|
||||
E --> H
|
||||
G --> H
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 敏感字段:身份标识、联系方式、健康与医疗记录、心理测评结果、生物特征数据(需求 7.1)。
|
||||
- 授权须仍在有效期内且范围与用途覆盖该用途(需求 7.3);不满足时阻止使用、保持数据不被使用并提示(需求 7.4)。
|
||||
- 学生撤销授权后立即失效,此后按未授权处理(需求 7.8)。
|
||||
- 脱敏 2 秒内、仅返回已授权字段(需求 7.5);审计日志 5 秒内记录、保留≥12 个月(需求 7.6)。
|
||||
- 权限范围列出可访问资源类型与允许操作类型(需求 17.1);越权访问拒绝并记录(需求 17.3)。
|
||||
|
||||
### 12. 导师端(Mentor 视图,跨模块)
|
||||
|
||||
**职责**:题目审核、成果点评、查看带教学生画像。
|
||||
|
||||
**对应需求**:需求 15。
|
||||
|
||||
**设计要点**:
|
||||
- 题目审核通过/退回并在退回时记录原因(需求 15.1,复用 Practice_Engine 审核接口)。
|
||||
- 成果点评关联到学习成果并对学生可见(需求 15.2)。
|
||||
- 存在带教关系时允许查看所带学生画像(需求 15.3);查看非所带学生画像时合规模块拒绝并记录越权尝试(需求 15.4)。
|
||||
|
||||
### 13. AI 可信层(横切)
|
||||
|
||||
**职责**:为所有 AI 输出统一附加可信度标注与来源溯源。
|
||||
|
||||
**对应需求**:需求 16。
|
||||
|
||||
**设计要点**:
|
||||
- 任意 AI 评价/建议/总结附带可信度标注(需求 16.1),含≥1 来源与 0%-100% 置信度(需求 16.2)。
|
||||
- 请求查看依据时 3 秒内展示可追溯来源条目及引用标识(需求 16.3)。
|
||||
- 无法溯源的输出标注"未经核验"并在呈现时一并显示(需求 16.4)。
|
||||
|
||||
## Data Models
|
||||
|
||||
### 核心实体关系
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Student ||--o{ Achievement : owns
|
||||
Student ||--|| StudentProfile : has
|
||||
Student ||--o{ Milestone : reaches
|
||||
Student ||--o{ ConsentRecord : grants
|
||||
Achievement }o--o{ CompetencyTag : "mapped to"
|
||||
Milestone ||--o{ Achievement : groups
|
||||
CompetencyTag }o--|| CompetencyDimension : "belongs to"
|
||||
CompetencyModel ||--o{ CompetencyTag : requires
|
||||
CareerGoal ||--|| CompetencyModel : maps
|
||||
DevelopmentPlan ||--o{ CompetencyGap : contains
|
||||
SkillDefinition ||--o{ SourceBinding : binds
|
||||
Question }o--|| AuthoritativeSource : "bound to"
|
||||
PracticeReport }o--o{ CompetencyTag : "scored on"
|
||||
AuditLogEntry }o--|| Student : about
|
||||
StudentProfile ||--o{ ProfileDimensionScore : contains
|
||||
```
|
||||
|
||||
### 关键数据结构
|
||||
|
||||
```typescript
|
||||
// 学习成果(需求1)
|
||||
interface Achievement {
|
||||
id: string;
|
||||
studentId: string;
|
||||
type: AchievementType; // 需求1.2 枚举
|
||||
title: string; // ≤200字符 (需求1.9)
|
||||
occurredAt: Date; // 有效日期 (需求1.9)
|
||||
academicYear: string; // 归档维度 (需求1.3)
|
||||
semester: string; // 归档维度 (需求1.3)
|
||||
rotationDept?: string; // 临床轮转科室 (需求1.8)
|
||||
attachments: AttachmentRef[];
|
||||
competencyTagIds: string[]; // 关联标签 (需求1.7)
|
||||
pendingTagAssociation: boolean; // 待关联 (需求1.10)
|
||||
}
|
||||
|
||||
// 能力标签与维度(需求14)
|
||||
interface CompetencyTag {
|
||||
id: string; // 唯一标识 (需求14.1)
|
||||
name: string;
|
||||
dimensionId: string; // 所属维度 (需求14.1)
|
||||
}
|
||||
|
||||
// 学生画像(需求6,7)
|
||||
interface StudentProfile {
|
||||
studentId: string;
|
||||
dimensions: ProfileDimensionScore[]; // 六维度,0-100 (需求6.2)
|
||||
generatedAt: Date;
|
||||
}
|
||||
interface ProfileDimensionScore {
|
||||
dimension: ProfileDimension;
|
||||
score: number | 'insufficient_data'; // 数据不足 (需求6.5)
|
||||
traceability: TraceabilityRecord[]; // 可追溯 (需求6.4)
|
||||
sensitive: boolean; // 敏感标记 (需求7.1)
|
||||
}
|
||||
|
||||
// 知情同意授权(需求7)
|
||||
interface ConsentRecord {
|
||||
id: string;
|
||||
studentId: string;
|
||||
scope: string[]; // 授权范围 (需求7.3)
|
||||
purpose: Purpose; // 授权用途 (需求7.3)
|
||||
validFrom: Date;
|
||||
validUntil: Date; // 有效期 (需求7.3)
|
||||
revoked: boolean; // 撤销 (需求7.8)
|
||||
}
|
||||
|
||||
// 可信度标注(需求16)
|
||||
interface CredibilityAnnotation {
|
||||
sources: SourceRef[]; // ≥1 来源 (需求16.2)
|
||||
confidence: number; // 0-100 (需求16.2)
|
||||
verified: boolean; // false => "未经核验" (需求16.4)
|
||||
}
|
||||
|
||||
// 审计日志(需求7.6, 17.4)
|
||||
interface AuditLogEntry {
|
||||
actorId: string; // 操作者
|
||||
timestamp: Date; // 时间
|
||||
action: Action; // 操作类型
|
||||
resourceScope: string; // 数据范围
|
||||
outcome: 'allow' | 'deny';
|
||||
reason?: string; // 拒绝原因 (需求17.3)
|
||||
// 保留≥12个月 (需求7.6)
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
系统采用统一的 `Result<T, E>` 返回模式,区分正常路径与错误路径,并遵循以下原则:
|
||||
|
||||
| 场景 | 处理策略 | 对应需求 |
|
||||
|------|----------|----------|
|
||||
| 输入校验失败(缺字段/超长/无效日期) | 拒绝操作、不创建记录(事务回滚)、返回指明问题的提示 | 1.6, 1.9, 13.4 |
|
||||
| AI 生成失败或超时 | 中止生成、保留触发数据不变、返回失败提示 | 2.6, 5.6, 11.6, 12.6 |
|
||||
| 内容/数据信息不足 | 标记"数据不足"或"信息不足"、保留其余结果、提示缺失类别 | 2.5, 6.5, 12.7, 14.5 |
|
||||
| 无法绑定权威知识源 | 不进入审核/不启动、记录原因或提示 | 3.8, 5.5 |
|
||||
| 资料来源不可用 | 中止检索、保留检索式、提示来源不可用 | 10.8 |
|
||||
| 检索无匹配 | 返回空结果列表并提示 | 10.7 |
|
||||
| 无法溯源 | 标注"未验证"/"未经核验"、不作为引用输出 | 11.4, 16.4 |
|
||||
| 无有效授权 | 阻止使用、保持数据不被使用、提示缺少授权 | 7.4 |
|
||||
| 越权访问 | 拒绝访问、保持资源不被访问、记录拒绝事件 | 9.6, 15.4, 17.3 |
|
||||
| 系统错误(如目标关联失败) | 返回错误提示、保留已有数据 | 8.5, 9.6 |
|
||||
|
||||
**核心错误处理不变量**:任何错误路径都不得使触发数据进入不一致状态(要么完整成功,要么完整回滚保留原状)。
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 测试层次
|
||||
|
||||
1. **单元测试**:各模块接口的正常路径与错误路径,覆盖每条验收标准。
|
||||
2. **集成测试**:跨模块流程,重点验证能力图谱作为底座的数据贯通(学习成果→标签→画像→职业规划)。
|
||||
3. **属性测试(Property-Based Testing)**:针对可形式化为不变量的核心正确性属性。
|
||||
4. **合规与安全测试**:权限矩阵、脱敏、授权生命周期、审计完整性。
|
||||
|
||||
测试数据与模拟说明:
|
||||
|
||||
- AI 网关在测试中以可控的 mock 替代,便于模拟生成成功、失败、超时、无法溯源等场景。
|
||||
- 资料来源适配器以 mock 模拟外部数据库的可用、不可用、空结果等状态。
|
||||
- 属性测试使用生成器构造任意有效的技能定义、能力数据、授权记录等输入。
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
以下属性应对任意有效输入成立,作为属性测试(Property-Based Testing)的基础:
|
||||
|
||||
### Property 1: 技能定义序列化往返一致性
|
||||
对任意有效的 `SkillDefinition def`,有 `deserialize(serialize(def))` 等价于 `def`。
|
||||
|
||||
**Validates: Requirements 13.9**
|
||||
|
||||
### Property 2: 能力标签映射有效性
|
||||
对任意提交的能力数据与标签集合,映射成功 ⟺ 所有标签均为已定义标签,且映射后标签数量在 1 到 10 之间;引用未定义标签时必然拒绝且不创建任何映射记录。
|
||||
|
||||
**Validates: Requirements 14.2, 14.3**
|
||||
|
||||
### Property 3: 画像维度分值有界性
|
||||
对任意学生数据生成的画像与能力概览,每个维度的输出要么是 0 到 100 之间的数值,要么是"数据不足"标记,不存在其他取值。
|
||||
|
||||
**Validates: Requirements 6.2, 14.4**
|
||||
|
||||
### Property 4: 授权强制不变量
|
||||
对任意非本人访问者与任意画像使用用途,仅当存在一条有效期内、范围与用途均覆盖的未撤销授权时,才允许完整访问/使用;授权撤销后任何后续访问都按未授权处理。
|
||||
|
||||
**Validates: Requirements 7.2, 7.3, 7.4, 7.8**
|
||||
|
||||
### Property 5: 引用可追溯不变量
|
||||
对任意生成的总结,作为引用输出的每条结论都必然可追溯到至少一条来源条目;无法溯源的结论必然被标注为"未验证/未经核验"且不出现在引用输出中。
|
||||
|
||||
**Validates: Requirements 11.1, 11.4, 16.4**
|
||||
|
||||
### Property 6: 权限判定一致性
|
||||
对任意(用户角色,资源,操作)三元组,访问被允许 ⟺ 该操作在该角色的权限范围内;越权访问必然被拒绝并产生一条拒绝审计记录。
|
||||
|
||||
**Validates: Requirements 17.2, 17.3**
|
||||
|
||||
### Property 7: 错误路径数据保全
|
||||
对任意触发错误路径的操作,操作前后触发数据保持不变(不产生部分写入)。
|
||||
|
||||
**Validates: Requirements 1.6, 1.9, 2.6, 5.6, 8.5**
|
||||
|
||||
### Property 8: 分页边界不变量
|
||||
对任意查询与分页参数,返回的单页结果数量不超过 50 条。
|
||||
|
||||
**Validates: Requirements 1.5, 10.2**
|
||||
|
||||
## Design Decisions and Rationale
|
||||
|
||||
1. **以能力图谱为统一底座**:医学教育成长链长、模块多,若各模块各自存储能力数据会形成孤岛。统一标签体系让画像与职业规划能在一致数据上聚合,也使"对照胜任力模型"有共同语言。代价是所有模块需遵循标签映射契约(需求 14.3 的拒绝机制保障了契约一致性)。
|
||||
|
||||
2. **技能能力中心作为 AI 能力底座**:课程对练、研究查询本质都是"绑定知识源的 AI 调用 + 可信度标注"。抽象为统一技能框架降低新增能力成本,并使可信度标注、知识源绑定、治理审计得以统一实现。序列化往返一致性(需求 13.9)保障技能定义可安全持久化与迁移。
|
||||
|
||||
3. **AI 可信层作为横切关注点**:医学场景幻觉风险高,将"可信度标注 + 来源溯源 + 未经核验标记"统一到一层,避免各模块各自实现导致遗漏,确保需求 16 在全系统一致生效。
|
||||
|
||||
4. **合规默认、授权可生命周期管理**:画像含敏感健康数据,采用"默认脱敏 + 用途化授权 + 撤销即时失效 + 全审计"模型,满足知情同意与数据最小化要求,并通过授权强制不变量(属性 4)可测试地保障。
|
||||
|
||||
5. **统一 Result 错误模型与数据保全不变量**:所有错误路径保证数据不进入部分写入状态(属性 7),契合医学数据对一致性的高要求。
|
||||
@@ -0,0 +1,279 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
医科类高校 AI 学习中心系统(以下简称"本系统")面向医学院校的学生、导师与管理员,提供一个以"能力"为核心、贯穿基础医学、临床医学、见习实习、住院医师培养全成长链的智能学习平台。系统将学生在校学习内容汇聚到个人学习空间,由 AI 进行过程性与终结性评价并给出发展建议;以"课程对练"与"临床情景对话对练"对学生进行模拟训练与诊断;基于学习数据形成可授权使用的学生画像;支持学生设定职业目标并对照岗位胜任力模型获得动态闭环的发展规划;提供研究资料检索、总结与引用管理等可复用 AI 技能;并系统性培养医学生与 AI 负责任协作的能力。
|
||||
|
||||
系统的关键设计是引入统一的**能力图谱(Competency Graph)**作为各模块共享的数据底座:学习成果、对练成绩、技能使用与协同能力评估均映射到统一的能力标签上,使各模块真正打通而非彼此孤立。**技能能力中心(Skill Center)**作为统一技术底座,课程对练与研究资料查询等本质上是技能的具体应用。鉴于医学内容的高准确性与合规要求,系统将 **AI 可信与可解释**、**数据安全与合规**作为贯穿全局的横切关注点。
|
||||
|
||||
本文档仅描述系统"做什么"(需求),不涉及"怎么做"(实现方案保留至设计阶段)。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **本系统(AI_Learning_Center)**:医科类高校 AI 学习中心系统整体。
|
||||
- **学生(Student)**:使用本系统进行学习、训练与科研的医学院校在校学生。
|
||||
- **导师(Mentor)**:负责审核题目、点评成果、查看所带学生画像与协同质量的教师或临床带教教师。
|
||||
- **管理员(Administrator)**:负责系统配置、技能治理、权限与合规管理的运营人员。
|
||||
- **个人学习空间(Learning_Space)**:汇聚单个学生在校学习内容的功能模块。
|
||||
- **学习成果(Achievement)**:学生在学习空间中新增的一条学习内容记录,包含类型、时间、标题、附件与元数据。
|
||||
- **成果类型(Achievement_Type)**:学习成果的分类,至少包括课程记录、作业、实验报告、临床见习与实习记录、OSCE 技能考核、文献阅读、科研成果、证书与执业资格考试备考记录。
|
||||
- **里程碑(Milestone)**:表示学生成长链上某一阶段节点(如完成某学期、完成某轮转科室)的标记。
|
||||
- **形成性评价(Formative_Evaluation)**:在学习过程中针对单条成果或阶段给出的过程性反馈。
|
||||
- **终结性评价(Summative_Evaluation)**:在阶段结束时对该阶段整体进行的总结性画像评价。
|
||||
- **AI评估引擎(AI_Evaluation_Engine)**:对学习成果、对练结果等进行评价并生成发展建议的 AI 组件。
|
||||
- **课程对练引擎(Practice_Engine)**:将课程内容整理为练习题、与学生进行模拟对练并对结果进行分析的功能模块。
|
||||
- **题目(Question)**:用于对练的练习题,题型包括 A1/A2/A3/A4 型选择题、病例分析题与临床决策题。
|
||||
- **临床情景对话对练(Clinical_Dialogue_Practice)**:以模拟问诊等临床情景与学生进行多轮对话训练的功能。
|
||||
- **权威知识源(Authoritative_Source)**:经认证的教材、临床指南、官方题库等可信内容来源。
|
||||
- **学生画像引擎(Profile_Engine)**:基于学习空间与能力图谱数据生成学生画像的功能模块。
|
||||
- **学生画像(Student_Profile)**:对学生能力、特长与发展倾向的结构化刻画,含敏感数据。
|
||||
- **画像维度(Profile_Dimension)**:学生画像的能力维度,至少包括知识掌握、临床技能、科研能力、人文素养、AI 协同素养与职业倾向。
|
||||
- **职业规划模块(Career_Module)**:支持学生设定职业目标、分解岗位胜任力并生成发展规划的功能模块。
|
||||
- **岗位胜任力模型(Competency_Model)**:描述某一医学岗位所需能力的结构化模型,参照权威医学胜任力框架(如临床医学本科毕业生胜任力的科学与学术、临床能力、健康与社会、职业素养四个维度)。
|
||||
- **研究资料查询技能(Research_Query_Skill)**:辅助学生检索、筛选、总结与引用医学研究资料的 AI 技能。
|
||||
- **检索式(Search_Query)**:用于在资料来源中检索的专业查询表达式,支持 PICO 要素与 MeSH 主题词。
|
||||
- **资料来源(Reference_Source)**:医学文献与数据库,如 PubMed、CNKI、万方、UpToDate、Cochrane 等。
|
||||
- **证据分级(Evidence_Level)**:对一条资料证据强度的标注。
|
||||
- **引用(Citation)**:按规范格式(如 Vancouver、GB/T 7714)生成、可导出至 EndNote/NoteExpress 的文献引用。
|
||||
- **AI协同能力培养模块(AI_Collaboration_Module)**:系统性培养学生与 AI 负责任协作能力的功能模块。
|
||||
- **协同能力维度(Collaboration_Dimension)**:协同能力的评估维度,包括提问能力、批判性验证、责任边界意识与协同工作流。
|
||||
- **技能能力中心(Skill_Center)**:提供可复用 AI 技能、并对技能进行统一定义与治理的功能模块。
|
||||
- **技能(Skill)**:一个可复用的 AI 能力单元,具备统一框架(输入规格、AI 处理逻辑、知识源绑定、输出格式、可信度标注规则)。
|
||||
- **技能定义(Skill_Definition)**:描述一个技能配置的结构化数据。
|
||||
- **能力图谱(Competency_Graph)**:连接各模块的统一能力标签体系与数据底座。
|
||||
- **能力标签(Competency_Tag)**:能力图谱中表示一项具体能力的标识。
|
||||
- **合规模块(Compliance_Module)**:负责权限分级、敏感数据脱敏、画像授权与审计日志的功能模块。
|
||||
- **可信度标注(Credibility_Annotation)**:AI 输出附带的来源依据、置信度与可追溯信息。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 需求 1:个人学习空间汇聚与归档
|
||||
|
||||
**用户故事:** 作为学生,我希望将贯穿成长链的学习内容汇聚到一个个人空间并自动归档,以便集中管理我的学习成果。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生新增一条学习成果时,个人学习空间应将该学习成果保存、归属到该学生账户,并在保存成功后向该学生返回保存成功的确认。
|
||||
2. 个人学习空间应支持的成果类型至少包括课程记录、作业、实验报告、临床见习与实习记录、OSCE 技能考核、文献阅读、科研成果、证书与执业资格考试备考记录。
|
||||
3. 当一条学习成果被成功保存时,个人学习空间应按学年与学期对该学习成果进行归档。
|
||||
4. 当学生完成一个里程碑节点时,个人学习空间应在成长链上标记该里程碑并关联该节点内的学习成果。
|
||||
5. 当学生请求查看个人学习空间时,个人学习空间应按学习成果的时间字段倒序展示该学生的学习成果,支持按成果类型、学年、学期与临床轮转科室进行筛选,并以每页最多 50 条对结果进行分页展示。
|
||||
6. 如果学生提交的学习成果缺少必填元数据(成果类型、时间、标题中的任一项),则个人学习空间应拒绝保存该学习成果、不创建任何记录,并返回指明全部缺失字段的提示信息。
|
||||
7. 当一条学习成果被成功保存时,个人学习空间应将该学习成果关联到能力图谱中的至少一个能力标签。
|
||||
8. 当一条包含临床轮转科室信息的学习成果被成功保存时,个人学习空间应额外按临床轮转科室对该学习成果进行归档。
|
||||
9. 如果学生提交的学习成果的标题超过 200 个字符,或其时间不是有效日期,则个人学习空间应拒绝保存该学习成果、不创建任何记录,并返回指明无效字段的提示信息。
|
||||
10. 如果一条已成功保存的学习成果无法匹配到能力图谱中任何已定义的能力标签,则个人学习空间应将该学习成果标记为"待关联能力标签",并提示学生补充能力标签关联。
|
||||
|
||||
### 需求 2:AI 形成性与终结性评价及发展建议
|
||||
|
||||
**用户故事:** 作为学生,我希望 AI 在我新增成果时给出过程性反馈、在阶段结束时给出总结性画像,以便明确改进方向。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当一条学习成果被成功保存时,AI评估引擎应在 30 秒内生成针对该学习成果的形成性评价与发展建议。
|
||||
2. 当学生完成一个里程碑节点且该节点内存在至少一条学习成果时,AI评估引擎应基于该节点内的全部学习成果生成终结性评价。
|
||||
3. AI评估引擎生成的每条评价应包含可信度标注,且该可信度标注应列出该评价所依据的至少一条学习成果或权威知识源。
|
||||
4. AI评估引擎生成的每条发展建议应引用能力图谱中的至少一个能力标签,以指明该建议对应的能力维度。
|
||||
5. 如果某条学习成果缺少 AI 评价所需的内容信息,则 AI评估引擎应中止本次评价生成、保留该学习成果数据不变,并返回表明信息不足无法评价的提示且说明缺失的内容类别。
|
||||
6. 如果 AI评估引擎在生成形成性评价、终结性评价或发展建议过程中失败或超出生成时限,则 AI评估引擎应中止本次生成、保留触发评价的学习成果数据不变,并返回表明评价生成失败的提示。
|
||||
7. 如果学生完成的里程碑节点内不存在任何学习成果,则 AI评估引擎应跳过终结性评价生成并返回表明该节点无可评价学习成果的提示。
|
||||
|
||||
### 需求 3:课程对练题目生成与权威性保障
|
||||
|
||||
**用户故事:** 作为学生,我希望 AI 将课程整理为练习题供我练习,并由导师把关准确性,以便巩固课程知识。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生选择一门课程发起对练时,课程对练引擎应在 30 秒内基于该课程内容生成 5 至 50 道练习题。
|
||||
2. 课程对练引擎生成的每道题目应绑定至少一个权威知识源,并记录该权威知识源的引用标识。
|
||||
3. 课程对练引擎应支持生成以下题型:A1 型、A2 型、A3 型、A4 型选择题、病例分析题与临床决策题。
|
||||
4. 当课程对练引擎生成一道题目时,课程对练引擎应将该题目置于"待导师审核"状态,且在该题目通过导师审核前不向学生发布该题目。
|
||||
5. 如果一道题目未通过导师审核,则课程对练引擎应将该题目置于"已退回"状态、阻止该题目进入对练,并记录该次退回的原因。
|
||||
6. 当一道题目通过导师审核时,课程对练引擎应将该题目置于"可用于对练"状态并允许该题目进入对练。
|
||||
7. 如果课程对练引擎无法基于所选课程内容生成练习题,则课程对练引擎应中止本次生成、不创建任何题目,并向该学生返回表明无法生成练习题的提示。
|
||||
8. 如果课程对练引擎无法为一道生成的题目绑定任何权威知识源,则课程对练引擎应不发布该题目、不将其置于"待导师审核"状态,并记录该题目缺少权威知识源的原因。
|
||||
|
||||
### 需求 4:模拟对练与结果诊断
|
||||
|
||||
**用户故事:** 作为学生,我希望与 AI 进行模拟对练并获得结果分析,以便了解自己的薄弱环节。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生开始一次对练时,课程对练引擎应按顺序逐题向该学生呈现题目,且单次对练包含的题目数量应为 1 至 50 道。
|
||||
2. 当课程对练引擎呈现一道题目时,课程对练引擎应采集该学生针对该题目的作答。
|
||||
3. 当学生提交一道题目的作答时,课程对练引擎应在 3 秒内根据该题目的标准答案将该作答判定为正确或错误。
|
||||
4. 如果学生在单题作答时限 120 秒内未提交作答,那么课程对练引擎应将该题判定为错误、停止采集该题作答并继续呈现下一道题目。
|
||||
5. 当一次对练结束时,课程对练引擎应生成对练报告,且该报告应包含取值范围为 0% 至 100% 的正确率、以秒为单位的总用时,以及薄弱环节列表(薄弱环节定义为该次对练中正确率低于 60% 的能力标签)。
|
||||
6. 当一次对练报告生成时,课程对练引擎应在 5 秒内将该次对练成绩按能力标签映射到能力图谱。
|
||||
7. 当一次对练报告生成时,AI评估引擎应针对该报告中的每个薄弱环节生成改进建议。
|
||||
8. 当 AI评估引擎生成一条改进建议时,AI评估引擎应为该改进建议附带取值范围为 0% 至 100% 的可信度标注。
|
||||
|
||||
### 需求 5:临床情景对话对练(模拟问诊)
|
||||
|
||||
**用户故事:** 作为学生,我希望与 AI 进行模拟问诊等临床情景多轮对话训练,以便锻炼临床沟通与决策能力。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生发起一次临床情景对话对练时,临床情景对话对练应在 10 秒内基于绑定的至少一个权威知识源呈现一个临床情景。
|
||||
2. 在临床情景对话对练进行期间,临床情景对话对练应以设定的对话角色应答学生,支持学生进行最多 50 轮提问与应答,并在每一轮结束后记录该轮的学生输入与系统应答内容。
|
||||
3. 当一次临床情景对话对练结束时,临床情景对话对练应在 30 秒内生成包含问诊完整性、临床推理与医患沟通三个评估方面的对练报告。
|
||||
4. 当一次临床情景对话对练报告生成时,临床情景对话对练应将该次结果按能力标签映射到能力图谱。
|
||||
5. 如果学生发起的临床情景对话对练未绑定任何权威知识源,则临床情景对话对练应阻止该次对练启动、不创建对话记录,并返回表明缺少权威知识源无法启动的提示。
|
||||
6. 如果临床情景对话对练在生成对练报告过程中失败或超出 30 秒生成时限,则临床情景对话对练应中止本次报告生成、保留该次对话记录不变,并返回表明报告生成失败的提示。
|
||||
|
||||
### 需求 6:学生画像生成与能力维度刻画
|
||||
|
||||
**用户故事:** 作为学生,我希望系统基于我的学习数据形成多维度学生画像,以便为我的就业与发展提供数据支持。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生首次访问学生画像,或个人学习空间内容或能力图谱数据发生更新时,学生画像引擎应在5秒内基于个人学习空间内容与能力图谱数据生成学生画像。
|
||||
2. 学生画像应针对每个画像维度以0到100的量化分值呈现学生能力分布,画像维度至少包括知识掌握、临床技能、科研能力、人文素养、AI 协同素养与职业倾向共六个维度。
|
||||
3. 当学生的能力图谱数据发生更新时,学生画像引擎应在数据更新后10秒内完成数据同步,并在下一次画像访问时反映更新后的数据。
|
||||
4. 学生画像引擎生成的每项画像结论应可追溯到生成该结论所依据的至少一条学习成果或对练数据记录。
|
||||
5. 如果个人学习空间内容或能力图谱数据缺失或不足以刻画某一画像维度,则学生画像引擎应将该维度标记为"数据不足"状态,保留其余维度的画像结论,并向学生呈现指示数据不足的提示。
|
||||
|
||||
### 需求 7:学生画像数据隐私与合规
|
||||
|
||||
**用户故事:** 作为学生,我希望我的画像数据受到隐私保护并由我授权使用,以便我的敏感信息不被滥用。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 学生画像引擎应将学生画像中的身份标识、联系方式、健康与医疗记录、心理测评结果与生物特征数据标记为敏感字段。
|
||||
2. 如果访问者不是学生本人且不持有该学生仍在有效期内的知情同意授权,则合规模块应拒绝其访问该学生的完整学生画像。
|
||||
3. 当画像被用于学生本人之外的用途时,合规模块应校验存在一条仍在有效期内、且授权范围与用途均覆盖该用途的知情同意授权记录。
|
||||
4. 如果不存在满足该用途的有效知情同意授权记录,则合规模块应阻止该次画像使用、保持画像数据不被使用,并返回表明缺少有效授权的提示。
|
||||
5. 当导师或管理员访问学生画像时,合规模块应在 2 秒内对未获授权的敏感字段进行脱敏处理,并仅返回已获授权的字段。
|
||||
6. 当任意角色访问或导出学生画像时,合规模块应在 5 秒内记录包含访问者身份、时间、字段范围与用途的审计日志,且该审计日志的保留期限不少于 12 个月。
|
||||
7. 学生画像引擎应仅采集与至少一个画像维度直接相关的数据字段,不采集与任一画像维度无关的字段。
|
||||
8. 当学生撤销一项知情同意授权时,合规模块应使该授权立即失效,并在此后对相关用途按未授权处理。
|
||||
|
||||
### 需求 8:职业目标设定与岗位胜任力分解
|
||||
|
||||
**用户故事:** 作为学生,我希望设定职业目标并查看对应岗位胜任力模型,以便明确努力方向。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生设定一个职业目标时,职业规划模块应在3秒内将该职业目标关联到对应的岗位胜任力模型,并向学生展示该模型。
|
||||
2. 岗位胜任力模型应参照权威医学胜任力框架,且至少包含科学与学术、临床能力、健康与社会、职业素养四个能力维度。
|
||||
3. 职业规划模块应将岗位胜任力模型分解为可对照的能力标签集合,且每个能力维度至少包含3个能力标签。
|
||||
4. 如果学生设定的职业目标没有匹配的岗位胜任力模型,则职业规划模块应提示该目标暂不支持,并推荐至少3个相近的可选目标。
|
||||
5. 如果将职业目标关联到岗位胜任力模型的过程发生系统错误,则职业规划模块应向学生显示指示关联失败的错误提示,并保留学生已设定的职业目标。
|
||||
|
||||
### 需求 9:胜任力对照与动态闭环发展规划
|
||||
|
||||
**用户故事:** 作为学生,我希望将我的能力与目标岗位胜任力对照并获得可随能力变化更新的发展规划,以便持续弥补能力差距。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生请求生成发展规划时,职业规划模块应将该学生能力图谱中的每一项能力水平与目标岗位胜任力模型中对应的要求水平逐项对照,并将学生水平低于要求水平的能力项标记为能力差距项;对于学生能力图谱中缺少数据的能力项,应将其一并标记为能力差距项。
|
||||
2. 职业规划模块应在发展规划中为每个能力差距项生成至少一项建议行动,且每项建议行动应标明该差距项的目标能力水平。
|
||||
3. 职业规划模块生成的发展规划应为每个能力差距项推荐能力图谱中对应的至少一项学习资源或对练任务。
|
||||
4. 职业规划模块生成的发展规划应为每个能力差距项引用能力图谱中对应的能力标签,以标明该项规划对应的能力维度。
|
||||
5. 当学生的能力图谱数据发生更新后,且学生再次请求生成发展规划时,职业规划模块应基于更新后的能力水平重新计算全部能力差距项并生成更新后的发展规划。
|
||||
6. 如果学生在请求生成发展规划时未选择目标岗位,或目标岗位胜任力模型不可用,则职业规划模块应拒绝生成发展规划,向学生返回指明缺少目标岗位胜任力模型的错误提示,并保留该学生已有的能力图谱数据不变。
|
||||
7. 如果某个能力差距项在能力图谱中不存在对应的学习资源或对练任务,则职业规划模块应在发展规划中保留该能力差距项并标注其暂无可推荐的学习资源或对练任务。
|
||||
|
||||
### 需求 10:研究资料检索与检索式生成
|
||||
|
||||
**用户故事:** 作为学生,我希望用自然语言描述研究问题并由 AI 生成专业检索式进行检索与筛选,以便高效获取医学研究资料。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生以自然语言提交一个研究问题时,研究资料查询技能应在 10 秒内将该研究问题转换为包含 PICO 要素与 MeSH 主题词的检索式,且该研究问题的长度应为 1 至 2000 个字符。
|
||||
2. 当学生确认一个检索式时,研究资料查询技能应在 30 秒内在指定的资料来源范围内检索相关资料,并以每页最多 50 条对结果列表进行分页返回。
|
||||
3. 研究资料查询技能应支持学生按发表时间、来源类型与相关度对检索结果进行筛选。
|
||||
4. 研究资料查询技能返回的每条资料应仅包含检索信息、摘要与指向原文的链接,且不存储或分发受版权保护的全文。
|
||||
5. 当一次查询完成时,研究资料查询技能应在 5 秒内将该次查询使用情况按能力标签映射到能力图谱。
|
||||
6. 如果学生提交的研究问题为空、超过 2000 个字符或不包含任何可识别的医学要素,则研究资料查询技能应拒绝生成检索式并返回指明问题的提示。
|
||||
7. 如果一个检索式在指定资料来源范围内未匹配到任何资料,则研究资料查询技能应返回空结果列表并提示未找到匹配资料。
|
||||
8. 如果指定的资料来源全部不可用,则研究资料查询技能应中止本次检索、保留该检索式不变,并返回表明资料来源不可用的提示。
|
||||
|
||||
### 需求 11:研究资料总结、证据分级与引用管理
|
||||
|
||||
**用户故事:** 作为学生,我希望 AI 对检索结果进行可追溯的总结与证据分级,并按规范格式生成可导出的引用,以便支持课题研究与论文写作。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当学生请求对检索结果生成资料总结时,研究资料查询技能应在 30 秒内生成资料总结,且生成的总结中的每条结论应附带至少一条指向其来源条目的引用。
|
||||
2. 当研究资料查询技能生成资料总结时,研究资料查询技能应为每条纳入总结的资料从预定义的证据分级等级集合中标注唯一的一个证据分级。
|
||||
3. 当学生选择 Vancouver 或 GB/T 7714 格式请求生成引用时,研究资料查询技能应按所选格式为选定资料生成引用,并支持将生成的引用导出为 EndNote 或 NoteExpress 可识别的格式。
|
||||
4. 如果研究资料查询技能无法将一条总结结论追溯到具体的资料来源,则该技能应将该结论标注为"未验证"且不得作为引用输出。
|
||||
5. 当研究资料查询技能向学生呈现资料总结时,本系统应向学生显示研究资料查询技能仅辅助检索与总结、不替代学生阅读原文的提示。
|
||||
6. 如果研究资料查询技能在生成资料总结过程中失败或超出 30 秒生成时限,则研究资料查询技能应中止本次总结生成、不输出任何结论,并返回表明总结生成失败的提示。
|
||||
7. 如果引用生成或导出过程失败,则研究资料查询技能应中止本次引用生成或导出、保留选定资料与已生成的引用不变,并返回表明引用生成或导出失败的提示。
|
||||
|
||||
### 需求 12:AI 协同能力培养与评估
|
||||
|
||||
**用户故事:** 作为学生,我希望系统性地培养我与 AI 负责任协作的能力,以便在临床与科研中正确使用 AI。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. AI协同能力培养模块应为提问能力、批判性验证、责任边界意识与协同工作流四个协同能力维度各提供至少一个以真实任务为载体的协同训练任务。
|
||||
2. 当学生完成一个 AI 协同训练任务时,AI协同能力培养模块应在 30 秒内对该学生在提问能力、批判性验证、责任边界意识与协同工作流四个协同能力维度以 0 到 100 的分值进行评估。
|
||||
3. 如果学生在训练任务中直接采用未经来源核验的 AI 输出,则 AI协同能力培养模块应向该学生返回提示并要求其进行来源核验,且将该行为计入批判性验证维度的评估。
|
||||
4. 当一个协同能力评估结果生成时,AI协同能力培养模块应在 5 秒内将该评估结果按能力标签映射到能力图谱,使其计入学生画像与岗位胜任力对照。
|
||||
5. 当一个 AI 协同训练任务完成时,AI协同能力培养模块应允许导师对该学生的协同过程质量进行点评,并将点评对该学生可见。
|
||||
6. 如果 AI协同能力培养模块在生成协同能力评估过程中失败或超时,则该模块应中止本次评估、保留该训练任务记录不变,并返回表明评估生成失败的提示。
|
||||
7. 如果某一协同能力维度缺少足以评估的数据,则 AI协同能力培养模块应将该维度标记为"数据不足",并保留其余维度的评估结果。
|
||||
|
||||
### 需求 13:技能能力中心与技能治理
|
||||
|
||||
**用户故事:** 作为学生,我希望按需调用一系列可复用的 AI 技能,并希望这些技能可被教师治理,以便安全可靠地使用 AI 能力。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 技能能力中心应向学生提供一个可调用的技能库,且该技能库应至少包含资料查询、检索式生成、文献综述、病例分析、鉴别诊断辅助、医患沟通模拟、医学翻译与引用生成共 8 个技能。
|
||||
2. 每个技能定义应包含输入规格、AI 处理逻辑说明、知识源绑定、输出格式与可信度标注规则五个组成部分。
|
||||
3. 当学生以符合输入规格的输入调用一个已启用的技能时,技能能力中心应在 30 秒内按该技能定义的输出格式返回结果,并附带符合该技能可信度标注规则的可信度标注。
|
||||
4. 如果学生调用技能的输入不符合该技能定义的输入规格,则技能能力中心应拒绝本次调用并返回指明输入问题的提示。
|
||||
5. 如果一个技能在调用过程中其绑定知识源不可用或处理失败,则技能能力中心应终止本次调用、不返回结果与可信度标注,并返回表明调用失败的提示。
|
||||
6. 在一个技能对学生开放之前,技能能力中心应要求该技能定义经管理员或导师配置并启用。
|
||||
7. 当管理员或导师新增、修改或启用一个技能定义时,技能能力中心应记录包含操作者身份、操作时间与变更内容的审计日志。
|
||||
8. 当任意技能被成功调用完成时,技能能力中心应将该次调用的使用数据按能力标签映射到能力图谱。
|
||||
9. 对于任意有效的技能定义,技能能力中心对其进行序列化保存再读取后,应得到与原技能定义等价的技能定义(往返一致性)。
|
||||
|
||||
### 需求 14:能力图谱统一底座
|
||||
|
||||
**用户故事:** 作为学生,我希望各模块的数据通过统一的能力体系连接,以便我的学习、对练、技能使用与画像彼此打通。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 能力图谱应维护一套统一的能力标签体系,其中每个能力标签具有唯一标识与所属能力维度,供学习空间、课程对练、临床情景对话对练、研究技能、协同能力与职业规划共同引用。
|
||||
2. 当任意模块产生一条可计入能力的数据时,能力图谱应在 5 秒内将该数据映射到 1 至 10 个已定义的能力标签。
|
||||
3. 如果某模块提交的数据引用了能力图谱中未定义的能力标签,则能力图谱应拒绝该映射、不创建映射记录、记录拒绝原因,并向提交方返回表明能力标签未定义的提示。
|
||||
4. 当学生请求查看能力概览时,能力图谱应在 5 秒内聚合来自各模块的能力数据,并按能力维度以 0 到 100 的量化分值返回该学生的能力水平。
|
||||
5. 如果某一能力维度缺少足以计算能力水平的数据,则能力图谱应将该维度标记为"数据不足",并保留其余维度的能力水平。
|
||||
|
||||
### 需求 15:导师端与教学问责
|
||||
|
||||
**用户故事:** 作为导师,我希望审核题目、点评学生成果并查看所带学生的画像,以便履行带教职责。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当导师审核一道题目时,导师端应允许该导师通过或退回该题目,并在退回时记录原因。
|
||||
2. 当导师对一条学习成果进行点评时,导师端应将点评内容关联到该学习成果并对该学生可见。
|
||||
3. 在导师与学生存在带教关系的情况下,导师端应允许该导师查看其所带学生的学生画像。
|
||||
4. 如果导师请求查看非其所带学生的画像,则合规模块应拒绝该请求并记录该次越权访问尝试。
|
||||
|
||||
### 需求 16:AI 可信与可解释
|
||||
|
||||
**用户故事:** 作为学生和导师,我希望 AI 的评价、建议与总结都给出依据,以便我能够信任并核验 AI 的输出。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 当本系统生成任意 AI 评价、建议或总结时,本系统应为该输出附带可信度标注。
|
||||
2. 可信度标注应包含 AI 输出所依据的至少一条来源以及该输出取值范围为 0% 至 100% 的置信度。
|
||||
3. 当用户请求查看某条 AI 输出的依据时,本系统应在 3 秒内展示该输出可追溯到的来源条目及其引用标识。
|
||||
4. 如果一条 AI 输出无法追溯到任何来源,则本系统应将该输出标注为"未经核验",并在向用户呈现该输出时一并显示该标注。
|
||||
|
||||
### 需求 17:权限分级与审计
|
||||
|
||||
**用户故事:** 作为管理员,我希望系统按角色分级控制访问并保留审计日志,以便保障数据安全与合规。
|
||||
|
||||
#### 验收标准(Acceptance Criteria)
|
||||
|
||||
1. 合规模块应为学生、导师与管理员各自定义访问权限范围,且每个权限范围应列出可访问的资源类型与允许的操作类型。
|
||||
2. 当用户访问一项处于其角色权限范围内的受保护资源时,合规模块应允许该次访问。
|
||||
3. 如果用户访问超出其角色权限范围的资源,则合规模块应拒绝该次访问、保持该资源不被访问,并记录包含操作者、时间、资源标识与拒绝原因的事件。
|
||||
4. 当用户对敏感数据执行访问、导出或修改操作时,合规模块应在 5 秒内记录包含操作者、时间、操作类型与数据范围的审计日志。
|
||||
@@ -0,0 +1,275 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
本任务清单将设计转化为增量式编码任务。任务遵循测试驱动、底座先行的顺序:先建立能力图谱与技能能力中心两个底座及合规/可信横切层,再实现各业务模块,最后串联端到端流程。带 `*` 的任务为可选。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 项目脚手架与共享基础设施
|
||||
- 初始化 NestJS + TypeScript 后端工程与 PostgreSQL 连接,建立模块化目录结构
|
||||
- 实现统一的 `Result<T, E>` 返回类型与错误基类(区分校验/生成/授权/系统错误)
|
||||
- 配置测试框架(Jest)与属性测试库(fast-check),建立 mock AI 网关与 mock 资料来源适配器骨架
|
||||
- _Requirements: 全局基础_
|
||||
|
||||
- [x] 2. 能力图谱底座(Competency_Graph)
|
||||
- [x] 2.1 能力标签体系与数据模型
|
||||
- 实现 `CompetencyTag`(唯一标识、所属维度)与 `CompetencyDimension` 模型及持久化
|
||||
- 实现统一标签体系的维护与 `listTags()` 接口
|
||||
- 编写单元测试覆盖标签定义与维度归属
|
||||
- _Requirements: 14.1_
|
||||
|
||||
- [x] 2.2 能力数据映射与校验
|
||||
- 实现 `mapData()`:将模块数据映射到 1-10 个已定义标签(5 秒内)
|
||||
- 实现未定义标签拒绝逻辑:拒绝映射、不创建记录、记录原因、返回提示
|
||||
- 编写属性测试验证 Property 2(能力标签映射有效性)
|
||||
- _Requirements: 14.2, 14.3_
|
||||
|
||||
- [x] 2.3 能力概览聚合
|
||||
- 实现 `getCompetencyOverview()`:按维度以 0-100 量化分值聚合(5 秒内)
|
||||
- 实现数据不足维度标记"数据不足"并保留其余维度
|
||||
- 编写属性测试验证 Property 3(画像维度分值有界性,能力概览部分)
|
||||
- _Requirements: 14.4, 14.5_
|
||||
|
||||
- [x] 3. 技能能力中心底座(Skill_Center)
|
||||
- [x] 3.1 技能定义模型与序列化
|
||||
- 实现 `SkillDefinition` 五要素结构(输入规格、AI 处理逻辑、知识源绑定、输出格式、可信度标注规则)
|
||||
- 实现 `serialize()` / `deserialize()`
|
||||
- 编写属性测试验证 Property 1(技能定义序列化往返一致性)
|
||||
- _Requirements: 13.2, 13.9_
|
||||
|
||||
- [x] 3.2 技能治理:配置、启用与审计
|
||||
- 实现 `upsertSkillDefinition()` 与 `enableSkill()`,限定管理员或导师操作
|
||||
- 实现技能定义变更的审计日志记录(操作者、时间、变更内容)
|
||||
- 初始化至少 8 个内置技能定义(资料查询、检索式生成、文献综述、病例分析、鉴别诊断辅助、医患沟通模拟、医学翻译、引用生成)
|
||||
- 编写单元测试覆盖治理流程与技能库完整性
|
||||
- _Requirements: 13.1, 13.6, 13.7_
|
||||
|
||||
- [x] 3.3 技能调用与失败处理
|
||||
- 实现 `invokeSkill()`:输入规格校验、按输出格式返回结果并附可信度标注(30 秒内)
|
||||
- 实现输入不符规格的拒绝、知识源不可用或处理失败的终止(不返回结果与标注、提示失败)
|
||||
- 仅成功调用完成才写入能力图谱
|
||||
- 编写单元测试覆盖正常路径与各错误路径
|
||||
- _Requirements: 13.3, 13.4, 13.5, 13.8_
|
||||
|
||||
- [x] 4. 横切层:合规模块与 AI 可信层
|
||||
- [x] 4.1 权限分级与访问判定
|
||||
- 实现 `checkAccess()`:为学生/导师/管理员定义权限范围(可访问资源类型与允许操作类型)
|
||||
- 实现范围内允许、越权拒绝并记录拒绝事件(操作者、时间、资源标识、拒绝原因)
|
||||
- 编写属性测试验证 Property 6(权限判定一致性)
|
||||
- _Requirements: 17.1, 17.2, 17.3_
|
||||
|
||||
- [x] 4.2 知情同意授权与画像脱敏
|
||||
- 实现 `ConsentRecord` 模型(范围、用途、有效期、撤销)与 `verifyConsent()` / `revokeConsent()`
|
||||
- 实现 `resolveProfileView()`:非本人无有效授权时拒绝完整画像、对未授权敏感字段脱敏(2 秒内,仅返回已授权字段)
|
||||
- 实现授权撤销即时失效、此后按未授权处理
|
||||
- 编写属性测试验证 Property 4(授权强制不变量)
|
||||
- _Requirements: 7.2, 7.3, 7.4, 7.5, 7.8_
|
||||
|
||||
- [x] 4.3 审计日志
|
||||
- 实现 `writeAuditLog()`:敏感数据访问/导出/修改记录(操作者、时间、操作类型、数据范围,5 秒内),保留≥12 个月
|
||||
- 编写单元测试覆盖审计字段完整性与保留策略
|
||||
- _Requirements: 7.6, 17.4_
|
||||
|
||||
- [x] 4.4 AI 可信层
|
||||
- 实现统一可信度标注封装:为 AI 输出附带≥1 来源与 0%-100% 置信度
|
||||
- 实现依据查看(3 秒内展示可追溯来源条目及引用标识)与无法溯源时标注"未经核验"
|
||||
- 编写单元测试覆盖可信度标注与未经核验标记
|
||||
- _Requirements: 16.1, 16.2, 16.3, 16.4_
|
||||
|
||||
- [x] 5. 个人学习空间(Learning_Space)
|
||||
- [x] 5.1 学习成果模型与新增校验
|
||||
- 实现 `Achievement` 模型与成果类型枚举
|
||||
- 实现 `addAchievement()`:必填元数据校验、标题≤200 字符与时间有效性校验,失败时拒绝且不创建记录
|
||||
- 编写属性测试验证 Property 7(错误路径数据保全,学习成果部分)
|
||||
- _Requirements: 1.1, 1.2, 1.6, 1.9_
|
||||
|
||||
- [x] 5.2 自动归档与里程碑
|
||||
- 实现保存成功后按学年、学期归档;含临床轮转科室信息时额外按科室归档
|
||||
- 实现 `markMilestone()`:标记里程碑并关联节点内成果
|
||||
- 编写单元测试覆盖归档维度与里程碑关联
|
||||
- _Requirements: 1.3, 1.4, 1.8_
|
||||
|
||||
- [x] 5.3 成果检索与能力标签关联
|
||||
- 实现 `listAchievements()`:按时间倒序、支持按成果类型/学年/学期/轮转科室筛选、每页≤50 分页
|
||||
- 实现保存成功后关联能力图谱标签;无匹配标签时标记"待关联能力标签"并提示
|
||||
- 编写属性测试验证 Property 8(分页边界不变量,学习空间部分)
|
||||
- _Requirements: 1.5, 1.7, 1.10_
|
||||
|
||||
- [x] 6. AI 评估引擎(AI_Evaluation_Engine)
|
||||
- [x] 6.1 形成性与终结性评价
|
||||
- 实现 `generateFormativeEvaluation()`(30 秒内)与 `generateSummativeEvaluation()`(节点须有成果)
|
||||
- 实现评价附带可信度标注(≥1 依据来源)与发展建议引用≥1 能力标签
|
||||
- 编写单元测试覆盖评价生成与标注
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.7_
|
||||
|
||||
- [x] 6.2 评价错误路径
|
||||
- 实现信息不足时中止、保留数据、提示缺失类别
|
||||
- 实现生成失败或超时时中止、保留数据、提示失败
|
||||
- 编写单元测试覆盖错误路径(验证 Property 7 相关部分)
|
||||
- _Requirements: 2.5, 2.6_
|
||||
|
||||
- [x] 7. 课程对练引擎(Practice_Engine)
|
||||
- [x] 7.1 题目生成与权威源绑定
|
||||
- 实现 `generateQuestions()`:基于课程内容生成 5-50 道题(30 秒内),支持 A1/A2/A3/A4/病例分析/临床决策题型
|
||||
- 实现每题绑定≥1 权威知识源并记录引用标识;无法绑定时不进入审核并记录原因
|
||||
- 实现课程内容不足时中止并提示
|
||||
- 编写单元测试覆盖生成、题型、绑定与错误路径
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.7, 3.8_
|
||||
|
||||
- [x] 7.2 题目审核状态机
|
||||
- 实现题目状态流转(待导师审核→可用于对练/已退回),审核前不向学生发布
|
||||
- 实现导师通过/退回(退回记录原因)
|
||||
- 编写单元测试覆盖状态机与导师审核
|
||||
- _Requirements: 3.4, 3.5, 3.6, 15.1_
|
||||
|
||||
- [x] 7.3 模拟对练与结果诊断
|
||||
- 实现 `startPractice()` / `submitAnswer()` / `finishPractice()`:逐题呈现采集作答、3 秒内判定正误、单题超 120 秒判错并继续
|
||||
- 实现对练报告(正确率、用时、薄弱环节=正确率<60% 的能力标签)并按能力标签映射能力图谱
|
||||
- 实现 AI 评估引擎对每个薄弱环节生成改进建议并附可信度标注
|
||||
- 编写单元测试覆盖对练流程、报告与映射
|
||||
- _Requirements: 4.1, 4.2, 4.4, 4.5, 4.6, 4.7, 4.8_
|
||||
|
||||
- [x] 8. 临床情景对话对练(Clinical_Dialogue_Practice)
|
||||
- 实现 `startDialogue()`:基于绑定权威知识源 10 秒内呈现情景;未绑定时阻止启动、不创建记录、提示
|
||||
- 实现 `sendTurn()` 多轮对话(最多 50 轮)并逐轮记录学生输入与系统应答
|
||||
- 实现 `finishDialogue()`:30 秒内生成含问诊完整性/临床推理/医患沟通三维报告并映射能力图谱;失败或超时时中止、保留对话记录、提示
|
||||
- 编写单元测试覆盖发起、多轮、报告与错误路径
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
|
||||
|
||||
- [x] 9. 学生画像引擎(Profile_Engine)
|
||||
- 实现 `generateProfile()`:基于学习空间与能力图谱生成六维度画像(0-100,5 秒内)
|
||||
- 实现能力图谱更新后 10 秒内同步、每项结论可追溯≥1 来源记录
|
||||
- 实现数据不足维度标记"数据不足"并保留其余;敏感字段标记;仅采集与维度直接相关字段(数据最小化)
|
||||
- 编写属性测试验证 Property 3(画像维度分值有界性,画像部分)
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 7.1, 7.7_
|
||||
|
||||
- [x] 10. 职业规划模块(Career_Module)
|
||||
- [x] 10.1 职业目标与胜任力模型
|
||||
- 实现 `setCareerGoal()`:3 秒内关联岗位胜任力模型并展示
|
||||
- 实现胜任力模型四维度(科学与学术/临床能力/健康与社会/职业素养),每维度≥3 能力标签
|
||||
- 实现无匹配模型时提示并推荐≥3 相近目标;关联系统错误时提示并保留已设目标
|
||||
- 编写单元测试覆盖目标设定与错误路径
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 10.2 动态闭环发展规划
|
||||
- 实现 `generateDevelopmentPlan()`:逐项对照能力水平与要求、标记能力差距项(含缺数据项)
|
||||
- 实现每差距项≥1 建议行动(标明目标水平)、推荐≥1 学习资源或对练任务、引用对应能力标签
|
||||
- 实现能力图谱更新后再次请求时重算全部差距项;未选目标/模型不可用时拒绝并保留数据;差距项无资源时保留并标注暂无推荐
|
||||
- 编写单元测试覆盖对照、规划生成、动态更新与错误路径
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
|
||||
|
||||
- [x] 11. 研究资料查询技能(Research_Query_Skill)
|
||||
- [x] 11.1 检索式生成与检索
|
||||
- 实现 `generateSearchQuery()`:NL→PICO/MeSH 检索式(10 秒内,问题 1-2000 字符)
|
||||
- 实现 `search()`:指定来源 30 秒内检索、每页≤50 分页;空结果返回空列表并提示;来源全不可用时中止、保留检索式、提示
|
||||
- 实现问题为空/超长/无医学要素时拒绝生成并提示
|
||||
- 实现结果每条仅含检索信息/摘要/原文链接(不存储分发全文)、查询完成 5 秒内映射能力图谱
|
||||
- 编写单元测试覆盖检索式、检索、筛选与错误路径
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7, 10.8_
|
||||
|
||||
- [x] 11.2 总结、证据分级与引用管理
|
||||
- 实现 `summarize()`:30 秒内生成总结,每条结论附≥1 来源引用,每条资料标注唯一证据分级
|
||||
- 实现无法溯源结论标注"未验证"且不作为引用输出;呈现总结时显示"仅辅助不替代阅读原文"提示
|
||||
- 实现 `generateCitation()` / `exportCitations()`:Vancouver/GB-T 7714 格式、导出 EndNote/NoteExpress;生成或导出失败时中止、保留数据、提示
|
||||
- 编写属性测试验证 Property 5(引用可追溯不变量)
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
|
||||
- [x] 12. AI 协同能力培养模块(AI_Collaboration_Module)
|
||||
- 实现协同训练任务(提问能力/批判性验证/责任边界意识/协同工作流四维度各≥1)
|
||||
- 实现 `evaluateTask()`:30 秒内四维度 0-100 评估;直接采用未核验 AI 输出时提示并要求核验、计入批判性验证;评估结果 5 秒内映射能力图谱计入画像与胜任力对照
|
||||
- 实现导师点评(对学生可见);评估失败或超时时中止、保留记录、提示;维度数据不足时标记"数据不足"并保留其余
|
||||
- 编写单元测试覆盖任务、评估、点评与错误路径
|
||||
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_
|
||||
|
||||
- [x] 13. 导师端集成
|
||||
- 集成题目审核(复用 7.2)、成果点评(关联学习成果并对学生可见)
|
||||
- 实现带教关系校验:允许查看所带学生画像;查看非所带学生画像时合规模块拒绝并记录越权尝试
|
||||
- 编写单元测试覆盖点评与带教关系访问控制
|
||||
- _Requirements: 15.2, 15.3, 15.4_
|
||||
|
||||
- [x] 14. 端到端集成与贯通验证
|
||||
- 串联学习成果→能力标签→画像→职业规划的数据贯通流程并编写集成测试
|
||||
- 串联技能调用(课程对练/研究查询)→能力图谱→画像的贯通流程并编写集成测试
|
||||
- 验证全系统 AI 输出均经 AI 可信层、敏感资源访问均经合规模块
|
||||
- 运行全部属性测试(Property 1-8)确认核心不变量成立
|
||||
- _Requirements: 2.4, 4.6, 5.4, 9.1, 12.4, 14.1, 16.1, 17.2_
|
||||
|
||||
- [x] 15. 三端前端界面(可选)
|
||||
- 实现学生端(学习空间、对练、画像、职业规划、研究查询、协同训练)核心界面
|
||||
- 实现导师端(题目审核、成果点评、带教学生画像)与管理端(技能治理、权限与合规管理)核心界面
|
||||
- _Requirements: 全部用户故事的界面呈现_
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T1["1. 脚手架与基础设施"]
|
||||
T2["2. 能力图谱底座"]
|
||||
T3["3. 技能能力中心底座"]
|
||||
T4["4. 合规与AI可信横切层"]
|
||||
T5["5. 个人学习空间"]
|
||||
T6["6. AI评估引擎"]
|
||||
T7["7. 课程对练引擎"]
|
||||
T8["8. 临床情景对话对练"]
|
||||
T9["9. 学生画像引擎"]
|
||||
T10["10. 职业规划模块"]
|
||||
T11["11. 研究资料查询技能"]
|
||||
T12["12. AI协同能力培养"]
|
||||
T13["13. 导师端集成"]
|
||||
T14["14. 端到端集成"]
|
||||
T15["15*. 三端前端界面"]
|
||||
|
||||
T1 --> T2
|
||||
T1 --> T3
|
||||
T1 --> T4
|
||||
T2 --> T5
|
||||
T3 --> T7
|
||||
T3 --> T8
|
||||
T3 --> T11
|
||||
T4 --> T9
|
||||
T4 --> T13
|
||||
T5 --> T6
|
||||
T5 --> T9
|
||||
T6 --> T7
|
||||
T2 --> T6
|
||||
T2 --> T9
|
||||
T9 --> T10
|
||||
T2 --> T10
|
||||
T2 --> T12
|
||||
T7 --> T13
|
||||
T9 --> T13
|
||||
T5 --> T14
|
||||
T6 --> T14
|
||||
T7 --> T14
|
||||
T8 --> T14
|
||||
T9 --> T14
|
||||
T10 --> T14
|
||||
T11 --> T14
|
||||
T12 --> T14
|
||||
T13 --> T14
|
||||
T14 --> T15
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "wave": 1, "tasks": ["1"] },
|
||||
{ "wave": 2, "tasks": ["2", "3", "4"] },
|
||||
{ "wave": 3, "tasks": ["5"] },
|
||||
{ "wave": 4, "tasks": ["6", "7", "8", "9", "11", "12"] },
|
||||
{ "wave": 5, "tasks": ["10", "13"] },
|
||||
{ "wave": 6, "tasks": ["14"] },
|
||||
{ "wave": 7, "tasks": ["15"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- 任务 2(能力图谱)与任务 3(技能能力中心)是两大底座,应优先实现,多数业务模块依赖它们。
|
||||
- 任务 4(合规与 AI 可信横切层)为所有涉及敏感数据访问与 AI 输出的模块提供统一保障,建议与底座并行推进。
|
||||
- 每个业务模块任务均包含对应的单元测试或属性测试,覆盖正常路径与错误路径。
|
||||
- 属性测试(Property 1-8)分散在相关任务中实现,并在任务 14 统一运行确认。
|
||||
- 任务 15 为可选的前端界面实现,后端模块与测试可独立于前端完成验证。
|
||||
- 技术栈(NestJS + TypeScript + PostgreSQL + Jest + fast-check)为设计阶段的假设,实现前可调整。
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# ===== 燃尽模式规则 START =====
|
||||
# 最高优先级规则 - 必须严格遵守(Windsurf 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "windsurf" "qlvw9j" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
|
||||
# ===== 燃尽模式规则 END =====
|
||||
|
||||
# 最高优先级规则 - 必须严格遵守(Windsurf 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "windsurf" "qlvw9j" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
@@ -0,0 +1,32 @@
|
||||
医科类高校的AI学习中心系统
|
||||
|
||||
1、大学生个人学习空间,将个人在校学习相关的内容都汇集在这个空间,通过AI对每个阶段或每个成果加入后进行评价,给出学习和发展的建议
|
||||
|
||||
2、课程对练,将课程通过AI进行梳理形成练习题,和学生进行模拟联系,对结果进行分析、评价和建议
|
||||
|
||||
3、根据学习空间的内容形成学生画像,为未来就业发展提供数据支持。
|
||||
|
||||
4、学生可以确定职业目标,分解岗位胜任力模型,对照学生模型,给出发展规划建议
|
||||
|
||||
5、学生的研究资料查询 skill:学生可以通过 AI 检索医学研究资料(文献、指南、数据库等),AI 辅助进行检索、筛选、总结与引用,支持科研学习与课题研究
|
||||
|
||||
6、医科类学生 AI 协同能力培养:系统性培养医学生与 AI 协作的能力,包括如何向 AI 提问、如何审视和验证 AI 输出、如何在临床与科研场景中负责任地使用 AI,提升人机协同素养
|
||||
|
||||
7、Skill 能力中心:为学生提供一系列可复用的 AI skill(如资料查询 skill、病例分析 skill、文献综述 skill、检索式生成 skill 等),形成可扩展的 skill 库,学生按需调用
|
||||
|
||||
8、你的好的建议?
|
||||
|
||||
学生要有学号,导师、管理员要有工号
|
||||
|
||||
9、管理员:
|
||||
用户管理:用户、角色、权限、合规
|
||||
AI应用管理:生成、上架、下架、审核等
|
||||
日志和审计:
|
||||
AI用量:用户、AI应用、用例
|
||||
|
||||
10、每类用户增加profie
|
||||
|
||||
11、导师:
|
||||
带教学生列表
|
||||
针对每个学生带教的工作安排和进度展现
|
||||
带教学生工作完成情况和总体表现(问题分析)、指导方向和要点等
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 百度网盘备份脚本
|
||||
# 用法: ./baidu-backup.sh [目标目录]
|
||||
# 示例: ./baidu-backup.sh /2026/0517
|
||||
# ./baidu-backup.sh # 自动使用 /年份/月日
|
||||
# ============================================================
|
||||
|
||||
# 不使用 set -e(与并行后台进程不兼容)
|
||||
|
||||
# ---- 配置 ----
|
||||
APP_KEY="z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh"
|
||||
SECRET_KEY="ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR"
|
||||
TOKEN_FILE="$HOME/.baidu_pan_token.json"
|
||||
BLOCK_SIZE=$((32 * 1024 * 1024)) # 32MB 大分片减少HTTP开销
|
||||
PARALLEL=4 # 并行上传数
|
||||
|
||||
# ---- 自动识别项目 ----
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_NAME="$(basename "$SCRIPT_DIR")"
|
||||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ZIP_FILE="/tmp/${PROJECT_NAME}.zip"
|
||||
MD5_DIR="/tmp/baidu_md5"
|
||||
|
||||
# ---- 目标目录 ----
|
||||
if [ -n "$1" ]; then
|
||||
REMOTE_DIR="$1"
|
||||
else
|
||||
REMOTE_DIR="/$(date +%Y)/$(date +%m%d)"
|
||||
fi
|
||||
REMOTE_PATH="${REMOTE_DIR}/${PROJECT_NAME}.zip"
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ 📦 百度网盘备份工具 ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " 项目: $PROJECT_NAME"
|
||||
echo " 目标: $REMOTE_PATH"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 步骤1: 打包
|
||||
# ============================================================
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[1/6] 📦 打包项目文件..."
|
||||
rm -f "$ZIP_FILE"
|
||||
cd "$PARENT_DIR"
|
||||
|
||||
# 先统计文件总数
|
||||
TOTAL_FILES=$(find "$PROJECT_NAME" -not -path "${PROJECT_NAME}/.git/*" -type f | wc -l | tr -d ' ')
|
||||
echo " 📊 共 ${TOTAL_FILES} 个文件"
|
||||
|
||||
# 打包并实时显示百分比
|
||||
COUNTER=0
|
||||
zip -r "$ZIP_FILE" "$PROJECT_NAME" -x "${PROJECT_NAME}/.git/*" 2>&1 | while IFS= read -r line; do
|
||||
COUNTER=$((COUNTER + 1))
|
||||
PCT=$((COUNTER * 100 / TOTAL_FILES))
|
||||
if [ $PCT -gt 100 ]; then PCT=100; fi
|
||||
# 进度条
|
||||
BAR_FILLED=$((PCT * 30 / 100))
|
||||
BAR_EMPTY=$((30 - BAR_FILLED))
|
||||
BAR=""
|
||||
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}█"; done
|
||||
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}░"; done
|
||||
printf "\r %s %3d%% (%d/%d) " "$BAR" "$PCT" "$COUNTER" "$TOTAL_FILES"
|
||||
done
|
||||
echo ""
|
||||
|
||||
FILE_SIZE=$(stat -f%z "$ZIP_FILE" 2>/dev/null || stat -c%s "$ZIP_FILE" 2>/dev/null)
|
||||
FILE_SIZE_MB=$((FILE_SIZE / 1024 / 1024))
|
||||
echo " ✅ 打包完成: ${FILE_SIZE_MB}MB"
|
||||
|
||||
# ============================================================
|
||||
# 步骤2: 获取 Token(缓存 / 刷新 / 设备授权)
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[2/6] 🔑 获取百度网盘授权..."
|
||||
|
||||
ACCESS_TOKEN=""
|
||||
|
||||
# 尝试读取缓存
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
CACHED=$(python3 -c "
|
||||
import json, time, sys
|
||||
with open('$TOKEN_FILE') as f:
|
||||
d = json.load(f)
|
||||
if time.time() < d.get('expires_at', 0):
|
||||
print('VALID|' + d['access_token'])
|
||||
else:
|
||||
print('EXPIRED|' + d.get('refresh_token', ''))
|
||||
" 2>/dev/null || echo "FAIL|")
|
||||
|
||||
STATUS="${CACHED%%|*}"
|
||||
VALUE="${CACHED#*|}"
|
||||
|
||||
if [ "$STATUS" = "VALID" ]; then
|
||||
ACCESS_TOKEN="$VALUE"
|
||||
echo " ✅ 使用缓存 Token(有效)"
|
||||
elif [ "$STATUS" = "EXPIRED" ] && [ -n "$VALUE" ]; then
|
||||
echo " ⏳ Token 已过期,尝试刷新..."
|
||||
RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||||
-d "grant_type=refresh_token&refresh_token=${VALUE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
|
||||
NEW_TOKEN=$(echo "$RESP" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('access_token',''))" 2>/dev/null)
|
||||
if [ -n "$NEW_TOKEN" ]; then
|
||||
ACCESS_TOKEN="$NEW_TOKEN"
|
||||
# 更新缓存
|
||||
python3 -c "
|
||||
import json, time
|
||||
d = json.loads('$RESP'.replace(\"'\", '\"'))
|
||||
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
|
||||
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
|
||||
" 2>/dev/null
|
||||
echo " ✅ Token 刷新成功"
|
||||
else
|
||||
echo " ⚠️ 刷新失败,需要重新授权"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 如果没有有效 Token,走设备授权流程
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo " 🔐 需要设备授权..."
|
||||
DEVICE_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/device/code" \
|
||||
-d "response_type=device_code&client_id=${APP_KEY}&scope=basic,netdisk")
|
||||
|
||||
DEVICE_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_code'])")
|
||||
USER_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['user_code'])")
|
||||
QRCODE_URL=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['qrcode_url'])")
|
||||
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────┐"
|
||||
echo " │ 请打开: https://openapi.baidu.com/device"
|
||||
echo " │ 输入码: $USER_CODE"
|
||||
echo " │ 或扫码: $QRCODE_URL"
|
||||
echo " └─────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -p " 授权完成后按回车继续..." _
|
||||
|
||||
TOKEN_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||||
-d "grant_type=device_token&code=${DEVICE_CODE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo " ❌ 授权失败: $TOKEN_RESP"
|
||||
rm -f "$ZIP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 保存缓存
|
||||
echo "$TOKEN_RESP" | python3 -c "
|
||||
import sys, json, time
|
||||
d = json.load(sys.stdin)
|
||||
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
|
||||
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
|
||||
"
|
||||
echo " ✅ 授权成功,Token 已缓存"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 步骤3: 创建远程目录
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[3/6] 📁 创建远程目录 ${REMOTE_DIR} ..."
|
||||
DIR_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${ACCESS_TOKEN}" \
|
||||
-d "path=${REMOTE_DIR}&size=0&isdir=1")
|
||||
DIR_ERRNO=$(echo "$DIR_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('errno',99))" 2>/dev/null)
|
||||
if [ "$DIR_ERRNO" = "0" ] || [ "$DIR_ERRNO" = "-8" ]; then
|
||||
echo " ✅ 目录已就绪"
|
||||
else
|
||||
echo " ⚠️ 目录创建返回: errno=$DIR_ERRNO(继续尝试上传)"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 步骤4: 预创建文件
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
BLOCKS=$(( (FILE_SIZE + BLOCK_SIZE - 1) / BLOCK_SIZE ))
|
||||
echo "[4/6] 📋 预创建文件(${BLOCKS}个分片)..."
|
||||
BLOCK_LIST=$(python3 -c "import json; print(json.dumps(['0'*32]*$BLOCKS))")
|
||||
PRE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=${ACCESS_TOKEN}" \
|
||||
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&autoinit=1&block_list=${BLOCK_LIST}")
|
||||
UPLOAD_ID=$(echo "$PRE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('uploadid',''))" 2>/dev/null)
|
||||
if [ -z "$UPLOAD_ID" ]; then
|
||||
echo " ❌ 预创建失败: $PRE_RESP"
|
||||
rm -f "$ZIP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ 预创建成功"
|
||||
|
||||
# ============================================================
|
||||
# 步骤5: 分片上传(32MB大分片 + 并行上传)
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[5/6] 🚀 上传中(${PARALLEL}路并行, 每片$((BLOCK_SIZE/1024/1024))MB)..."
|
||||
echo ""
|
||||
|
||||
# 预切割文件
|
||||
SPLIT_DIR="/tmp/baidu_chunks"
|
||||
rm -rf "$SPLIT_DIR"
|
||||
mkdir -p "$SPLIT_DIR"
|
||||
split -b ${BLOCK_SIZE} -a 4 "$ZIP_FILE" "${SPLIT_DIR}/chunk_"
|
||||
CHUNK_FILES=($(ls "${SPLIT_DIR}/chunk_"* | sort))
|
||||
BLOCKS=${#CHUNK_FILES[@]}
|
||||
echo " 📊 已切割为 ${BLOCKS} 个分片"
|
||||
echo ""
|
||||
|
||||
# 创建md5结果目录
|
||||
MD5_DIR="/tmp/baidu_md5"
|
||||
rm -rf "$MD5_DIR"
|
||||
mkdir -p "$MD5_DIR"
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
DONE_COUNT=0
|
||||
FAIL_FLAG="/tmp/baidu_upload_fail"
|
||||
rm -f "$FAIL_FLAG"
|
||||
|
||||
# 单片上传函数(带重试)
|
||||
upload_chunk() {
|
||||
local SEQ=$1
|
||||
local CHUNK_FILE=$2
|
||||
local RETRY=0
|
||||
local MAX_RETRY=3
|
||||
while [ $RETRY -lt $MAX_RETRY ]; do
|
||||
local RESP
|
||||
RESP=$(curl -s --connect-timeout 30 --max-time 300 \
|
||||
"https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?method=upload&access_token=${ACCESS_TOKEN}&type=tmpfile&path=${REMOTE_PATH}&uploadid=${UPLOAD_ID}&partseq=${SEQ}" \
|
||||
-F "file=@${CHUNK_FILE}" 2>&1)
|
||||
local MD5
|
||||
MD5=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('md5',''))" 2>/dev/null)
|
||||
if [ -n "$MD5" ]; then
|
||||
echo "$MD5" > "${MD5_DIR}/$(printf '%04d' $SEQ).md5"
|
||||
return 0
|
||||
fi
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ $RETRY -lt $MAX_RETRY ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
echo "chunk $SEQ failed: $RESP" > "${FAIL_FLAG}_${SEQ}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 并行上传
|
||||
for ((i=0; i<BLOCKS; i+=PARALLEL)); do
|
||||
PIDS=()
|
||||
for ((j=0; j<PARALLEL && i+j<BLOCKS; j++)); do
|
||||
SEQ=$((i + j))
|
||||
upload_chunk $SEQ "${CHUNK_FILES[$SEQ]}" &
|
||||
PIDS+=($!)
|
||||
done
|
||||
|
||||
# 等待本批完成
|
||||
BATCH_FAIL=0
|
||||
for PID in "${PIDS[@]}"; do
|
||||
wait $PID || BATCH_FAIL=1
|
||||
done
|
||||
if [ $BATCH_FAIL -ne 0 ]; then
|
||||
echo ""
|
||||
echo " ❌ 上传失败:"
|
||||
cat "${FAIL_FLAG}_"* 2>/dev/null
|
||||
rm -rf "$SPLIT_DIR" "$MD5_DIR" "$ZIP_FILE" "${FAIL_FLAG}_"*
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 计算进度
|
||||
DONE_COUNT=$((i + ${#PIDS[@]}))
|
||||
if [ $DONE_COUNT -gt $BLOCKS ]; then DONE_COUNT=$BLOCKS; fi
|
||||
PCT=$((DONE_COUNT * 100 / BLOCKS))
|
||||
UPLOADED_MB=$((DONE_COUNT * BLOCK_SIZE / 1024 / 1024))
|
||||
if [ $UPLOADED_MB -gt $FILE_SIZE_MB ]; then UPLOADED_MB=$FILE_SIZE_MB; fi
|
||||
NOW=$(date +%s)
|
||||
ELAPSED=$((NOW - START_TIME))
|
||||
if [ $ELAPSED -gt 0 ]; then
|
||||
SPEED_MB=$((UPLOADED_MB / ELAPSED))
|
||||
REMAINING_MB=$((FILE_SIZE_MB - UPLOADED_MB))
|
||||
if [ $SPEED_MB -gt 0 ]; then
|
||||
ETA=$((REMAINING_MB / SPEED_MB))
|
||||
ETA_STR="${ETA}s"
|
||||
if [ $ETA -ge 60 ]; then ETA_STR="$((ETA/60))m$((ETA%60))s"; fi
|
||||
else
|
||||
ETA_STR="计算中"
|
||||
fi
|
||||
else
|
||||
SPEED_MB=0
|
||||
ETA_STR="计算中"
|
||||
fi
|
||||
|
||||
BAR_FILLED=$((PCT * 30 / 100))
|
||||
BAR_EMPTY=$((30 - BAR_FILLED))
|
||||
BAR=""
|
||||
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}█"; done
|
||||
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}░"; done
|
||||
printf "\r %s %3d%% | %d/%dMB | %dMB/s | 剩余%s " "$BAR" "$PCT" "$UPLOADED_MB" "$FILE_SIZE_MB" "$SPEED_MB" "$ETA_STR"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo " ✅ 所有分片上传完成"
|
||||
|
||||
# 删除临时分片
|
||||
rm -rf "$SPLIT_DIR"
|
||||
|
||||
# ============================================================
|
||||
# 步骤6: 合并文件
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[6/6] 🔗 合并文件..."
|
||||
MD5_LIST=$(cat "${MD5_DIR}/"*.md5 | awk '{printf "\"%s\",", $0}' | sed 's/,$//')
|
||||
CREATE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${ACCESS_TOKEN}" \
|
||||
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&uploadid=${UPLOAD_ID}&block_list=[${MD5_LIST}]")
|
||||
CREATE_ERRNO=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('errno',99))" 2>/dev/null)
|
||||
FINAL_PATH=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
|
||||
|
||||
# 清理
|
||||
rm -f "$ZIP_FILE" /tmp/chunk_part
|
||||
rm -rf "$MD5_DIR" "$FAIL_FLAG"
|
||||
|
||||
# 计算耗时
|
||||
END_TIME=$(date +%s)
|
||||
TOTAL=$((END_TIME - START_TIME))
|
||||
T_MIN=$((TOTAL / 60))
|
||||
T_SEC=$((TOTAL % 60))
|
||||
|
||||
# 输出结果
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════╗"
|
||||
if [ "$CREATE_ERRNO" = "0" ]; then
|
||||
echo "║ 📦 百度网盘备份完成! ║"
|
||||
echo "╠══════════════════════════════════════════════╣"
|
||||
echo " 📁 路径: $FINAL_PATH"
|
||||
echo " 📊 大小: ${FILE_SIZE_MB} MB"
|
||||
echo " ⏱️ 耗时: ${T_MIN}分${T_SEC}秒"
|
||||
echo " ✅ 状态: 上传成功"
|
||||
else
|
||||
echo "║ ❌ 合并失败 ║"
|
||||
echo "╠══════════════════════════════════════════════╣"
|
||||
echo " errno: $CREATE_ERRNO"
|
||||
echo " 响应: $CREATE_RESP"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,52 @@
|
||||
# 医科高校 AI 学习中心 · 前端(三端)
|
||||
|
||||
基于 Next.js 14(App Router)+ TypeScript + Tailwind CSS 的三端前端,与 NestJS 后端真实联调。
|
||||
|
||||
## 三端
|
||||
|
||||
- 学生端 `/student`:学习空间、能力画像、职业规划、课程对练、临床对话对练、研究资料查询、AI 协同训练
|
||||
- 导师端 `/mentor`:题目审核、成果点评、带教学生画像
|
||||
- 管理端 `/admin`:技能治理、权限与合规
|
||||
|
||||
登录后按账号角色自动进入对应工作台;`AppShell` 做角色访问守卫,跨角色访问会被拦截。
|
||||
|
||||
## 与后端联调
|
||||
|
||||
- 所有请求走 `/api/*`,由 `next.config.mjs` 的 rewrites 代理到后端(默认 `http://localhost:3000`),避免浏览器跨域。
|
||||
- 鉴权:登录/注册成功后将 JWT 存入 `localStorage`,后续请求自动附加 `Authorization: Bearer <token>`。
|
||||
- 错误:后端统一错误体(`AppError.toJSON()`)由 `ApiError` 承载,UI 展示 `message`。
|
||||
|
||||
可用环境变量 `BACKEND_URL` 覆盖后端地址(如部署时)。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
# 1) 先启动后端(仓库根目录)
|
||||
npm run start:dev # 监听 3000,需 .env 中 DB_ENABLED 与 PostgreSQL(可选)
|
||||
|
||||
# 2) 启动前端(frontend 目录)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 监听 4000
|
||||
```
|
||||
|
||||
打开 http://localhost:4000 ,注册一个账号(可选角色:学生 / 导师 / 管理员)后即可进入对应工作台。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build && npm run start
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
src/
|
||||
app/ # App Router 页面(login / student / mentor / admin)
|
||||
components/ # AppShell(外壳+守卫)与基础 UI 组件
|
||||
lib/
|
||||
api.ts # fetch 封装:鉴权头、错误解析(ApiError)
|
||||
auth-context.tsx # 登录态上下文(login/register/logout/me)
|
||||
services.ts # 按后端模块组织的 API 服务函数
|
||||
types.ts # 与后端对齐的类型与枚举
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// 将 /api/* 代理到 NestJS 后端,避免浏览器跨域并统一前端调用路径。
|
||||
async rewrites() {
|
||||
const backend = process.env.BACKEND_URL ?? 'http://localhost:3000';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${backend}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1664
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "college-ai-center-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.15",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.10",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.6",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
/** 权限与合规:查看三类角色权限范围、审计日志与越权拒绝事件(可按操作者过滤)。 */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { adminComplianceApi } from '@/lib/services';
|
||||
|
||||
export default function AdminCompliancePage() {
|
||||
const [scopes, setScopes] = useState<any[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
const [denials, setDenials] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actorId, setActorId] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [sc, logs, dn] = await Promise.all([
|
||||
adminComplianceApi.permissionScopes(),
|
||||
adminComplianceApi.auditLogs(actorId || undefined),
|
||||
adminComplianceApi.denialEvents(actorId || undefined),
|
||||
]);
|
||||
setScopes(Array.isArray(sc) ? sc : []);
|
||||
setAuditLogs(Array.isArray(logs) ? logs : []);
|
||||
setDenials(Array.isArray(dn) ? dn : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actorId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">权限与合规</h1>
|
||||
|
||||
<Card title="角色权限范围">
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{scopes.map((sc: any, i: number) => (
|
||||
<div
|
||||
key={sc.role ?? i}
|
||||
className="rounded-lg border border-slate-100 p-3"
|
||||
>
|
||||
<p className="mb-2 text-sm font-semibold text-brand-700">
|
||||
{sc.role}
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs text-slate-500">
|
||||
{(sc.permissions ?? []).map((p: any, pi: number) => (
|
||||
<li key={pi}>
|
||||
{typeof p === 'string' ? (
|
||||
<>· {p}</>
|
||||
) : (
|
||||
<>
|
||||
· <span className="font-medium">{p.resourceType}</span>
|
||||
:{Array.isArray(p.actions) ? p.actions.join(' / ') : ''}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="审计与越权事件"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="input max-w-[200px]"
|
||||
value={actorId}
|
||||
onChange={(e) => setActorId(e.target.value)}
|
||||
placeholder="按操作者 ID 过滤"
|
||||
/>
|
||||
<button className="btn-ghost" onClick={load} disabled={loading}>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
审计日志({auditLogs.length})
|
||||
</h3>
|
||||
{auditLogs.length === 0 ? (
|
||||
<EmptyState message="暂无审计日志。" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{auditLogs.map((log: any, i: number) => (
|
||||
<li
|
||||
key={log.id ?? i}
|
||||
className="rounded-lg border border-slate-100 p-3 text-xs text-slate-600"
|
||||
>
|
||||
<pre className="overflow-auto">
|
||||
{JSON.stringify(log, null, 2)}
|
||||
</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
越权拒绝事件({denials.length})
|
||||
</h3>
|
||||
{denials.length === 0 ? (
|
||||
<EmptyState message="暂无越权拒绝事件。" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{denials.map((d: any, i: number) => (
|
||||
<li
|
||||
key={d.id ?? i}
|
||||
className="rounded-lg border border-red-100 bg-red-50 p-3 text-xs text-red-700"
|
||||
>
|
||||
<pre className="overflow-auto">
|
||||
{JSON.stringify(d, null, 2)}
|
||||
</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { AppShell, type NavGroup } from '@/components/app-shell';
|
||||
import { Role } from '@/lib/types';
|
||||
|
||||
const NAV: NavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ href: '/admin', label: '工作台', icon: 'home' },
|
||||
{ href: '/admin/skills', label: '技能治理', icon: 'sliders' },
|
||||
{ href: '/admin/compliance', label: '权限与合规', icon: 'shield' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppShell requiredRole={Role.Administrator} nav={NAV} title="管理端">
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card } from '@/components/ui';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
href: '/admin/skills',
|
||||
title: '技能治理',
|
||||
desc: '查看、新增/修改、启用技能定义,查看治理审计日志。',
|
||||
},
|
||||
{
|
||||
href: '/admin/compliance',
|
||||
title: '权限与合规',
|
||||
desc: '查看角色权限范围、审计日志与越权拒绝事件。',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminHome() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-800">
|
||||
管理工作台,{user?.username}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">技能治理与合规管理。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{FEATURES.map((f) => (
|
||||
<Link key={f.href} href={f.href}>
|
||||
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
|
||||
<h2 className="text-base font-semibold text-brand-700">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 技能治理:列出技能定义、查看详情、启用,并以「引导表单 + JSON 编辑器」新增/修改技能。
|
||||
*
|
||||
* 技能定义为五要素深层嵌套结构:id/name/enabled 用表单字段,五要素用 JSON 编辑(与后端
|
||||
* 「最小校验 + 透传服务层规范化」约定一致),并提供模板降低输入成本。审计日志结构化展示。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { InfoRow, RawDetails } from '@/components/display';
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { adminSkillsApi } from '@/lib/services';
|
||||
import type { SkillAuditLogEntry, SkillDefinition } from '@/lib/types';
|
||||
|
||||
const FIVE_ELEMENTS_TEMPLATE = JSON.stringify(
|
||||
{
|
||||
inputSpec: { fields: [{ name: 'items', type: 'array', required: true }] },
|
||||
processingLogic: { strategy: 'summarize', model: 'mock' },
|
||||
knowledgeSources: [{ type: 'PUBMED', weight: 1 }],
|
||||
outputFormat: { type: 'structured', schema: 'summary' },
|
||||
credibilityRule: { minEvidenceLevel: 'B', annotate: true },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [skills, setSkills] = useState<SkillDefinition[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<SkillAuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 引导表单字段
|
||||
const [skillId, setSkillId] = useState('research-summary');
|
||||
const [skillName, setSkillName] = useState('研究资料总结技能');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [elementsJson, setElementsJson] = useState(FIVE_ELEMENTS_TEMPLATE);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [detail, setDetail] = useState<SkillDefinition | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, logs] = await Promise.all([
|
||||
adminSkillsApi.list(),
|
||||
adminSkillsApi.auditLogs(),
|
||||
]);
|
||||
setSkills(Array.isArray(s) ? s : []);
|
||||
setAuditLogs(Array.isArray(logs) ? logs : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleUpsert(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
let elements: any;
|
||||
try {
|
||||
elements = JSON.parse(elementsJson);
|
||||
} catch {
|
||||
setFormError('五要素 JSON 格式不正确,请检查后重试。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await adminSkillsApi.upsert({
|
||||
id: skillId,
|
||||
name: skillName,
|
||||
enabled,
|
||||
inputSpec: elements.inputSpec,
|
||||
processingLogic: elements.processingLogic,
|
||||
knowledgeSources: elements.knowledgeSources,
|
||||
outputFormat: elements.outputFormat,
|
||||
credibilityRule: elements.credibilityRule,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await adminSkillsApi.enable(id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '启用失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(s: SkillDefinition) {
|
||||
// 载入已有技能到表单(便于修改)。
|
||||
setSkillId(s.id);
|
||||
setSkillName(s.name);
|
||||
setEnabled(s.enabled);
|
||||
setElementsJson(
|
||||
JSON.stringify(
|
||||
{
|
||||
inputSpec: s.inputSpec ?? {},
|
||||
processingLogic: s.processingLogic ?? {},
|
||||
knowledgeSources: s.knowledgeSources ?? [],
|
||||
outputFormat: s.outputFormat ?? {},
|
||||
credibilityRule: s.credibilityRule ?? {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
setDetail(s);
|
||||
}
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">技能治理</h1>
|
||||
|
||||
<Card
|
||||
title="技能定义"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Badge tone="green">{enabledCount} 已启用</Badge>
|
||||
<Badge tone="slate">{skills.length} 总计</Badge>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : skills.length === 0 ? (
|
||||
<EmptyState message="技能库为空,可在下方新增技能定义。" />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{skills.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{s.name}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{s.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{s.enabled ? (
|
||||
<Badge tone="green">已启用</Badge>
|
||||
) : (
|
||||
<Badge tone="slate">未启用</Badge>
|
||||
)}
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => setDetail(s)}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={() => handleEdit(s)}>
|
||||
编辑
|
||||
</button>
|
||||
{!s.enabled && (
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => handleEnable(s.id)}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{detail && (
|
||||
<Card
|
||||
title={`技能详情 · ${detail.name}`}
|
||||
actions={
|
||||
<button className="btn-ghost" onClick={() => setDetail(null)}>
|
||||
收起
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<InfoRow label="标识">{detail.id}</InfoRow>
|
||||
<InfoRow label="名称">{detail.name}</InfoRow>
|
||||
<InfoRow label="状态">
|
||||
{detail.enabled ? (
|
||||
<Badge tone="green">已启用</Badge>
|
||||
) : (
|
||||
<Badge tone="slate">未启用</Badge>
|
||||
)}
|
||||
</InfoRow>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<ElementCard title="输入规格" data={detail.inputSpec} />
|
||||
<ElementCard title="AI 处理逻辑" data={detail.processingLogic} />
|
||||
<ElementCard title="知识源绑定" data={detail.knowledgeSources} />
|
||||
<ElementCard title="输出格式" data={detail.outputFormat} />
|
||||
<ElementCard title="可信度标注规则" data={detail.credibilityRule} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="新增 / 修改技能定义">
|
||||
<form onSubmit={handleUpsert} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="label">技能标识</label>
|
||||
<input
|
||||
className="input"
|
||||
value={skillId}
|
||||
onChange={(e) => setSkillId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">技能名称</label>
|
||||
<input
|
||||
className="input"
|
||||
value={skillName}
|
||||
onChange={(e) => setSkillName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
创建后即启用
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">
|
||||
五要素定义(inputSpec / processingLogic / knowledgeSources /
|
||||
outputFormat / credibilityRule)
|
||||
</label>
|
||||
<textarea
|
||||
className="input font-mono text-xs"
|
||||
rows={14}
|
||||
value={elementsJson}
|
||||
onChange={(e) => setElementsJson(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && <ErrorBanner message={formError} />}
|
||||
<div className="flex gap-3">
|
||||
<button className="btn-primary" disabled={submitting}>
|
||||
{submitting ? '提交中…' : '提交技能定义'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
onClick={() => setElementsJson(FIVE_ELEMENTS_TEMPLATE)}
|
||||
>
|
||||
重置五要素模板
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="治理审计日志">
|
||||
{auditLogs.length === 0 ? (
|
||||
<EmptyState message="暂无审计日志。" />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{auditLogs.map((log, i) => (
|
||||
<li key={(log.id as string) ?? i} className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{log.action && <Badge tone="blue">{String(log.action)}</Badge>}
|
||||
{log.skillId && (
|
||||
<span className="text-sm text-slate-700">{String(log.skillId)}</span>
|
||||
)}
|
||||
{log.timestamp && (
|
||||
<span className="ml-auto text-xs text-slate-400">
|
||||
{new Date(String(log.timestamp)).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(log.actorId || log.actorRole) && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
操作者:{String(log.actorId ?? '')}
|
||||
{log.actorRole ? `(${String(log.actorRole)})` : ''}
|
||||
</p>
|
||||
)}
|
||||
<RawDetails data={log} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 技能五要素之一的小卡片。 */
|
||||
function ElementCard({ title, data }: { title: string; data: unknown }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-100 p-3">
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">{title}</p>
|
||||
<pre className="max-h-40 overflow-auto rounded bg-slate-50 p-2 text-xs text-slate-600">
|
||||
{JSON.stringify(data ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-800 antialiased;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-600 text-white hover:bg-brand-700;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1 block text-sm font-medium text-slate-600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import './globals.css';
|
||||
import { AuthProvider } from '@/lib/auth-context';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '医科高校 AI 学习中心',
|
||||
description: '面向医科类高校的 AI 学习、对练、画像与科研支持平台',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 登录 / 注册页。
|
||||
*
|
||||
* 支持两种模式切换:登录与注册。注册时可选择角色(学生/导师/管理员),便于演示三端。
|
||||
* 成功后依据角色跳转到对应工作台。错误信息取自后端统一错误体。
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { homeForRole } from '@/components/app-shell';
|
||||
import { ErrorBanner } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { ROLE_LABELS, Role } from '@/lib/types';
|
||||
|
||||
/** 内置测试账户(与后端 SEED_TEST_ACCOUNTS 一致),用于一键登录。 */
|
||||
const TEST_ACCOUNTS: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}[] = [
|
||||
{ username: 'student', password: 'student123', role: Role.Student },
|
||||
{ username: 'mentor', password: 'mentor123', role: Role.Mentor },
|
||||
{ username: 'admin', password: 'admin123', role: Role.Administrator },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading, login, register } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [role, setRole] = useState<Role>(Role.Student);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [quickRole, setQuickRole] = useState<Role | null>(null);
|
||||
|
||||
// 已登录则直接跳转。
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.replace(homeForRole(user.role));
|
||||
}
|
||||
}, [loading, user, router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const u =
|
||||
mode === 'login'
|
||||
? await login(username, password)
|
||||
: await register({ username, password, role, displayName });
|
||||
router.replace(homeForRole(u.role));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.body.message ?? '操作失败');
|
||||
} else {
|
||||
setError('网络错误,请确认后端服务已启动');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键登录某测试账户。 */
|
||||
async function quickLogin(acct: (typeof TEST_ACCOUNTS)[number]) {
|
||||
setError(null);
|
||||
setQuickRole(acct.role);
|
||||
try {
|
||||
const u = await login(acct.username, acct.password);
|
||||
router.replace(homeForRole(u.role));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(
|
||||
`${ROLE_LABELS[acct.role]}测试账户登录失败:${err.body.message ?? ''}`,
|
||||
);
|
||||
} else {
|
||||
setError('网络错误,请确认后端服务已启动');
|
||||
}
|
||||
setQuickRole(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-brand-700">
|
||||
医科高校 AI 学习中心
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
学习 · 对练 · 画像 · 科研 · 协同
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-5 grid grid-cols-2 gap-2 rounded-lg bg-slate-100 p-1">
|
||||
{(['login', 'register'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
setError(null);
|
||||
}}
|
||||
className={`rounded-md py-2 text-sm font-medium transition ${
|
||||
mode === m
|
||||
? 'bg-white text-brand-700 shadow-sm'
|
||||
: 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{m === 'login' ? '登录' : '注册'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">用户名</label>
|
||||
<input
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="至少 3 个字符"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">密码</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 8 个字符"
|
||||
autoComplete={
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">展示名</label>
|
||||
<input
|
||||
className="input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="如:张三"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">角色</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.values(Role).map((r) => (
|
||||
<button
|
||||
type="button"
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm transition ${
|
||||
role === r
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary w-full"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? '处理中…'
|
||||
: mode === 'login'
|
||||
? '登录'
|
||||
: '注册并登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 border-t border-slate-100 pt-4">
|
||||
<p className="mb-2 text-center text-xs text-slate-400">
|
||||
测试账户 · 一键登录
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TEST_ACCOUNTS.map((acct) => (
|
||||
<button
|
||||
key={acct.role}
|
||||
type="button"
|
||||
onClick={() => quickLogin(acct)}
|
||||
disabled={quickRole !== null}
|
||||
className="flex flex-col items-center rounded-lg border border-slate-200 px-2 py-2 text-center transition hover:border-brand-400 hover:bg-brand-50 disabled:opacity-60"
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{ROLE_LABELS[acct.role]}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[11px] text-slate-400">
|
||||
{quickRole === acct.role ? '登录中…' : acct.username}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] text-slate-300">
|
||||
口令:student123 / mentor123 / admin123
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-slate-400">
|
||||
后端服务默认运行于 http://localhost:3000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
/** 成果点评:对指定学习成果添加点评,并查看该成果的全部点评。 */
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { mentorApi } from '@/lib/services';
|
||||
|
||||
export default function MentorCommentsPage() {
|
||||
const [achievementId, setAchievementId] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [comments, setComments] = useState<any[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadComments() {
|
||||
if (!achievementId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const v = await mentorApi.listComments(achievementId);
|
||||
setComments(Array.isArray(v) ? v : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await mentorApi.addComment(achievementId, comment);
|
||||
setComment('');
|
||||
await loadComments();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '点评失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">成果点评</h1>
|
||||
|
||||
<Card title="点评学习成果">
|
||||
<form onSubmit={addComment} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">成果标识</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input"
|
||||
value={achievementId}
|
||||
onChange={(e) => setAchievementId(e.target.value)}
|
||||
placeholder="输入学习成果的 ID"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost shrink-0"
|
||||
onClick={loadComments}
|
||||
disabled={!achievementId || busy}
|
||||
>
|
||||
查看点评
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">点评内容</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="填写对该成果的点评(对学生可见)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<button className="btn-primary" disabled={busy}>
|
||||
{busy ? '提交中…' : '提交点评'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="该成果的点评">
|
||||
{busy && <Loading />}
|
||||
{comments === null ? (
|
||||
<EmptyState message="输入成果标识并点击「查看点评」。" />
|
||||
) : comments.length === 0 ? (
|
||||
<EmptyState message="该成果暂无点评。" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{comments.map((c: any, i: number) => (
|
||||
<li
|
||||
key={c.id ?? i}
|
||||
className="rounded-lg border border-slate-100 p-3 text-sm text-slate-600"
|
||||
>
|
||||
{c.comment ?? c.content ?? JSON.stringify(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { AppShell, type NavGroup } from '@/components/app-shell';
|
||||
import { Role } from '@/lib/types';
|
||||
|
||||
const NAV: NavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ href: '/mentor', label: '工作台', icon: 'home' },
|
||||
{ href: '/mentor/review', label: '题目审核', icon: 'clipboard-check' },
|
||||
{ href: '/mentor/comments', label: '成果点评', icon: 'message-square' },
|
||||
{ href: '/mentor/students', label: '带教学生画像', icon: 'user-check' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MentorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppShell requiredRole={Role.Mentor} nav={NAV} title="导师端">
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card } from '@/components/ui';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
href: '/mentor/review',
|
||||
title: '题目审核',
|
||||
desc: '审核学生练习题,通过或退回并填写原因。',
|
||||
},
|
||||
{
|
||||
href: '/mentor/comments',
|
||||
title: '成果点评',
|
||||
desc: '对学生学习成果进行点评,点评对学生可见。',
|
||||
},
|
||||
{
|
||||
href: '/mentor/students',
|
||||
title: '带教学生画像',
|
||||
desc: '查看所带学生的能力画像(脱敏授权)。',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MentorHome() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-800">
|
||||
导师工作台,{user?.username}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">审核、点评与带教管理。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{FEATURES.map((f) => (
|
||||
<Link key={f.href} href={f.href}>
|
||||
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
|
||||
<h2 className="text-base font-semibold text-brand-700">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
/** 题目审核:输入题目标识,通过或退回(退回须填原因)。 */
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Card, ErrorBanner } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { mentorApi } from '@/lib/services';
|
||||
|
||||
export default function MentorReviewPage() {
|
||||
const [questionId, setQuestionId] = useState('');
|
||||
const [decision, setDecision] = useState<'approve' | 'return'>('approve');
|
||||
const [reason, setReason] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const v = await mentorApi.reviewQuestion(questionId, {
|
||||
decision,
|
||||
reason: decision === 'return' ? reason : undefined,
|
||||
});
|
||||
setResult(v);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '审核失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">题目审核</h1>
|
||||
|
||||
<Card title="审核题目">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">题目标识</label>
|
||||
<input
|
||||
className="input"
|
||||
value={questionId}
|
||||
onChange={(e) => setQuestionId(e.target.value)}
|
||||
placeholder="输入待审核题目的 ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">审核决定</label>
|
||||
<div className="flex gap-2">
|
||||
{(['approve', 'return'] as const).map((d) => (
|
||||
<button
|
||||
type="button"
|
||||
key={d}
|
||||
onClick={() => setDecision(d)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm transition ${
|
||||
decision === d
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{d === 'approve' ? '通过' : '退回'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{decision === 'return' && (
|
||||
<div>
|
||||
<label className="label">退回原因</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="说明退回原因"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<button className="btn-primary" disabled={busy}>
|
||||
{busy ? '提交中…' : '提交审核'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
审核结果
|
||||
</h3>
|
||||
<pre className="overflow-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
/** 带教学生画像:列出所带学生 → 查看某学生的画像(合规授权与脱敏由后端负责)。 */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { InfoRow, RawDetails } from '@/components/display';
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { mentorApi } from '@/lib/services';
|
||||
import type { StudentProfileView } from '@/lib/types';
|
||||
|
||||
export default function MentorStudentsPage() {
|
||||
const [students, setStudents] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [profile, setProfile] = useState<StudentProfileView | null>(null);
|
||||
const [activeStudent, setActiveStudent] = useState<string | null>(null);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const v = await mentorApi.listStudents();
|
||||
setStudents(Array.isArray(v) ? v : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function viewProfile(studentId: string) {
|
||||
setActiveStudent(studentId);
|
||||
setProfile(null);
|
||||
setProfileError(null);
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
setProfile(await mentorApi.viewStudentProfile(studentId));
|
||||
} catch (err) {
|
||||
setProfileError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">带教学生画像</h1>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card title="所带学生" className="lg:col-span-1">
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : students.length === 0 ? (
|
||||
<EmptyState message="暂无带教学生。" />
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{students.map((s) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
onClick={() => viewProfile(s)}
|
||||
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition ${
|
||||
activeStudent === s
|
||||
? 'bg-brand-50 font-medium text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="学生画像" className="lg:col-span-2">
|
||||
{!activeStudent ? (
|
||||
<EmptyState message="从左侧选择一名学生查看画像。" />
|
||||
) : profileLoading ? (
|
||||
<Loading />
|
||||
) : profileError ? (
|
||||
<ErrorBanner message={profileError} />
|
||||
) : profile ? (
|
||||
<ProfileViewCard profile={profile} />
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生画像视图:授权状态 + 字段(敏感字段脱敏标记)。 */
|
||||
function ProfileViewCard({ profile }: { profile: StudentProfileView }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{profile.notice && (
|
||||
<p className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
⚠️ {profile.notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{profile.fullAccess ? (
|
||||
<Badge tone="green">完整访问</Badge>
|
||||
) : profile.authorized ? (
|
||||
<Badge tone="blue">按授权范围</Badge>
|
||||
) : (
|
||||
<Badge tone="red">无有效授权</Badge>
|
||||
)}
|
||||
{profile.redactedFieldKeys?.length > 0 && (
|
||||
<Badge tone="amber">
|
||||
{profile.redactedFieldKeys.length} 个字段已脱敏
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">用途:{profile.purpose}</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{profile.fields?.map((f) => (
|
||||
<div
|
||||
key={f.key}
|
||||
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-slate-500">
|
||||
{f.key}
|
||||
{f.sensitive && (
|
||||
<span className="ml-1.5 align-middle">
|
||||
<Badge tone="amber">敏感</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
f.redacted ? 'text-slate-300' : 'font-medium text-slate-700'
|
||||
}
|
||||
>
|
||||
{f.redacted
|
||||
? '已脱敏'
|
||||
: typeof f.value === 'object'
|
||||
? JSON.stringify(f.value)
|
||||
: String(f.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RawDetails data={profile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { homeForRole } from '@/components/app-shell';
|
||||
import { Loading } from '@/components/ui';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
/** 入口页:依据登录态与角色重定向到对应工作台或登录页。 */
|
||||
export default function HomePage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
router.replace(user ? homeForRole(user.role) : '/login');
|
||||
}, [user, loading, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Loading label="正在进入…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
/** 职业规划:设定职业目标 → 关联岗位胜任力模型 → 生成动态闭环发展规划。 */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
InfoRow,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
} from '@/components/display';
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { careerApi } from '@/lib/services';
|
||||
import type {
|
||||
CareerGoalAssociation,
|
||||
CompetencyGap,
|
||||
DevelopmentPlan,
|
||||
} from '@/lib/types';
|
||||
|
||||
export default function CareerPage() {
|
||||
const [goal, setGoal] = useState<CareerGoalAssociation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [goalId, setGoalId] = useState('');
|
||||
const [goalTitle, setGoalTitle] = useState('');
|
||||
const [goalDesc, setGoalDesc] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [plan, setPlan] = useState<DevelopmentPlan | null>(null);
|
||||
const [planError, setPlanError] = useState<string | null>(null);
|
||||
const [planning, setPlanning] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setGoal(await careerApi.getGoal());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleSetGoal(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await careerApi.setGoal({
|
||||
id: goalId,
|
||||
title: goalTitle,
|
||||
description: goalDesc || undefined,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const recs = (err.body.details as any)?.recommendedGoals;
|
||||
setFormError(
|
||||
recs?.length
|
||||
? `${err.message}(建议目标:${recs
|
||||
.map((r: any) => r.title ?? r.id ?? r)
|
||||
.join('、')})`
|
||||
: err.message,
|
||||
);
|
||||
} else {
|
||||
setFormError('设定失败');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGeneratePlan() {
|
||||
const gid = goal?.goal?.id ?? goalId;
|
||||
if (!gid) {
|
||||
setPlanError('请先设定职业目标');
|
||||
return;
|
||||
}
|
||||
setPlanError(null);
|
||||
setPlanning(true);
|
||||
try {
|
||||
setPlan(await careerApi.generatePlan(gid));
|
||||
} catch (err) {
|
||||
setPlanError(err instanceof ApiError ? err.message : '生成失败');
|
||||
} finally {
|
||||
setPlanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">职业规划</h1>
|
||||
|
||||
<Card title="设定职业目标">
|
||||
<form
|
||||
onSubmit={handleSetGoal}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3"
|
||||
>
|
||||
<div>
|
||||
<label className="label">目标标识</label>
|
||||
<input
|
||||
className="input"
|
||||
value={goalId}
|
||||
onChange={(e) => setGoalId(e.target.value)}
|
||||
placeholder="如:clinical-physician"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">目标名称</label>
|
||||
<input
|
||||
className="input"
|
||||
value={goalTitle}
|
||||
onChange={(e) => setGoalTitle(e.target.value)}
|
||||
placeholder="如:临床医师"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">描述(可选)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={goalDesc}
|
||||
onChange={(e) => setGoalDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<div className="sm:col-span-3">
|
||||
<ErrorBanner message={formError} />
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-3">
|
||||
<button className="btn-primary" disabled={submitting}>
|
||||
{submitting ? '提交中…' : '设定目标'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="当前目标与岗位胜任力模型"
|
||||
actions={
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={handleGeneratePlan}
|
||||
disabled={planning}
|
||||
>
|
||||
{planning ? '生成中…' : '生成发展规划'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : !goal ? (
|
||||
<EmptyState message="尚未设定职业目标。" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-slate-50 p-4">
|
||||
<InfoRow label="职业目标">{goal.goal?.title}</InfoRow>
|
||||
<InfoRow label="参照框架">{goal.model?.framework}</InfoRow>
|
||||
{goal.goal?.description && (
|
||||
<InfoRow label="目标说明">{goal.goal.description}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{goal.model?.dimensions?.map((d) => (
|
||||
<div
|
||||
key={d.dimension}
|
||||
className="rounded-lg border border-slate-100 p-3"
|
||||
>
|
||||
<p className="mb-2 text-sm font-semibold text-brand-700">
|
||||
{d.dimensionName}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{d.competencyTags?.map((t) => (
|
||||
<ScoreBar
|
||||
key={t.tagId}
|
||||
label={t.tagName ?? t.tagId}
|
||||
score={t.requiredLevel}
|
||||
tone="brand"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planError && (
|
||||
<div className="mt-4">
|
||||
<ErrorBanner message={planError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{plan && <DevelopmentPlanView plan={plan} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 发展规划展示:能力差距项 + 建议行动 + 推荐资源。 */
|
||||
function DevelopmentPlanView({ plan }: { plan: DevelopmentPlan }) {
|
||||
const gapCount = plan.gaps?.length ?? 0;
|
||||
const missingCount = plan.gaps?.filter((g) => g.missingData).length ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`发展规划 · ${plan.goalTitle}`}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Badge tone="amber">{gapCount} 项能力差距</Badge>
|
||||
{missingCount > 0 && <Badge tone="slate">{missingCount} 项缺数据</Badge>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{gapCount === 0 ? (
|
||||
<EmptyState message="未发现能力差距,已达到目标岗位要求。" />
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{plan.gaps.map((gap) => (
|
||||
<GapItem key={gap.tagId} gap={gap} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<RawDetails data={plan} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GapItem({ gap }: { gap: CompetencyGap }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-slate-200 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{gap.tagName}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{gap.dimensionName}</p>
|
||||
</div>
|
||||
{gap.missingData ? (
|
||||
<Badge tone="slate">缺数据</Badge>
|
||||
) : (
|
||||
<Badge tone="amber">待提升</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScoreBar
|
||||
label="当前水平"
|
||||
score={gap.missingData ? 'insufficient_data' : gap.currentLevel}
|
||||
required={gap.requiredLevel}
|
||||
tone="red"
|
||||
/>
|
||||
|
||||
{gap.suggestedActions?.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-semibold text-slate-500">建议行动</p>
|
||||
<ul className="space-y-1">
|
||||
{gap.suggestedActions.map((a, i) => (
|
||||
<li key={i} className="text-sm text-slate-600">
|
||||
· {a.description}
|
||||
<span className="ml-1 text-xs text-slate-400">
|
||||
(目标水平 {a.targetLevel})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-semibold text-slate-500">
|
||||
推荐学习资源 / 对练任务
|
||||
</p>
|
||||
{gap.recommendedResources?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gap.recommendedResources.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="rounded-md border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs text-brand-700"
|
||||
>
|
||||
{r.type === 'practice_task' ? '🎯 ' : '📘 '}
|
||||
{r.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">
|
||||
{gap.resourceNote ?? '暂无可推荐资源'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 临床情景对话对练:输入情景标识发起会话 → 逐轮对话 → 结束生成三维评估报告。
|
||||
*
|
||||
* 对话历史在本地累积展示;系统应答取自每轮返回。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
CredibilityBadge,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
StatTile,
|
||||
} from '@/components/display';
|
||||
import { Card, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { clinicalApi } from '@/lib/services';
|
||||
import type { DialogueReport } from '@/lib/types';
|
||||
|
||||
interface ChatTurn {
|
||||
role: 'student' | 'system';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function ClinicalPage() {
|
||||
const [scenarioId, setScenarioId] = useState('');
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [report, setReport] = useState<DialogueReport | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
after?.(await fn());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function extractSystemReply(v: any): string {
|
||||
return (
|
||||
v?.systemResponse ??
|
||||
v?.reply ??
|
||||
v?.message ??
|
||||
v?.turn?.systemResponse ??
|
||||
JSON.stringify(v)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">临床对话对练</h1>
|
||||
|
||||
<Card title="发起对练">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<label className="label">情景标识</label>
|
||||
<input
|
||||
className="input"
|
||||
value={scenarioId}
|
||||
onChange={(e) => setScenarioId(e.target.value)}
|
||||
placeholder="如:chest-pain-triage"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!scenarioId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => clinicalApi.start(scenarioId),
|
||||
(v: any) => {
|
||||
setSession(v);
|
||||
setTurns([]);
|
||||
setReport(null);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
开始对话
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{session && !report && (
|
||||
<Card title="对话">
|
||||
<div className="mb-4 max-h-96 space-y-3 overflow-auto">
|
||||
{turns.length === 0 && (
|
||||
<p className="text-sm text-slate-400">
|
||||
输入你的第一句话开始对话。
|
||||
</p>
|
||||
)}
|
||||
{turns.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${t.role === 'student' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
|
||||
t.role === 'student'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{t.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{busy && <Loading />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入对话内容…"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && input && !busy) {
|
||||
e.preventDefault();
|
||||
const text = input;
|
||||
setTurns((t) => [...t, { role: 'student', text }]);
|
||||
setInput('');
|
||||
run(
|
||||
() => clinicalApi.sendTurn(session.id, { studentInput: text }),
|
||||
(v: any) =>
|
||||
setTurns((t) => [
|
||||
...t,
|
||||
{ role: 'system', text: extractSystemReply(v) },
|
||||
]),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!input || busy}
|
||||
onClick={() => {
|
||||
const text = input;
|
||||
setTurns((t) => [...t, { role: 'student', text }]);
|
||||
setInput('');
|
||||
run(
|
||||
() => clinicalApi.sendTurn(session.id, { studentInput: text }),
|
||||
(v: any) =>
|
||||
setTurns((t) => [
|
||||
...t,
|
||||
{ role: 'system', text: extractSystemReply(v) },
|
||||
]),
|
||||
);
|
||||
}}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => clinicalApi.finish(session.id),
|
||||
(v: any) => setReport(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
结束并评估
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<Card title="三维评估报告">
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<StatTile
|
||||
label="综合评分"
|
||||
value={report.overallScore?.toFixed?.(0) ?? report.overallScore}
|
||||
tone="blue"
|
||||
/>
|
||||
<div className="text-sm text-slate-500">
|
||||
共 {report.turnCount} 轮对话
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{report.dimensions?.map((d) => (
|
||||
<div key={d.dimension}>
|
||||
<ScoreBar
|
||||
label={d.dimensionName}
|
||||
score={d.score}
|
||||
tone={d.score >= 60 ? 'green' : 'amber'}
|
||||
/>
|
||||
{d.comment && (
|
||||
<p className="mt-1 text-xs text-slate-500">{d.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CredibilityBadge annotation={report.annotation} />
|
||||
</div>
|
||||
|
||||
<RawDetails data={report} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AI 协同训练:列出协同训练任务 → 完成任务并评估四个协同能力维度 → 查看导师点评。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
CredibilityBadge,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
StatTile,
|
||||
} from '@/components/display';
|
||||
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { collaborationApi } from '@/lib/services';
|
||||
import type { CollaborationAssessment } from '@/lib/types';
|
||||
|
||||
export default function CollaborationPage() {
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [comments, setComments] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [activeTask, setActiveTask] = useState<any>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [adopted, setAdopted] = useState(false);
|
||||
const [assessment, setAssessment] = useState<CollaborationAssessment | null>(
|
||||
null,
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [t, c] = await Promise.all([
|
||||
collaborationApi.listTasks(),
|
||||
collaborationApi.listComments(),
|
||||
]);
|
||||
setTasks(Array.isArray(t) ? t : []);
|
||||
setComments(Array.isArray(c) ? c : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleEvaluate() {
|
||||
if (!activeTask) return;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const v = await collaborationApi.evaluateTask(
|
||||
activeTask.id ?? activeTask.taskId,
|
||||
{ content, adoptedUnverifiedAiOutput: adopted },
|
||||
);
|
||||
setAssessment(v);
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof ApiError ? err.message : '评估失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">AI 协同训练</h1>
|
||||
|
||||
<Card title="协同训练任务">
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState message="暂无训练任务。" />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{tasks.map((t: any, i: number) => (
|
||||
<li
|
||||
key={t.id ?? i}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition ${
|
||||
activeTask?.id === t.id
|
||||
? 'border-brand-500 bg-brand-50'
|
||||
: 'border-slate-200 hover:border-brand-300'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTask(t);
|
||||
setAssessment(null);
|
||||
setContent('');
|
||||
setAdopted(false);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{t.title ?? t.name ?? t.id}
|
||||
</p>
|
||||
{t.dimension && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
维度:{t.dimension}
|
||||
</p>
|
||||
)}
|
||||
{t.description && (
|
||||
<p className="mt-1 text-xs text-slate-500">{t.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activeTask && (
|
||||
<Card title={`完成任务:${activeTask.title ?? activeTask.id}`}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">任务说明 / 产出</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="描述你的完成过程与产出"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adopted}
|
||||
onChange={(e) => setAdopted(e.target.checked)}
|
||||
/>
|
||||
直接采用了未经来源核验的 AI 输出
|
||||
</label>
|
||||
{submitError && <ErrorBanner message={submitError} />}
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
onClick={handleEvaluate}
|
||||
>
|
||||
{submitting ? '评估中…' : '提交并评估'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{assessment && (
|
||||
<div className="mt-4">
|
||||
{assessment.requiresSourceVerification && (
|
||||
<p className="mb-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
⚠️ {assessment.verificationPrompt ?? '请对 AI 输出进行来源核验'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<StatTile
|
||||
label="综合评分"
|
||||
value={
|
||||
typeof assessment.overallScore === 'number'
|
||||
? assessment.overallScore.toFixed(0)
|
||||
: '数据不足'
|
||||
}
|
||||
tone="blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
四维协同能力评估
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{assessment.dimensions?.map((d) => (
|
||||
<div key={d.dimension}>
|
||||
<ScoreBar
|
||||
label={d.dimensionName}
|
||||
score={d.score}
|
||||
tone={
|
||||
typeof d.score === 'number' && d.score >= 60
|
||||
? 'green'
|
||||
: 'amber'
|
||||
}
|
||||
/>
|
||||
{d.comment && (
|
||||
<p className="mt-1 text-xs text-slate-500">{d.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CredibilityBadge annotation={assessment.annotation} />
|
||||
</div>
|
||||
|
||||
<RawDetails data={assessment} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="导师点评">
|
||||
{comments.length === 0 ? (
|
||||
<EmptyState message="暂无导师点评。" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{comments.map((c: any, i: number) => (
|
||||
<li
|
||||
key={c.id ?? i}
|
||||
className="rounded-lg border border-slate-100 p-3 text-sm text-slate-600"
|
||||
>
|
||||
{c.comment ?? c.content ?? JSON.stringify(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { AppShell, type NavGroup } from '@/components/app-shell';
|
||||
import { Role } from '@/lib/types';
|
||||
|
||||
const NAV: NavGroup[] = [
|
||||
{ items: [{ href: '/student', label: '工作台', icon: 'home' }] },
|
||||
{
|
||||
label: '学习训练',
|
||||
items: [
|
||||
{ href: '/student/learning-space', label: '学习空间', icon: 'book-open' },
|
||||
{ href: '/student/practice', label: '课程对练', icon: 'target' },
|
||||
{ href: '/student/clinical', label: '临床对话对练', icon: 'stethoscope' },
|
||||
{ href: '/student/collaboration', label: 'AI 协同训练', icon: 'users' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '成长发展',
|
||||
items: [
|
||||
{ href: '/student/profile', label: '能力画像', icon: 'chart' },
|
||||
{ href: '/student/career', label: '职业规划', icon: 'compass' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '研究',
|
||||
items: [
|
||||
{ href: '/student/research', label: '研究资料查询', icon: 'search' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function StudentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppShell requiredRole={Role.Student} nav={NAV} title="学生端">
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
/** 学习空间:新增学习成果 + 按筛选条件分页检索。 */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { learningSpaceApi } from '@/lib/services';
|
||||
import {
|
||||
ACHIEVEMENT_TYPE_LABELS,
|
||||
AchievementType,
|
||||
type Achievement,
|
||||
type Paginated,
|
||||
} from '@/lib/types';
|
||||
|
||||
export default function LearningSpacePage() {
|
||||
const [page, setPage] = useState<Paginated<Achievement> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filterType, setFilterType] = useState<string>('');
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
|
||||
// 表单状态
|
||||
const [type, setType] = useState<AchievementType>(
|
||||
AchievementType.CourseRecord,
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [occurredAt, setOccurredAt] = useState(
|
||||
() => new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await learningSpaceApi.listAchievements({
|
||||
type: filterType ? (filterType as AchievementType) : undefined,
|
||||
page: pageNum,
|
||||
pageSize: 10,
|
||||
});
|
||||
setPage(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType, pageNum]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await learningSpaceApi.addAchievement({
|
||||
type,
|
||||
title,
|
||||
occurredAt: new Date(occurredAt).toISOString(),
|
||||
});
|
||||
setTitle('');
|
||||
setPageNum(1);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : '新增失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = page ? Math.max(1, Math.ceil(page.total / page.pageSize)) : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">学习空间</h1>
|
||||
|
||||
<Card title="新增学习成果">
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label className="label">类型</label>
|
||||
<select
|
||||
className="input"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as AchievementType)}
|
||||
>
|
||||
{Object.values(AchievementType).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{ACHIEVEMENT_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">标题</label>
|
||||
<input
|
||||
className="input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:完成内科学期末考试"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">日期</label>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<div className="sm:col-span-4">
|
||||
<ErrorBanner message={formError} />
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-4">
|
||||
<button className="btn-primary" disabled={submitting}>
|
||||
{submitting ? '提交中…' : '新增成果'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="我的学习成果"
|
||||
actions={
|
||||
<select
|
||||
className="input max-w-[140px]"
|
||||
value={filterType}
|
||||
onChange={(e) => {
|
||||
setFilterType(e.target.value);
|
||||
setPageNum(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.values(AchievementType).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{ACHIEVEMENT_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : !page || page.items.length === 0 ? (
|
||||
<EmptyState message="暂无学习成果,先在上方新增一条吧。" />
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{page.items.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{a.title}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
{new Date(a.occurredAt).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="blue">
|
||||
{ACHIEVEMENT_TYPE_LABELS[a.type] ?? a.type}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
|
||||
<span>共 {page.total} 条</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={pageNum <= 1}
|
||||
onClick={() => setPageNum((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page.page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={pageNum >= totalPages}
|
||||
onClick={() => setPageNum((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card } from '@/components/ui';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
href: '/student/learning-space',
|
||||
title: '学习空间',
|
||||
desc: '记录课程、实践、科研等学习成果,标记成长里程碑。',
|
||||
},
|
||||
{
|
||||
href: '/student/profile',
|
||||
title: '能力画像',
|
||||
desc: '查看六维度能力量化画像与数据来源追溯。',
|
||||
},
|
||||
{
|
||||
href: '/student/career',
|
||||
title: '职业规划',
|
||||
desc: '设定职业目标,生成动态闭环发展规划。',
|
||||
},
|
||||
{
|
||||
href: '/student/practice',
|
||||
title: '课程对练',
|
||||
desc: '基于课程内容生成练习题并进行对练。',
|
||||
},
|
||||
{
|
||||
href: '/student/clinical',
|
||||
title: '临床对话对练',
|
||||
desc: '在临床情景中与 AI 角色对话,获得三维评估。',
|
||||
},
|
||||
{
|
||||
href: '/student/research',
|
||||
title: '研究资料查询',
|
||||
desc: '自然语言转检索式,检索、总结并生成引用。',
|
||||
},
|
||||
{
|
||||
href: '/student/collaboration',
|
||||
title: 'AI 协同训练',
|
||||
desc: '完成协同任务,评估四个协同能力维度。',
|
||||
},
|
||||
];
|
||||
|
||||
export default function StudentHome() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-800">
|
||||
欢迎,{user?.username}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
选择下面的功能开始今天的学习与训练。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map((f) => (
|
||||
<Link key={f.href} href={f.href}>
|
||||
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
|
||||
<h2 className="text-base font-semibold text-brand-700">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 课程对练:输入课程标识 → 生成题目 → 查看可对练题目 → 发起对练逐题作答 → 结束生成报告。
|
||||
*
|
||||
* 题目结构由后端动态生成,前端以宽松对象消费并展示题干/选项;作答提交后端逐题判定。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
CredibilityBadge,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
StatTile,
|
||||
} from '@/components/display';
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { practiceApi } from '@/lib/services';
|
||||
import type { AnswerResult, PracticeReport } from '@/lib/types';
|
||||
|
||||
export default function PracticePage() {
|
||||
const [courseId, setCourseId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [lastResult, setLastResult] = useState<AnswerResult | null>(null);
|
||||
const [report, setReport] = useState<PracticeReport | null>(null);
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const v = await fn();
|
||||
after?.(v);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const currentQuestion: any =
|
||||
session?.questions?.[session?.currentIndex ?? 0] ??
|
||||
session?.currentQuestion ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">课程对练</h1>
|
||||
|
||||
<Card title="选择课程">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<label className="label">课程标识</label>
|
||||
<input
|
||||
className="input"
|
||||
value={courseId}
|
||||
onChange={(e) => setCourseId(e.target.value)}
|
||||
placeholder="如:internal-medicine-101"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.generateQuestions(courseId, {}),
|
||||
(v: any) => setQuestions(v?.questions ?? v?.items ?? []),
|
||||
)
|
||||
}
|
||||
>
|
||||
生成题目
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.listAvailableQuestions(courseId),
|
||||
(v: any) => setQuestions(Array.isArray(v) ? v : []),
|
||||
)
|
||||
}
|
||||
>
|
||||
查看可对练题目
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.startSession(courseId),
|
||||
(v: any) => {
|
||||
setSession(v);
|
||||
setReport(null);
|
||||
setLastResult(null);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
发起对练
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{busy && <Loading />}
|
||||
|
||||
{questions.length > 0 && (
|
||||
<Card title={`题目(${questions.length})`}>
|
||||
<ul className="space-y-3">
|
||||
{questions.map((q: any, i: number) => (
|
||||
<li key={q.id ?? i} className="rounded-lg border border-slate-100 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-slate-700">
|
||||
{i + 1}. {q.stem ?? q.title ?? q.content ?? '(题干)'}
|
||||
</p>
|
||||
{q.reviewStatus && (
|
||||
<Badge tone="amber">{q.reviewStatus}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{Array.isArray(q.options) && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-500">
|
||||
{q.options.map((o: any, oi: number) => (
|
||||
<li key={oi}>
|
||||
{o.key ?? String.fromCharCode(65 + oi)}.{' '}
|
||||
{o.text ?? o.label ?? String(o)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{session && !report && (
|
||||
<Card title="进行对练">
|
||||
{currentQuestion ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{currentQuestion.stem ??
|
||||
currentQuestion.title ??
|
||||
currentQuestion.content ??
|
||||
'当前题目'}
|
||||
</p>
|
||||
{Array.isArray(currentQuestion.options) && (
|
||||
<ul className="space-y-1 text-xs text-slate-500">
|
||||
{currentQuestion.options.map((o: any, oi: number) => (
|
||||
<li key={oi}>
|
||||
{o.key ?? String.fromCharCode(65 + oi)}.{' '}
|
||||
{o.text ?? o.label ?? String(o)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="输入你的作答(选择题填选项 key,分析题填要点)"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={busy || !answer}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
practiceApi.submitAnswer(session.id, {
|
||||
questionId:
|
||||
currentQuestion.id ?? currentQuestion.questionId,
|
||||
answer,
|
||||
}),
|
||||
(v: any) => {
|
||||
setLastResult(v);
|
||||
setAnswer('');
|
||||
if (v?.session) setSession(v.session);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
提交作答
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.finishSession(session.id),
|
||||
(v: any) => setReport(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
结束并生成报告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="本次对练暂无可作答题目,可直接结束生成报告。" />
|
||||
)}
|
||||
|
||||
{lastResult && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
|
||||
lastResult.correct
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{lastResult.correct ? '✓ 回答正确' : '✗ 回答错误'}
|
||||
</span>
|
||||
{lastResult.timedOut && (
|
||||
<Badge tone="amber">超时判错</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-slate-400">
|
||||
用时 {(lastResult.elapsedMs / 1000).toFixed(1)} 秒
|
||||
</span>
|
||||
</div>
|
||||
{lastResult.next && (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
下一题:第 {lastResult.next.position} / {lastResult.next.total} 题
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && <PracticeReportView report={report} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 对练报告:正确率、用时、能力标签统计、薄弱环节与改进建议。 */
|
||||
function PracticeReportView({ report }: { report: PracticeReport }) {
|
||||
return (
|
||||
<Card title="对练报告">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile
|
||||
label="正确率"
|
||||
value={`${report.accuracy.toFixed(0)}%`}
|
||||
tone={report.accuracy >= 60 ? 'green' : 'red'}
|
||||
/>
|
||||
<StatTile label="题目总数" value={report.totalQuestions} />
|
||||
<StatTile label="正确 / 错误" value={`${report.correctCount}/${report.incorrectCount}`} />
|
||||
<StatTile
|
||||
label="总用时"
|
||||
value={`${report.totalTimeSeconds}s`}
|
||||
tone="blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{report.tagBreakdown?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
能力标签正确率
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{report.tagBreakdown.map((t) => (
|
||||
<ScoreBar
|
||||
key={t.tagId}
|
||||
label={`${t.tagId}(${t.correctCount}/${t.totalQuestions})`}
|
||||
score={t.correctnessRate}
|
||||
tone={t.correctnessRate >= 60 ? 'green' : 'red'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.weakAreas?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
薄弱环节(正确率 < 60%)
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.weakAreas.map((w) => (
|
||||
<Badge key={w.tagId} tone="red">
|
||||
{w.tagName ?? w.tagId}({w.correctnessRate.toFixed(0)}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.suggestions?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
改进建议
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{report.suggestions.map((s, i) => (
|
||||
<li key={i} className="rounded-lg border border-slate-100 p-3">
|
||||
<p className="text-sm text-slate-700">{s.content}</p>
|
||||
<div className="mt-2">
|
||||
<CredibilityBadge annotation={s.annotation} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RawDetails data={report} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
/** 能力画像:生成/刷新六维度画像并以条形可视化展示。 */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { profileApi } from '@/lib/services';
|
||||
import { DIMENSION_LABELS, type StudentProfile } from '@/lib/types';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProfile(await profileApi.generate());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-slate-800">能力画像</h1>
|
||||
<button className="btn-ghost" onClick={load} disabled={loading}>
|
||||
刷新画像
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card title="六维度能力概览">
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorBanner message={error} />
|
||||
) : !profile || profile.dimensions.length === 0 ? (
|
||||
<EmptyState message="暂无足够数据生成画像,先在学习空间录入成果。" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{profile.dimensions.map((d) => {
|
||||
const label = d.dimensionName ?? DIMENSION_LABELS[d.dimension] ?? d.dimension;
|
||||
const numericScore =
|
||||
typeof d.score === 'number' ? d.score : Number(d.score);
|
||||
const insufficient =
|
||||
d.score === null ||
|
||||
d.score === undefined ||
|
||||
d.score === 'insufficient_data' ||
|
||||
Number.isNaN(numericScore);
|
||||
const score = insufficient ? 0 : numericScore;
|
||||
return (
|
||||
<div key={d.dimension}>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">
|
||||
{label}
|
||||
{d.sensitive && (
|
||||
<span className="ml-2 align-middle">
|
||||
<Badge tone="red">敏感</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{insufficient ? (
|
||||
<Badge tone="amber">数据不足</Badge>
|
||||
) : (
|
||||
<span className="text-slate-500">{score.toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
insufficient ? 'bg-amber-300' : 'bg-brand-500'
|
||||
}`}
|
||||
style={{ width: `${insufficient ? 8 : score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 研究资料查询:自然语言问题 → 生成检索式 → 在选定来源检索 → 总结 / 生成引用。
|
||||
*
|
||||
* 检索式、检索结果等复杂对象由后端产出后在前端状态中原样回传给后续端点(与后端约定一致)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CredibilityBadge, InfoRow, RawDetails } from '@/components/display';
|
||||
import { Badge, Card, ErrorBanner, Loading } from '@/components/ui';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { researchApi } from '@/lib/services';
|
||||
import type { Citation, SearchQuery, Summary } from '@/lib/types';
|
||||
|
||||
const SOURCES = ['PUBMED', 'CNKI', 'WANFANG', 'UPTODATE', 'COCHRANE'];
|
||||
|
||||
/** 证据分级 → 配色(越强越绿)。 */
|
||||
function evidenceTone(level: string): 'green' | 'blue' | 'amber' | 'slate' {
|
||||
if (level.startsWith('1')) return 'green';
|
||||
if (level.startsWith('2')) return 'blue';
|
||||
if (level.startsWith('3') || level === '4') return 'amber';
|
||||
return 'slate';
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState<SearchQuery | null>(null);
|
||||
const [selectedSources, setSelectedSources] = useState<string[]>(['PUBMED']);
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [citations, setCitations] = useState<Citation[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
after?.(await fn());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const items: any[] = results?.page?.items ?? results?.items ?? [];
|
||||
|
||||
function toggleSource(s: string) {
|
||||
setSelectedSources((prev) =>
|
||||
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-slate-800">研究资料查询</h1>
|
||||
|
||||
<Card title="① 生成检索式">
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="用自然语言描述研究问题,如:他汀类药物对老年冠心病患者二级预防的疗效"
|
||||
/>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!question || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => researchApi.generateSearchQuery({ question }),
|
||||
(v: any) => {
|
||||
setSearchQuery(v?.query ?? v);
|
||||
setResults(null);
|
||||
setSummary(null);
|
||||
setCitations(null);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
生成检索式
|
||||
</button>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-2 font-mono text-sm text-brand-700">
|
||||
{searchQuery.expression}
|
||||
</p>
|
||||
{searchQuery.meshTerms?.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{searchQuery.meshTerms.map((m) => (
|
||||
<Badge key={m} tone="blue">
|
||||
MeSH: {m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchQuery.pico && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 text-xs sm:grid-cols-4">
|
||||
{searchQuery.pico.population && (
|
||||
<InfoRow label="P 人群">{searchQuery.pico.population}</InfoRow>
|
||||
)}
|
||||
{searchQuery.pico.intervention && (
|
||||
<InfoRow label="I 干预">
|
||||
{searchQuery.pico.intervention}
|
||||
</InfoRow>
|
||||
)}
|
||||
{searchQuery.pico.comparison && (
|
||||
<InfoRow label="C 对照">{searchQuery.pico.comparison}</InfoRow>
|
||||
)}
|
||||
{searchQuery.pico.outcome && (
|
||||
<InfoRow label="O 结局">{searchQuery.pico.outcome}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{searchQuery.rationale && (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{searchQuery.rationale}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{searchQuery && (
|
||||
<Card title="② 检索资料">
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{SOURCES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => toggleSource(s)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
|
||||
selectedSources.includes(s)
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-slate-300 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={busy || selectedSources.length === 0}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
researchApi.search({
|
||||
query: searchQuery,
|
||||
sources: selectedSources,
|
||||
}),
|
||||
(v: any) => setResults(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
检索
|
||||
</button>
|
||||
|
||||
{results?.empty && (
|
||||
<p className="mt-4 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{results.notice ?? '未找到匹配资料'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{items.map((it: any, i: number) => (
|
||||
<li
|
||||
key={it.id ?? i}
|
||||
className="rounded-lg border border-slate-100 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<a
|
||||
href={it.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-brand-700 hover:underline"
|
||||
>
|
||||
{it.title}
|
||||
</a>
|
||||
{it.source && <Badge tone="blue">{it.source}</Badge>}
|
||||
</div>
|
||||
{it.authors && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{Array.isArray(it.authors)
|
||||
? it.authors.join(', ')
|
||||
: it.authors}
|
||||
{it.publishedAt && ` · ${it.publishedAt}`}
|
||||
</p>
|
||||
)}
|
||||
{it.abstract && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
|
||||
{it.abstract}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<Card title="③ 总结与引用">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => researchApi.summarize({ items }),
|
||||
(v: Summary) => setSummary(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
生成总结
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
researchApi.generateCitation({
|
||||
items,
|
||||
format: 'VANCOUVER',
|
||||
}),
|
||||
(v: Citation[]) => setCitations(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
生成引用(Vancouver)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{summary && <SummaryView summary={summary} />}
|
||||
|
||||
{citations && citations.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
引用({citations.length})
|
||||
</h3>
|
||||
<ol className="list-inside list-decimal space-y-1.5 text-sm text-slate-600">
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.itemId ?? i}>{c.text}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{busy && <Loading />}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 资料总结:结论(含可追溯/未验证)+ 证据分级 + 提示 + 可信度。 */
|
||||
function SummaryView({ summary }: { summary: Summary }) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<p className="mb-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
⚠️ {summary.notice}
|
||||
</p>
|
||||
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">总结结论</h3>
|
||||
<ul className="space-y-2">
|
||||
{summary.conclusions?.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="rounded-lg border border-slate-100 p-3 text-sm"
|
||||
>
|
||||
<p className="text-slate-700">{c.statement}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
{c.verified ? (
|
||||
<Badge tone="green">已核验</Badge>
|
||||
) : (
|
||||
<Badge tone="red">{c.unverifiedLabel ?? '未验证'}</Badge>
|
||||
)}
|
||||
{c.citations?.map((s, i) => (
|
||||
<span key={i} className="text-xs text-slate-400">
|
||||
[{s.title ?? s.id}]
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{summary.gradedItems?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
证据分级
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.gradedItems.map((g) => (
|
||||
<li
|
||||
key={g.itemId}
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<a
|
||||
href={g.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-slate-600 hover:text-brand-700 hover:underline"
|
||||
>
|
||||
{g.title}
|
||||
</a>
|
||||
<Badge tone={evidenceTone(g.evidenceLevel)}>
|
||||
证据等级 {g.evidenceLevel}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<CredibilityBadge annotation={summary.annotation} />
|
||||
</div>
|
||||
|
||||
<RawDetails data={summary} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 已认证页面的统一外壳:侧边导航 + 顶栏 + 角色访问守卫。
|
||||
*
|
||||
* - 未登录:重定向到 /login。
|
||||
* - 角色不匹配:提示无权访问(避免学生进入导师/管理端等)。
|
||||
* - 提供按角色定制的导航项。
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { ROLE_LABELS, Role } from '@/lib/types';
|
||||
import { NavIcon, type IconName } from './nav-icons';
|
||||
import { Loading } from './ui';
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
/** 导航分组:label 为可选分组标题(不填则不渲染标题),items 为该组导航项。 */
|
||||
export interface NavGroup {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
requiredRole,
|
||||
nav,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
requiredRole: Role;
|
||||
nav: NavGroup[];
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [loading, user, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Loading label="正在校验登录态…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (user.role !== requiredRole) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 className="text-xl font-semibold text-slate-800">无权访问</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
当前账号角色为「{ROLE_LABELS[user.role]}」,无法访问{title}。
|
||||
</p>
|
||||
<Link href={homeForRole(user.role)} className="btn-primary">
|
||||
前往我的工作台
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* 移动端抽屉遮罩层 */}
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-slate-900/40 md:hidden"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-60 shrink-0 flex-col border-r border-slate-200 bg-white transition-transform duration-200 md:static md:translate-x-0 ${
|
||||
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brand-700">AI 学习中心</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{title}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 md:hidden"
|
||||
aria-label="关闭导航"
|
||||
>
|
||||
<NavIcon name="close" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto p-3">
|
||||
{nav.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label ?? `group-${groupIndex}`}
|
||||
className="space-y-1"
|
||||
>
|
||||
{group.label && (
|
||||
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
{group.items.map((item) => {
|
||||
// 根路径(如 /student)仅精确匹配,避免「工作台」在所有子页面常驻高亮。
|
||||
const isRoot =
|
||||
item.href.split('/').filter(Boolean).length <= 1;
|
||||
const active = isRoot
|
||||
? pathname === item.href
|
||||
: pathname === item.href ||
|
||||
pathname.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${
|
||||
active
|
||||
? 'bg-brand-50 font-medium text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{item.icon && (
|
||||
<NavIcon
|
||||
name={item.icon}
|
||||
className={`h-[18px] w-[18px] shrink-0 ${
|
||||
active ? 'text-brand-600' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 md:hidden"
|
||||
aria-label="打开导航"
|
||||
>
|
||||
<NavIcon name="menu" className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="text-sm text-slate-500">
|
||||
{ROLE_LABELS[user.role]}工作台
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">ID: {user.id}</p>
|
||||
</div>
|
||||
<button onClick={logout} className="btn-ghost">
|
||||
<NavIcon name="log-out" className="h-4 w-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按角色返回默认首页。 */
|
||||
export function homeForRole(role: Role): string {
|
||||
switch (role) {
|
||||
case Role.Student:
|
||||
return '/student';
|
||||
case Role.Mentor:
|
||||
return '/mentor';
|
||||
case Role.Administrator:
|
||||
return '/admin';
|
||||
default:
|
||||
return '/login';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 跨页面复用的「领域数据展示」组件。
|
||||
*
|
||||
* 将后端返回的结构化结果(评分、可信度标注、能力差距、证据分级等)渲染为统一样式的
|
||||
* 可读 UI,替代早期的 JSON 原样展示。组件均为纯展示、对缺失字段做防御性处理。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Badge } from './ui';
|
||||
|
||||
/** "数据不足"标记(与后端 INSUFFICIENT_DATA / 'insufficient_data' 对齐)。 */
|
||||
const INSUFFICIENT = 'insufficient_data';
|
||||
|
||||
/** 判断一个分值是否为"数据不足"。 */
|
||||
export function isInsufficient(score: unknown): boolean {
|
||||
return (
|
||||
score === null ||
|
||||
score === undefined ||
|
||||
score === INSUFFICIENT ||
|
||||
score === 'no_data' ||
|
||||
(typeof score === 'string' && Number.isNaN(Number(score)))
|
||||
);
|
||||
}
|
||||
|
||||
/** 0-100 分值条;支持"数据不足"。 */
|
||||
export function ScoreBar({
|
||||
label,
|
||||
score,
|
||||
required,
|
||||
tone = 'brand',
|
||||
}: {
|
||||
label: ReactNode;
|
||||
score: number | string | null | undefined;
|
||||
/** 可选:目标/要求水平,渲染为参考刻度线。 */
|
||||
required?: number;
|
||||
tone?: 'brand' | 'green' | 'amber' | 'red';
|
||||
}) {
|
||||
const insufficient = isInsufficient(score);
|
||||
const value = insufficient ? 0 : Math.max(0, Math.min(100, Number(score)));
|
||||
const tones: Record<string, string> = {
|
||||
brand: 'bg-brand-500',
|
||||
green: 'bg-green-500',
|
||||
amber: 'bg-amber-400',
|
||||
red: 'bg-red-500',
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{label}</span>
|
||||
{insufficient ? (
|
||||
<Badge tone="amber">数据不足</Badge>
|
||||
) : (
|
||||
<span className="tabular-nums text-slate-500">
|
||||
{value.toFixed(0)}
|
||||
{typeof required === 'number' && (
|
||||
<span className="text-slate-400"> / 目标 {required}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${insufficient ? 'bg-amber-300' : tones[tone]}`}
|
||||
style={{ width: `${insufficient ? 8 : value}%` }}
|
||||
/>
|
||||
{typeof required === 'number' && !insufficient && (
|
||||
<span
|
||||
className="absolute top-0 h-full w-0.5 bg-slate-700/60"
|
||||
style={{ left: `${Math.min(100, required)}%` }}
|
||||
title={`目标水平 ${required}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 来源引用条目(可信度标注中的 sources)。 */
|
||||
interface SourceRefLike {
|
||||
id?: string;
|
||||
title?: string;
|
||||
citation?: string;
|
||||
}
|
||||
|
||||
/** 可信度标注:来源 + 置信度 + 是否经核验。 */
|
||||
export function CredibilityBadge({
|
||||
annotation,
|
||||
}: {
|
||||
annotation?: {
|
||||
sources?: SourceRefLike[];
|
||||
confidence?: number;
|
||||
verified?: boolean;
|
||||
} | null;
|
||||
}) {
|
||||
if (!annotation) return null;
|
||||
const { sources = [], confidence, verified } = annotation;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{verified ? (
|
||||
<Badge tone="green">已核验</Badge>
|
||||
) : (
|
||||
<Badge tone="red">未经核验</Badge>
|
||||
)}
|
||||
{typeof confidence === 'number' && (
|
||||
<span className="text-xs text-slate-500">
|
||||
置信度 {confidence.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sources.length > 0 ? (
|
||||
<ul className="space-y-1 text-xs text-slate-500">
|
||||
{sources.map((s, i) => (
|
||||
<li key={s.id ?? i}>
|
||||
· {s.title ?? s.id}
|
||||
{s.citation && (
|
||||
<span className="text-slate-400">({s.citation})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">无可追溯来源</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统计数字小卡。 */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
tone = 'slate',
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
tone?: 'slate' | 'green' | 'amber' | 'red' | 'blue';
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
slate: 'text-slate-800',
|
||||
green: 'text-green-600',
|
||||
amber: 'text-amber-600',
|
||||
red: 'text-red-600',
|
||||
blue: 'text-brand-600',
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 text-center">
|
||||
<p className={`text-2xl font-semibold tabular-nums ${tones[tone]}`}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 键值信息行。 */
|
||||
export function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-3 py-1.5 text-sm">
|
||||
<span className="w-28 shrink-0 text-slate-400">{label}</span>
|
||||
<span className="text-slate-700">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 可折叠的原始数据(保底查看,默认折叠)。 */
|
||||
export function RawDetails({ data }: { data: unknown }) {
|
||||
return (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs text-slate-400 hover:text-slate-600">
|
||||
查看原始数据
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-500">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 导航图标集:lucide 风格的内联 SVG(统一 1.75 描边、currentColor)。
|
||||
*
|
||||
* 项目未引入图标库,故以内联 SVG 提供,避免新增依赖。
|
||||
* 通过 <NavIcon name="..." /> 按名称渲染,颜色随父级文字色变化。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** 受支持的图标名称(与各角色导航配置对齐)。 */
|
||||
export type IconName =
|
||||
| 'home'
|
||||
| 'book-open'
|
||||
| 'target'
|
||||
| 'stethoscope'
|
||||
| 'users'
|
||||
| 'user-check'
|
||||
| 'chart'
|
||||
| 'compass'
|
||||
| 'search'
|
||||
| 'clipboard-check'
|
||||
| 'message-square'
|
||||
| 'sliders'
|
||||
| 'shield'
|
||||
| 'menu'
|
||||
| 'close'
|
||||
| 'log-out';
|
||||
|
||||
/** 各图标对应的 SVG 子元素(path / circle / line 等)。 */
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
home: (
|
||||
<>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<path d="M9 22V12h6v10" />
|
||||
</>
|
||||
),
|
||||
'book-open': (
|
||||
<>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</>
|
||||
),
|
||||
target: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</>
|
||||
),
|
||||
stethoscope: (
|
||||
<>
|
||||
<path d="M4 2v6a6 6 0 0 0 12 0V2" />
|
||||
<path d="M5 2H3" />
|
||||
<path d="M17 2h-2" />
|
||||
<path d="M10 14v3a5 5 0 0 0 10 0v-1" />
|
||||
<circle cx="20" cy="10" r="2" />
|
||||
</>
|
||||
),
|
||||
users: (
|
||||
<>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</>
|
||||
),
|
||||
'user-check': (
|
||||
<>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<polyline points="16 11 18 13 22 9" />
|
||||
</>
|
||||
),
|
||||
chart: (
|
||||
<>
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M18 17V9" />
|
||||
<path d="M13 17V5" />
|
||||
<path d="M8 17v-3" />
|
||||
</>
|
||||
),
|
||||
compass: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</>
|
||||
),
|
||||
'clipboard-check': (
|
||||
<>
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="m9 14 2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
'message-square': (
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
),
|
||||
sliders: (
|
||||
<>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</>
|
||||
),
|
||||
shield: (
|
||||
<>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</>
|
||||
),
|
||||
menu: (
|
||||
<>
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</>
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</>
|
||||
),
|
||||
'log-out': (
|
||||
<>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" x2="9" y1="12" y2="12" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** 按名称渲染的导航图标。颜色继承父级 text 色,尺寸由 className 控制。 */
|
||||
export function NavIcon({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name: IconName;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
/** 跨端复用的基础 UI 组件。 */
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** 区块卡片。 */
|
||||
export function Card({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{(title || actions) && (
|
||||
<header className="mb-4 flex items-center justify-between gap-3">
|
||||
{title && (
|
||||
<h2 className="text-base font-semibold text-slate-800">{title}</h2>
|
||||
)}
|
||||
{actions}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载提示。 */
|
||||
export function Loading({ label = '加载中…' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-slate-500">
|
||||
<span className="h-3 w-3 animate-pulse rounded-full bg-brand-400" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 错误提示条。 */
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 空态提示。 */
|
||||
export function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-400">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标签徽章。 */
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'slate',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'slate' | 'green' | 'amber' | 'blue' | 'red';
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
slate: 'bg-slate-100 text-slate-600',
|
||||
green: 'bg-green-100 text-green-700',
|
||||
amber: 'bg-amber-100 text-amber-700',
|
||||
blue: 'bg-brand-100 text-brand-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
};
|
||||
return <span className={`badge ${tones[tone]}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 后端 API 调用客户端。
|
||||
*
|
||||
* 统一处理:
|
||||
* - 基础路径:所有请求走 `/api/*`,由 Next.js rewrites 代理到 NestJS(避免跨域)。
|
||||
* - 鉴权:自动附加 `Authorization: Bearer <token>`(token 由 auth 上下文写入 localStorage)。
|
||||
* - 错误:非 2xx 响应解析后端统一错误体(AppError.toJSON())并抛出 `ApiError`。
|
||||
*/
|
||||
|
||||
import type { ApiErrorBody } from './types';
|
||||
|
||||
const TOKEN_KEY = 'cac.token';
|
||||
|
||||
/** 携带后端错误码与 HTTP 状态的错误对象,便于 UI 精细处理。 */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: ApiErrorBody,
|
||||
) {
|
||||
super(body.message ?? `请求失败(HTTP ${status})`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取访问令牌(仅浏览器环境)。 */
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 写入访问令牌。 */
|
||||
export function setToken(token: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/** 清除访问令牌。 */
|
||||
export function clearToken(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
/** 查询参数;值为 undefined / null / '' 时跳过。 */
|
||||
query?: Record<string, string | number | undefined | null>;
|
||||
/** 是否需要鉴权(默认 true)。 */
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
params.append(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
/** 发起一次 API 请求并返回解析后的 JSON(或 void)。 */
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, query, auth = true } = options;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (auth) {
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path, query), {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? safeParse(text) : undefined;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody: ApiErrorBody =
|
||||
data && typeof data === 'object' ? (data as ApiErrorBody) : { message: text };
|
||||
throw new ApiError(res.status, errBody);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function safeParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 认证上下文:在客户端管理登录态(当前用户 + 令牌)。
|
||||
*
|
||||
* - 登录/注册成功后写入 localStorage 并更新内存态。
|
||||
* - 初次挂载时若本地有令牌,调用 `/api/auth/me` 还原会话;失败则清除。
|
||||
* - 提供 `login` / `register` / `logout` 与当前用户 / 加载态。
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { apiRequest, clearToken, getToken, setToken } from './api';
|
||||
import type { AuthResult, AuthenticatedUser, Role } from './types';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthenticatedUser | null;
|
||||
/** 初始会话还原是否进行中。 */
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<AuthenticatedUser>;
|
||||
register: (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
displayName: string;
|
||||
}) => Promise<AuthenticatedUser>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthenticatedUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 初次挂载:若本地有令牌则还原会话。
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
apiRequest<AuthenticatedUser>('/api/auth/me')
|
||||
.then((u) => setUser(u))
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const result = await apiRequest<AuthResult>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
auth: false,
|
||||
});
|
||||
setToken(result.accessToken);
|
||||
setUser(result.user);
|
||||
return result.user;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
displayName: string;
|
||||
}) => {
|
||||
const result = await apiRequest<AuthResult>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
auth: false,
|
||||
});
|
||||
setToken(result.accessToken);
|
||||
setUser(result.user);
|
||||
return result.user;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, loading, login, register, logout }),
|
||||
[user, loading, login, register, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/** 读取认证上下文。 */
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 按后端模块组织的前端 API 服务函数。
|
||||
*
|
||||
* 仅封装 URL / 方法 / 参数,鉴权与错误处理在 `api.ts` 中统一完成。
|
||||
* 返回类型尽量贴合后端,未严格建模的复杂结构用 `unknown` / 宽松对象表示,UI 按需取用。
|
||||
*/
|
||||
|
||||
import { apiRequest } from './api';
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementType,
|
||||
CareerGoalAssociation,
|
||||
Citation,
|
||||
CollaborationAssessment,
|
||||
CompetencyModel,
|
||||
DevelopmentPlan,
|
||||
DialogueReport,
|
||||
Paginated,
|
||||
PracticeReport,
|
||||
SearchQuery,
|
||||
SkillAuditLogEntry,
|
||||
SkillDefinition,
|
||||
StudentProfile,
|
||||
StudentProfileView,
|
||||
Summary,
|
||||
} from './types';
|
||||
|
||||
/* ----------------------------- 学习空间 ----------------------------- */
|
||||
|
||||
export interface AddAchievementInput {
|
||||
type: AchievementType;
|
||||
title: string;
|
||||
occurredAt: string; // ISO-8601
|
||||
academicYear?: string;
|
||||
semester?: string;
|
||||
rotationDept?: string;
|
||||
}
|
||||
|
||||
export const learningSpaceApi = {
|
||||
addAchievement: (input: AddAchievementInput) =>
|
||||
apiRequest<{ achievement: Achievement; mappedTags?: unknown[] }>(
|
||||
'/api/learning-space/achievements',
|
||||
{ method: 'POST', body: input },
|
||||
),
|
||||
|
||||
listAchievements: (params: {
|
||||
type?: AchievementType;
|
||||
academicYear?: string;
|
||||
semester?: string;
|
||||
rotationDept?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) =>
|
||||
apiRequest<Paginated<Achievement>>('/api/learning-space/achievements', {
|
||||
query: params,
|
||||
}),
|
||||
|
||||
markMilestone: (input: {
|
||||
name: string;
|
||||
reachedAt?: string;
|
||||
achievementIds?: string[];
|
||||
}) =>
|
||||
apiRequest<unknown>('/api/learning-space/milestones', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
};
|
||||
|
||||
/* ----------------------------- 画像引擎 ----------------------------- */
|
||||
|
||||
export const profileApi = {
|
||||
generate: () => apiRequest<StudentProfile>('/api/profile'),
|
||||
|
||||
traceability: (dimension: string) =>
|
||||
apiRequest<unknown[]>(
|
||||
`/api/profile/dimensions/${encodeURIComponent(dimension)}/traceability`,
|
||||
),
|
||||
};
|
||||
|
||||
/* ----------------------------- 职业规划 ----------------------------- */
|
||||
|
||||
export const careerApi = {
|
||||
getGoal: () =>
|
||||
apiRequest<CareerGoalAssociation | null>('/api/career/goal'),
|
||||
|
||||
setGoal: (input: { id?: string; title: string; description?: string }) =>
|
||||
apiRequest<CompetencyModel>('/api/career/goal', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
|
||||
generatePlan: (goalId: string) =>
|
||||
apiRequest<DevelopmentPlan>('/api/career/development-plan', {
|
||||
method: 'POST',
|
||||
body: { goalId },
|
||||
}),
|
||||
};
|
||||
|
||||
/* --------------------------- 课程对练引擎 --------------------------- */
|
||||
|
||||
export const practiceApi = {
|
||||
generateQuestions: (courseId: string, body: unknown) =>
|
||||
apiRequest<unknown>(`/api/practice/courses/${courseId}/questions`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
listAvailableQuestions: (courseId: string) =>
|
||||
apiRequest<unknown[]>(
|
||||
`/api/practice/courses/${courseId}/available-questions`,
|
||||
),
|
||||
|
||||
startSession: (courseId: string, body: unknown = {}) =>
|
||||
apiRequest<unknown>(`/api/practice/courses/${courseId}/sessions`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
submitAnswer: (sessionId: string, body: unknown) =>
|
||||
apiRequest<unknown>(`/api/practice/sessions/${sessionId}/answers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
finishSession: (sessionId: string) =>
|
||||
apiRequest<PracticeReport>(`/api/practice/sessions/${sessionId}/finish`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
}),
|
||||
};
|
||||
|
||||
/* ------------------------- 临床情景对话对练 ------------------------- */
|
||||
|
||||
export const clinicalApi = {
|
||||
start: (scenarioId: string, body: unknown = {}) =>
|
||||
apiRequest<unknown>(
|
||||
`/api/clinical-dialogue/scenarios/${scenarioId}/sessions`,
|
||||
{ method: 'POST', body },
|
||||
),
|
||||
|
||||
sendTurn: (sessionId: string, body: unknown) =>
|
||||
apiRequest<unknown>(`/api/clinical-dialogue/sessions/${sessionId}/turns`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
finish: (sessionId: string) =>
|
||||
apiRequest<DialogueReport>(
|
||||
`/api/clinical-dialogue/sessions/${sessionId}/finish`,
|
||||
{ method: 'POST', body: {} },
|
||||
),
|
||||
};
|
||||
|
||||
/* --------------------------- 研究资料查询 --------------------------- */
|
||||
|
||||
export const researchApi = {
|
||||
generateSearchQuery: (body: unknown) =>
|
||||
apiRequest<{ query?: SearchQuery } & SearchQuery>(
|
||||
'/api/research/search-queries',
|
||||
{ method: 'POST', body },
|
||||
),
|
||||
|
||||
search: (body: unknown) =>
|
||||
apiRequest<unknown>('/api/research/search', { method: 'POST', body }),
|
||||
|
||||
summarize: (body: unknown) =>
|
||||
apiRequest<Summary>('/api/research/summaries', { method: 'POST', body }),
|
||||
|
||||
generateCitation: (body: unknown) =>
|
||||
apiRequest<Citation[]>('/api/research/citations', {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
};
|
||||
|
||||
/* --------------------------- AI 协同训练 --------------------------- */
|
||||
|
||||
export const collaborationApi = {
|
||||
listTasks: (params: Record<string, string | undefined> = {}) =>
|
||||
apiRequest<unknown[]>('/api/collaboration/tasks', { query: params }),
|
||||
|
||||
evaluateTask: (taskId: string, body: unknown) =>
|
||||
apiRequest<CollaborationAssessment>(
|
||||
`/api/collaboration/tasks/${taskId}/evaluations`,
|
||||
{ method: 'POST', body },
|
||||
),
|
||||
|
||||
listComments: () => apiRequest<unknown[]>('/api/collaboration/comments'),
|
||||
};
|
||||
|
||||
/* ------------------------------ 导师端 ------------------------------ */
|
||||
|
||||
export const mentorApi = {
|
||||
reviewQuestion: (questionId: string, body: { decision: string; reason?: string }) =>
|
||||
apiRequest<unknown>(`/api/mentor/questions/${questionId}/review`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
addComment: (achievementId: string, comment: string) =>
|
||||
apiRequest<unknown>(
|
||||
`/api/mentor/achievements/${achievementId}/comments`,
|
||||
{ method: 'POST', body: { comment } },
|
||||
),
|
||||
|
||||
listComments: (achievementId: string) =>
|
||||
apiRequest<unknown[]>(
|
||||
`/api/mentor/achievements/${achievementId}/comments`,
|
||||
),
|
||||
|
||||
listStudents: () => apiRequest<string[]>('/api/mentor/students'),
|
||||
|
||||
viewStudentProfile: (studentId: string) =>
|
||||
apiRequest<StudentProfileView>(
|
||||
`/api/mentor/students/${studentId}/profile`,
|
||||
),
|
||||
};
|
||||
|
||||
/* ------------------------------ 管理端 ------------------------------ */
|
||||
|
||||
export interface SkillDefinitionInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
inputSpec: unknown;
|
||||
processingLogic: unknown;
|
||||
knowledgeSources: unknown;
|
||||
outputFormat: unknown;
|
||||
credibilityRule: unknown;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const adminSkillsApi = {
|
||||
list: () => apiRequest<SkillDefinition[]>('/api/admin/skills'),
|
||||
|
||||
get: (skillId: string) =>
|
||||
apiRequest<SkillDefinition | null>(`/api/admin/skills/${skillId}`),
|
||||
|
||||
auditLogs: () =>
|
||||
apiRequest<SkillAuditLogEntry[]>('/api/admin/skills/audit-logs'),
|
||||
|
||||
upsert: (body: SkillDefinitionInput) =>
|
||||
apiRequest<SkillDefinition>('/api/admin/skills', {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
enable: (skillId: string) =>
|
||||
apiRequest<SkillDefinition>(`/api/admin/skills/${skillId}/enable`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
}),
|
||||
};
|
||||
|
||||
export const adminComplianceApi = {
|
||||
permissionScopes: () =>
|
||||
apiRequest<unknown[]>('/api/admin/compliance/permission-scopes'),
|
||||
|
||||
auditLogs: (actorId?: string) =>
|
||||
apiRequest<unknown[]>('/api/admin/compliance/audit-logs', {
|
||||
query: { actorId },
|
||||
}),
|
||||
|
||||
denialEvents: (actorId?: string) =>
|
||||
apiRequest<unknown[]>('/api/admin/compliance/denial-events', {
|
||||
query: { actorId },
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 与后端领域类型对齐的前端类型定义。
|
||||
*
|
||||
* 仅声明前端实际消费的字段;后端返回的对象可能含更多字段,按需扩展即可。
|
||||
* 枚举值与后端字符串字面量保持一致(如 Role 的 'student' / 'mentor' / 'administrator')。
|
||||
*/
|
||||
|
||||
/** 全系统规范角色(与后端 compliance.Role 对齐)。 */
|
||||
export enum Role {
|
||||
Student = 'student',
|
||||
Mentor = 'mentor',
|
||||
Administrator = 'administrator',
|
||||
}
|
||||
|
||||
/** 角色中文展示名。 */
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
[Role.Student]: '学生',
|
||||
[Role.Mentor]: '导师',
|
||||
[Role.Administrator]: '管理员',
|
||||
};
|
||||
|
||||
/** 认证身份(与后端 AuthenticatedUser 对齐)。 */
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/** 登录/注册结果(与后端 AuthResult 对齐)。 */
|
||||
export interface AuthResult {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/** 后端统一错误响应体(AppError.toJSON())。 */
|
||||
export interface ApiErrorBody {
|
||||
kind?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/** 学习成果类型(与后端 AchievementType 对齐,共 9 种)。 */
|
||||
export enum AchievementType {
|
||||
CourseRecord = 'course_record',
|
||||
Assignment = 'assignment',
|
||||
LabReport = 'lab_report',
|
||||
ClinicalClerkship = 'clinical_clerkship',
|
||||
OsceAssessment = 'osce_assessment',
|
||||
LiteratureReading = 'literature_reading',
|
||||
ResearchOutput = 'research_output',
|
||||
Certificate = 'certificate',
|
||||
LicensingExamPrep = 'licensing_exam_prep',
|
||||
}
|
||||
|
||||
export const ACHIEVEMENT_TYPE_LABELS: Record<string, string> = {
|
||||
course_record: '课程记录',
|
||||
assignment: '作业',
|
||||
lab_report: '实验报告',
|
||||
clinical_clerkship: '临床见习/实习',
|
||||
osce_assessment: 'OSCE 技能考核',
|
||||
literature_reading: '文献阅读',
|
||||
research_output: '科研成果',
|
||||
certificate: '证书',
|
||||
licensing_exam_prep: '执业资格备考',
|
||||
};
|
||||
|
||||
/** 分页结果(与后端 Paginated<T> 对齐)。 */
|
||||
export interface Paginated<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 学习成果。 */
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
studentId: string;
|
||||
type: AchievementType;
|
||||
title: string;
|
||||
occurredAt: string;
|
||||
academicYear?: string;
|
||||
semester?: string;
|
||||
rotationDept?: string;
|
||||
attachments?: { id: string; name: string; url: string }[];
|
||||
}
|
||||
|
||||
/** 画像维度分值(与后端画像引擎对齐)。score 为数字或字符串 'insufficient_data'。 */
|
||||
export interface ProfileDimensionScore {
|
||||
dimension: string;
|
||||
dimensionName?: string;
|
||||
score: number | string | null;
|
||||
sensitive?: boolean;
|
||||
sensitiveCategory?: string;
|
||||
}
|
||||
|
||||
/** 学生六维画像。 */
|
||||
export interface StudentProfile {
|
||||
studentId: string;
|
||||
dimensions: ProfileDimensionScore[];
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
/** 维度中文展示名(覆盖常见维度键,未覆盖的回退为原值)。 */
|
||||
export const DIMENSION_LABELS: Record<string, string> = {
|
||||
knowledge: '知识掌握',
|
||||
clinical: '临床能力',
|
||||
research: '科研能力',
|
||||
collaboration: '协同能力',
|
||||
practice: '实践能力',
|
||||
professionalism: '职业素养',
|
||||
};
|
||||
|
||||
/* ===================== 各模块结构化返回类型(与后端对齐) ===================== */
|
||||
|
||||
/** 可信度标注来源。 */
|
||||
export interface SourceRef {
|
||||
id: string;
|
||||
title: string;
|
||||
citation?: string;
|
||||
}
|
||||
|
||||
/** 可信度标注(AI 输出统一封装)。 */
|
||||
export interface CredibilityAnnotation {
|
||||
sources: SourceRef[];
|
||||
confidence: number;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/* ---------- 职业规划:发展规划 ---------- */
|
||||
export interface SuggestedAction {
|
||||
description: string;
|
||||
targetLevel: number;
|
||||
}
|
||||
export interface RecommendedResource {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
tagId: string;
|
||||
}
|
||||
export interface CompetencyGap {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
dimension: string;
|
||||
dimensionName: string;
|
||||
currentLevel: number | string;
|
||||
requiredLevel: number;
|
||||
missingData: boolean;
|
||||
suggestedActions: SuggestedAction[];
|
||||
recommendedResources: RecommendedResource[];
|
||||
resourceNote?: string;
|
||||
}
|
||||
export interface DevelopmentPlan {
|
||||
studentId: string;
|
||||
goalId: string;
|
||||
goalTitle: string;
|
||||
modelId: string;
|
||||
gaps: CompetencyGap[];
|
||||
generatedAt: string;
|
||||
}
|
||||
/** 岗位胜任力模型(setCareerGoal 返回)。 */
|
||||
export interface CompetencyModel {
|
||||
id: string;
|
||||
goalId: string;
|
||||
goalTitle: string;
|
||||
framework: string;
|
||||
dimensions: {
|
||||
dimension: string;
|
||||
dimensionName: string;
|
||||
competencyTags: {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
dimension: string;
|
||||
requiredLevel: number;
|
||||
}[];
|
||||
}[];
|
||||
generatedAt: string;
|
||||
}
|
||||
export interface CareerGoalAssociation {
|
||||
studentId: string;
|
||||
goal: { id: string; title: string; description?: string };
|
||||
model: CompetencyModel;
|
||||
associatedAt: string;
|
||||
}
|
||||
|
||||
/* ---------- 课程对练:报告 ---------- */
|
||||
export interface WeakArea {
|
||||
tagId: string;
|
||||
tagName?: string;
|
||||
totalQuestions: number;
|
||||
correctCount: number;
|
||||
correctnessRate: number;
|
||||
}
|
||||
export interface PracticeImprovementSuggestion {
|
||||
content: string;
|
||||
competencyTagIds: string[];
|
||||
annotation: CredibilityAnnotation;
|
||||
trustOutputId: string;
|
||||
}
|
||||
export interface CompetencyTagBreakdown {
|
||||
tagId: string;
|
||||
totalQuestions: number;
|
||||
correctCount: number;
|
||||
correctnessRate: number;
|
||||
}
|
||||
export interface PracticeReport {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
studentId: string;
|
||||
courseId: string;
|
||||
totalQuestions: number;
|
||||
correctCount: number;
|
||||
incorrectCount: number;
|
||||
accuracy: number;
|
||||
totalTimeSeconds: number;
|
||||
tagBreakdown: CompetencyTagBreakdown[];
|
||||
weakAreas: WeakArea[];
|
||||
suggestions: PracticeImprovementSuggestion[];
|
||||
competencyMappingIds: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
export interface AnswerResult {
|
||||
sessionId: string;
|
||||
questionId: string;
|
||||
correct: boolean;
|
||||
timedOut: boolean;
|
||||
elapsedMs: number;
|
||||
next?: { questionId: string; position: number; total: number };
|
||||
}
|
||||
|
||||
/* ---------- 临床对话:三维报告 ---------- */
|
||||
export interface DialogueDimensionScore {
|
||||
dimension: string;
|
||||
dimensionName: string;
|
||||
score: number;
|
||||
competencyTagId?: string;
|
||||
comment: string;
|
||||
}
|
||||
export interface DialogueReport {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
studentId: string;
|
||||
scenarioId: string;
|
||||
dimensions: DialogueDimensionScore[];
|
||||
overallScore: number;
|
||||
annotation: CredibilityAnnotation;
|
||||
trustOutputId: string;
|
||||
turnCount: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/* ---------- 研究查询:检索式/总结/引用 ---------- */
|
||||
export interface SearchQuery {
|
||||
id: string;
|
||||
question: string;
|
||||
pico: {
|
||||
population?: string;
|
||||
intervention?: string;
|
||||
comparison?: string;
|
||||
outcome?: string;
|
||||
};
|
||||
meshTerms: string[];
|
||||
keywords: string[];
|
||||
expression: string;
|
||||
rationale?: string;
|
||||
}
|
||||
export interface SummaryConclusion {
|
||||
id: string;
|
||||
statement: string;
|
||||
verified: boolean;
|
||||
citations: SourceRef[];
|
||||
unverifiedLabel?: string;
|
||||
}
|
||||
export interface GradedReference {
|
||||
itemId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
evidenceLevel: string;
|
||||
}
|
||||
export interface Summary {
|
||||
id: string;
|
||||
conclusions: SummaryConclusion[];
|
||||
citationOutput: SummaryConclusion[];
|
||||
gradedItems: GradedReference[];
|
||||
notice: string;
|
||||
annotation: CredibilityAnnotation;
|
||||
trustOutputId: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
export interface Citation {
|
||||
itemId: string;
|
||||
format: string;
|
||||
text: string;
|
||||
title: string;
|
||||
authors: string[];
|
||||
year: string;
|
||||
url: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/* ---------- 协同训练:评估结果 ---------- */
|
||||
export interface CollaborationDimensionScore {
|
||||
dimension: string;
|
||||
dimensionName: string;
|
||||
score: number | string;
|
||||
competencyTagId?: string;
|
||||
comment: string;
|
||||
}
|
||||
export interface CollaborationAssessment {
|
||||
id: string;
|
||||
studentId: string;
|
||||
taskId: string;
|
||||
dimensions: CollaborationDimensionScore[];
|
||||
overallScore: number | string;
|
||||
requiresSourceVerification: boolean;
|
||||
verificationPrompt?: string;
|
||||
annotation: CredibilityAnnotation;
|
||||
trustOutputId: string;
|
||||
competencyMappingIds: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/* ---------- 导师:学生画像视图(脱敏) ---------- */
|
||||
export interface ProfileFieldView {
|
||||
key: string;
|
||||
value: unknown;
|
||||
sensitive: boolean;
|
||||
category?: string;
|
||||
redacted: boolean;
|
||||
}
|
||||
export interface StudentProfileView {
|
||||
studentId: string;
|
||||
viewerId: string;
|
||||
purpose: string;
|
||||
fullAccess: boolean;
|
||||
authorized: boolean;
|
||||
authorizedScope: string[];
|
||||
fields: ProfileFieldView[];
|
||||
redactedFieldKeys: string[];
|
||||
notice?: string;
|
||||
resolvedAt: string;
|
||||
}
|
||||
|
||||
/* ---------- 管理端:技能定义 ---------- */
|
||||
export interface SkillDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
inputSpec?: unknown;
|
||||
processingLogic?: unknown;
|
||||
knowledgeSources?: unknown;
|
||||
outputFormat?: unknown;
|
||||
credibilityRule?: unknown;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface SkillAuditLogEntry {
|
||||
id?: string;
|
||||
skillId?: string;
|
||||
action?: string;
|
||||
actorId?: string;
|
||||
actorRole?: string;
|
||||
timestamp?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef6ff',
|
||||
100: '#d9eaff',
|
||||
200: '#bcd9ff',
|
||||
300: '#8ec1ff',
|
||||
400: '#599dff',
|
||||
500: '#3377f5',
|
||||
600: '#1f59db',
|
||||
700: '#1a47b0',
|
||||
800: '#1b3e8c',
|
||||
900: '#1c386f',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+8570
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "college-ai-center",
|
||||
"version": "0.1.0",
|
||||
"description": "医科类高校 AI 学习中心系统 - 后端服务",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "10.4.4",
|
||||
"@nestjs/config": "3.2.3",
|
||||
"@nestjs/core": "10.4.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "10.4.4",
|
||||
"@nestjs/typeorm": "10.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"pg": "8.12.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.1",
|
||||
"typeorm": "0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "10.4.5",
|
||||
"@nestjs/schematics": "10.1.4",
|
||||
"@nestjs/testing": "10.4.4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/node": "22.5.5",
|
||||
"fast-check": "3.22.0",
|
||||
"jest": "29.7.0",
|
||||
"source-map-support": "0.5.21",
|
||||
"ts-jest": "29.2.5",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@common/(.*)$": "<rootDir>/common/$1",
|
||||
"^@infra/(.*)$": "<rootDir>/infra/$1",
|
||||
"^@modules/(.*)$": "<rootDir>/modules/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { AI_GATEWAY } from './infra/ai-gateway/ai-gateway.types';
|
||||
import { REFERENCE_SOURCE_ADAPTER } from './infra/reference-source/reference-source.types';
|
||||
import { SkillInvocationService } from './modules/skill-center/skill-invocation.service';
|
||||
|
||||
describe('AppModule (冒烟测试)', () => {
|
||||
it('应用模块可成功编译并解析底座 provider', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
expect(moduleRef.get(AI_GATEWAY)).toBeDefined();
|
||||
expect(moduleRef.get(REFERENCE_SOURCE_ADAPTER)).toBeDefined();
|
||||
// 技能调用服务的依赖(AI 网关、知识源可用性、能力图谱映射)可被完整解析(任务 3.3)。
|
||||
expect(moduleRef.get(SkillInvocationService)).toBeDefined();
|
||||
|
||||
await moduleRef.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { AIGatewayModule } from './infra/ai-gateway/ai-gateway.module';
|
||||
import { databaseConfig } from './infra/database/database.config';
|
||||
import { DatabaseModule } from './infra/database/database.module';
|
||||
import { isDatabaseEnabled } from './infra/database/persistence.util';
|
||||
import { ReferenceSourceModule } from './infra/reference-source/reference-source.module';
|
||||
import { AICollaborationModule } from './modules/ai-collaboration/ai-collaboration.module';
|
||||
import { AIEvaluationModule } from './modules/ai-evaluation/ai-evaluation.module';
|
||||
import { AITrustModule } from './modules/ai-trust/ai-trust.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CareerModule } from './modules/career-module/career.module';
|
||||
import { CaseReasoningModule } from './modules/case-reasoning/case-reasoning.module';
|
||||
import { ClinicalDialogueModule } from './modules/clinical-dialogue/clinical-dialogue.module';
|
||||
import { ComplianceModule } from './modules/compliance/compliance.module';
|
||||
import { CompetencyGraphModule } from './modules/competency-graph/competency-graph.module';
|
||||
import { LearningSpaceModule } from './modules/learning-space/learning-space.module';
|
||||
import { MedicalKnowledgeModule } from './modules/medical-knowledge/medical-knowledge.module';
|
||||
import { MedicationSafetyModule } from './modules/medication-safety/medication-safety.module';
|
||||
import { MentorModule } from './modules/mentor/mentor.module';
|
||||
import { PracticeEngineModule } from './modules/practice-engine/practice-engine.module';
|
||||
import { ProfileEngineModule } from './modules/profile-engine/profile-engine.module';
|
||||
import { ResearchQueryModule } from './modules/research-query/research-query.module';
|
||||
import { SkillCenterModule } from './modules/skill-center/skill-center.module';
|
||||
import { RotationModule } from './modules/rotation/rotation.module';
|
||||
import { SkillVideoModule } from './modules/skill-video/skill-video.module';
|
||||
import { ExamPrepModule } from './modules/exam-prep/exam-prep.module';
|
||||
import { AcademicModule } from './modules/academic/academic.module';
|
||||
|
||||
/**
|
||||
* 应用根模块。
|
||||
*
|
||||
* 注册全局配置与基础设施模块(AI 网关、资料来源适配器)。
|
||||
*
|
||||
* 持久化开关(`DB_ENABLED`):
|
||||
* - `DB_ENABLED=true`:引入 `DatabaseModule` 建立 PostgreSQL 连接(TypeORM),各业务
|
||||
* 模块同时将仓库令牌绑定到 TypeORM 实现并注册各自实体(见各模块 `*.module.ts`)。
|
||||
* - 其他(含未设置、测试环境):不引入 `DatabaseModule`,各模块绑定内存实现,
|
||||
* 无需真实数据库即可启动与测试。
|
||||
*
|
||||
* 配置已通过 ConfigModule.forFeature(databaseConfig) 预加载。开启数据库时建议在开发
|
||||
* 环境设置 `DB_SYNCHRONIZE=true` 以自动建表(生产环境应使用迁移)。
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [databaseConfig],
|
||||
}),
|
||||
...(isDatabaseEnabled() ? [DatabaseModule] : []),
|
||||
AIGatewayModule,
|
||||
ReferenceSourceModule,
|
||||
AuthModule,
|
||||
CompetencyGraphModule,
|
||||
LearningSpaceModule,
|
||||
SkillCenterModule,
|
||||
ComplianceModule,
|
||||
AITrustModule,
|
||||
AIEvaluationModule,
|
||||
PracticeEngineModule,
|
||||
ClinicalDialogueModule,
|
||||
ProfileEngineModule,
|
||||
CareerModule,
|
||||
ResearchQueryModule,
|
||||
AICollaborationModule,
|
||||
MentorModule,
|
||||
CaseReasoningModule,
|
||||
MedicationSafetyModule,
|
||||
MedicalKnowledgeModule,
|
||||
RotationModule,
|
||||
SkillVideoModule,
|
||||
ExamPrepModule,
|
||||
AcademicModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ErrorKind } from './error-kind';
|
||||
|
||||
/**
|
||||
* 应用错误基类。
|
||||
*
|
||||
* 所有领域错误均继承自 `AppError`,统一携带:
|
||||
* - `kind`:错误分类(校验/生成/授权/系统),便于上层统一映射处理策略与 HTTP 状态码。
|
||||
* - `code`:稳定的机器可读错误码(如 `VALIDATION.MISSING_FIELDS`)。
|
||||
* - `message`:面向用户/调用方的可读提示。
|
||||
* - `details`:可选的结构化细节(如缺失字段列表、无效字段名等)。
|
||||
*
|
||||
* 配合 `Result<T, E>` 使用:错误经由 `Result.error` 返回,而非抛出,
|
||||
* 以保证错误路径显式、数据不进入部分写入状态。
|
||||
*/
|
||||
export abstract class AppError {
|
||||
abstract readonly kind: ErrorKind;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly details?: Readonly<Record<string, unknown>>;
|
||||
|
||||
protected constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.details = details ? Object.freeze({ ...details }) : undefined;
|
||||
}
|
||||
|
||||
/** 序列化为可安全返回给客户端的结构。 */
|
||||
toJSON(): {
|
||||
kind: ErrorKind;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
} {
|
||||
return {
|
||||
kind: this.kind,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.details ? { details: { ...this.details } } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppError } from './app-error';
|
||||
import { ErrorKind } from './error-kind';
|
||||
|
||||
/**
|
||||
* 授权错误:权限不足、缺少有效授权或越权访问时返回。
|
||||
*
|
||||
* 典型场景:非本人无有效知情同意授权访问画像、越权访问受保护资源、
|
||||
* 导师查看非所带学生画像。对应需求 7.4、15.4、17.3。
|
||||
*
|
||||
* 错误路径应保证:拒绝访问、保持资源/数据不被访问或使用、记录拒绝事件。
|
||||
*/
|
||||
export class AuthorizationError extends AppError {
|
||||
readonly kind = ErrorKind.Authorization;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(code, message, details);
|
||||
}
|
||||
|
||||
/** 越权访问:超出角色权限范围。 */
|
||||
static accessDenied(
|
||||
resource: string,
|
||||
reason = '超出权限范围',
|
||||
): AuthorizationError {
|
||||
return new AuthorizationError('AUTHORIZATION.ACCESS_DENIED', '访问被拒绝', {
|
||||
resource,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/** 缺少满足用途的有效知情同意授权。 */
|
||||
static missingConsent(purpose: string): AuthorizationError {
|
||||
return new AuthorizationError(
|
||||
'AUTHORIZATION.MISSING_CONSENT',
|
||||
'缺少有效的知情同意授权',
|
||||
{ purpose },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 系统错误分类。
|
||||
*
|
||||
* 对应 design.md 错误处理章节中的主要错误场景类别:
|
||||
* - VALIDATION:输入校验失败(缺字段/超长/无效日期等),如需求 1.6、1.9、13.4。
|
||||
* - GENERATION:AI 生成失败或超时、信息不足无法生成,如需求 2.5、2.6、5.6、11.6。
|
||||
* - AUTHORIZATION:权限不足、缺少有效授权、越权访问,如需求 7.4、15.4、17.3。
|
||||
* - SYSTEM:系统级故障(依赖不可用、目标关联失败等),如需求 8.5、9.6、10.8。
|
||||
*/
|
||||
export enum ErrorKind {
|
||||
Validation = 'VALIDATION',
|
||||
Generation = 'GENERATION',
|
||||
Authorization = 'AUTHORIZATION',
|
||||
System = 'SYSTEM',
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
AuthorizationError,
|
||||
ErrorKind,
|
||||
GenerationError,
|
||||
SystemError,
|
||||
ValidationError,
|
||||
} from './index';
|
||||
|
||||
describe('错误基类与分类', () => {
|
||||
it('ValidationError 归类为 VALIDATION 且 missingFields 列出缺失字段', () => {
|
||||
const e = ValidationError.missingFields(['type', 'title']);
|
||||
expect(e.kind).toBe(ErrorKind.Validation);
|
||||
expect(e.code).toBe('VALIDATION.MISSING_FIELDS');
|
||||
expect(e.details?.missingFields).toEqual(['type', 'title']);
|
||||
});
|
||||
|
||||
it('ValidationError.invalidFields 列出无效字段', () => {
|
||||
const e = ValidationError.invalidFields(['occurredAt']);
|
||||
expect(e.kind).toBe(ErrorKind.Validation);
|
||||
expect(e.details?.invalidFields).toEqual(['occurredAt']);
|
||||
});
|
||||
|
||||
it('GenerationError 区分失败/超时/信息不足', () => {
|
||||
expect(GenerationError.failed().kind).toBe(ErrorKind.Generation);
|
||||
expect(GenerationError.timedOut(30000).details?.limitMs).toBe(30000);
|
||||
const insufficient = GenerationError.insufficientContent(['正文内容']);
|
||||
expect(insufficient.details?.missingCategories).toEqual(['正文内容']);
|
||||
});
|
||||
|
||||
it('AuthorizationError 归类为 AUTHORIZATION', () => {
|
||||
expect(AuthorizationError.accessDenied('profile').kind).toBe(
|
||||
ErrorKind.Authorization,
|
||||
);
|
||||
expect(AuthorizationError.missingConsent('research').details?.purpose).toBe(
|
||||
'research',
|
||||
);
|
||||
});
|
||||
|
||||
it('SystemError 归类为 SYSTEM', () => {
|
||||
expect(SystemError.internal().kind).toBe(ErrorKind.System);
|
||||
expect(
|
||||
SystemError.dependencyUnavailable('PubMed').details?.dependency,
|
||||
).toBe('PubMed');
|
||||
});
|
||||
|
||||
it('toJSON 输出包含 kind/code/message/details', () => {
|
||||
const json = ValidationError.missingFields(['title']).toJSON();
|
||||
expect(json).toEqual({
|
||||
kind: ErrorKind.Validation,
|
||||
code: 'VALIDATION.MISSING_FIELDS',
|
||||
message: expect.any(String),
|
||||
details: { missingFields: ['title'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('details 为不可变(冻结)副本', () => {
|
||||
const e = ValidationError.missingFields(['a']);
|
||||
expect(Object.isFrozen(e.details)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AppError } from './app-error';
|
||||
import { ErrorKind } from './error-kind';
|
||||
|
||||
/**
|
||||
* 生成错误:AI 生成过程失败、超时或信息不足时返回。
|
||||
*
|
||||
* 典型场景:评价/总结/题目生成失败或超出时限、内容信息不足无法生成。
|
||||
* 对应需求 2.5、2.6、3.7、5.6、11.6、12.6、13.5。
|
||||
*
|
||||
* 错误路径应保证:中止本次生成、保留触发数据不变、返回失败提示。
|
||||
*/
|
||||
export class GenerationError extends AppError {
|
||||
readonly kind = ErrorKind.Generation;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(code, message, details);
|
||||
}
|
||||
|
||||
/** 生成失败。 */
|
||||
static failed(message = '生成失败,请稍后重试'): GenerationError {
|
||||
return new GenerationError('GENERATION.FAILED', message);
|
||||
}
|
||||
|
||||
/** 生成超时。 */
|
||||
static timedOut(limitMs: number): GenerationError {
|
||||
return new GenerationError('GENERATION.TIMEOUT', '生成超出时限', {
|
||||
limitMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** 信息不足,无法生成;列出缺失的内容类别。 */
|
||||
static insufficientContent(missingCategories: string[]): GenerationError {
|
||||
return new GenerationError(
|
||||
'GENERATION.INSUFFICIENT_CONTENT',
|
||||
`信息不足,无法生成:缺少 ${missingCategories.join('、')}`,
|
||||
{ missingCategories },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程内容不足以生成练习题(需求 3.7)。
|
||||
*
|
||||
* 当课程对练引擎无法基于所选课程内容生成足量练习题(课程不存在、无可用内容,或
|
||||
* 可发布题目不足最小题量)时返回。错误路径应保证:中止本次生成、不创建任何题目、
|
||||
* 返回表明无法生成练习题的提示(数据保全)。
|
||||
*/
|
||||
static courseContentInsufficient(
|
||||
message = '无法基于所选课程内容生成练习题',
|
||||
details?: Record<string, unknown>,
|
||||
): GenerationError {
|
||||
return new GenerationError(
|
||||
'GENERATION.COURSE_CONTENT_INSUFFICIENT',
|
||||
message,
|
||||
details,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './error-kind';
|
||||
export * from './app-error';
|
||||
export * from './validation-error';
|
||||
export * from './generation-error';
|
||||
export * from './authorization-error';
|
||||
export * from './system-error';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AppError } from './app-error';
|
||||
import { ErrorKind } from './error-kind';
|
||||
|
||||
/**
|
||||
* 系统错误:依赖不可用、内部故障或外部集成失败时返回。
|
||||
*
|
||||
* 典型场景:目标关联过程发生系统错误、资料来源全部不可用、知识源不可用。
|
||||
* 对应需求 8.5、9.6、10.8、13.5。
|
||||
*
|
||||
* 错误路径应保证:返回错误提示、保留已有数据不变。
|
||||
*/
|
||||
export class SystemError extends AppError {
|
||||
readonly kind = ErrorKind.System;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(code, message, details);
|
||||
}
|
||||
|
||||
/** 通用内部错误。 */
|
||||
static internal(message = '系统内部错误'): SystemError {
|
||||
return new SystemError('SYSTEM.INTERNAL', message);
|
||||
}
|
||||
|
||||
/** 依赖/来源不可用。 */
|
||||
static dependencyUnavailable(dependency: string): SystemError {
|
||||
return new SystemError(
|
||||
'SYSTEM.DEPENDENCY_UNAVAILABLE',
|
||||
`依赖不可用:${dependency}`,
|
||||
{ dependency },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AppError } from './app-error';
|
||||
import { ErrorKind } from './error-kind';
|
||||
|
||||
/**
|
||||
* 校验错误:输入不满足规格时返回。
|
||||
*
|
||||
* 典型场景:必填元数据缺失、字段超长、日期无效、技能输入不符 inputSpec。
|
||||
* 对应需求 1.6、1.9、13.4。
|
||||
*
|
||||
* 错误路径应保证:拒绝操作、不创建任何记录(事务回滚)、返回指明问题的提示。
|
||||
*/
|
||||
export class ValidationError extends AppError {
|
||||
readonly kind = ErrorKind.Validation;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(code, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个"缺失必填字段"校验错误,details 中列出全部缺失字段。
|
||||
*/
|
||||
static missingFields(fields: string[]): ValidationError {
|
||||
return new ValidationError(
|
||||
'VALIDATION.MISSING_FIELDS',
|
||||
`缺少必填字段:${fields.join('、')}`,
|
||||
{ missingFields: fields },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个"无效字段"校验错误,details 中列出无效字段。
|
||||
*/
|
||||
static invalidFields(fields: string[]): ValidationError {
|
||||
return new ValidationError(
|
||||
'VALIDATION.INVALID_FIELDS',
|
||||
`存在无效字段:${fields.join('、')}`,
|
||||
{ invalidFields: fields },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { AppError, ErrorKind } from '@common/errors';
|
||||
|
||||
/**
|
||||
* 将领域错误(`AppError`)映射为合适的 HTTP 异常。
|
||||
*
|
||||
* 统一 Controller 层的错误响应:业务 service 返回 `Result<T, AppError>`,Controller 在
|
||||
* 失败分支调用本函数抛出对应的 HTTP 异常,由 Nest 内置异常层产出标准错误响应。
|
||||
*
|
||||
* 映射规则(按 `kind` 与错误码细化):
|
||||
* - VALIDATION:默认 400;用户名已占用等「冲突」语义映射为 409。
|
||||
* - AUTHORIZATION:凭据/认证类映射为 401,其余越权类映射为 403。
|
||||
* - GENERATION:500(AI 生成失败/超时属服务端不可用)。
|
||||
* - SYSTEM:500。
|
||||
* - 「未找到」语义(错误码含 NOT_FOUND):404。
|
||||
*
|
||||
* 响应体复用 `AppError.toJSON()`,携带 `kind` / `code` / `message` / `details`,
|
||||
* 便于前端按 `code` 做精细处理。
|
||||
*/
|
||||
export function toHttpException(error: AppError): HttpException {
|
||||
const body = error.toJSON();
|
||||
const code = error.code.toUpperCase();
|
||||
|
||||
// 资源未找到(任意 kind 下错误码含 NOT_FOUND)。
|
||||
if (code.includes('NOT_FOUND')) {
|
||||
return new NotFoundException(body);
|
||||
}
|
||||
|
||||
switch (error.kind) {
|
||||
case ErrorKind.Validation:
|
||||
// 唯一性冲突(如用户名已占用)语义上为 409。
|
||||
if (code.includes('TAKEN') || code.includes('CONFLICT') || code.includes('ALREADY')) {
|
||||
return new ConflictException(body);
|
||||
}
|
||||
return new BadRequestException(body);
|
||||
|
||||
case ErrorKind.Authorization:
|
||||
// 凭据无效 / 未认证 → 401;其余越权 → 403。
|
||||
if (
|
||||
code.includes('INVALID_CREDENTIALS') ||
|
||||
code.includes('UNAUTHENTICATED') ||
|
||||
code.includes('ACCOUNT_DISABLED')
|
||||
) {
|
||||
return new HttpException(body, 401);
|
||||
}
|
||||
return new ForbiddenException(body);
|
||||
|
||||
case ErrorKind.Generation:
|
||||
case ErrorKind.System:
|
||||
default:
|
||||
return new InternalServerErrorException(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './app-error.http';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './result';
|
||||
export * from './errors';
|
||||
export * from './pagination';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './pagination';
|
||||
@@ -0,0 +1,42 @@
|
||||
import fc from 'fast-check';
|
||||
|
||||
import { MAX_PAGE_SIZE, normalizePagination, paginate } from './pagination';
|
||||
|
||||
describe('分页工具', () => {
|
||||
it('normalizePagination 将页大小钳制到 [1, MAX_PAGE_SIZE]', () => {
|
||||
expect(normalizePagination({ page: 1, pageSize: 1000 }).pageSize).toBe(
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
expect(normalizePagination({ page: 1, pageSize: 0 }).pageSize).toBe(1);
|
||||
expect(normalizePagination({ page: 0, pageSize: 10 }).page).toBe(1);
|
||||
});
|
||||
|
||||
it('paginate 返回正确的切片与总数', () => {
|
||||
const all = Array.from({ length: 120 }, (_, i) => i);
|
||||
const p1 = paginate(all, { page: 1, pageSize: 50 });
|
||||
expect(p1.items).toHaveLength(50);
|
||||
expect(p1.items[0]).toBe(0);
|
||||
expect(p1.total).toBe(120);
|
||||
|
||||
const p3 = paginate(all, { page: 3, pageSize: 50 });
|
||||
expect(p3.items).toHaveLength(20);
|
||||
expect(p3.items[0]).toBe(100);
|
||||
});
|
||||
|
||||
describe('属性(Property 8 雏形):单页结果数量不超过 50', () => {
|
||||
it('对任意结果集与分页参数,单页数量 ≤ MAX_PAGE_SIZE', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer(), { maxLength: 500 }),
|
||||
fc.integer({ min: -5, max: 50 }),
|
||||
fc.integer({ min: -10, max: 200 }),
|
||||
(all, page, pageSize) => {
|
||||
const result = paginate(all, { page, pageSize });
|
||||
expect(result.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE);
|
||||
expect(result.items.length).toBeLessThanOrEqual(all.length);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 统一分页类型。
|
||||
*
|
||||
* 多个模块要求"每页最多 50 条"(需求 1.5、10.2,对应 Property 8 分页边界不变量)。
|
||||
* `MAX_PAGE_SIZE` 在此集中定义,分页逻辑应据此对页大小进行钳制。
|
||||
*/
|
||||
|
||||
/** 单页最大结果数量(需求 1.5、10.2)。 */
|
||||
export const MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 分页请求参数。 */
|
||||
export interface Pagination {
|
||||
/** 页码,从 1 开始。 */
|
||||
page: number;
|
||||
/** 每页条数,最大不超过 MAX_PAGE_SIZE。 */
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 分页结果。 */
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将请求的分页参数规整到合法范围:
|
||||
* - page 至少为 1。
|
||||
* - pageSize 至少为 1,且不超过 MAX_PAGE_SIZE。
|
||||
*/
|
||||
export function normalizePagination(input: Partial<Pagination>): Pagination {
|
||||
const page = Math.max(1, Math.floor(input.page ?? 1));
|
||||
const requestedSize = Math.floor(input.pageSize ?? MAX_PAGE_SIZE);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于完整结果集构造一页数据,保证单页数量不超过 MAX_PAGE_SIZE(Property 8)。
|
||||
*/
|
||||
export function paginate<T>(all: T[], input: Partial<Pagination>): Page<T> {
|
||||
const { page, pageSize } = normalizePagination(input);
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = all.slice(start, start + pageSize);
|
||||
return { items, page, pageSize, total: all.length };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './result';
|
||||
@@ -0,0 +1,77 @@
|
||||
import fc from 'fast-check';
|
||||
|
||||
import {
|
||||
andThen,
|
||||
err,
|
||||
isErr,
|
||||
isOk,
|
||||
map,
|
||||
mapErr,
|
||||
ok,
|
||||
Result,
|
||||
unwrapOr,
|
||||
} from './result';
|
||||
|
||||
describe('Result<T, E>', () => {
|
||||
describe('构造与类型守卫', () => {
|
||||
it('ok 构造成功结果且 isOk 为真', () => {
|
||||
const r = ok(42);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(isOk(r)).toBe(true);
|
||||
expect(isErr(r)).toBe(false);
|
||||
if (isOk(r)) {
|
||||
expect(r.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('err 构造失败结果且 isErr 为真', () => {
|
||||
const r = err('boom');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(isErr(r)).toBe(true);
|
||||
expect(isOk(r)).toBe(false);
|
||||
if (isErr(r)) {
|
||||
expect(r.error).toBe('boom');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('组合子', () => {
|
||||
it('map 仅作用于成功值', () => {
|
||||
expect(map(ok(2), (n: number) => n * 3)).toEqual(ok(6));
|
||||
const e: Result<number, string> = err('x');
|
||||
expect(map(e, (n: number) => n * 3)).toEqual(err('x'));
|
||||
});
|
||||
|
||||
it('mapErr 仅作用于失败值', () => {
|
||||
const e: Result<number, string> = err('x');
|
||||
expect(mapErr(e, (s: string) => `${s}!`)).toEqual(err('x!'));
|
||||
const o: Result<number, string> = ok(1);
|
||||
expect(mapErr(o, (s: string) => `${s}!`)).toEqual(ok(1));
|
||||
});
|
||||
|
||||
it('andThen 在成功时链式,失败时短路', () => {
|
||||
const half = (n: number): Result<number, string> =>
|
||||
n % 2 === 0 ? ok(n / 2) : err('odd');
|
||||
expect(andThen(ok(8), half)).toEqual(ok(4));
|
||||
expect(andThen(ok(7), half)).toEqual(err('odd'));
|
||||
expect(andThen(err<string>('pre'), half)).toEqual(err('pre'));
|
||||
});
|
||||
|
||||
it('unwrapOr 在失败时返回默认值', () => {
|
||||
expect(unwrapOr(ok(5), 0)).toBe(5);
|
||||
expect(unwrapOr(err('e'), 99)).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('属性:Result 恰为 Ok 或 Err 二者之一', () => {
|
||||
it('对任意值,isOk 与 isErr 互斥且穷尽', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.anything(), fc.boolean(), (value, makeOk) => {
|
||||
const r: Result<unknown, unknown> = makeOk ? ok(value) : err(value);
|
||||
expect(isOk(r)).toBe(!isErr(r));
|
||||
expect(isOk(r) || isErr(r)).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 统一的 `Result<T, E>` 返回类型。
|
||||
*
|
||||
* 系统采用 Result 模式区分正常路径与错误路径(见 design.md 的 Error Handling 章节)。
|
||||
* 任何可能失败的操作都应返回 `Result`,而不是抛出异常或返回 `null`,
|
||||
* 以保证错误路径可被显式处理、且不会让触发数据进入不一致状态(数据保全不变量)。
|
||||
*
|
||||
* - `Ok<T>` 表示成功,携带值 `value: T`。
|
||||
* - `Err<E>` 表示失败,携带错误 `error: E`。
|
||||
*/
|
||||
|
||||
export interface Ok<T> {
|
||||
readonly ok: true;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export interface Err<E> {
|
||||
readonly ok: false;
|
||||
readonly error: E;
|
||||
}
|
||||
|
||||
export type Result<T, E> = Ok<T> | Err<E>;
|
||||
|
||||
/** 构造一个成功结果。 */
|
||||
export function ok<T>(value: T): Ok<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** 构造一个失败结果。 */
|
||||
export function err<E>(error: E): Err<E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/** 类型守卫:判断结果是否成功。 */
|
||||
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
||||
return result.ok === true;
|
||||
}
|
||||
|
||||
/** 类型守卫:判断结果是否失败。 */
|
||||
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
||||
return result.ok === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对成功值应用映射函数;若为失败则原样返回。
|
||||
*/
|
||||
export function map<T, U, E>(
|
||||
result: Result<T, E>,
|
||||
fn: (value: T) => U,
|
||||
): Result<U, E> {
|
||||
return isOk(result) ? ok(fn(result.value)) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对失败值应用映射函数;若为成功则原样返回。
|
||||
*/
|
||||
export function mapErr<T, E, F>(
|
||||
result: Result<T, E>,
|
||||
fn: (error: E) => F,
|
||||
): Result<T, F> {
|
||||
return isErr(result) ? err(fn(result.error)) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式组合:对成功值应用返回 Result 的函数;若为失败则短路返回。
|
||||
*/
|
||||
export function andThen<T, U, E>(
|
||||
result: Result<T, E>,
|
||||
fn: (value: T) => Result<U, E>,
|
||||
): Result<U, E> {
|
||||
return isOk(result) ? fn(result.value) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取成功值,若为失败则返回提供的默认值。
|
||||
*/
|
||||
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
|
||||
return isOk(result) ? result.value : fallback;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AI_GATEWAY } from './ai-gateway.types';
|
||||
import { MockAIGateway } from './mock-ai-gateway';
|
||||
import { OpenAICompatibleGateway } from './openai-ai-gateway';
|
||||
|
||||
const useLLM = !!process.env.LLM_API_KEY;
|
||||
|
||||
/**
|
||||
* AI 网关模块。
|
||||
*
|
||||
* - `LLM_API_KEY` 已设置时绑定 OpenAI 兼容网关(支持通义千问 / DeepSeek / OpenAI 等)。
|
||||
* - 未设置时回退到 Mock 实现,便于本地开发与测试。
|
||||
*
|
||||
* 业务模块通过 `AI_GATEWAY` 令牌注入,无需感知底层切换。
|
||||
*/
|
||||
@Module({
|
||||
providers: useLLM
|
||||
? [
|
||||
OpenAICompatibleGateway,
|
||||
{ provide: AI_GATEWAY, useExisting: OpenAICompatibleGateway },
|
||||
MockAIGateway,
|
||||
]
|
||||
: [
|
||||
MockAIGateway,
|
||||
{ provide: AI_GATEWAY, useExisting: MockAIGateway },
|
||||
],
|
||||
exports: [AI_GATEWAY, MockAIGateway],
|
||||
})
|
||||
export class AIGatewayModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 网关类型定义。
|
||||
*
|
||||
* design.md 规定:所有大模型调用统一经过 AI 网关(AI Gateway)封装,
|
||||
* 调用强制经过可信度标注与来源绑定。测试中以可控 mock 替代,
|
||||
* 以模拟生成成功、失败、超时、无法溯源等场景。
|
||||
*/
|
||||
|
||||
/** 一条来源引用,用于可信度标注的溯源(需求 16.2)。 */
|
||||
export interface SourceRef {
|
||||
/** 来源标识(如权威知识源 ID、文献条目 ID)。 */
|
||||
id: string;
|
||||
/** 来源标题或简述。 */
|
||||
title: string;
|
||||
/** 可选的引用标识(如 DOI、PMID、章节编号)。 */
|
||||
citation?: string;
|
||||
}
|
||||
|
||||
/** 可信度标注(需求 16)。 */
|
||||
export interface CredibilityAnnotation {
|
||||
/** 依据的来源,至少 1 条;为空表示无法溯源。 */
|
||||
sources: SourceRef[];
|
||||
/** 置信度,取值范围 0-100(需求 16.2)。 */
|
||||
confidence: number;
|
||||
/** false => 无法溯源,应标注"未经核验"(需求 16.4)。 */
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/** AI 生成请求。 */
|
||||
export interface AIGenerationRequest {
|
||||
/** 提示词/输入内容。 */
|
||||
prompt: string;
|
||||
/** 可绑定的来源,用于来源溯源。 */
|
||||
sources?: SourceRef[];
|
||||
/** 生成时限(毫秒),超过则视为超时。 */
|
||||
timeoutMs?: number;
|
||||
/** 透传的附加上下文。 */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** AI 生成结果。 */
|
||||
export interface AIGenerationResult {
|
||||
/** 生成的文本内容。 */
|
||||
content: string;
|
||||
/** 该输出的可信度标注。 */
|
||||
annotation: CredibilityAnnotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 网关抽象接口。所有模块通过该接口调用大模型,便于统一治理与测试替换。
|
||||
*/
|
||||
export interface AIGateway {
|
||||
generate(
|
||||
request: AIGenerationRequest,
|
||||
): Promise<AIGenerationResult>;
|
||||
}
|
||||
|
||||
/** 依赖注入令牌。 */
|
||||
export const AI_GATEWAY = Symbol('AI_GATEWAY');
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './ai-gateway.types';
|
||||
export * from './mock-ai-gateway';
|
||||
export * from './openai-ai-gateway';
|
||||
export * from './ai-gateway.module';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MockAIGateway } from './mock-ai-gateway';
|
||||
|
||||
describe('MockAIGateway', () => {
|
||||
let gateway: MockAIGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
gateway = new MockAIGateway();
|
||||
});
|
||||
|
||||
it('默认 success 模式返回内容与已核验的可信度标注', async () => {
|
||||
const result = await gateway.generate({ prompt: '生成评价' });
|
||||
expect(result.content).toContain('生成评价');
|
||||
expect(result.annotation.verified).toBe(true);
|
||||
expect(result.annotation.sources.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.annotation.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(result.annotation.confidence).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('success 模式透传调用方提供的来源', async () => {
|
||||
const result = await gateway.generate({
|
||||
prompt: 'x',
|
||||
sources: [{ id: 's1', title: '指南A', citation: 'G-1' }],
|
||||
});
|
||||
expect(result.annotation.sources).toEqual([
|
||||
{ id: 's1', title: '指南A', citation: 'G-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fail 模式抛出错误(模拟生成失败)', async () => {
|
||||
gateway.setBehavior('fail');
|
||||
await expect(gateway.generate({ prompt: 'x' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('timeout 模式抛出错误(模拟超时)', async () => {
|
||||
gateway.setBehavior('timeout');
|
||||
await expect(gateway.generate({ prompt: 'x' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('unverified 模式返回无来源、未核验的标注', async () => {
|
||||
gateway.setBehavior('unverified');
|
||||
const result = await gateway.generate({ prompt: 'x' });
|
||||
expect(result.annotation.verified).toBe(false);
|
||||
expect(result.annotation.sources).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
AIGateway,
|
||||
AIGenerationRequest,
|
||||
AIGenerationResult,
|
||||
CredibilityAnnotation,
|
||||
} from './ai-gateway.types';
|
||||
|
||||
/**
|
||||
* Mock AI 网关骨架。
|
||||
*
|
||||
* 用于测试与本地开发,替代真实大模型调用。可通过 `setBehavior` 配置不同响应模式,
|
||||
* 以模拟 design.md 测试策略中要求的场景:生成成功、失败、超时、无法溯源。
|
||||
*
|
||||
* 真实实现(对接大模型服务)应在后续任务中以同样的 `AIGateway` 接口提供。
|
||||
*/
|
||||
export type MockGatewayMode = 'success' | 'fail' | 'timeout' | 'unverified';
|
||||
|
||||
@Injectable()
|
||||
export class MockAIGateway implements AIGateway {
|
||||
private mode: MockGatewayMode = 'success';
|
||||
/** 模拟超时模式下抛错前的延迟时间(毫秒)。 */
|
||||
private timeoutDelayMs = 0;
|
||||
|
||||
/** 配置 mock 行为,便于测试驱动不同分支。 */
|
||||
setBehavior(mode: MockGatewayMode, timeoutDelayMs = 0): void {
|
||||
this.mode = mode;
|
||||
this.timeoutDelayMs = timeoutDelayMs;
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: AIGenerationRequest,
|
||||
): Promise<AIGenerationResult> {
|
||||
switch (this.mode) {
|
||||
case 'fail':
|
||||
throw new Error('MockAIGateway: 模拟生成失败');
|
||||
|
||||
case 'timeout':
|
||||
if (this.timeoutDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.timeoutDelayMs),
|
||||
);
|
||||
}
|
||||
throw new Error('MockAIGateway: 模拟生成超时');
|
||||
|
||||
case 'unverified':
|
||||
return {
|
||||
content: `[mock-unverified] ${request.prompt}`,
|
||||
annotation: this.buildAnnotation([], 0, false),
|
||||
};
|
||||
|
||||
case 'success':
|
||||
default: {
|
||||
const sources = request.sources ?? [
|
||||
{ id: 'mock-source-1', title: 'Mock 权威来源', citation: 'MOCK-1' },
|
||||
];
|
||||
return {
|
||||
content: `[mock] ${request.prompt}`,
|
||||
annotation: this.buildAnnotation(sources, 85, true),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildAnnotation(
|
||||
sources: CredibilityAnnotation['sources'],
|
||||
confidence: number,
|
||||
verified: boolean,
|
||||
): CredibilityAnnotation {
|
||||
return { sources, confidence, verified };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import {
|
||||
AIGateway,
|
||||
AIGenerationRequest,
|
||||
AIGenerationResult,
|
||||
CredibilityAnnotation,
|
||||
} from './ai-gateway.types';
|
||||
|
||||
/**
|
||||
* 基于 OpenAI 兼容 API 的 AI 网关实现。
|
||||
*
|
||||
* 支持通义千问(DashScope)、DeepSeek、OpenAI 等所有兼容 `/v1/chat/completions` 的服务。
|
||||
*
|
||||
* 环境变量:
|
||||
* - `LLM_API_KEY`:API 密钥(必需)
|
||||
* - `LLM_BASE_URL`:API 基地址(默认通义千问 DashScope 兼容端点)
|
||||
* - `LLM_MODEL`:模型名(默认 `qwen-plus`)
|
||||
* - `LLM_TIMEOUT_MS`:请求超时毫秒数(默认 30000)
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpenAICompatibleGateway implements AIGateway {
|
||||
private readonly logger = new Logger(OpenAICompatibleGateway.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.apiKey = this.config.get<string>('LLM_API_KEY', '');
|
||||
this.baseUrl = this.config.get<string>(
|
||||
'LLM_BASE_URL',
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
this.model = this.config.get<string>('LLM_MODEL', 'qwen-plus');
|
||||
this.defaultTimeoutMs = this.config.get<number>('LLM_TIMEOUT_MS', 30000);
|
||||
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('LLM_API_KEY 未设置,AI 网关将无法正常调用大模型');
|
||||
} else {
|
||||
this.logger.log(
|
||||
`AI 网关已配置 → ${this.baseUrl} / 模型: ${this.model}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async generate(request: AIGenerationRequest): Promise<AIGenerationResult> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('LLM_API_KEY 未配置,无法调用 AI 服务');
|
||||
}
|
||||
|
||||
const timeoutMs = request.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: request.prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
};
|
||||
|
||||
const url = `${this.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
this.logger.error(
|
||||
`LLM API 调用失败: HTTP ${res.status} - ${errText.slice(0, 500)}`,
|
||||
);
|
||||
throw new Error(`AI 服务返回错误 (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
|
||||
const sources = request.sources ?? [];
|
||||
const annotation: CredibilityAnnotation = {
|
||||
sources,
|
||||
confidence: sources.length > 0 ? 75 : 50,
|
||||
verified: sources.length > 0,
|
||||
};
|
||||
|
||||
return { content, annotation };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(`AI 生成超时(${timeoutMs}ms)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* PostgreSQL 数据库配置。
|
||||
*
|
||||
* 从环境变量读取连接参数(见 .env.example)。生产环境应禁用 synchronize,
|
||||
* 使用迁移管理 schema。design.md 选用 PostgreSQL 作为关系型主存储。
|
||||
*/
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
synchronize: boolean;
|
||||
logging: boolean;
|
||||
}
|
||||
|
||||
export const databaseConfig = registerAs(
|
||||
'database',
|
||||
(): DatabaseConfig => ({
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
username: process.env.DB_USERNAME ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? 'postgres',
|
||||
database: process.env.DB_DATABASE ?? 'college_ai_center',
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
logging: process.env.DB_LOGGING === 'true',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { databaseConfig, DatabaseConfig } from './database.config';
|
||||
|
||||
/**
|
||||
* 数据库模块:基于 TypeORM 建立 PostgreSQL 连接。
|
||||
*
|
||||
* 连接参数通过 ConfigService 从环境变量异步加载。实体将随各业务模块逐步注册,
|
||||
* 这里通过 autoLoadEntities 自动收集各模块通过 forFeature 注册的实体。
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule.forFeature(databaseConfig)],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
const db = config.get<DatabaseConfig>('database');
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: db?.host,
|
||||
port: db?.port,
|
||||
username: db?.username,
|
||||
password: db?.password,
|
||||
database: db?.database,
|
||||
synchronize: db?.synchronize ?? false,
|
||||
logging: db?.logging ?? false,
|
||||
autoLoadEntities: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 持久化装配开关工具。
|
||||
*
|
||||
* 全系统采用「仓库接口 + DI 令牌」解耦持久化实现:每个模块既提供内存实现
|
||||
* (`InMemoryXxxRepository`,无需数据库即可运行,供测试与本地默认装配),也提供
|
||||
* 基于 TypeORM / PostgreSQL 的实现(`TypeOrmXxxRepository`)。
|
||||
*
|
||||
* 由环境变量 `DB_ENABLED` 统一切换:
|
||||
* - `DB_ENABLED=true`:各模块导入 `TypeOrmModule.forFeature([...实体])` 并将仓库令牌
|
||||
* 绑定到 TypeORM 实现,`AppModule` 同时引入 `DatabaseModule` 建立 PostgreSQL 连接。
|
||||
* - 其他(含未设置、测试环境):各模块绑定内存实现,不依赖数据库——保证既有单元/
|
||||
* 属性/集成测试无需真实数据库即可运行。
|
||||
*
|
||||
* 该开关在模块定义(装饰器求值)时读取,故须在应用启动前设置 `DB_ENABLED`
|
||||
* (见 .env.example)。
|
||||
*/
|
||||
export function isDatabaseEnabled(): boolean {
|
||||
return process.env.DB_ENABLED === 'true';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './database/database.config';
|
||||
export * from './database/database.module';
|
||||
export * from './ai-gateway';
|
||||
export * from './reference-source';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './reference-source.types';
|
||||
export * from './mock-reference-source.adapter';
|
||||
export * from './reference-source.module';
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MockReferenceSourceAdapter } from './mock-reference-source.adapter';
|
||||
import { ReferenceSourceType } from './reference-source.types';
|
||||
|
||||
describe('MockReferenceSourceAdapter', () => {
|
||||
let adapter: MockReferenceSourceAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new MockReferenceSourceAdapter();
|
||||
});
|
||||
|
||||
it('默认列出全部五个资料来源', async () => {
|
||||
const sources = await adapter.listAvailableSources();
|
||||
expect(sources).toEqual([
|
||||
ReferenceSourceType.PubMed,
|
||||
ReferenceSourceType.CNKI,
|
||||
ReferenceSourceType.Wanfang,
|
||||
ReferenceSourceType.UpToDate,
|
||||
ReferenceSourceType.Cochrane,
|
||||
]);
|
||||
});
|
||||
|
||||
it('默认每个可用来源返回一条 mock 条目(仅含元信息,无全文)', async () => {
|
||||
const out = await adapter.search({
|
||||
query: '糖尿病管理',
|
||||
sources: [ReferenceSourceType.PubMed, ReferenceSourceType.CNKI],
|
||||
});
|
||||
expect(out.items).toHaveLength(2);
|
||||
expect(out.availableSources).toEqual([
|
||||
ReferenceSourceType.PubMed,
|
||||
ReferenceSourceType.CNKI,
|
||||
]);
|
||||
for (const item of out.items) {
|
||||
expect(item).toHaveProperty('abstract');
|
||||
expect(item).toHaveProperty('url');
|
||||
// 版权安全:不应包含全文字段
|
||||
expect(item).not.toHaveProperty('fullText');
|
||||
}
|
||||
});
|
||||
|
||||
it('可模拟全部来源不可用(需求 10.8 场景)', async () => {
|
||||
adapter.setAvailableSources([]);
|
||||
const out = await adapter.search({
|
||||
query: 'x',
|
||||
sources: [ReferenceSourceType.PubMed],
|
||||
});
|
||||
expect(out.availableSources).toHaveLength(0);
|
||||
expect(out.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('可模拟空结果(需求 10.7 场景)', async () => {
|
||||
adapter.setCannedItems([]);
|
||||
const out = await adapter.search({
|
||||
query: '极冷门检索式',
|
||||
sources: [ReferenceSourceType.PubMed],
|
||||
});
|
||||
expect(out.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('遵循 limit 限制返回数量', async () => {
|
||||
const out = await adapter.search({
|
||||
query: 'x',
|
||||
sources: [
|
||||
ReferenceSourceType.PubMed,
|
||||
ReferenceSourceType.CNKI,
|
||||
ReferenceSourceType.Wanfang,
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
expect(out.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ReferenceItem,
|
||||
ReferenceSearchInput,
|
||||
ReferenceSearchOutput,
|
||||
ReferenceSourceAdapter,
|
||||
ReferenceSourceType,
|
||||
} from './reference-source.types';
|
||||
|
||||
/**
|
||||
* Mock 资料来源适配器骨架。
|
||||
*
|
||||
* 用于测试与本地开发,模拟外部医学数据库的不同状态:
|
||||
* - 正常返回若干条目;
|
||||
* - 指定来源不可用(需求 10.8);
|
||||
* - 空结果(需求 10.7)。
|
||||
*
|
||||
* 真实实现(对接 PubMed/CNKI 等 API)应在后续任务中以同样接口提供。
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockReferenceSourceAdapter implements ReferenceSourceAdapter {
|
||||
private availableSources: ReferenceSourceType[] = [
|
||||
ReferenceSourceType.PubMed,
|
||||
ReferenceSourceType.CNKI,
|
||||
ReferenceSourceType.Wanfang,
|
||||
ReferenceSourceType.UpToDate,
|
||||
ReferenceSourceType.Cochrane,
|
||||
];
|
||||
/** 配置的固定返回结果;为空数组表示空结果场景。 */
|
||||
private cannedItems: ReferenceItem[] | null = null;
|
||||
|
||||
/** 配置当前可用的资料来源(空数组模拟全部不可用,需求 10.8)。 */
|
||||
setAvailableSources(sources: ReferenceSourceType[]): void {
|
||||
this.availableSources = [...sources];
|
||||
}
|
||||
|
||||
/** 配置检索返回的固定条目(传入 [] 模拟空结果,需求 10.7)。 */
|
||||
setCannedItems(items: ReferenceItem[] | null): void {
|
||||
this.cannedItems = items ? [...items] : items;
|
||||
}
|
||||
|
||||
async listAvailableSources(): Promise<ReferenceSourceType[]> {
|
||||
return [...this.availableSources];
|
||||
}
|
||||
|
||||
async search(
|
||||
input: ReferenceSearchInput,
|
||||
): Promise<ReferenceSearchOutput> {
|
||||
const availableSources = input.sources.filter((s) =>
|
||||
this.availableSources.includes(s),
|
||||
);
|
||||
|
||||
if (this.cannedItems !== null) {
|
||||
const limited =
|
||||
typeof input.limit === 'number'
|
||||
? this.cannedItems.slice(0, input.limit)
|
||||
: this.cannedItems;
|
||||
return { items: [...limited], availableSources };
|
||||
}
|
||||
|
||||
// 默认:每个可用来源返回一条 mock 条目。
|
||||
const items: ReferenceItem[] = availableSources.map((source, index) => ({
|
||||
id: `mock-${source}-${index}`,
|
||||
source,
|
||||
title: `Mock 文献条目 (${source})`,
|
||||
authors: ['Mock Author'],
|
||||
publishedAt: '2024-01-01',
|
||||
abstract: `针对查询「${input.query}」的 mock 摘要。`,
|
||||
url: `https://example.org/${source}/${index}`,
|
||||
}));
|
||||
|
||||
const limited =
|
||||
typeof input.limit === 'number' ? items.slice(0, input.limit) : items;
|
||||
return { items: limited, availableSources };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MockReferenceSourceAdapter } from './mock-reference-source.adapter';
|
||||
import { REFERENCE_SOURCE_ADAPTER } from './reference-source.types';
|
||||
|
||||
/**
|
||||
* 资料来源模块。
|
||||
*
|
||||
* 当前默认绑定 Mock 适配器作为骨架。后续接入真实数据库 API 时,
|
||||
* 替换 provider 即可,业务模块通过 `REFERENCE_SOURCE_ADAPTER` 令牌注入。
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
MockReferenceSourceAdapter,
|
||||
{
|
||||
provide: REFERENCE_SOURCE_ADAPTER,
|
||||
useExisting: MockReferenceSourceAdapter,
|
||||
},
|
||||
],
|
||||
exports: [REFERENCE_SOURCE_ADAPTER, MockReferenceSourceAdapter],
|
||||
})
|
||||
export class ReferenceSourceModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 资料来源适配器类型定义。
|
||||
*
|
||||
* design.md 规定:通过资料来源适配器(Reference Source Adapter)对接
|
||||
* PubMed/CNKI/万方/UpToDate/Cochrane 等外部数据库 API。
|
||||
* 测试中以 mock 模拟外部数据库的可用、不可用、空结果等状态(需求 10.7、10.8)。
|
||||
*
|
||||
* 版权安全(需求 10.4):检索条目仅含检索信息、摘要与原文链接,不存储或分发全文。
|
||||
*/
|
||||
|
||||
/** 支持的资料来源类型。 */
|
||||
export enum ReferenceSourceType {
|
||||
PubMed = 'PUBMED',
|
||||
CNKI = 'CNKI',
|
||||
Wanfang = 'WANFANG',
|
||||
UpToDate = 'UPTODATE',
|
||||
Cochrane = 'COCHRANE',
|
||||
}
|
||||
|
||||
/** 一条检索结果条目(仅元信息,不含受版权保护全文,需求 10.4)。 */
|
||||
export interface ReferenceItem {
|
||||
id: string;
|
||||
source: ReferenceSourceType;
|
||||
title: string;
|
||||
authors: string[];
|
||||
publishedAt: string;
|
||||
/** 摘要。 */
|
||||
abstract: string;
|
||||
/** 指向原文的链接。 */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 检索查询入参。 */
|
||||
export interface ReferenceSearchInput {
|
||||
/** 检索表达式(含 PICO 要素 / MeSH 主题词)。 */
|
||||
query: string;
|
||||
/** 指定检索的资料来源范围。 */
|
||||
sources: ReferenceSourceType[];
|
||||
/** 限制返回数量。 */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** 检索查询结果。 */
|
||||
export interface ReferenceSearchOutput {
|
||||
items: ReferenceItem[];
|
||||
/** 本次检索中实际可用的来源。 */
|
||||
availableSources: ReferenceSourceType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资料来源适配器抽象接口。
|
||||
*/
|
||||
export interface ReferenceSourceAdapter {
|
||||
/** 返回当前可用的资料来源。 */
|
||||
listAvailableSources(): Promise<ReferenceSourceType[]>;
|
||||
/** 在指定来源范围内检索。 */
|
||||
search(input: ReferenceSearchInput): Promise<ReferenceSearchOutput>;
|
||||
}
|
||||
|
||||
/** 依赖注入令牌。 */
|
||||
export const REFERENCE_SOURCE_ADAPTER = Symbol('REFERENCE_SOURCE_ADAPTER');
|
||||
@@ -0,0 +1,211 @@
|
||||
import { isOk } from '@common/result';
|
||||
|
||||
import { ModuleSource } from '@modules/competency-graph';
|
||||
import { AchievementType } from '@modules/learning-space';
|
||||
import { ProfileDimension, INSUFFICIENT_DATA } from '@modules/profile-engine';
|
||||
import { CareerGoal } from '@modules/career-module';
|
||||
|
||||
import {
|
||||
IntegrationHarness,
|
||||
makeIntegrationHarness,
|
||||
} from './integration-harness';
|
||||
|
||||
/**
|
||||
* 端到端集成:学习成果 → 能力标签 → 画像 → 职业规划 的数据贯通验证
|
||||
* (任务 14;需求 1.7、14.1、6.x、9.1)。
|
||||
*
|
||||
* 贯通核心:学习空间、画像引擎与职业规划模块**共享同一个能力图谱仓库**。
|
||||
* 本测试验证一条数据自学习空间写入能力图谱后,能被画像引擎与职业规划模块读取并反映,
|
||||
* 形成「成果 → 标签 → 画像维度 → 发展规划差距项」的贯通链路。
|
||||
*/
|
||||
describe('集成:学习成果→能力标签→画像→职业规划 贯通(需求 14.1、6.x、9.1)', () => {
|
||||
let h: IntegrationHarness;
|
||||
const studentId = 'stu-integration-1';
|
||||
const clinicalGoal: CareerGoal = {
|
||||
id: 'goal.clinical-physician',
|
||||
title: '临床医师',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
it('学习成果新增后映射到能力图谱的已定义标签(成果→标签贯通,需求 1.7、14.1)', async () => {
|
||||
const added = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.ClinicalClerkship,
|
||||
title: '内科见习记录',
|
||||
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
expect(isOk(added)).toBe(true);
|
||||
if (!isOk(added)) {
|
||||
return;
|
||||
}
|
||||
// 成果关联到 ≥1 已定义能力标签(需求 1.7)。
|
||||
expect(added.value.pendingTagAssociation).toBe(false);
|
||||
expect(added.value.achievement.competencyTagIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 贯通验证:同一共享能力图谱中存在一条来自学习空间、指向该成果的映射记录。
|
||||
const mappings = await h.mappingService.listMappings(studentId);
|
||||
expect(mappings.length).toBeGreaterThanOrEqual(1);
|
||||
const mapping = mappings.find(
|
||||
(m) => m.referenceId === added.value.achievement.id,
|
||||
);
|
||||
expect(mapping).toBeDefined();
|
||||
expect(mapping?.source).toBe(ModuleSource.LearningSpace);
|
||||
// 映射的标签均为已定义临床类标签。
|
||||
for (const tagId of mapping?.tagIds ?? []) {
|
||||
expect(await h.tagService.isDefinedTag(tagId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('能力图谱状态经画像引擎读取并反映到对应维度(图谱→画像贯通,需求 6.1、6.4)', async () => {
|
||||
// 先经学习空间建立成果与标签关联(无分值映射)。
|
||||
const added = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.CourseRecord,
|
||||
title: '系统解剖学课程记录',
|
||||
occurredAt: new Date('2024-10-08T00:00:00Z'),
|
||||
});
|
||||
expect(isOk(added)).toBe(true);
|
||||
|
||||
// 初次画像:知识维度尚无分值数据 → 数据不足(需求 6.5)。
|
||||
const before = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(before)).toBe(true);
|
||||
if (!isOk(before)) {
|
||||
return;
|
||||
}
|
||||
const knowledgeBefore = before.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.KnowledgeMastery,
|
||||
)!;
|
||||
expect(knowledgeBefore.score).toBe(INSUFFICIENT_DATA);
|
||||
|
||||
// 向共享能力图谱写入一条带分值的知识能力数据(代表对成果的能力评定)。
|
||||
const mapped = await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{
|
||||
studentId,
|
||||
referenceId: added.ok ? added.value.achievement.id : 'x',
|
||||
score: 72,
|
||||
},
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
expect(isOk(mapped)).toBe(true);
|
||||
|
||||
// 再次画像:读取同一共享图谱,知识维度反映为有界分值并可追溯(需求 6.1、6.3、6.4)。
|
||||
const after = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(after)).toBe(true);
|
||||
if (!isOk(after)) {
|
||||
return;
|
||||
}
|
||||
const knowledgeAfter = after.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.KnowledgeMastery,
|
||||
)!;
|
||||
expect(knowledgeAfter.score).toBe(72);
|
||||
expect(knowledgeAfter.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(knowledgeAfter.score as number).toBeLessThanOrEqual(100);
|
||||
expect(knowledgeAfter.traceability.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 画像分值与能力概览口径一致(复用同一共享图谱聚合)。
|
||||
const overview = await h.overviewService.getCompetencyOverview(studentId);
|
||||
const overviewKnowledge = overview.dimensions.find(
|
||||
(d) => d.dimensionId === 'dim.knowledge',
|
||||
)!;
|
||||
expect(knowledgeAfter.score).toBe(overviewKnowledge.score);
|
||||
});
|
||||
|
||||
it('职业规划发展规划基于同一共享能力图谱计算差距项(图谱→职业规划贯通,需求 9.1)', async () => {
|
||||
// 设定职业目标(关联岗位胜任力模型)。
|
||||
const goalResult = await h.career.setCareerGoal(studentId, clinicalGoal);
|
||||
expect(isOk(goalResult)).toBe(true);
|
||||
|
||||
// 向共享能力图谱写入知识维度分值(低于模型要求水平 80)。
|
||||
const mapped = await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'eval-knowledge-1', score: 50 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
expect(isOk(mapped)).toBe(true);
|
||||
|
||||
// 生成发展规划:读取同一共享图谱逐项对照(需求 9.1)。
|
||||
const plan = await h.career.generateDevelopmentPlan(
|
||||
studentId,
|
||||
clinicalGoal.id,
|
||||
);
|
||||
expect(isOk(plan)).toBe(true);
|
||||
if (!isOk(plan)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tag.knowledge.basic 当前 50 < 要求 80 → 数值差距项(贯通:career 读到了写入的分值)。
|
||||
const knowledgeGap = plan.value.gaps.find(
|
||||
(g) => g.tagId === 'tag.knowledge.basic',
|
||||
);
|
||||
expect(knowledgeGap).toBeDefined();
|
||||
expect(knowledgeGap?.missingData).toBe(false);
|
||||
expect(knowledgeGap?.currentLevel).toBe(50);
|
||||
expect(knowledgeGap?.requiredLevel).toBe(80);
|
||||
// 每个差距项引用对应能力标签并含建议行动(需求 9.2、9.4)。
|
||||
expect(knowledgeGap?.suggestedActions.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 缺数据的能力标签(未写入分值)作为缺数据差距项(需求 9.1)。
|
||||
const clinicalGap = plan.value.gaps.find(
|
||||
(g) => g.tagId === 'tag.clinical.decision',
|
||||
);
|
||||
expect(clinicalGap?.missingData).toBe(true);
|
||||
});
|
||||
|
||||
it('能力图谱更新后职业规划动态重算差距项(闭环贯通,需求 9.5)', async () => {
|
||||
await h.career.setCareerGoal(studentId, clinicalGoal);
|
||||
|
||||
// 初始:知识维度 50 → 差距项存在。
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'eval-k-1', score: 50 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
const first = await h.career.generateDevelopmentPlan(
|
||||
studentId,
|
||||
clinicalGoal.id,
|
||||
);
|
||||
expect(isOk(first)).toBe(true);
|
||||
if (isOk(first)) {
|
||||
expect(
|
||||
first.value.gaps.find((g) => g.tagId === 'tag.knowledge.basic'),
|
||||
).toBeDefined();
|
||||
}
|
||||
|
||||
// 共享图谱更新:补充高分映射,平均 (50+100+100)/3 = 83 ≥ 80 → 达标。
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'eval-k-2', score: 100 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'eval-k-3', score: 100 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
|
||||
const second = await h.career.generateDevelopmentPlan(
|
||||
studentId,
|
||||
clinicalGoal.id,
|
||||
);
|
||||
expect(isOk(second)).toBe(true);
|
||||
if (isOk(second)) {
|
||||
// 动态闭环:再次请求基于更新后的图谱重算,知识差距项消失(需求 9.5)。
|
||||
expect(
|
||||
second.value.gaps.find((g) => g.tagId === 'tag.knowledge.basic'),
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
// 画像同步反映更新后的知识维度分值(需求 6.3)。
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (isOk(profile)) {
|
||||
const knowledge = profile.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.KnowledgeMastery,
|
||||
)!;
|
||||
expect(knowledge.score).toBe(83);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { isErr, isOk } from '@common/result';
|
||||
import { MAX_PAGE_SIZE } from '@common/pagination';
|
||||
|
||||
import {
|
||||
INSUFFICIENT_DATA,
|
||||
ModuleSource,
|
||||
} from '@modules/competency-graph';
|
||||
import { AchievementType } from '@modules/learning-space';
|
||||
import {
|
||||
SkillDefinitionService,
|
||||
createBuiltinSkillDefinitions,
|
||||
} from '@modules/skill-center';
|
||||
import {
|
||||
Action,
|
||||
Purpose,
|
||||
ResourceType,
|
||||
Role,
|
||||
SensitiveFieldCategory,
|
||||
User,
|
||||
} from '@modules/compliance';
|
||||
|
||||
import {
|
||||
IntegrationHarness,
|
||||
buildReferenceItem,
|
||||
makeIntegrationHarness,
|
||||
} from './integration-harness';
|
||||
|
||||
/**
|
||||
* 端到端集成:核心不变量贯通触达(任务 14;确认 Property 1-8 的核心不变量在装配后成立)。
|
||||
*
|
||||
* 说明:Property 1-8 的完整属性测试分散在各模块的 `*.property.spec.ts` /
|
||||
* `*.spec.ts` 中(fast-check 驱动),由全量测试套件统一运行。本文件不重复属性测试,
|
||||
* 而是以集成装配的真实服务对每个 Property 所在领域做一次"核心不变量"冒烟断言,
|
||||
* 确认底座装配正确、跨模块共享时各不变量仍然成立。
|
||||
*/
|
||||
describe('集成:核心不变量贯通触达(Property 1-8 冒烟确认)', () => {
|
||||
let h: IntegrationHarness;
|
||||
const studentId = 'stu-invariant-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
it('Property 1(技能定义序列化往返一致性):内置技能定义往返等价', () => {
|
||||
const codec = new SkillDefinitionService();
|
||||
for (const def of createBuiltinSkillDefinitions()) {
|
||||
const roundTripped = codec.deserialize(codec.serialize(def));
|
||||
expect(codec.equivalent(def, roundTripped)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Property 2(能力标签映射有效性):已定义标签映射成功、未定义标签拒绝且不创建记录', async () => {
|
||||
// 已定义标签 → 成功。
|
||||
const okResult = await h.mappingService.mapData(
|
||||
ModuleSource.LearningSpace,
|
||||
{ studentId, referenceId: 'ref-ok', score: 60 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
expect(isOk(okResult)).toBe(true);
|
||||
|
||||
// 未定义标签 → 拒绝、不创建映射记录(需求 14.3)。
|
||||
const before = (await h.mappingService.listMappings(studentId)).length;
|
||||
const badResult = await h.mappingService.mapData(
|
||||
ModuleSource.LearningSpace,
|
||||
{ studentId, referenceId: 'ref-bad' },
|
||||
['tag.does-not-exist'],
|
||||
);
|
||||
expect(isErr(badResult)).toBe(true);
|
||||
const after = (await h.mappingService.listMappings(studentId)).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('Property 3(画像维度分值有界性):每个维度为 [0,100] 数值或数据不足标记', async () => {
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'ref-k', score: 150 }, // 越界 → clamp
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (!isOk(profile)) {
|
||||
return;
|
||||
}
|
||||
for (const dim of profile.value.dimensions) {
|
||||
if (dim.score === INSUFFICIENT_DATA) {
|
||||
expect(dim.traceability).toEqual([]);
|
||||
} else {
|
||||
expect(typeof dim.score).toBe('number');
|
||||
expect(dim.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(dim.score as number).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
// 越界分值被 clamp 到 100。
|
||||
const knowledge = profile.value.dimensions.find(
|
||||
(d) => d.dimensionName === '知识掌握',
|
||||
)!;
|
||||
expect(knowledge.score).toBe(100);
|
||||
});
|
||||
|
||||
it('Property 4(授权强制不变量):本人完整、非本人无授权脱敏、授权后释放、撤销后失效', async () => {
|
||||
const owner: User = { id: studentId, role: Role.Student };
|
||||
const mentor: User = { id: 'mentor-inv-1', role: Role.Mentor };
|
||||
const profile = {
|
||||
studentId,
|
||||
fields: [
|
||||
{ key: 'dimension.knowledge', value: 80, sensitive: false },
|
||||
{
|
||||
key: 'dimension.clinical',
|
||||
value: 70,
|
||||
sensitive: true,
|
||||
category: SensitiveFieldCategory.MedicalRecord,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 本人 → 完整。
|
||||
const ownerView = await h.consent.resolveProfileView(
|
||||
owner,
|
||||
profile,
|
||||
Purpose.CareerGuidance,
|
||||
);
|
||||
expect(ownerView.fullAccess).toBe(true);
|
||||
expect(ownerView.redactedFieldKeys).toEqual([]);
|
||||
|
||||
// 非本人无授权 → 敏感字段脱敏。
|
||||
const noAuth = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(noAuth.fullAccess).toBe(false);
|
||||
expect(noAuth.redactedFieldKeys).toContain('dimension.clinical');
|
||||
|
||||
// 授权后 → 敏感字段释放。
|
||||
const now = new Date();
|
||||
const grant = await h.consent.grantConsent({
|
||||
studentId,
|
||||
scope: [SensitiveFieldCategory.MedicalRecord],
|
||||
purpose: Purpose.MentorReview,
|
||||
validFrom: new Date(now.getTime() - 1000),
|
||||
validUntil: new Date(now.getTime() + 60_000),
|
||||
});
|
||||
expect(isOk(grant)).toBe(true);
|
||||
if (!isOk(grant)) {
|
||||
return;
|
||||
}
|
||||
const authed = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(authed.redactedFieldKeys).not.toContain('dimension.clinical');
|
||||
|
||||
// 撤销后 → 立即按未授权处理(需求 7.8)。
|
||||
await h.consent.revokeConsent(studentId, grant.value.id);
|
||||
const afterRevoke = await h.consent.resolveProfileView(
|
||||
mentor,
|
||||
profile,
|
||||
Purpose.MentorReview,
|
||||
);
|
||||
expect(afterRevoke.redactedFieldKeys).toContain('dimension.clinical');
|
||||
});
|
||||
|
||||
it('Property 5(引用可追溯不变量):引用输出仅含可追溯结论,无法溯源不作为引用输出', async () => {
|
||||
const summary = await h.research.summarize([
|
||||
buildReferenceItem({ id: 'ref-traceable' }),
|
||||
]);
|
||||
expect(isOk(summary)).toBe(true);
|
||||
if (!isOk(summary)) {
|
||||
return;
|
||||
}
|
||||
for (const conclusion of summary.value.citationOutput) {
|
||||
expect(conclusion.verified).toBe(true);
|
||||
expect(conclusion.citations.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
// 未验证结论绝不出现在引用输出中。
|
||||
const unverified = summary.value.conclusions.filter((c) => !c.verified);
|
||||
for (const c of unverified) {
|
||||
expect(summary.value.citationOutput).not.toContain(c);
|
||||
}
|
||||
});
|
||||
|
||||
it('Property 6(权限判定一致性):允许 ⟺ 操作在角色范围内;越权必记录拒绝', async () => {
|
||||
const admin: User = { id: 'admin-inv-1', role: Role.Administrator };
|
||||
const student: User = { id: 'stu-inv-2', role: Role.Student };
|
||||
|
||||
// 管理员可配置技能定义(范围内)。
|
||||
const adminDecision = await h.compliance.checkAccess(
|
||||
admin,
|
||||
{ resourceType: ResourceType.SkillDefinition, identifier: 's-1' },
|
||||
Action.Configure,
|
||||
);
|
||||
expect(adminDecision.allowed).toBe(true);
|
||||
|
||||
// 学生删除审计日志(越权)→ 拒绝并记录。
|
||||
const studentDecision = await h.compliance.checkAccess(
|
||||
student,
|
||||
{ resourceType: ResourceType.AuditLog, identifier: 'log-1' },
|
||||
Action.Delete,
|
||||
);
|
||||
expect(studentDecision.allowed).toBe(false);
|
||||
expect(studentDecision.denialEventId).toBeTruthy();
|
||||
const denials = await h.compliance.listDenialEventsByActor(student.id);
|
||||
expect(denials.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Property 7(错误路径数据保全):非法成果新增被拒且不创建任何记录', async () => {
|
||||
const before = await h.learningSpaceRepo.countAchievements();
|
||||
// 缺少必填元数据(标题为空)→ 拒绝(需求 1.6)。
|
||||
const result = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.CourseRecord,
|
||||
title: ' ',
|
||||
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
||||
});
|
||||
expect(isErr(result)).toBe(true);
|
||||
// 数据保全:无任何记录被创建。
|
||||
expect(await h.learningSpaceRepo.countAchievements()).toBe(before);
|
||||
});
|
||||
|
||||
it('Property 8(分页边界不变量):单页结果数量不超过 50', async () => {
|
||||
// 新增 55 条成果。
|
||||
for (let i = 0; i < 55; i += 1) {
|
||||
const result = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.CourseRecord,
|
||||
title: `课程记录 ${i + 1}`,
|
||||
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
||||
});
|
||||
expect(isOk(result)).toBe(true);
|
||||
}
|
||||
// 请求 pageSize 超过上界 → 被钳制到 MAX_PAGE_SIZE。
|
||||
const page = await h.learningSpace.listAchievements(
|
||||
studentId,
|
||||
{},
|
||||
{ page: 1, pageSize: 1000 },
|
||||
);
|
||||
expect(page.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE);
|
||||
expect(page.items.length).toBe(MAX_PAGE_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { isErr, isOk } from '@common/result';
|
||||
|
||||
import { CollaborationDimension } from '@modules/ai-collaboration';
|
||||
import { AchievementType } from '@modules/learning-space';
|
||||
import {
|
||||
Action,
|
||||
ComplianceAccessService,
|
||||
ResourceType,
|
||||
Role,
|
||||
User,
|
||||
} from '@modules/compliance';
|
||||
|
||||
import {
|
||||
DEFAULT_REFERENCE_SOURCES,
|
||||
IntegrationHarness,
|
||||
buildReferenceItem,
|
||||
makeIntegrationHarness,
|
||||
seedAvailablePracticeQuestions,
|
||||
} from './integration-harness';
|
||||
|
||||
/**
|
||||
* 端到端集成:横切层贯通验证(任务 14;需求 16.1、17.2)。
|
||||
*
|
||||
* - AI 可信层(需求 16.1、16.2、16.4):验证全系统涉及 AI 输出的模块(AI 评估、对练
|
||||
* 改进建议、临床对话报告、研究总结、AI 协同评估)的输出均携带可信度标注,且满足
|
||||
* 不变量:存在 ≥1 来源 ⟺ verified;置信度恒处于 0-100。
|
||||
* - 合规模块(需求 17.2、17.3):验证受保护资源访问经 `checkAccess` 判定——范围内
|
||||
* 允许、越权拒绝并记录拒绝事件。
|
||||
*/
|
||||
describe('集成:AI 可信层横切贯通(需求 16.1、16.2、16.4)', () => {
|
||||
let h: IntegrationHarness;
|
||||
const studentId = 'stu-trust-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
/** 可信度标注不变量断言:≥1 来源 ⟺ verified;置信度 0-100。 */
|
||||
const expectCredibilityInvariants = (annotation: {
|
||||
sources: unknown[];
|
||||
confidence: number;
|
||||
verified: boolean;
|
||||
}): void => {
|
||||
expect(annotation.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(annotation.confidence).toBeLessThanOrEqual(100);
|
||||
expect(annotation.verified).toBe(annotation.sources.length >= 1);
|
||||
};
|
||||
|
||||
it('AI 评估引擎形成性评价携带可信度标注且可经可信层查看依据(需求 2.3、16.1-16.3)', async () => {
|
||||
const added = await h.learningSpace.addAchievement(studentId, {
|
||||
type: AchievementType.ResearchOutput,
|
||||
title: '一项关于高血压管理的文献综述',
|
||||
occurredAt: new Date('2024-09-20T00:00:00Z'),
|
||||
});
|
||||
expect(isOk(added)).toBe(true);
|
||||
if (!isOk(added)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const evaluation = await h.aiEvaluation.generateFormativeEvaluation(
|
||||
added.value.achievement,
|
||||
);
|
||||
expect(isOk(evaluation)).toBe(true);
|
||||
if (!isOk(evaluation)) {
|
||||
return;
|
||||
}
|
||||
// 评价附带可信度标注,列出 ≥1 依据来源(需求 2.3、16.1、16.2)。
|
||||
expect(evaluation.value.annotation.sources.length).toBeGreaterThanOrEqual(1);
|
||||
expectCredibilityInvariants(evaluation.value.annotation);
|
||||
expect(evaluation.value.annotation.verified).toBe(true);
|
||||
// 每条发展建议引用 ≥1 能力标签(需求 2.4)。
|
||||
expect(evaluation.value.suggestions.length).toBeGreaterThanOrEqual(1);
|
||||
for (const suggestion of evaluation.value.suggestions) {
|
||||
expect(suggestion.competencyTagIds.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
// 经 AI 可信层按标识查看依据(需求 16.3)。
|
||||
const evidence = h.aiTrust.getEvidenceById(evaluation.value.trustOutputId);
|
||||
expect(evidence).not.toBeNull();
|
||||
expect(evidence?.verified).toBe(true);
|
||||
expect(evidence?.entries.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('课程对练改进建议携带 0%-100% 可信度标注(需求 4.8、16.1)', async () => {
|
||||
const courseId = 'course-trust-101';
|
||||
// 一道题作答错误 → 该能力标签构成薄弱环节 → 触发改进建议。
|
||||
const [q1] = await seedAvailablePracticeQuestions(h, courseId, studentId, [
|
||||
{ tagIds: ['tag.clinical.decision'] },
|
||||
]);
|
||||
const start = await h.practice.startPractice(studentId, courseId);
|
||||
expect(isOk(start)).toBe(true);
|
||||
if (!isOk(start)) {
|
||||
return;
|
||||
}
|
||||
await h.practice.submitAnswer(start.value.id, q1.id, 'B'); // 错
|
||||
const report = await h.practice.finishPractice(start.value.id);
|
||||
expect(isOk(report)).toBe(true);
|
||||
if (!isOk(report)) {
|
||||
return;
|
||||
}
|
||||
expect(report.value.suggestions.length).toBeGreaterThanOrEqual(1);
|
||||
for (const suggestion of report.value.suggestions) {
|
||||
expectCredibilityInvariants(suggestion.annotation);
|
||||
expect(suggestion.annotation.sources.length).toBeGreaterThanOrEqual(1);
|
||||
expect(suggestion.trustOutputId).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('临床对话报告携带可信度标注(需求 5.3、16.1)', async () => {
|
||||
const start = await h.dialogue.startDialogue(
|
||||
studentId,
|
||||
'scenario.chest-pain-001',
|
||||
);
|
||||
expect(isOk(start)).toBe(true);
|
||||
if (!isOk(start)) {
|
||||
return;
|
||||
}
|
||||
await h.dialogue.sendTurn(start.value.id, '请描述您的症状。');
|
||||
const report = await h.dialogue.finishDialogue(start.value.id);
|
||||
expect(isOk(report)).toBe(true);
|
||||
if (!isOk(report)) {
|
||||
return;
|
||||
}
|
||||
expectCredibilityInvariants(report.value.annotation);
|
||||
expect(report.value.annotation.sources.length).toBeGreaterThanOrEqual(1);
|
||||
expect(report.value.trustOutputId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('研究资料总结携带可信度标注且引用输出仅含可追溯结论(需求 11.1、11.4、16.4)', async () => {
|
||||
const summary = await h.research.summarize([buildReferenceItem()]);
|
||||
expect(isOk(summary)).toBe(true);
|
||||
if (!isOk(summary)) {
|
||||
return;
|
||||
}
|
||||
expectCredibilityInvariants(summary.value.annotation);
|
||||
// 引用输出中的每条结论均可追溯(需求 11.4,Property 5)。
|
||||
for (const conclusion of summary.value.citationOutput) {
|
||||
expect(conclusion.verified).toBe(true);
|
||||
expect(conclusion.citations.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('AI 协同评估携带可信度标注(需求 12.x、16.1)', async () => {
|
||||
const tasks = await h.collaboration.listTrainingTasks(
|
||||
CollaborationDimension.Questioning,
|
||||
);
|
||||
const evaluation = await h.collaboration.evaluateTask(
|
||||
studentId,
|
||||
tasks[0].id,
|
||||
{
|
||||
content: '协同评估提交',
|
||||
dimensionEvidence: {
|
||||
[CollaborationDimension.Questioning]: '结构化提问证据。',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(isOk(evaluation)).toBe(true);
|
||||
if (!isOk(evaluation)) {
|
||||
return;
|
||||
}
|
||||
expectCredibilityInvariants(evaluation.value.annotation);
|
||||
expect(evaluation.value.trustOutputId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('无法溯源的 AI 输出被标注「未经核验」(需求 16.4)', async () => {
|
||||
// 经 AI 可信层封装一条无来源输出,验证未经核验标注。
|
||||
const wrapped = h.aiTrust.wrap({ content: '无来源的 AI 输出' });
|
||||
expect(wrapped.annotation.verified).toBe(false);
|
||||
expect(wrapped.annotation.sources.length).toBe(0);
|
||||
expect(wrapped.unverified).toBe(true);
|
||||
expect(wrapped.unverifiedLabel).toBe('未经核验');
|
||||
expectCredibilityInvariants(wrapped.annotation);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 合规模块横切贯通验证(需求 17.1、17.2、17.3)。
|
||||
*/
|
||||
describe('集成:合规模块横切贯通(需求 17.2、17.3)', () => {
|
||||
let h: IntegrationHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
const student: User = { id: 'stu-c-1', role: Role.Student };
|
||||
const mentor: User = { id: 'mentor-c-1', role: Role.Mentor };
|
||||
|
||||
it('范围内访问被允许且不记录拒绝事件(需求 17.2)', async () => {
|
||||
const decision = await h.compliance.checkAccess(
|
||||
student,
|
||||
{ resourceType: ResourceType.Achievement, identifier: 'ach-1' },
|
||||
Action.Read,
|
||||
);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.reason).toBeUndefined();
|
||||
const denials = await h.compliance.listDenialEventsByActor(student.id);
|
||||
expect(denials.length).toBe(0);
|
||||
});
|
||||
|
||||
it('越权访问被拒绝并记录拒绝事件(需求 17.3)', async () => {
|
||||
// 学生无权配置技能定义(仅管理员/导师可 Configure)。
|
||||
const decision = await h.compliance.checkAccess(
|
||||
student,
|
||||
{
|
||||
resourceType: ResourceType.SkillDefinition,
|
||||
identifier: 'skill.research-query',
|
||||
},
|
||||
Action.Configure,
|
||||
);
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toBe(
|
||||
ComplianceAccessService.REASON_OUT_OF_SCOPE,
|
||||
);
|
||||
expect(decision.denialEventId).toBeTruthy();
|
||||
|
||||
// 拒绝事件被记录(含操作者、资源标识、原因,需求 17.3)。
|
||||
const denials = await h.compliance.listDenialEventsByActor(student.id);
|
||||
expect(denials.length).toBe(1);
|
||||
expect(denials[0].resourceType).toBe(ResourceType.SkillDefinition);
|
||||
expect(denials[0].resourceId).toBe('skill.research-query');
|
||||
expect(denials[0].actorId).toBe(student.id);
|
||||
});
|
||||
|
||||
it('导师审核题目在权限范围内(需求 15.1、17.2)', async () => {
|
||||
const decision = await h.compliance.checkAccess(
|
||||
mentor,
|
||||
{ resourceType: ResourceType.Question, identifier: 'q-1' },
|
||||
Action.Review,
|
||||
);
|
||||
expect(decision.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('导师查看非所带学生画像经记录级校验记录越权尝试(需求 15.4、17.3)', async () => {
|
||||
// 角色级 checkAccess 允许导师读取学生画像(需求 17.1)。
|
||||
const roleDecision = await h.compliance.checkAccess(
|
||||
mentor,
|
||||
{ resourceType: ResourceType.StudentProfile, identifier: 'stu-x' },
|
||||
Action.Read,
|
||||
);
|
||||
expect(roleDecision.allowed).toBe(true);
|
||||
|
||||
// 记录级:缺少带教关系 → 由调用方记录越权尝试(需求 15.4)。
|
||||
const denial = await h.compliance.recordDenial(
|
||||
mentor,
|
||||
{ resourceType: ResourceType.StudentProfile, identifier: 'stu-x' },
|
||||
Action.Read,
|
||||
);
|
||||
expect(denial.allowed).toBe(false);
|
||||
expect(denial.reason).toBe(ComplianceAccessService.REASON_NO_MENTORSHIP);
|
||||
const denials = await h.compliance.listDenialEventsByActor(mentor.id);
|
||||
expect(denials.length).toBe(1);
|
||||
expect(denials[0].resourceId).toBe('stu-x');
|
||||
});
|
||||
|
||||
it('研究查询来源全部不可用时中止检索并保留检索式(合规/数据保全贯通,需求 10.8)', async () => {
|
||||
const studentId = 'stu-c-2';
|
||||
const queryResult = await h.research.generateSearchQuery(
|
||||
studentId,
|
||||
'阿司匹林预防冠心病的疗效',
|
||||
);
|
||||
expect(isOk(queryResult)).toBe(true);
|
||||
if (!isOk(queryResult)) {
|
||||
return;
|
||||
}
|
||||
const before = await h.researchRepo.countQueries();
|
||||
|
||||
// 所有指定来源不可用。
|
||||
h.referenceAdapter.setAvailableSources([]);
|
||||
const search = await h.research.search(
|
||||
queryResult.value,
|
||||
DEFAULT_REFERENCE_SOURCES,
|
||||
);
|
||||
expect(isErr(search)).toBe(true);
|
||||
// 数据保全:检索式记录不变(需求 10.8)。
|
||||
expect(await h.researchRepo.countQueries()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 端到端集成测试装配(任务 14:端到端集成与贯通验证)。
|
||||
*
|
||||
* 本文件提供一个**共享同一能力图谱仓库**的全系统装配工厂,是「贯通验证」的核心:
|
||||
* 各业务模块(学习空间、课程对练、临床情景对话、研究查询、AI 协同、画像、职业规划)
|
||||
* 与底座(能力图谱、技能能力中心、AI 可信层、合规模块)均使用内存仓库 / mock 适配器
|
||||
* 直接装配(与各模块单测一致的 `new` 装配风格),并**共享同一个**
|
||||
* `InMemoryCompetencyGraphRepository` 实例——使一个模块写入能力图谱的数据可被下游
|
||||
* 模块(画像、职业规划)读取,从而对跨模块数据贯通(需求 14.1)进行集成验证。
|
||||
*
|
||||
* 该文件不含测试用例(非 `*.spec.ts`),仅作为集成测试的共享装配与工具。
|
||||
*/
|
||||
|
||||
import { MockAIGateway } from '@infra/ai-gateway';
|
||||
import {
|
||||
MockReferenceSourceAdapter,
|
||||
ReferenceItem,
|
||||
ReferenceSourceType,
|
||||
} from '@infra/reference-source';
|
||||
|
||||
import {
|
||||
CompetencyMappingService,
|
||||
CompetencyOverviewService,
|
||||
CompetencyTagService,
|
||||
InMemoryCompetencyGraphRepository,
|
||||
} from '@modules/competency-graph';
|
||||
import {
|
||||
InMemoryLearningSpaceRepository,
|
||||
LearningSpaceService,
|
||||
} from '@modules/learning-space';
|
||||
import { AIEvaluationService } from '@modules/ai-evaluation';
|
||||
import { AITrustService } from '@modules/ai-trust';
|
||||
import {
|
||||
AuthoritativeSource as PracticeAuthoritativeSource,
|
||||
AuthoritativeSourceType as PracticeSourceType,
|
||||
InMemoryPracticeEngineRepository,
|
||||
MockCourseContentProvider,
|
||||
PracticeEngineService,
|
||||
Question,
|
||||
QuestionStatus,
|
||||
QuestionType,
|
||||
SystemPracticeClock,
|
||||
buildDefaultAuthoritativeSources as buildDefaultPracticeSources,
|
||||
} from '@modules/practice-engine';
|
||||
import {
|
||||
ClinicalDialogueService,
|
||||
InMemoryClinicalDialogueRepository,
|
||||
MockClinicalScenarioProvider,
|
||||
SystemDialogueClock,
|
||||
buildDefaultAuthoritativeSources as buildDefaultDialogueSources,
|
||||
buildDefaultScenario,
|
||||
} from '@modules/clinical-dialogue';
|
||||
import {
|
||||
InMemoryResearchQueryRepository,
|
||||
ResearchQueryService,
|
||||
SystemResearchQueryClock,
|
||||
} from '@modules/research-query';
|
||||
import {
|
||||
AICollaborationService,
|
||||
InMemoryAICollaborationRepository,
|
||||
SystemCollaborationClock,
|
||||
} from '@modules/ai-collaboration';
|
||||
import { ProfileEngineService } from '@modules/profile-engine';
|
||||
import {
|
||||
CareerService,
|
||||
InMemoryCareerRepository,
|
||||
SystemCareerClock,
|
||||
buildSeededMockCompetencyModelProvider,
|
||||
buildSeededMockLearningResourceProvider,
|
||||
} from '@modules/career-module';
|
||||
import {
|
||||
ComplianceAccessService,
|
||||
ConsentService,
|
||||
InMemoryComplianceRepository,
|
||||
} from '@modules/compliance';
|
||||
|
||||
/**
|
||||
* 端到端集成装配:全系统服务 + 共享底座仓库。
|
||||
*
|
||||
* `graphRepo` 是贯通验证的关键——全部接触能力图谱的服务共享该实例。
|
||||
*/
|
||||
export interface IntegrationHarness {
|
||||
/** 共享能力图谱仓库(贯通验证核心:所有模块共享同一实例)。 */
|
||||
graphRepo: InMemoryCompetencyGraphRepository;
|
||||
tagService: CompetencyTagService;
|
||||
mappingService: CompetencyMappingService;
|
||||
overviewService: CompetencyOverviewService;
|
||||
aiTrust: AITrustService;
|
||||
gateway: MockAIGateway;
|
||||
|
||||
learningSpaceRepo: InMemoryLearningSpaceRepository;
|
||||
learningSpace: LearningSpaceService;
|
||||
|
||||
aiEvaluation: AIEvaluationService;
|
||||
|
||||
practiceRepo: InMemoryPracticeEngineRepository;
|
||||
practiceProvider: MockCourseContentProvider;
|
||||
practice: PracticeEngineService;
|
||||
|
||||
dialogueRepo: InMemoryClinicalDialogueRepository;
|
||||
dialogueProvider: MockClinicalScenarioProvider;
|
||||
dialogue: ClinicalDialogueService;
|
||||
|
||||
researchRepo: InMemoryResearchQueryRepository;
|
||||
referenceAdapter: MockReferenceSourceAdapter;
|
||||
research: ResearchQueryService;
|
||||
|
||||
collaborationRepo: InMemoryAICollaborationRepository;
|
||||
collaboration: AICollaborationService;
|
||||
|
||||
profile: ProfileEngineService;
|
||||
|
||||
careerRepo: InMemoryCareerRepository;
|
||||
career: CareerService;
|
||||
|
||||
complianceRepo: InMemoryComplianceRepository;
|
||||
compliance: ComplianceAccessService;
|
||||
consent: ConsentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个全系统集成装配,并装载默认能力标签体系(需求 14.1)。
|
||||
*
|
||||
* 所有接触能力图谱的服务(学习空间、对练、对话、研究、协同、画像、职业规划)共享
|
||||
* 同一个 `InMemoryCompetencyGraphRepository`,从而可对「一个模块写入 → 下游模块读取」
|
||||
* 的数据贯通进行集成验证。AI 网关默认为 `success` 模式。
|
||||
*/
|
||||
export async function makeIntegrationHarness(): Promise<IntegrationHarness> {
|
||||
// ---- 共享底座:能力图谱 + AI 可信层 + AI 网关 ----
|
||||
const graphRepo = new InMemoryCompetencyGraphRepository();
|
||||
const tagService = new CompetencyTagService(graphRepo);
|
||||
const mappingService = new CompetencyMappingService(graphRepo);
|
||||
const overviewService = new CompetencyOverviewService(graphRepo);
|
||||
await tagService.seedDefaultTaxonomy();
|
||||
|
||||
const aiTrust = new AITrustService();
|
||||
const gateway = new MockAIGateway();
|
||||
gateway.setBehavior('success');
|
||||
|
||||
// ---- 个人学习空间(写入共享能力图谱)----
|
||||
const learningSpaceRepo = new InMemoryLearningSpaceRepository();
|
||||
const learningSpace = new LearningSpaceService(
|
||||
learningSpaceRepo,
|
||||
tagService,
|
||||
mappingService,
|
||||
);
|
||||
|
||||
// ---- AI 评估引擎(经 AI 可信层封装输出)----
|
||||
const aiEvaluation = new AIEvaluationService(gateway, aiTrust, tagService);
|
||||
|
||||
// ---- 课程对练引擎(对练报告写入共享能力图谱)----
|
||||
const practiceProvider = new MockCourseContentProvider();
|
||||
for (const source of buildDefaultPracticeSources()) {
|
||||
practiceProvider.registerAuthoritativeSource(source);
|
||||
}
|
||||
const practiceRepo = new InMemoryPracticeEngineRepository();
|
||||
const practice = new PracticeEngineService(
|
||||
gateway,
|
||||
practiceProvider,
|
||||
practiceRepo,
|
||||
new SystemPracticeClock(),
|
||||
mappingService,
|
||||
tagService,
|
||||
aiEvaluation,
|
||||
);
|
||||
|
||||
// ---- 临床情景对话对练(对练报告写入共享能力图谱)----
|
||||
const dialogueProvider = new MockClinicalScenarioProvider();
|
||||
for (const source of buildDefaultDialogueSources()) {
|
||||
dialogueProvider.registerAuthoritativeSource(source);
|
||||
}
|
||||
dialogueProvider.setScenario(buildDefaultScenario());
|
||||
const dialogueRepo = new InMemoryClinicalDialogueRepository();
|
||||
const dialogue = new ClinicalDialogueService(
|
||||
gateway,
|
||||
dialogueProvider,
|
||||
dialogueRepo,
|
||||
new SystemDialogueClock(),
|
||||
aiTrust,
|
||||
mappingService,
|
||||
tagService,
|
||||
);
|
||||
|
||||
// ---- 研究资料查询技能(查询使用写入共享能力图谱)----
|
||||
const referenceAdapter = new MockReferenceSourceAdapter();
|
||||
const researchRepo = new InMemoryResearchQueryRepository();
|
||||
const research = new ResearchQueryService(
|
||||
gateway,
|
||||
referenceAdapter,
|
||||
researchRepo,
|
||||
new SystemResearchQueryClock(),
|
||||
mappingService,
|
||||
tagService,
|
||||
aiTrust,
|
||||
);
|
||||
|
||||
// ---- AI 协同能力培养(评估写入共享能力图谱)----
|
||||
const collaborationRepo = new InMemoryAICollaborationRepository();
|
||||
const collaboration = new AICollaborationService(
|
||||
gateway,
|
||||
collaborationRepo,
|
||||
new SystemCollaborationClock(),
|
||||
aiTrust,
|
||||
mappingService,
|
||||
tagService,
|
||||
);
|
||||
|
||||
// ---- 学生画像引擎(读取共享能力图谱 + 学习空间)----
|
||||
const profile = new ProfileEngineService(
|
||||
graphRepo,
|
||||
overviewService,
|
||||
learningSpace,
|
||||
);
|
||||
|
||||
// ---- 职业规划模块(读取共享能力图谱对照胜任力模型)----
|
||||
const careerRepo = new InMemoryCareerRepository();
|
||||
const career = new CareerService(
|
||||
buildSeededMockCompetencyModelProvider(),
|
||||
careerRepo,
|
||||
new SystemCareerClock(),
|
||||
tagService,
|
||||
graphRepo,
|
||||
buildSeededMockLearningResourceProvider(),
|
||||
);
|
||||
|
||||
// ---- 合规横切层 ----
|
||||
const complianceRepo = new InMemoryComplianceRepository();
|
||||
const compliance = new ComplianceAccessService(complianceRepo);
|
||||
const consent = new ConsentService(complianceRepo);
|
||||
|
||||
return {
|
||||
graphRepo,
|
||||
tagService,
|
||||
mappingService,
|
||||
overviewService,
|
||||
aiTrust,
|
||||
gateway,
|
||||
learningSpaceRepo,
|
||||
learningSpace,
|
||||
aiEvaluation,
|
||||
practiceRepo,
|
||||
practiceProvider,
|
||||
practice,
|
||||
dialogueRepo,
|
||||
dialogueProvider,
|
||||
dialogue,
|
||||
researchRepo,
|
||||
referenceAdapter,
|
||||
research,
|
||||
collaborationRepo,
|
||||
collaboration,
|
||||
profile,
|
||||
careerRepo,
|
||||
career,
|
||||
complianceRepo,
|
||||
compliance,
|
||||
consent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接向课程对练仓库登记一批「可用于对练」题目(绕过生成/审核,聚焦贯通流程)。
|
||||
*
|
||||
* 每道题为 A1 选择题、标准答案为 'A',并关联给定能力标签,便于驱动对练报告映射到
|
||||
* 能力图谱(需求 4.6)。
|
||||
*
|
||||
* @param harness 集成装配。
|
||||
* @param courseId 题目所属课程标识。
|
||||
* @param studentId 归属学生标识。
|
||||
* @param specs 每道题的能力标签配置。
|
||||
*/
|
||||
export async function seedAvailablePracticeQuestions(
|
||||
harness: IntegrationHarness,
|
||||
courseId: string,
|
||||
studentId: string,
|
||||
specs: { tagIds: string[] }[],
|
||||
): Promise<Question[]> {
|
||||
const defaultSource: PracticeAuthoritativeSource =
|
||||
buildDefaultPracticeSources()[0];
|
||||
const questions: Question[] = [];
|
||||
for (let i = 0; i < specs.length; i += 1) {
|
||||
const question: Question = {
|
||||
id: `${courseId}-q-${i + 1}`,
|
||||
courseId,
|
||||
studentId,
|
||||
topicId: `${courseId}-topic-${i + 1}`,
|
||||
type: QuestionType.A1,
|
||||
stem: `集成测试题目 ${i + 1}`,
|
||||
options: [
|
||||
{ key: 'A', text: '选项 A' },
|
||||
{ key: 'B', text: '选项 B' },
|
||||
],
|
||||
answer: 'A',
|
||||
authoritativeSources: [
|
||||
{
|
||||
sourceId: defaultSource.id,
|
||||
title: defaultSource.title,
|
||||
citation: defaultSource.citation,
|
||||
type: PracticeSourceType.Textbook,
|
||||
},
|
||||
],
|
||||
competencyTagIds: [...specs[i].tagIds],
|
||||
status: QuestionStatus.Available,
|
||||
generatedAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
};
|
||||
questions.push(await harness.practiceRepo.saveQuestion(question));
|
||||
}
|
||||
return questions;
|
||||
}
|
||||
|
||||
/** 默认资料来源集合(供研究查询检索使用)。 */
|
||||
export const DEFAULT_REFERENCE_SOURCES: ReferenceSourceType[] = [
|
||||
ReferenceSourceType.PubMed,
|
||||
ReferenceSourceType.Cochrane,
|
||||
];
|
||||
|
||||
/** 一个可追溯的资料条目工厂(供研究查询总结/引用流程使用)。 */
|
||||
export function buildReferenceItem(
|
||||
over: Partial<ReferenceItem> = {},
|
||||
): ReferenceItem {
|
||||
return {
|
||||
id: 'ref-1',
|
||||
source: ReferenceSourceType.PubMed,
|
||||
title: '二甲双胍治疗 2 型糖尿病的随机对照试验系统评价',
|
||||
authors: ['张三', '李四'],
|
||||
publishedAt: '2023-06-01',
|
||||
abstract: '本研究系统评价了二甲双胍对 2 型糖尿病血糖控制的疗效。',
|
||||
url: 'https://example.org/pubmed/ref-1',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { isOk } from '@common/result';
|
||||
|
||||
import { ModuleSource } from '@modules/competency-graph';
|
||||
import { ProfileDimension } from '@modules/profile-engine';
|
||||
import { CollaborationDimension } from '@modules/ai-collaboration';
|
||||
|
||||
import {
|
||||
DEFAULT_REFERENCE_SOURCES,
|
||||
IntegrationHarness,
|
||||
makeIntegrationHarness,
|
||||
seedAvailablePracticeQuestions,
|
||||
} from './integration-harness';
|
||||
|
||||
/**
|
||||
* 端到端集成:技能调用(课程对练 / 研究查询 / AI 协同)→ 能力图谱 → 画像 的贯通验证
|
||||
* (任务 14;需求 4.6、5.4、10.5、12.4、6.x、14.1)。
|
||||
*
|
||||
* 贯通核心:技能类模块与画像引擎**共享同一个能力图谱仓库**。本测试验证技能调用结果
|
||||
* 经能力图谱映射后,能被画像引擎读取并反映到对应画像维度。
|
||||
*/
|
||||
describe('集成:技能调用→能力图谱→画像 贯通(需求 4.6、5.4、10.5、12.4)', () => {
|
||||
let h: IntegrationHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await makeIntegrationHarness();
|
||||
});
|
||||
|
||||
it('课程对练报告映射到能力图谱并反映到临床画像维度(对练→图谱→画像贯通,需求 4.6)', async () => {
|
||||
const studentId = 'stu-practice-1';
|
||||
const courseId = 'course-cardio-101';
|
||||
// 关联临床类标签的可用题目(对练后将映射到临床维度)。
|
||||
const [q1, q2] = await seedAvailablePracticeQuestions(h, courseId, studentId, [
|
||||
{ tagIds: ['tag.clinical.decision'] },
|
||||
{ tagIds: ['tag.clinical.history-taking'] },
|
||||
]);
|
||||
|
||||
const start = await h.practice.startPractice(studentId, courseId);
|
||||
expect(isOk(start)).toBe(true);
|
||||
if (!isOk(start)) {
|
||||
return;
|
||||
}
|
||||
const sessionId = start.value.id;
|
||||
// 两题均答对 → 临床标签正确率高、分值贡献为正。
|
||||
await h.practice.submitAnswer(sessionId, q1.id, 'A');
|
||||
await h.practice.submitAnswer(sessionId, q2.id, 'A');
|
||||
|
||||
const report = await h.practice.finishPractice(sessionId);
|
||||
expect(isOk(report)).toBe(true);
|
||||
if (!isOk(report)) {
|
||||
return;
|
||||
}
|
||||
// 报告将成绩映射到共享能力图谱(需求 4.6)。
|
||||
expect(report.value.competencyMappingIds.length).toBeGreaterThan(0);
|
||||
const mappings = await h.mappingService.listMappings(studentId);
|
||||
expect(mappings.some((m) => m.source === ModuleSource.PracticeEngine)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// 贯通验证:画像引擎读取同一共享图谱,临床技能维度反映对练成绩。
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (!isOk(profile)) {
|
||||
return;
|
||||
}
|
||||
const clinical = profile.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.ClinicalSkills,
|
||||
)!;
|
||||
expect(typeof clinical.score).toBe('number');
|
||||
expect(clinical.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(clinical.score as number).toBeLessThanOrEqual(100);
|
||||
// 该维度可追溯到课程对练数据记录(需求 6.4)。
|
||||
expect(
|
||||
clinical.traceability.some(
|
||||
(t) => t.sourceModule === ModuleSource.PracticeEngine,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('研究查询使用映射到能力图谱并反映到科研画像维度(研究→图谱→画像贯通,需求 10.5)', async () => {
|
||||
const studentId = 'stu-research-1';
|
||||
|
||||
// 生成检索式并检索(mock 适配器默认可用来源 + 返回条目)。
|
||||
const queryResult = await h.research.generateSearchQuery(
|
||||
studentId,
|
||||
'二甲双胍治疗2型糖尿病对降低血糖的疗效如何?',
|
||||
);
|
||||
expect(isOk(queryResult)).toBe(true);
|
||||
if (!isOk(queryResult)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResult = await h.research.search(
|
||||
queryResult.value,
|
||||
DEFAULT_REFERENCE_SOURCES,
|
||||
);
|
||||
expect(isOk(searchResult)).toBe(true);
|
||||
if (!isOk(searchResult)) {
|
||||
return;
|
||||
}
|
||||
// 查询使用情况映射到共享能力图谱(需求 10.5)。
|
||||
expect(searchResult.value.competencyMappingIds.length).toBeGreaterThan(0);
|
||||
|
||||
const mappings = await h.mappingService.listMappings(studentId);
|
||||
expect(
|
||||
mappings.some((m) => m.source === ModuleSource.ResearchQuerySkill),
|
||||
).toBe(true);
|
||||
// 研究查询映射文献检索 / 检索式构建标签(科研维度)。
|
||||
const researchTags = new Set(mappings.flatMap((m) => m.tagIds));
|
||||
expect(
|
||||
researchTags.has('tag.research.literature-search') ||
|
||||
researchTags.has('tag.research.query-building'),
|
||||
).toBe(true);
|
||||
|
||||
// 贯通验证:画像引擎读取共享图谱。研究查询映射默认无分值,故科研维度可能数据不足;
|
||||
// 这里追加一条带分值的科研映射以体现可量化反映,再验证维度分值有界。
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.ResearchQuerySkill,
|
||||
{ studentId, referenceId: queryResult.value.id, score: 70 },
|
||||
['tag.research.literature-search'],
|
||||
);
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (isOk(profile)) {
|
||||
const research = profile.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.ResearchAbility,
|
||||
)!;
|
||||
expect(research.score).toBe(70);
|
||||
}
|
||||
});
|
||||
|
||||
it('AI 协同评估映射到能力图谱并反映到 AI 协同画像维度(协同→图谱→画像贯通,需求 12.4)', async () => {
|
||||
const studentId = 'stu-collab-1';
|
||||
const tasks = await h.collaboration.listTrainingTasks(
|
||||
CollaborationDimension.Questioning,
|
||||
);
|
||||
expect(tasks.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const evaluation = await h.collaboration.evaluateTask(
|
||||
studentId,
|
||||
tasks[0].id,
|
||||
{
|
||||
content: '向 AI 提出结构化临床问题并核验其回答。',
|
||||
dimensionEvidence: {
|
||||
[CollaborationDimension.Questioning]:
|
||||
'我向 AI 提供了年龄、伴随症状与排除项等结构化情境信息。',
|
||||
[CollaborationDimension.CriticalVerification]:
|
||||
'我逐条核验了 AI 输出的来源与证据等级。',
|
||||
[CollaborationDimension.ResponsibilityBoundary]:
|
||||
'我明确了 AI 辅助与医师责任的边界。',
|
||||
[CollaborationDimension.CollaborationWorkflow]:
|
||||
'我设计了包含复核节点的人机协同工作流。',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(isOk(evaluation)).toBe(true);
|
||||
if (!isOk(evaluation)) {
|
||||
return;
|
||||
}
|
||||
// 评估结果映射到共享能力图谱(需求 12.4)。
|
||||
expect(evaluation.value.competencyMappingIds.length).toBeGreaterThan(0);
|
||||
|
||||
const mappings = await h.mappingService.listMappings(studentId);
|
||||
expect(
|
||||
mappings.some((m) => m.source === ModuleSource.AICollaboration),
|
||||
).toBe(true);
|
||||
|
||||
// 贯通验证:画像引擎读取共享图谱,AI 协同素养维度反映评估结果(需求 12.4)。
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (isOk(profile)) {
|
||||
const aiCollab = profile.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.AICollaborationLiteracy,
|
||||
)!;
|
||||
expect(typeof aiCollab.score).toBe('number');
|
||||
expect(aiCollab.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(aiCollab.score as number).toBeLessThanOrEqual(100);
|
||||
expect(
|
||||
aiCollab.traceability.some(
|
||||
(t) => t.sourceModule === ModuleSource.AICollaboration,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('临床情景对话报告映射到能力图谱(对话→图谱贯通,需求 5.4)', async () => {
|
||||
const studentId = 'stu-dialogue-1';
|
||||
const start = await h.dialogue.startDialogue(
|
||||
studentId,
|
||||
'scenario.chest-pain-001',
|
||||
);
|
||||
expect(isOk(start)).toBe(true);
|
||||
if (!isOk(start)) {
|
||||
return;
|
||||
}
|
||||
await h.dialogue.sendTurn(start.value.id, '请问您哪里不舒服?');
|
||||
await h.dialogue.sendTurn(start.value.id, '疼痛持续多久了?');
|
||||
|
||||
const report = await h.dialogue.finishDialogue(start.value.id);
|
||||
expect(isOk(report)).toBe(true);
|
||||
if (!isOk(report)) {
|
||||
return;
|
||||
}
|
||||
// 三维结果映射到共享能力图谱(需求 5.4)。
|
||||
expect(report.value.competencyMappingIds.length).toBeGreaterThan(0);
|
||||
const mappings = await h.mappingService.listMappings(studentId);
|
||||
expect(
|
||||
mappings.some(
|
||||
(m) => m.source === ModuleSource.ClinicalDialoguePractice,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// 贯通验证:画像引擎读取共享图谱,临床/人文维度反映对话评估(需求 5.4、6.4)。
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (isOk(profile)) {
|
||||
const clinical = profile.value.dimensions.find(
|
||||
(d) => d.dimension === ProfileDimension.ClinicalSkills,
|
||||
)!;
|
||||
expect(
|
||||
clinical.traceability.some(
|
||||
(t) => t.sourceModule === ModuleSource.ClinicalDialoguePractice,
|
||||
) || typeof clinical.score === 'number',
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('多模块写入同一共享能力图谱后画像综合反映多个维度(多源贯通,需求 14.1、6.2)', async () => {
|
||||
const studentId = 'stu-multi-1';
|
||||
const courseId = 'course-multi-101';
|
||||
|
||||
// 课程对练 → 临床维度。
|
||||
const [q1] = await seedAvailablePracticeQuestions(h, courseId, studentId, [
|
||||
{ tagIds: ['tag.clinical.decision'] },
|
||||
]);
|
||||
const start = await h.practice.startPractice(studentId, courseId);
|
||||
if (isOk(start)) {
|
||||
await h.practice.submitAnswer(start.value.id, q1.id, 'A');
|
||||
await h.practice.finishPractice(start.value.id);
|
||||
}
|
||||
|
||||
// 研究查询(带分值)→ 科研维度。
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.ResearchQuerySkill,
|
||||
{ studentId, referenceId: 'q-multi-1', score: 65 },
|
||||
['tag.research.literature-search'],
|
||||
);
|
||||
|
||||
// 学习成果(带分值评定)→ 知识维度。
|
||||
await h.mappingService.mapData(
|
||||
ModuleSource.AIEvaluationEngine,
|
||||
{ studentId, referenceId: 'ach-multi-1', score: 88 },
|
||||
['tag.knowledge.basic'],
|
||||
);
|
||||
|
||||
const profile = await h.profile.generateProfile(studentId);
|
||||
expect(isOk(profile)).toBe(true);
|
||||
if (!isOk(profile)) {
|
||||
return;
|
||||
}
|
||||
// 多个维度均有数值分值(多源数据在统一图谱上聚合,需求 14.1)。
|
||||
const scored = profile.value.dimensions.filter(
|
||||
(d) => typeof d.score === 'number',
|
||||
);
|
||||
const scoredDims = new Set(scored.map((d) => d.dimension));
|
||||
expect(scoredDims.has(ProfileDimension.KnowledgeMastery)).toBe(true);
|
||||
expect(scoredDims.has(ProfileDimension.ResearchAbility)).toBe(true);
|
||||
expect(scoredDims.has(ProfileDimension.ClinicalSkills)).toBe(true);
|
||||
// 每个有分值维度均有界且可追溯。
|
||||
for (const dim of scored) {
|
||||
expect(dim.score as number).toBeGreaterThanOrEqual(0);
|
||||
expect(dim.score as number).toBeLessThanOrEqual(100);
|
||||
expect(dim.traceability.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 应用入口。
|
||||
*
|
||||
* 启动 NestJS HTTP 服务,端口由环境变量 APP_PORT 决定(默认 3000)。
|
||||
*
|
||||
* 环境变量加载时机(关键):持久化开关 `DB_ENABLED` 在各模块定义(装饰器求值)时被
|
||||
* 读取,早于 `ConfigModule.forRoot()` 在运行时加载 `.env`。因此这里在**导入 AppModule
|
||||
* 之前**先用 `process.loadEnvFile('.env')` 将 `.env` 注入 `process.env`,再以动态
|
||||
* `import()` 加载 AppModule,确保 `DB_ENABLED` 等开关在模块求值时已可见。
|
||||
*
|
||||
* 跨域(CORS):默认放开跨域以便前端(Next.js)本地联调;可由 `CORS_ORIGIN` 收窄。
|
||||
* 全局校验管道:启用 DTO 校验与白名单过滤(配合各 Controller 的 class-validator DTO)。
|
||||
*/
|
||||
async function bootstrap(): Promise<void> {
|
||||
// 在导入 AppModule 之前加载 .env(若存在),使导入期求值的开关可见。
|
||||
if (existsSync('.env')) {
|
||||
process.loadEnvFile('.env');
|
||||
}
|
||||
|
||||
// 动态导入:确保 AppModule 的导入期求值发生在 .env 注入之后。
|
||||
const { AppModule } = await import('./app.module');
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 前端联调:默认允许跨域;CORS_ORIGIN 可设为具体来源以收窄。
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN ?? true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// 全局校验管道:剔除非白名单字段并转换基础类型。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = parseInt(process.env.APP_PORT ?? '3000', 10);
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,26 @@
|
||||
# 业务模块目录(modules)
|
||||
|
||||
各业务模块按设计文档(design.md)的 Components and Interfaces 章节划分,将在后续任务中逐个实现。
|
||||
每个模块自成一个 NestJS 模块(含 service / controller / entity / 测试),通过依赖注入复用底座与横切层。
|
||||
|
||||
底座层(优先实现):
|
||||
- `competency-graph/` —— 能力图谱(任务 2)
|
||||
- `skill-center/` —— 技能能力中心(任务 3)
|
||||
|
||||
横切层:
|
||||
- `compliance/` —— 合规模块(权限/脱敏/授权/审计,任务 4)
|
||||
- `ai-trust/` —— AI 可信层(可信度标注/来源溯源,任务 4)
|
||||
|
||||
业务模块:
|
||||
- `learning-space/` —— 个人学习空间(任务 5)
|
||||
- `evaluation/` —— AI 评估引擎(任务 6)
|
||||
- `practice/` —— 课程对练引擎(任务 7)
|
||||
- `clinical-dialogue/` —— 临床情景对话对练(任务 8)
|
||||
- `profile/` —— 学生画像引擎(任务 9)
|
||||
- `career/` —— 职业规划模块(任务 10)
|
||||
- `research-query/` —— 研究资料查询技能(任务 11)
|
||||
- `ai-collaboration/` —— AI 协同能力培养模块(任务 12)
|
||||
- `mentor/` —— 导师端集成(任务 13)
|
||||
|
||||
> 共享基础设施位于 `src/common`(Result/错误基类/分页)与 `src/infra`
|
||||
> (数据库、AI 网关、资料来源适配器)。
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
|
||||
import { AuthenticatedUser, CurrentUser, Role, Roles } from '@modules/auth';
|
||||
|
||||
import { AcademicService } from './academic.service';
|
||||
|
||||
@Controller('api/academic')
|
||||
@Roles(Role.Student)
|
||||
export class AcademicController {
|
||||
constructor(private readonly service: AcademicService) {}
|
||||
|
||||
@Post('records')
|
||||
addRecord(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() body: { type?: string; title: string; content: string; mentorFeedback?: string },
|
||||
) {
|
||||
return this.service.addRecord(user.id, body);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
listRecords(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.service.listRecords(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AcademicController } from './academic.controller';
|
||||
import { AcademicService } from './academic.service';
|
||||
|
||||
@Module({
|
||||
providers: [AcademicService],
|
||||
controllers: [AcademicController],
|
||||
exports: [AcademicService],
|
||||
})
|
||||
export class AcademicModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface AcademicRecord {
|
||||
id: string;
|
||||
studentId: string;
|
||||
type?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
mentorFeedback?: string;
|
||||
date: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const DEMO_RECORDS: AcademicRecord[] = [
|
||||
{
|
||||
id: 'demo-1',
|
||||
studentId: 'demo',
|
||||
type: 'journal-club',
|
||||
title: '糖尿病肾病最新诊疗进展',
|
||||
content: '本周 Journal Club 汇报了 2024 ADA 指南中关于糖尿病肾病筛查与治疗的新建议,重点讨论了 SGLT2i 在延缓肾功能恶化中的证据。',
|
||||
mentorFeedback: '汇报结构清晰,但需要对 DAPA-CKD 研究的亚组分析做更深入理解。建议下周补充。',
|
||||
date: '2024-06-01',
|
||||
createdAt: '2024-06-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'demo-2',
|
||||
studentId: 'demo',
|
||||
type: 'case-discussion',
|
||||
title: '不明原因发热的鉴别诊断思路',
|
||||
content: '参与教学查房,讨论了一例发热待查患者。从感染性疾病、肿瘤、自身免疫病三个方向进行鉴别,最终诊断为结核性腹膜炎。',
|
||||
date: '2024-05-28',
|
||||
createdAt: '2024-05-28T14:30:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AcademicService {
|
||||
private records = new Map<string, AcademicRecord[]>();
|
||||
|
||||
constructor() {
|
||||
this.records.set('demo', [...DEMO_RECORDS]);
|
||||
}
|
||||
|
||||
addRecord(
|
||||
studentId: string,
|
||||
body: { type?: string; title: string; content: string; mentorFeedback?: string },
|
||||
): AcademicRecord {
|
||||
const record: AcademicRecord = {
|
||||
id: randomUUID(),
|
||||
studentId,
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
mentorFeedback: body.mentorFeedback,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const userRecords = this.records.get(studentId) ?? [];
|
||||
userRecords.unshift(record);
|
||||
this.records.set(studentId, userRecords);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
listRecords(studentId: string): AcademicRecord[] {
|
||||
return this.records.get(studentId) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AcademicModule } from './academic.module';
|
||||
export { AcademicService } from './academic.service';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
|
||||
import { isErr } from '@common/result';
|
||||
import { toHttpException } from '@common/http';
|
||||
|
||||
import { AuthenticatedUser, CurrentUser, Role, Roles } from '@modules/auth';
|
||||
|
||||
import {
|
||||
AddMentorCommentDto,
|
||||
EvaluateTaskDto,
|
||||
ListTrainingTasksQueryDto,
|
||||
} from './ai-collaboration.dto';
|
||||
import { AICollaborationService } from './ai-collaboration.service';
|
||||
import {
|
||||
CollaborationAssessment,
|
||||
MentorComment,
|
||||
TrainingTask,
|
||||
} from './ai-collaboration.types';
|
||||
|
||||
/**
|
||||
* AI 协同能力培养 HTTP 接口(`/api/collaboration`,需求 12)。
|
||||
*
|
||||
* 类级默认面向**学生本人**(`@Roles(Role.Student)`):归属学生取自认证身份
|
||||
* (`@CurrentUser().id`),不接受请求体/路径中的 studentId,避免越权(学生角色的
|
||||
* `id` 即 `studentId`)。导师点评端点以方法级 `@Roles(Role.Mentor)` 覆盖类级角色
|
||||
* (`RolesGuard` 经 `getAllAndOverride` 取方法级优先)。
|
||||
*
|
||||
* 端点:
|
||||
* - `GET /api/collaboration/tasks?dimension=...`:列出协同训练任务(需求 12.1,学生)。
|
||||
* - `POST /api/collaboration/tasks/:taskId/evaluations`:完成任务并四维评估(需求 12.2-12.4、12.6、12.7,学生)。
|
||||
* - `POST /api/collaboration/assessments/:assessmentId/comments`:导师点评协同过程质量(需求 12.5,导师)。
|
||||
* - `GET /api/collaboration/comments`:列出对本人可见的导师点评(需求 12.5,学生)。
|
||||
*
|
||||
* 业务错误经 `toHttpException` 统一映射为标准 HTTP 错误响应(任务/评估不存在 → 404、
|
||||
* 生成失败/超时 → 500 等)。
|
||||
*/
|
||||
@Controller('api/collaboration')
|
||||
@Roles(Role.Student)
|
||||
export class AICollaborationController {
|
||||
constructor(private readonly collaboration: AICollaborationService) {}
|
||||
|
||||
/** 列出协同训练任务(可按维度筛选,学生,需求 12.1)。 */
|
||||
@Get('tasks')
|
||||
async listTrainingTasks(
|
||||
@Query() query: ListTrainingTasksQueryDto,
|
||||
): Promise<TrainingTask[]> {
|
||||
return this.collaboration.listTrainingTasks(query.dimension);
|
||||
}
|
||||
|
||||
/** 完成一个协同训练任务并评估四个协同能力维度(学生本人,需求 12.2-12.4、12.6、12.7)。 */
|
||||
@Post('tasks/:taskId/evaluations')
|
||||
async evaluateTask(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: EvaluateTaskDto,
|
||||
): Promise<CollaborationAssessment> {
|
||||
const result = await this.collaboration.evaluateTask(user.id, taskId, {
|
||||
content: dto.content,
|
||||
adoptedUnverifiedAiOutput: dto.adoptedUnverifiedAiOutput,
|
||||
dimensionEvidence: dto.dimensionEvidence,
|
||||
});
|
||||
if (isErr(result)) {
|
||||
throw toHttpException(result.error);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** 导师对某评估结果点评协同过程质量并对学生可见(导师,需求 12.5)。 */
|
||||
@Post('assessments/:assessmentId/comments')
|
||||
@Roles(Role.Mentor)
|
||||
async addMentorComment(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('assessmentId') assessmentId: string,
|
||||
@Body() dto: AddMentorCommentDto,
|
||||
): Promise<MentorComment> {
|
||||
const result = await this.collaboration.addMentorComment(
|
||||
assessmentId,
|
||||
user.id,
|
||||
dto.comment,
|
||||
);
|
||||
if (isErr(result)) {
|
||||
throw toHttpException(result.error);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** 列出对本人可见的全部导师点评(学生本人,需求 12.5)。 */
|
||||
@Get('comments')
|
||||
async listVisibleComments(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<MentorComment[]> {
|
||||
return this.collaboration.listVisibleComments(user.id);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user