feat: Add Personal Center module and UI improvements

- Add ProfilePage with avatar upload, password change, and module status display

- Update logo and favicon for login page and browser tab

- Redirect Data Cleaning module default route to Tool C

- Hide Settings button from top navigation for MVP

- Add avatar display in top navigation bar with refresh sync

- Add Prompt knowledge base integration development plan docs
This commit is contained in:
2026-01-28 18:18:09 +08:00
parent 5d5a174dd7
commit 3a4aa9123c
17 changed files with 1309 additions and 22 deletions

View File

@@ -197,6 +197,142 @@ export async function changePassword(
}
}
/**
* 更新头像URL方式
*
* PUT /api/v1/auth/me/avatar
*/
export async function updateAvatar(
request: FastifyRequest<{ Body: { avatarUrl: string } }>,
reply: FastifyReply
) {
try {
if (!request.user) {
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message: '未认证',
});
}
const { avatarUrl } = request.body;
if (!avatarUrl) {
return reply.status(400).send({
success: false,
error: 'BadRequest',
message: '请提供头像URL',
});
}
const result = await authService.updateAvatar(request.user.userId, avatarUrl);
return reply.status(200).send({
success: true,
data: result,
});
} catch (error) {
const message = error instanceof Error ? error.message : '更新头像失败';
logger.warn('更新头像失败', { error: message, userId: request.user?.userId });
return reply.status(400).send({
success: false,
error: 'BadRequest',
message,
});
}
}
/**
* 上传头像(文件上传方式)
*
* POST /api/v1/auth/me/avatar/upload
*
* 接收 multipart/form-data上传到 staticStorage公开访问
*/
export async function uploadAvatar(
request: FastifyRequest,
reply: FastifyReply
) {
try {
if (!request.user) {
return reply.status(401).send({
success: false,
error: 'Unauthorized',
message: '未认证',
});
}
// 获取上传的文件
const data = await request.file();
if (!data) {
return reply.status(400).send({
success: false,
error: 'BadRequest',
message: '请上传头像文件',
});
}
// 检查文件类型
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedMimeTypes.includes(data.mimetype)) {
return reply.status(400).send({
success: false,
error: 'BadRequest',
message: '仅支持 JPG、PNG、GIF、WebP 格式的图片',
});
}
// 获取文件内容
const buffer = await data.toBuffer();
// 检查文件大小(最大 5MB
if (buffer.length > 5 * 1024 * 1024) {
return reply.status(400).send({
success: false,
error: 'BadRequest',
message: '头像文件大小不能超过 5MB',
});
}
// 生成存储 Key
const { randomUUID } = await import('crypto');
const path = await import('path');
const uuid = randomUUID().replace(/-/g, '').substring(0, 16);
const ext = path.extname(data.filename).toLowerCase() || '.jpg';
const storageKey = `avatars/${request.user.userId}/${uuid}${ext}`;
// 上传到 staticStorage公开访问
const { staticStorage } = await import('../storage/index.js');
const avatarUrl = await staticStorage.upload(storageKey, buffer);
// 更新数据库
const result = await authService.updateAvatar(request.user.userId, avatarUrl);
logger.info('头像上传成功', {
userId: request.user.userId,
storageKey,
avatarUrl,
fileSize: buffer.length
});
return reply.status(200).send({
success: true,
data: result,
});
} catch (error) {
const message = error instanceof Error ? error.message : '头像上传失败';
logger.error('头像上传失败', { error: message, userId: request.user?.userId });
return reply.status(500).send({
success: false,
error: 'InternalServerError',
message,
});
}
}
/**
* 刷新 Token
*

View File

@@ -12,6 +12,8 @@ import {
getCurrentUser,
getUserModules,
changePassword,
updateAvatar,
uploadAvatar,
refreshToken,
logout,
} from './auth.controller.js';
@@ -65,6 +67,16 @@ const changePasswordSchema = {
},
};
const updateAvatarSchema = {
body: {
type: 'object',
required: ['avatarUrl'],
properties: {
avatarUrl: { type: 'string', description: '头像URLOSS地址' },
},
},
};
const refreshTokenSchema = {
body: {
type: 'object',
@@ -128,6 +140,22 @@ export async function authRoutes(
preHandler: [authenticate],
}, getUserModules);
/**
* 更新头像URL方式
*/
fastify.put('/me/avatar', {
preHandler: [authenticate],
schema: updateAvatarSchema,
}, updateAvatar as any);
/**
* 上传头像(文件上传方式)
* 接收 multipart/form-data
*/
fastify.post('/me/avatar/upload', {
preHandler: [authenticate],
}, uploadAvatar as any);
/**
* 修改密码
*/

View File

@@ -56,6 +56,13 @@ export interface UserInfoResponse {
isDefaultPassword: boolean;
permissions: string[];
modules: string[]; // 用户可访问的模块代码列表
// 2026-01-28: 个人中心扩展字段
avatarUrl?: string | null;
status?: string;
kbQuota?: number;
kbUsed?: number;
isTrial?: boolean;
trialEndsAt?: Date | null;
}
/**
@@ -279,6 +286,13 @@ export class AuthService {
isDefaultPassword: user.is_default_password,
permissions,
modules, // 新增:返回模块列表
// 2026-01-28: 个人中心扩展字段
avatarUrl: user.avatarUrl,
status: user.status,
kbQuota: user.kbQuota,
kbUsed: user.kbUsed,
isTrial: user.isTrial,
trialEndsAt: user.trialEndsAt,
};
}
@@ -330,6 +344,30 @@ export class AuthService {
logger.info('用户修改密码成功', { userId });
}
/**
* 更新用户头像
*
* @param userId 用户ID
* @param avatarUrl 头像URLOSS地址
*/
async updateAvatar(userId: string, avatarUrl: string): Promise<{ avatarUrl: string }> {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('用户不存在');
}
await prisma.user.update({
where: { id: userId },
data: { avatarUrl },
});
logger.info('用户更新头像成功', { userId, avatarUrl });
return { avatarUrl };
}
/**
* 发送验证码
*/

View File

@@ -0,0 +1,125 @@
# **Prompt 管理系统升级:知识库集成 (RAG) 方案**
## **1\. 背景与需求**
统计学专家及其他业务方提出Prompt 需要能够动态引用外部知识库如统计学规范、ICD-10 编码手册、CRF 设计指南)。为了避免代码硬编码,我们需要在 Prompt 管理后台提供“知识库绑定”能力。
## **2\. 核心变更**
### **2.1 数据库 Schema 变更**
修改 capability\_schema.prompt\_templates 表,新增 knowledge\_config 字段。
我们引入 injection\_mode 字段,支持 **RAG 检索****全量注入** 两种策略。
model prompt\_templates {
// ...
// 存储 JSON 配置
// 示例:
// {
// "enabled": true,
// "source\_type": "KB",
// "kb\_codes": \["STAT\_SAS\_GUIDE", "STAT\_R\_GUIDE"\],
//
// // ✨ 新增:注入模式
// // "RAG": 向量检索 TopK (适合海量文档, \>20篇)
// // "FULL": 全量上下文注入 (适合少量核心文档, \<10篇, 精度最高)
// "injection\_mode": "FULL",
//
// "query\_field": "user\_question", // RAG模式必填
// "target\_variable": "reference\_context",
// "top\_k": 3, // RAG模式生效
// "min\_score": 0.6 // RAG模式生效
// }
knowledge\_config Json?
}
### **2.2 管理端 UI 升级 (Prompt Editor)**
在 Prompt 编辑器右侧边栏增加 **"知识增强 (RAG)"** 面板。
#### **面板功能:**
1. **开关**:启用/禁用知识库引用。
2. **知识库选择**:下拉选择已存在的知识库。
3. **注入模式选择 (✨ 新增)**
* **智能检索 (RAG)**:推荐用于大型知识库。只检索最相关的片段,节省 Token响应快。
* **全量注入 (Full Context)**:推荐用于小型核心知识库(如\<10个文档。将所有文档内容直接作为上下文发送给模型**准确率最高,无检索损失**,但消耗 Token 较多。
4. **检索触发词**(仅 RAG 模式显示) 选择 Prompt 变量中的哪一个作为搜索关键词。
5. **注入变量名**:指定内容放入哪个变量(默认 {{context}})。
### **2.3 渲染逻辑流程 (更新版)**
业务侧调用 promptService.get 时,根据 injection\_mode 走不同分支:
**分支 A全量注入模式 (Full Context) —— 专家推荐**
1. 读取绑定的知识库(如 "SAS 9.4 手册")。
2. 直接读取该知识库下所有文档的**全文内容**(需注意总 Token 限制,可设置上限如 100k
3. 拼接全文赋值给 variables.context。
4. **优势**:模型能看到文档全貌,跨章节推理能力极强,完全避免"检索不到"的问题。
**分支 B智能检索模式 (RAG)**
1. 提取检索词(如 user\_requirement
2. 调用向量数据库检索 Top K 片段。
3. 拼接片段赋值给 variables.context。
4. **优势**:便宜,快,适合从几千本书里找答案。
## **3\. 开发任务清单**
* \[ \] **DB**: 更新 Prisma Schema添加 knowledge\_config 字段。
* \[ \] **Backend**:
* \[ \] PromptService 引入 RagService 和 DocumentService。
* \[ \] **新增**PromptService.get 实现 injection\_mode 分流逻辑。
* \[ \] 实现全量拉取文档内容的逻辑 (Cache 优化)。
* \[ \] 管理端 API 支持保存 knowledge\_config。
* \[ \] **Frontend**:
* \[ \] PromptEditor 新增知识库配置侧边栏。
* \[ \] **新增**:注入模式切换开关 (RAG / FULL)。
* \[ \] **Verification**: 使用统计学 SAS 代码生成场景进行验证,对比两种模式的效果。
## **4\. 常见问答 (Q\&A)**
**Q: 全量注入模式会不会太贵?**
A: 取决于文档量。目前 DeepSeek-V3 输入价格约 1元/百万Token。假设 5 个文档共 5万 Token单次调用成本约 0.05 元。对于专家级辅助工具,这个成本是完全可以接受的。
**Q: 全量注入模式有延迟吗?**
A: 首字延迟会比 RAG 略高(因为预填充处理时间长),但目前 LLM 推理速度很快,体验差异通常在 1-2 秒内。
## **5\. AIA 智能体实战配置指南 (基于 V3.1)**
根据 AIA 模块的 10 个核心智能体,建议的知识库绑定策略如下:
### **Phase 1: 选题优化**
此阶段重点在于**创新性**和**临床价值**评估。
| Prompt Code | 智能体名称 | 建议绑定知识库 | 注入模式 | 理由 |
| :---- | :---- | :---- | :---- | :---- |
| AIA\_SCIENTIFIC\_QUESTION | 科学问题梳理 | **\[公共\] 临床研究方法学导论** | **FULL** | 方法学书不厚,全量读入能让 AI 像读过书的教授一样指导,避免断章取义。 |
| AIA\_PICO\_ANALYSIS | PICO 梳理 | **\[公共\] ICD-10/11 编码手册** | **RAG** | 编码手册是字典型的,成千上万条,**必须用 RAG**,全量读入会撑爆上下文且无必要。 |
| AIA\_TOPIC\_EVALUATION | 选题评价 | **\[公共\] 顶级期刊选题指南** | **FULL** | 指南通常只有几页纸,全量注入效果最佳。 |
### **Phase 2: 方案设计**
此阶段重点在于**规范性**和**标准化**。
| Prompt Code | 智能体名称 | 建议绑定知识库 | 注入模式 | 理由 |
| :---- | :---- | :---- | :---- | :---- |
| AIA\_OUTCOME\_DESIGN | 观察指标设计 | **\[公共\] COMET (Core Outcome Measures)** | **RAG** | COMET 数据库很大,适合检索特定疾病的指标集。 |
| AIA\_CRF\_DESIGN | CRF 设计 | **\[公共\] CDISC/CDASH 标准指南** | **FULL** | CDASH 核心字段集文档适中,全量注入能保证字段间的逻辑关系正确。 |
| AIA\_PROTOCOL\_WRITING | 方案撰写 | **\[公共\] SPIRIT 声明 (2013)** | **FULL** | SPIRIT 清单很短,必须全量参考。 |
| AIA\_SAMPLE\_SIZE | 样本量计算 | **\[公共\] 常用样本量计算公式手册** | **FULL** | 公式手册通常很精简。 |
### **Phase 3: 方案预评审**
此阶段重点在于**查漏补缺**。
| Prompt Code | 智能体名称 | 建议绑定知识库 | 注入模式 | 理由 |
| :---- | :---- | :---- | :---- | :---- |
| AIA\_METHODOLOGY\_REVIEW | 方法学评审 | **\[公共\] CONSORT 声明 (RCT)** | **FULL** | 评审需要对照 Checklist 的每一条,**必须全量注入**RAG 如果漏检索一条就会导致评审不完整。 |
*注:需要在运营管理端提前创建上述\[公共\]知识库,并导入相应的 PDF/Markdown 规范文档。*

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1,490 @@
# Prompt 知识库集成开发计划
> **版本:** v1.1
> **创建日期:** 2026-01-28
> **优先级:** P0核心能力增强
> **状态:** 📋 规划中
> **预计工期:** 6-7 个工作日
> **前置依赖:** Prompt 管理系统 (已完成)、RAG 引擎 (已完成)
---
## 📋 目录
1. [项目概述](#1-项目概述)
2. [需求分析](#2-需求分析)
3. [技术方案](#3-技术方案)
4. [数据库设计](#4-数据库设计)
5. [UI 设计](#5-ui-设计)
6. [开发任务清单](#6-开发任务清单)
7. [AIA 智能体配置指南](#7-aia-智能体配置指南)
8. [测试计划](#8-测试计划)
9. [风险与应对](#9-风险与应对)
---
## 1. 项目概述
### 1.1 背景
当前 Prompt 管理系统已支持:
- ✅ 数据库存储与版本管理
- ✅ 灰度预览DRAFT/ACTIVE
- ✅ 变量渲染Handlebars
- ✅ 三级容灾(数据库→缓存→兜底)
**缺失能力:** Prompt 无法动态引用外部知识库如方法学规范、GCP 指南、统计学手册)。
### 1.2 目标
构建 **Prompt + 知识库** 集成能力,实现:
- 🎯 在 Prompt 管理后台配置知识库绑定(非硬编码)
- 🎯 支持两种注入模式:**RAG 检索** 和 **全量注入**
- 🎯 运行时自动将知识库内容注入到 Prompt 变量中
- 🎯 为 AIA 10 个智能体配置最佳知识库策略
### 1.3 核心价值
| 痛点 | 解决方案 |
|------|---------|
| 专家知识只能硬编码到 Prompt | 知识库绑定,动态引用 |
| RAG 检索可能遗漏关键内容 | 支持 FULL 全量注入模式 |
| 每个智能体配置分散在代码中 | 统一后台配置,运营可调整 |
---
## 2. 需求分析
### 2.1 业务需求
| 需求方 | 需求描述 | 优先级 |
|--------|---------|--------|
| 方法学专家 | 方法学评审需要完整参考 CONSORT 声明 | P0 |
| 统计专家 | 样本量计算需要参考公式手册 | P0 |
| 产品经理 | 可在后台配置和调整知识库绑定 | P0 |
| 运营人员 | 无需开发即可为新 Prompt 配置知识库 | P1 |
### 2.2 技术需求
| 需求 | 描述 |
|------|------|
| 两种注入模式 | RAG向量检索+ FULL全量注入 |
| 配置化 | 在 Prompt 管理后台 UI 配置 |
| 变量注入 | 自动将内容注入到指定 Prompt 变量 |
| 缓存优化 | FULL 模式需要缓存知识库全文 |
### 2.3 已有能力复用
| 能力 | 位置 | 状态 |
|------|------|------|
| PromptService | `backend/src/common/prompt/` | ✅ 可扩展 |
| RAG 引擎 | `backend/src/common/rag/` | ✅ 可复用 |
| PKB 知识库 | `backend/src/modules/pkb/` | ✅ 可复用 |
| Prompt 管理 UI | `frontend-v2/src/pages/admin/` | ✅ 可扩展 |
---
## 3. 技术方案
### 3.1 整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Prompt 管理后台(运营端) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Prompt 编辑器 │ │
│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │
│ │ │ 内容编辑区域 │ │ 🆕 知识库增强配置面板 │ │ │
│ │ │ {{context}} │ │ ☑️ 启用知识库增强 │ │ │
│ │ │ {{question}} │ │ 📚 知识库:[CONSORT声明] │ │ │
│ │ │ ... │ │ 🔄 模式FULL / RAG │ │ │
│ │ └─────────────────┘ │ 📝 注入变量context │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PromptService增强版
│ │
│ get(code, variables, userId, userQuery?) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. 获取 Prompt 模板 + knowledge_config │ │
│ │ 2. 检查 knowledge_config.enabled │ │
│ │ ├─ 未启用 → 直接渲染返回 │ │
│ │ └─ 已启用 → 根据 injection_mode 分支处理 │ │
│ │ ├─ FULL → 全量加载知识库文档 │ │
│ │ └─ RAG → 向量检索 Top K │ │
│ │ 3. 将内容注入 variables[target_variable] │ │
│ │ 4. 渲染完整 Prompt 返回 │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
┌─────────────┴─────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ FULL 模式 │ │ RAG 模式 │
│ DocumentService │ │ RagEngine │
│ - 全量加载文档 │ │ - 向量检索 │
│ - 缓存优化 │ │ - Top K + 相似度过滤 │
└──────────────────────┘ └──────────────────────────────────┘
```
### 3.2 注入模式对比
| 特性 | FULL 全量注入 | RAG 向量检索 |
|------|--------------|-------------|
| **适用场景** | 小型核心知识库(<10 篇) | 大型知识库(>20 篇) |
| **准确率** | ⭐⭐⭐⭐⭐ 最高 | ⭐⭐⭐⭐ 较高 |
| **Token 消耗** | 较多(全文) | 较少(片段) |
| **响应速度** | 略慢(预填充) | 较快 |
| **典型用例** | CONSORT 声明、SPIRIT 清单 | ICD-10 编码、文献库 |
### 3.3 数据流
```
用户提问
业务模块调用 promptService.get(code, vars, userId, userQuery)
┌─────────────────────────────────────────┐
│ PromptService.get() │
│ │
│ 1. 查询 prompt_templates 获取配置 │
│ 2. 检查 knowledge_config │
└─────────────┬───────────────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
enabled: false enabled: true
│ │
▼ ▼
直接渲染 判断 injection_mode
┌─────────────┴─────────────┐
│ │
▼ ▼
mode: "FULL" mode: "RAG"
│ │
▼ ▼
加载知识库全文 向量检索 Top K
(DocumentService) (RagEngine)
│ │
└─────────┬─────────────────┘
注入到 variables[target_variable]
Handlebars 渲染
返回完整 Prompt
```
---
## 4. 数据库设计
### 4.1 Schema 变更
**修改表:** `capability_schema.prompt_templates`
```prisma
model prompt_templates {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(100)
name String @db.VarChar(200)
description String? @db.Text
module String @db.VarChar(50)
variables Json?
model_config Json? @map("model_config")
// 🆕 知识库配置
knowledge_config Json? @map("knowledge_config")
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @updatedAt @map("updated_at")
versions prompt_versions[]
@@map("prompt_templates")
@@schema("capability_schema")
}
```
### 4.2 knowledge_config 结构
```typescript
interface KnowledgeConfig {
enabled: boolean;
kb_codes: string[]; // 关联的知识库编码
injection_mode: 'FULL' | 'RAG';
target_variable: string; // 默认 'context'
// RAG 模式配置
query_field?: string;
top_k?: number;
min_score?: number;
}
```
### 4.3 系统知识库表(新建)
```sql
-- 系统知识库
CREATE TABLE capability_schema.system_knowledge_bases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
category VARCHAR(50),
document_count INT DEFAULT 0,
total_tokens INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 知识库文档
CREATE TABLE capability_schema.system_kb_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kb_id UUID REFERENCES capability_schema.system_knowledge_bases(id),
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500),
file_size INT,
token_count INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
```
---
## 5. UI 设计
### 5.1 系统知识库管理
**入口:** 运营管理端 → 系统知识库
| 功能 | 说明 |
|------|------|
| 知识库列表 | 查看、搜索、按分类筛选 |
| 创建知识库 | 编码、名称、分类、描述 |
| 文档管理 | 上传/删除文档 |
| 状态查看 | 文档数、Token 数 |
### 5.2 Prompt 编辑器(方案 A右侧独立卡片
在现有右侧面板新增「📚 知识库增强」卡片:
```
┌───────────────────────────────────┬─────────────────────────┐
│ 📝 Prompt 内容 │ ⚙️ 配置 │
│ ┌─────────────────────────────┐ │ 模块 / 状态 / 版本 │
│ │ {{context}} │ ├─────────────────────────┤
│ │ 你是一位方法学专家... │ │ 📚 知识库增强 │
│ └─────────────────────────────┘ │ ☑️ 启用 │
│ │ 知识库: [CONSORT ▼] │
│ 🧪 测试渲染 │ 模式: ● FULL ○ RAG │
│ │ 注入变量: [context ▼] │
│ ├─────────────────────────┤
│ │ 🔤 变量列表 │
└───────────────────────────────────┴─────────────────────────┘
```
---
## 6. 开发任务清单
### 6.1 总体时间线
```
Day 1: 数据库设计 + 迁移
Day 2: 系统知识库管理(后端 API
Day 3: 系统知识库管理(前端页面)
Day 4: PromptService 增强FULL/RAG
Day 5: Prompt 编辑器 UI知识库配置卡片
Day 6: AIA 智能体配置 + 集成测试
Day 7: 优化 + 文档 + 上线
```
### 6.2 详细任务
#### Phase 1: 数据库Day 1
| ID | 任务 | 状态 |
|----|------|------|
| 1.1 | prompt_templates 添加 knowledge_config 字段 | ⏳ |
| 1.2 | 创建 system_knowledge_bases 表 | ⏳ |
| 1.3 | 创建 system_kb_documents 表 | ⏳ |
| 1.4 | 执行 Prisma 迁移 | ⏳ |
#### Phase 2: 系统知识库管理Day 2-3
| ID | 任务 | 状态 |
|----|------|------|
| 2.1 | 知识库 CRUD API | ⏳ |
| 2.2 | 文档上传/删除 API | ⏳ |
| 2.3 | 文档向量化集成 | ⏳ |
| 2.4 | 知识库列表页 | ⏳ |
| 2.5 | 知识库详情页(文档管理) | ⏳ |
#### Phase 3: PromptService 增强Day 4
| ID | 任务 | 状态 |
|----|------|------|
| 3.1 | 扩展 get() 方法支持 knowledge_config | ⏳ |
| 3.2 | 实现 FULL 模式(全量加载) | ⏳ |
| 3.3 | 实现 RAG 模式(向量检索) | ⏳ |
| 3.4 | 添加缓存优化 | ⏳ |
#### Phase 4: 前端 UIDay 5
| ID | 任务 | 状态 |
|----|------|------|
| 4.1 | PromptEditor 新增知识库配置卡片 | ⏳ |
| 4.2 | 知识库选择器组件 | ⏳ |
| 4.3 | 注入模式切换 | ⏳ |
| 4.4 | 保存/加载配置联调 | ⏳ |
#### Phase 5: 联调收尾Day 6-7
| ID | 任务 | 状态 |
|----|------|------|
| 5.1 | 配置 AIA 智能体的 knowledge_config | ⏳ |
| 5.2 | 端到端测试 | ⏳ |
| 5.3 | 上线部署 | ⏳ |
---
## 7. AIA 智能体配置指南
### 7.1 Phase 1: 选题优化
| Prompt Code | 智能体 | 知识库 | 模式 | 理由 |
|-------------|--------|--------|------|------|
| `AIA_SCIENTIFIC_QUESTION` | 科学问题梳理 | 临床研究方法学导论 | **FULL** | 方法学书籍需要全量理解 |
| `AIA_PICO_ANALYSIS` | PICO 梳理 | ICD-10/11 编码手册 | **RAG** | 编码手册数据量大,需检索 |
| `AIA_TOPIC_EVALUATION` | 选题评价 | 顶级期刊选题指南 | **FULL** | 指南内容精简,全量最佳 |
### 7.2 Phase 2: 方案设计
| Prompt Code | 智能体 | 知识库 | 模式 | 理由 |
|-------------|--------|--------|------|------|
| `AIA_OUTCOME_DESIGN` | 观察指标设计 | COMET 核心指标集 | **RAG** | COMET 数据库较大 |
| `AIA_CRF_DESIGN` | CRF 设计 | CDISC/CDASH 标准 | **FULL** | 字段逻辑需要完整参考 |
| `AIA_PROTOCOL_WRITING` | 方案撰写 | SPIRIT 2013 声明 | **FULL** | 清单必须完整参考 |
| `AIA_SAMPLE_SIZE` | 样本量计算 | 样本量公式手册 | **FULL** | 公式手册内容精简 |
### 7.3 Phase 3: 方案预评审
| Prompt Code | 智能体 | 知识库 | 模式 | 理由 |
|-------------|--------|--------|------|------|
| `AIA_METHODOLOGY_REVIEW` | 方法学评审 | CONSORT 2010 声明 | **FULL** | ⭐ **必须全量**,漏检会导致评审不完整 |
### 7.4 Phase 5: 写作助手
| Prompt Code | 智能体 | 知识库 | 模式 | 理由 |
|-------------|--------|--------|------|------|
| `AIA_PAPER_POLISH` | 论文润色 | - | 无 | 纯语言处理,无需知识库 |
| `AIA_PAPER_TRANSLATE` | 论文翻译 | - | 无 | 纯翻译任务,无需知识库 |
---
## 8. 测试计划
### 8.1 单元测试
| 测试项 | 用例 |
|--------|------|
| PromptService.get() | 知识库未启用时正常返回 |
| PromptService.get() | FULL 模式正确加载全文 |
| PromptService.get() | RAG 模式正确检索 Top K |
| PromptService.get() | 知识库不存在时降级处理 |
### 8.2 集成测试
| 场景 | 步骤 | 预期 |
|------|------|------|
| 方法学评审 | 调用 AIA_METHODOLOGY_REVIEW | 返回包含 CONSORT 全文的 Prompt |
| PICO 梳理 | 输入疾病名称 | 返回包含相关 ICD 编码的 Prompt |
| 无知识库 | 调用 AIA_PAPER_POLISH | 正常返回,无知识库内容 |
### 8.3 效果对比测试
| 智能体 | FULL 模式效果 | RAG 模式效果 | 结论 |
|--------|--------------|-------------|------|
| 方法学评审 | 完整覆盖所有条目 | 可能遗漏 | 使用 FULL |
| PICO 梳理 | Token 超限 | 精准命中 | 使用 RAG |
---
## 9. 风险与应对
### 9.1 技术风险
| 风险 | 影响 | 概率 | 应对 |
|------|------|------|------|
| FULL 模式 Token 超限 | 请求失败 | 中 | 设置 max_tokens 限制,超限警告 |
| RAG 检索不准确 | 内容遗漏 | 中 | 提供 FULL 模式备选 |
| 知识库加载慢 | 用户等待 | 低 | 缓存 + 预加载 |
### 9.2 业务风险
| 风险 | 影响 | 应对 |
|------|------|------|
| 知识库内容过时 | 引用错误规范 | 定期更新机制 |
| 配置错误 | 智能体效果下降 | 灰度预览 + 测试 |
### 9.3 成本分析
| 模式 | 典型场景 | Token 消耗 | 成本估算 |
|------|---------|-----------|---------|
| FULL | 5 篇文档,共 5 万 Token | 5 万 | ¥0.05/次 |
| RAG | Top 5 片段,共 5000 Token | 5000 | ¥0.005/次 |
> DeepSeek-V3 输入价格:约 ¥1/百万 Token
---
## 📎 附录
### A. 相关文档
- [Prompt 管理系统设计方案](../00-系统设计/Prompt管理系统升级知识库集成方案.md)
- [Prompt 管理系统开发计划](./02-Prompt管理系统开发计划.md)
- [AIA 模块状态文档](../../AIA-AI智能问答/00-模块当前状态与开发指南.md)
### B. 代码位置
| 类型 | 路径 |
|------|------|
| PromptService | `backend/src/common/prompt/prompt.service.ts` |
| RAG 引擎 | `backend/src/common/rag/` |
| 前端编辑器 | `frontend-v2/src/pages/admin/PromptEditorPage.tsx` |
| 数据库 Schema | `backend/prisma/schema.prisma` |
### C. 知识库命名规范
```
{CATEGORY}_{NAME}_{VERSION}
示例:
- METHODOLOGY_CONSORT_2010
- METHODOLOGY_SPIRIT_2013
- STATISTICS_SAMPLE_SIZE_FORMULAS
- CRF_CDASH_V2
```
---
*最后更新2026-01-28*
**🚀 准备好开始了吗?从 Phase 1 数据库设计开始!**

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-v2</title>
<title>AI临床研究平台</title>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -21,6 +21,8 @@ import { MODULES } from './framework/modules/moduleRegistry'
import UserListPage from './modules/admin/pages/UserListPage'
import UserFormPage from './modules/admin/pages/UserFormPage'
import UserDetailPage from './modules/admin/pages/UserDetailPage'
// 个人中心页面
import ProfilePage from './pages/user/ProfilePage'
/**
* 应用根组件
@@ -86,6 +88,10 @@ function App() {
}
/>
))}
{/* 个人中心路由 - 2026-01-28 新增 */}
<Route path="/user/profile" element={<ProfilePage />} />
<Route path="/user/settings" element={<ProfilePage />} />
</Route>
{/* 运营管理端 /admin/* */}

View File

@@ -188,6 +188,19 @@ export function AuthProvider({ children }: AuthProviderProps) {
return user.modules?.includes(moduleCode) || false;
}, [user]);
/**
* 刷新用户信息(头像更新等场景使用)
*/
const refreshUser = useCallback(async () => {
try {
const freshUser = await authApi.getCurrentUser();
setUser(freshUser);
authApi.saveUser(freshUser);
} catch (err) {
console.error('刷新用户信息失败:', err);
}
}, []);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
@@ -201,7 +214,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
refreshToken,
hasPermission,
hasRole,
hasModule, // 新增
hasModule,
refreshUser, // 2026-01-28: 头像更新等场景使用
};
return (

View File

@@ -198,6 +198,29 @@ export async function changePassword(request: ChangePasswordRequest): Promise<vo
}
}
/**
* 更新头像
*/
export async function updateAvatar(avatarUrl: string): Promise<{ avatarUrl: string }> {
const response = await authFetch<{ avatarUrl: string }>(`${API_BASE}/me/avatar`, {
method: 'PUT',
body: JSON.stringify({ avatarUrl }),
});
if (!response.success || !response.data) {
throw new Error(response.message || '更新头像失败');
}
// 更新本地存储的用户信息
const user = getSavedUser();
if (user) {
user.avatarUrl = avatarUrl;
saveUser(user);
}
return response.data;
}
/**
* 刷新Token
*/

View File

@@ -29,6 +29,13 @@ export interface AuthUser {
isDefaultPassword: boolean;
permissions: string[];
modules: string[]; // 用户可访问的模块代码列表(如 ['AIA', 'PKB', 'RVW']
// 2026-01-28: 个人中心扩展字段
avatarUrl?: string | null;
status?: string;
kbQuota?: number;
kbUsed?: number;
isTrial?: boolean;
trialEndsAt?: string | null; // ISO date string
}
/** Token信息 */
@@ -100,6 +107,8 @@ export interface AuthContextType extends AuthState {
hasRole: (...roles: UserRole[]) => boolean;
/** 检查模块权限 */
hasModule: (moduleCode: string) => boolean;
/** 刷新用户信息(头像更新等场景使用) */
refreshUser: () => Promise<void>;
}

View File

@@ -1,17 +1,15 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Dropdown, Avatar } from 'antd'
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
ControlOutlined,
BankOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { MODULES } from '../modules/moduleRegistry'
import { useAuth } from '../auth'
import type { ModuleDefinition } from '../modules/types'
/**
* 顶部导航栏组件
@@ -53,11 +51,12 @@ const TopNavigation = () => {
icon: <UserOutlined />,
label: '个人中心',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '设置',
},
// MVP阶段暂时隐藏设置按钮
// {
// key: 'settings',
// icon: <SettingOutlined />,
// label: '设置',
// },
// 切换入口 - 根据权限显示
...(canAccessOrg || canAccessAdmin ? [{ type: 'divider' as const }] : []),
...(canAccessOrg ? [{
@@ -103,9 +102,9 @@ const TopNavigation = () => {
onClick={() => navigate('/')}
>
<img
src="/logo.jpg"
src="/logo-new.png"
alt="AI临床研究平台"
className="h-[52px] w-auto"
className="h-[48px] w-auto"
/>
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
</div>
@@ -133,14 +132,15 @@ const TopNavigation = () => {
})}
</div>
{/* 用户菜单 - 显示真实用户信息 */}
{/* 用户菜单 - 显示真实用户信息和头像 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-2 rounded-md hover:bg-gray-50">
<Avatar
icon={<UserOutlined />}
src={user?.avatarUrl}
icon={!user?.avatarUrl && <UserOutlined />}
size="small"
/>
<div className="flex flex-col">

View File

@@ -3,14 +3,17 @@
* 数据清洗整理模块
*
* 路由结构:
* - / → Portal工作台主页
* - / → 直接跳转到 Tool C科研数据编辑器
* - /tool-a → Tool A - 超级合并器(暂未开发)
* - /tool-b → Tool B - 病历结构化机器人(✅ 已完成)
* - /tool-c → Tool C - 科研数据编辑器(🚀 Day 4-5开发中
* - /tool-c → Tool C - 科研数据编辑器(🚀 主力工具
* - /portal → Portal工作台保留暂不展示
*
* 2026-01-28 更新:默认直接进入 Tool C不再显示 Portal 页面
*/
import { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Spin } from 'antd';
import Placeholder from '@/shared/components/Placeholder';
@@ -29,8 +32,11 @@ const DCModule = () => {
}
>
<Routes>
{/* Portal主页 */}
<Route index element={<Portal />} />
{/* 默认直接跳转到 Tool C */}
<Route index element={<Navigate to="tool-c" replace />} />
{/* Portal工作台保留可通过 /data-cleaning/portal 访问) */}
<Route path="portal" element={<Portal />} />
{/* Tool A - 超级合并器(暂未开发) */}
<Route

View File

@@ -33,6 +33,7 @@ interface TenantConfig {
// 默认配置
const DEFAULT_CONFIG: TenantConfig = {
name: 'AI临床研究平台',
logo: '/logo-new.png', // 默认使用新 Logo
primaryColor: '#1890ff',
systemName: 'AI临床研究平台',
isReviewOnly: false,
@@ -261,7 +262,7 @@ export default function LoginPage() {
<img
src={tenantConfig.logo}
alt={tenantConfig.name}
style={{ height: 48, marginBottom: 16 }}
style={{ height: 108, marginBottom: 16, borderRadius: 8 }}
/>
) : (
<div style={{

View File

@@ -0,0 +1,411 @@
/**
* 个人中心页面
*
* 功能:
* - 查看/修改头像(可选)
* - 查看姓名、手机号
* - 修改密码
* - 查看账户状态(有效期)
* - 查看已开通模块(展示所有模块,标记已开通)
*
* @version 2026-01-28
*/
import { useState, useEffect } from 'react';
import {
Card,
Avatar,
Button,
Upload,
Modal,
Form,
Input,
message,
Spin,
Tag,
Divider,
Typography,
Row,
Col
} from 'antd';
import {
UserOutlined,
CameraOutlined,
LockOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PhoneOutlined,
SafetyCertificateOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { useAuth } from '@/framework/auth';
import { getCurrentUser, changePassword } from '@/framework/auth/api';
import type { AuthUser, ChangePasswordRequest } from '@/framework/auth/types';
import { MODULES } from '@/framework/modules/moduleRegistry';
import type { ModuleDefinition } from '@/framework/modules/types';
const { Title, Text } = Typography;
const ProfilePage = () => {
const { refreshUser } = useAuth();
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<AuthUser | null>(null);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
const [uploadLoading, setUploadLoading] = useState(false);
const [form] = Form.useForm();
// 加载用户详细信息
useEffect(() => {
loadUserInfo();
}, []);
const loadUserInfo = async () => {
try {
setLoading(true);
const user = await getCurrentUser();
setUserInfo(user);
} catch (error) {
console.error('加载用户信息失败', error);
message.error('加载用户信息失败');
} finally {
setLoading(false);
}
};
// 处理头像上传
const handleAvatarUpload: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
try {
setUploadLoading(true);
// 创建 FormData
const formData = new FormData();
formData.append('file', file as Blob);
// 调用后端头像上传接口(上传到 staticStorage
const response = await fetch('/api/v1/auth/me/avatar/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '上传失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '上传失败');
}
// 刷新用户信息(本地页面 + 全局 AuthContext
await loadUserInfo();
await refreshUser(); // 同步更新右上角头像
message.success('头像更新成功');
onSuccess?.(result);
} catch (error) {
console.error('头像上传失败', error);
const errorMessage = error instanceof Error ? error.message : '头像上传失败';
message.error(errorMessage);
onError?.(error as Error);
} finally {
setUploadLoading(false);
}
};
// 处理修改密码
const handleChangePassword = async (values: ChangePasswordRequest) => {
try {
setPasswordLoading(true);
await changePassword(values);
message.success('密码修改成功');
setPasswordModalOpen(false);
form.resetFields();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '修改密码失败';
message.error(errorMessage);
} finally {
setPasswordLoading(false);
}
};
// 格式化手机号隐藏中间4位
const formatPhone = (phone: string) => {
if (!phone || phone.length !== 11) return phone;
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
};
// 计算账户有效期显示
const getAccountStatus = () => {
if (!userInfo) return { text: '未知', color: 'default' };
if (userInfo.status !== 'active') {
return { text: '已禁用', color: 'error' };
}
if (userInfo.isTrial && userInfo.trialEndsAt) {
const endDate = new Date(userInfo.trialEndsAt);
const now = new Date();
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) {
return { text: '试用已过期', color: 'error' };
} else if (daysLeft <= 7) {
return { text: `试用中(剩余 ${daysLeft} 天)`, color: 'warning' };
} else {
return { text: `试用中(剩余 ${daysLeft} 天)`, color: 'processing' };
}
}
return { text: '正式用户', color: 'success' };
};
// 获取有效期显示文本
const getExpiryText = () => {
if (!userInfo) return '-';
if (userInfo.isTrial && userInfo.trialEndsAt) {
return new Date(userInfo.trialEndsAt).toLocaleDateString('zh-CN');
}
return '永久有效';
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center h-[calc(100vh-64px)]">
<Spin size="large" />
</div>
);
}
const accountStatus = getAccountStatus();
return (
<div className="flex-1 p-8 bg-gray-50 min-h-[calc(100vh-64px)] overflow-y-auto">
<div className="max-w-4xl mx-auto pb-8">
<Title level={2} className="mb-6"></Title>
{/* 基本信息卡片 */}
<Card className="mb-6">
<div className="flex items-start gap-6">
{/* 头像区域 */}
<div className="flex flex-col items-center">
<Upload
name="avatar"
showUploadList={false}
accept="image/*"
customRequest={handleAvatarUpload}
disabled={uploadLoading}
>
<div className="relative cursor-pointer group">
<Avatar
size={100}
src={userInfo?.avatarUrl}
icon={<UserOutlined />}
className="border-2 border-gray-200"
/>
<div className="absolute inset-0 bg-black bg-opacity-40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{uploadLoading ? (
<Spin size="small" />
) : (
<CameraOutlined className="text-white text-2xl" />
)}
</div>
</div>
</Upload>
<Text type="secondary" className="mt-2 text-xs"></Text>
</div>
{/* 用户信息 */}
<div className="flex-1">
<div className="mb-4">
<Title level={4} className="mb-1">{userInfo?.name}</Title>
<Tag color={accountStatus.color as any}>{accountStatus.text}</Tag>
</div>
<Row gutter={[24, 16]}>
<Col span={12}>
<div className="flex items-center gap-2 text-gray-600">
<PhoneOutlined />
<Text>{formatPhone(userInfo?.phone || '')}</Text>
</div>
</Col>
<Col span={12}>
<div className="flex items-center gap-2 text-gray-600">
<SafetyCertificateOutlined />
<Text>{getExpiryText()}</Text>
</div>
</Col>
</Row>
</div>
</div>
</Card>
{/* 账户安全卡片 */}
<Card title="账户安全" className="mb-6">
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<LockOutlined className="text-xl text-gray-400" />
<div>
<Text strong></Text>
<br />
<Text type="secondary" className="text-sm">
{userInfo?.isDefaultPassword
? '您正在使用默认密码,建议尽快修改'
: '定期修改密码可以提高账户安全性'}
</Text>
</div>
</div>
<Button
type={userInfo?.isDefaultPassword ? 'primary' : 'default'}
onClick={() => setPasswordModalOpen(true)}
>
{userInfo?.isDefaultPassword ? '立即修改' : '修改密码'}
</Button>
</div>
</Card>
{/* 已开通模块卡片 */}
<Card
title={
<div className="flex items-center gap-2">
<AppstoreOutlined />
<span></span>
</div>
}
>
<Text type="secondary" className="mb-4 block">
绿
</Text>
<Row gutter={[16, 16]}>
{MODULES.map((module: ModuleDefinition) => {
const isEnabled = userInfo?.modules?.includes(module.moduleCode || '');
return (
<Col xs={24} sm={12} md={8} key={module.id}>
<div
className={`
p-4 rounded-lg border-2 transition-all
${isEnabled
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50 opacity-60'}
`}
>
<div className="flex items-start gap-3">
<div className={`text-2xl ${isEnabled ? 'text-green-600' : 'text-gray-400'}`}>
{module.icon && <module.icon />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text strong className={isEnabled ? 'text-green-700' : 'text-gray-500'}>
{module.name}
</Text>
{isEnabled ? (
<CheckCircleOutlined className="text-green-500" />
) : (
<CloseCircleOutlined className="text-gray-400" />
)}
</div>
<Text type="secondary" className="text-xs">
{module.description}
</Text>
</div>
</div>
</div>
</Col>
);
})}
</Row>
{/* 开通更多模块提示 */}
<Divider />
<div className="text-center">
<Text type="secondary">
</Text>
</div>
</Card>
</div>
{/* 修改密码弹窗 */}
<Modal
title="修改密码"
open={passwordModalOpen}
onCancel={() => {
setPasswordModalOpen(false);
form.resetFields();
}}
footer={null}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleChangePassword}
className="mt-4"
>
{!userInfo?.isDefaultPassword && (
<Form.Item
name="oldPassword"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
)}
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password placeholder="请输入新密码至少6位" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item className="mb-0 flex justify-end">
<Button onClick={() => setPasswordModalOpen(false)} className="mr-2">
</Button>
<Button type="primary" htmlType="submit" loading={passwordLoading}>
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProfilePage;