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:
@@ -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
|
||||
*
|
||||
|
||||
@@ -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: '头像URL(OSS地址)' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
|
||||
@@ -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 头像URL(OSS地址)
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
|
||||
125
docs/03-业务模块/ADMIN-运营管理端/00-系统设计/Prompt管理系统升级:知识库集成方案.md
Normal file
125
docs/03-业务模块/ADMIN-运营管理端/00-系统设计/Prompt管理系统升级:知识库集成方案.md
Normal 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 规范文档。*
|
||||
BIN
docs/03-业务模块/ADMIN-运营管理端/03-UI设计/AILOGO.png
Normal file
BIN
docs/03-业务模块/ADMIN-运营管理端/03-UI设计/AILOGO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
490
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/05-Prompt知识库集成开发计划.md
Normal file
490
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/05-Prompt知识库集成开发计划.md
Normal 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: 前端 UI(Day 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 数据库设计开始!**
|
||||
@@ -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>
|
||||
|
||||
BIN
frontend-v2/public/favicon.jpg
Normal file
BIN
frontend-v2/public/favicon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
frontend-v2/public/logo-new.png
Normal file
BIN
frontend-v2/public/logo-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -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/* */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
411
frontend-v2/src/pages/user/ProfilePage.tsx
Normal file
411
frontend-v2/src/pages/user/ProfilePage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user