6.12号版本

This commit is contained in:
2026-06-14 14:56:54 +08:00
parent 0910ba9cbe
commit 4633149d3f
51 changed files with 3381 additions and 8610 deletions

View File

@@ -1,399 +0,0 @@
# AIChatController 交互流程图
## 1. AI流式问答流程 (/ai/chat/stream)
### 1.1 流程图
```mermaid
flowchart TD
A[开始: 用户发起AI问答] --> B{用户已登录?}
B -->|否| C[返回错误: 用户未登录]
B -->|是| D{用户信息获取成功?}
D -->|否| E[返回错误: 用户信息获取失败]
D -->|是| F[获取部门信息]
F --> G[获取用户可访问知识库]
G --> H{知识库列表为空?}
H -->|是| I[返回错误: 没有可用的知识库]
H -->|否| J[获取或生成session_id]
J --> K[从数据库获取历史记录]
K --> L[调用AI服务进行流式问答]
L --> M[返回SseEmitter流式响应]
C --> N[结束]
E --> N
I --> N
M --> N
```
### 1.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant Dept as DepartmentService
participant KB as KnowledgeBasePathService
participant Chat as ChatService
participant AIChat as AiChatService
Client->>+AI: POST /ai/chat/stream
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
AI->>+Dept: getDepartmentById(deptId)
Dept-->>-AI: Department
AI->>+KB: getUserAccessibleKnowledgeBaseNames(userId, deptId, deptCode)
KB-->>-AI: List<String> collections
alt 知识库为空
AI-->>Client: 错误: 没有可用的知识库
end
AI->>+Chat: getSessionMessagesAsHistory(sessionId)
Chat-->>-AI: List<HistoryItem>
AI->>+AIChat: streamFromRagService(request, userId, deptId)
loop 流式响应
AIChat-->>Client: SSE事件流
end
AIChat-->>-AI: SseEmitter
AI-->>-Client: SseEmitter流式响应
```
### 1.3 流程说明
| 步骤 | 说明 | 代码位置 |
|------|------|----------|
| 1. 用户发起请求 | POST /ai/chat/stream | AIChatController:56 |
| 2. 验证登录状态 | 检查Authorization header | AIChatController:61-73 |
| 3. 验证用户信息 | validateToken获取用户 | AIChatController:75-87 |
| 4. 获取部门信息 | 根据deptId获取Department | AIChatController:94-104 |
| 5. 获取知识库列表 | getUserAccessibleKnowledgeBaseNames | AIChatController:106 |
| 6. 生成session_id | 无session_id时生成新的 | AIChatController:123-127 |
| 7. 获取历史记录 | getSessionMessagesAsHistory | AIChatController:134-140 |
| 8. 调用AI服务 | streamFromRagService | AIChatController:145 |
---
## 2. 获取用户会话列表 (/ai/sessions)
### 2.1 流程图
```mermaid
flowchart TD
A[开始: 获取会话列表] --> B{用户已登录?}
B -->|否| C[返回: success=false, 用户未登录]
B -->|是| D[获取用户所有会话]
D --> E[实现简单分页]
E --> F[返回会话列表及分页信息]
C --> G[结束]
F --> G
```
### 2.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant Chat as ChatService
Client->>+AI: GET /ai/sessions
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
AI->>+Chat: getUserSessions(userId)
Chat-->>-AI: List<ChatSession>
Note over AI: 实现简单分页<br/>计算start/end
AI-->>-Client: 返回会话列表及分页信息
```
### 2.3 流程说明
| 步骤 | 说明 | 代码位置 |
|------|------|----------|
| 1. 用户发起请求 | GET /ai/sessions | AIChatController:163 |
| 2. 验证登录状态 | getCurrentUserId | AIChatController:170-175 |
| 3. 获取会话列表 | chatService.getUserSessions | AIChatController:177 |
| 4. 分页处理 | 计算start/end | AIChatController:179-185 |
| 5. 返回结果 | 返回data/total/page等信息 | AIChatController:187-192 |
---
## 3. 获取会话详情 (/ai/session/{sessionId})
### 3.1 流程图
```mermaid
flowchart TD
A[开始: 获取会话详情] --> B{用户已登录?}
B -->|否| C[返回: 用户未登录]
B -->|是| D[获取用户所有会话]
D --> E{会话属于当前用户?}
E -->|否| F[返回: 无权访问该会话]
E -->|是| G[获取会话消息列表]
G --> H[实现简单分页]
H --> I[返回会话详情及分页信息]
C --> J[结束]
F --> J
I --> J
```
### 3.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant Chat as ChatService
Client->>+AI: GET /ai/session/{sessionId}
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
AI->>+Chat: getUserSessions(userId)
Chat-->>-AI: List<ChatSession>
Note over AI: 验证会话所属权
alt 会话不属于用户
AI-->>Client: 错误: 无权访问该会话
else 会话属于用户
AI->>+Chat: getSessionMessages(sessionId)
Chat-->>-AI: List<ChatMessage>
Note over AI: 实现简单分页
AI-->>-Client: 返回会话详情及分页信息
end
```
---
## 4. 删除单个会话 (/ai/session/{sessionId})
### 4.1 流程图
```mermaid
flowchart TD
A[开始: 删除单个会话] --> B{用户已登录?}
B -->|否| C[返回: 用户未登录]
B -->|是| D[获取用户所有会话]
D --> E{会话属于当前用户?}
E -->|否| F[返回: 无权删除该会话]
E -->|是| G[执行删除操作]
G --> H{删除成功?}
H -->|是| I[返回: 删除成功]
H -->|否| J[返回: 删除失败]
C --> K[结束]
F --> K
I --> K
J --> K
```
### 4.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant Chat as ChatService
Client->>+AI: DELETE /ai/session/{sessionId}
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
AI->>+Chat: getUserSessions(userId)
Chat-->>-AI: List<ChatSession>
Note over AI: 验证会话所属权
alt 会话不属于用户
AI-->>Client: 错误: 无权删除该会话
else 会话属于用户
AI->>+Chat: deleteSession(sessionId, userId)
Chat-->>-AI: boolean success
alt 删除成功
AI-->>Client: {success: true, message: "删除成功"}
else 删除失败
AI-->>Client: {success: false, message: "删除失败"}
end
end
```
---
## 5. 批量删除会话 (/ai/sessions)
### 5.1 流程图
```mermaid
flowchart TD
A[开始: 批量删除会话] --> B{用户已登录?}
B -->|否| C[返回: 用户未登录]
B -->|是| D{sessionIds列表有效?}
D -->|否| E[返回: 会话ID列表不能为空]
D -->|是| F[获取用户所有会话]
F --> G[提取用户会话ID集合]
G --> H{所有会话属于用户?}
H -->|否| I[返回: 无权删除部分会话]
H -->|是| J[执行批量删除]
J --> K{删除成功?}
K -->|是| L[返回: 批量删除成功]
K -->|否| M[返回: 批量删除失败]
C --> N[结束]
E --> N
I --> N
L --> N
M --> N
```
### 5.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant Chat as ChatService
Client->>+AI: DELETE /ai/sessions
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
alt sessionIds为空
AI-->>Client: 错误: 会话ID列表不能为空
end
AI->>+Chat: getUserSessions(userId)
Chat-->>-AI: List<ChatSession>
Note over AI: 提取用户会话ID集合<br/>验证所有会话属于用户
alt 部分会话不属于用户
AI-->>Client: 错误: 无权删除部分会话
else 所有会话属于用户
AI->>+Chat: deleteSessions(sessionIds, userId)
Chat-->>-AI: boolean success
alt 删除成功
AI-->>Client: {success: true, deletedCount: N}
else 删除失败
AI-->>Client: {success: false, message: "批量删除失败"}
end
end
```
---
## 6. 中断AI流式问答 (/ai/chat/stop)
### 6.1 流程图
```mermaid
flowchart TD
A[开始: 中断AI问答] --> B{用户已登录?}
B -->|否| C[返回: 用户未登录]
B -->|是| D[调用sseEmitterManager.stop]
D --> E{中断成功?}
E -->|是| F[返回: 已中断AI回答]
E -->|否| G[返回: 未找到活跃AI进程]
C --> H[结束]
F --> H
G --> H
```
### 6.2 时序图
```mermaid
sequenceDiagram
autonumber
participant Client as 用户
participant AI as AIChatController
participant Auth as AuthService
participant SSE as SseEmitterManager
Client->>+AI: POST /ai/chat/stop
AI->>+Auth: validateToken(token)
Auth-->>-AI: User对象
AI->>+SSE: stop(sessionId)
SSE-->>-AI: boolean success
alt 中断成功
AI-->>Client: {success: true, message: "已中断AI回答"}
else 中断失败
AI-->>Client: {success: false, message: "未找到活跃的AI进程或已结束"}
end
```
---
## 核心服务调用关系
```mermaid
flowchart LR
subgraph Controller
AI[AIChatController]
end
subgraph Service
AS[AuthService]
CS[ChatService]
ACS[AiChatService]
SS[SseEmitterManager]
KBS[KnowledgeBasePathService]
DS[DepartmentService]
end
subgraph Entity
U[User]
D[Department]
CS1[ChatSession]
CM[ChatMessage]
CR[ChatReference]
end
subgraph Database
DB[(MySQL)]
end
AI --> AS
AI --> CS
AI --> ACS
AI --> SS
AI --> KBS
AI --> DS
AS --> U
CS --> CS1
CS --> CM
CS --> CR
KBS --> DB
DS --> U
DS --> D
```
---
## 接口汇总
| 接口 | 方法 | 路径 | 功能 |
|------|------|------|------|
| chatStream | POST | /ai/chat/stream | AI流式问答 |
| getUserSessions | GET | /ai/sessions | 获取用户会话列表 |
| getSessionDetail | GET | /ai/session/{sessionId} | 获取会话详情 |
| getMessageReferences | GET | /ai/message/{messageId}/references | 获取消息引用 |
| getSessionReferences | GET | /ai/session/{sessionId}/references | 获取会话引用 |
| deleteSession | DELETE | /ai/session/{sessionId} | 删除单个会话 |
| deleteSessions | DELETE | /ai/sessions | 批量删除会话 |
| stopChatStream | POST | /ai/chat/stop | 中断AI流式问答 |

View File

@@ -1,396 +0,0 @@
# AI接口使用规范
## 1. 接口概览
| 接口路径 | 方法 | 功能描述 |
|---------|------|----------|
| `/ai/chat/stream` | POST | AI流式问答,实时返回处理过程 |
| `/ai/sessions` | GET | 获取用户会话列表,支持分页 |
| `/ai/session/{sessionId}` | GET | 获取指定会话的完整交互记录 |
| `/ai/message/{messageId}/references` | GET | 获取指定消息的引用信息 |
| `/ai/session/{sessionId}/references` | GET | 获取指定会话的所有引用信息 |
| `/ai/session/{sessionId}` | DELETE | 删除指定的会话及其相关信息 |
| `/ai/sessions` | DELETE | 批量删除多个会话及其相关信息 |
## 2. 认证方式
所有接口均需要在请求头中携带 `Authorization` 字段,格式为:
```
Authorization: Bearer {token}
```
其中 `{token}` 是用户登录后获取的JWT令牌。
## 3. 接口详情
### 3.1 AI流式问答接口
**接口路径**: `/ai/chat/stream`
**请求方法**: POST
**功能描述**: 向AI服务发起流式问答请求,实时返回处理过程,完成后自动保存到数据库
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| message | String | 是 | 用户提问内容 |
| id | String | 否 | 请求ID,用于追踪请求 |
| session_id | String | 否 | 会话ID,若不提供则自动生成 |
**请求示例**:
```json
{
"message": "公司的出差补助标准是什么?",
"id": "req_123456",
"session_id": "sess_123456"
}
```
**响应格式**: 流式SSE (Server-Sent Events)
**响应示例**:
```
event: message
data: {"type":"message","content":"根据公司政策,出差补助标准如下:"}
event: message
data: {"type":"message","content":"1. 一线城市:500元/天"}
event: message
data: {"type":"message","content":"2. 二线城市:350元/天"}
event: message
data: {"type":"message","content":"3. 三线城市:250元/天"}
event: complete
data: {"type":"complete","message":"回答完成"}
```
**错误响应**:
```
event: error
data: {"type":"error","message":"用户未登录"}
```
### 3.2 获取用户会话列表接口
**接口路径**: `/ai/sessions`
**请求方法**: GET
**功能描述**: 获取当前登录用户的所有会话列表,按创建时间倒序排列
**请求参数**:
| 参数名 | 类型 | 必选 | 默认值 | 描述 |
|--------|------|------|--------|------|
| page | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 10 | 每页大小 |
**请求示例**:
```
GET /ai/sessions?page=1&pageSize=10
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"sessionId": "sess_123456",
"userId": 1,
"summary": "用户询问公司政策相关问题",
"createTime": "2026-04-21 09:00:00"
}
],
"total": 1,
"page": 1,
"pageSize": 10,
"pages": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "用户未登录"
}
```
### 3.3 获取会话详情接口
**接口路径**: `/ai/session/{sessionId}`
**请求方法**: GET
**功能描述**: 获取指定会话的完整交互记录,包括用户提问与AI回答
**请求参数**:
| 参数名 | 类型 | 必选 | 默认值 | 描述 |
|--------|------|------|--------|------|
| sessionId | String | 是 | - | 会话ID |
| page | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 20 | 每页大小 |
**请求示例**:
```
GET /ai/session/sess_123456?page=1&pageSize=20
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"conversationId": "sess_123456",
"role": "user",
"content": "公司的出差补助标准是什么?",
"messageType": "text",
"isFinished": 1,
"createTime": "2026-04-21 09:00:00"
},
{
"id": 2,
"conversationId": "sess_123456",
"role": "assistant",
"content": "根据公司政策,出差补助标准如下:\n1. 一线城市:500元/天\n2. 二线城市:350元/天\n3. 三线城市:250元/天",
"messageType": "text",
"isFinished": 1,
"createTime": "2026-04-21 09:01:30"
}
],
"total": 2,
"page": 1,
"pageSize": 20,
"pages": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权访问该会话"
}
```
### 3.4 获取消息引用信息接口
**接口路径**: `/ai/message/{messageId}/references`
**请求方法**: GET
**功能描述**: 获取指定消息的引用信息,包括文档来源、页码、片段内容等
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| messageId | Long | 是 | 消息ID |
**请求示例**:
```
GET /ai/message/2/references
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"messageId": 2,
"chunkId": "chunk_001",
"docPath": "uploads/policies/travel_policy.pdf",
"docName": "出差政策.pdf",
"page": 10,
"excerpt": "出差补助标准:一线城市500元/天,二线城市350元/天,三线城市250元/天。此标准适用于所有员工。",
"createTime": "2026-04-21 09:01:30"
}
],
"total": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "用户未登录"
}
```
### 3.5 获取会话引用信息接口
**接口路径**: `/ai/session/{sessionId}/references`
**请求方法**: GET
**功能描述**: 获取指定会话的所有引用信息,包括文档来源、页码、片段内容等
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionId | String | 是 | 会话ID |
**请求示例**:
```
GET /ai/session/sess_123456/references
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"messageId": 2,
"chunkId": "chunk_001",
"docPath": "uploads/policies/travel_policy.pdf",
"docName": "出差政策.pdf",
"page": 10,
"excerpt": "出差补助标准:一线城市500元/天,二线城市350元/天,三线城市250元/天。此标准适用于所有员工。",
"createTime": "2026-04-21 09:01:30"
}
],
"total": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权访问该会话"
}
```
### 3.6 删除单个会话接口
**接口路径**: `/ai/session/{sessionId}`
**请求方法**: DELETE
**功能描述**: 删除指定的会话及其所有相关消息和引用信息
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionId | String | 是 | 会话ID |
**请求示例**:
```
DELETE /ai/session/sess_123456
```
**响应格式**:
```json
{
"success": true,
"message": "会话删除成功"
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权删除该会话"
}
```
### 3.7 批量删除会话接口
**接口路径**: `/ai/sessions`
**请求方法**: DELETE
**功能描述**: 批量删除多个会话及其所有相关消息和引用信息
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionIds | List<String> | 是 | 会话ID列表 |
**请求示例**:
```json
[
"sess_123456",
"sess_789012"
]
```
**响应格式**:
```json
{
"success": true,
"message": "会话批量删除成功",
"deletedCount": 2
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权删除部分会话"
}
```
## 4. 权限控制
1. **用户认证**:所有接口均需要有效的JWT令牌
2. **会话访问权限**:用户只能访问和删除自己的会话
3. **批量操作验证**:批量删除时,所有会话必须都属于当前用户
## 5. 错误处理
| 错误类型 | 状态码 | 错误信息 | 说明 |
|---------|--------|----------|------|
| 未登录 | 401 | 用户未登录 | 缺少有效的Authorization头 |
| 无权限 | 403 | 无权访问该会话/无权删除该会话 | 会话不属于当前用户 |
| 参数错误 | 400 | 会话ID列表不能为空 | 请求参数不符合要求 |
| 服务器错误 | 500 | 操作失败: {具体错误信息} | 服务器内部错误 |
## 6. 最佳实践
1. **会话管理**:
- 每次对话使用相同的session_id以保持上下文
- 定期清理不需要的会话以节省存储空间
2. **请求优化**:
- 使用分页参数减少返回数据量
- 合理设置pageSize,避免一次请求过多数据
3. **错误处理**:
- 捕获并处理接口返回的错误信息
- 对网络异常和超时进行适当处理
4. **安全性**:
- 不要在前端存储敏感的会话信息
- 定期更新JWT令牌
## 7. 接口性能
1. **响应时间**:
- 会话列表和详情接口:< 500ms
- 引用信息接口:< 300ms
- 删除接口:< 200ms
2. **并发处理**:
- 支持同时处理多个请求
- 流式接口采用SSE技术,支持长时间连接
3. **数据量限制**:
- 单个会话最多存储1000条消息
- 批量删除最多支持100个会话
## 8. 版本控制
| 版本 | 变更内容 | 发布日期 |
|------|----------|----------|
| v1.0 | 初始版本,包含基础AI对话功能 | 2026-04-25 |
| v1.1 | 新增会话管理和引用信息接口 | 2026-04-25 |
## 9. 联系支持
- **技术支持**:tech-support@example.com
- **API文档**:https://api.example.com/docs
- **服务状态**:https://status.example.com
---
*本规范文档由系统自动生成,如有变更请以实际接口为准。*

View File

@@ -1,123 +0,0 @@
# AI接口测试脚本
## 登录获取Token
```bash
curl --location --request POST 'http://127.0.0.1:8080/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "admin",
"password": "123456"
}'
```
**登录响应**:
```json
{
"code": 200,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzc3MTI1MjQ3LCJleHAiOjE3NzcxMzk2NDd9.tsgZohWhGhtK75hCCdDlE4sr8z9vZgbnfVL86QZhtDZfrTmc0pFQbpmRD8K4jA6P0eBYSoV-0FIcQ41-rV419w",
"expiresIn": 7200000,
"userInfo": {
"id": 1,
"username": "admin",
"realName": "系统管理员",
"email": "admin@system.com",
"phone": "13800000000",
"avatar": null,
"userType": 1,
"lastLoginTime": null
}
},
"timestamp": "2026-04-25 13:54:07",
"success": true
}
```
## 测试命令
### 1. AI流式问答接口
```bash
curl --location --request POST 'http://127.0.0.1:8080/api/ai/chat/stream' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzIxODYzMywiZXhwIjoxNzc3MjI1ODMzfQ.U1bkByjiPkA3GcUagPRTG1n08aPjW8Tj0vVSclUIqoOgOpZVHAo5a4b8b44Ewt0Td-xdCk9u_pTzdWzWVuzR1Q' \
--header 'Content-Type: application/json' \
--data-raw '{
"message": "2026三峡在哪",
"id": "test_req_123",
"session_id": "test_sess_123"
}'
```
### 2. 获取用户会话列表接口
```bash
curl --location --request GET 'http://127.0.0.1:8080/api/ai/sessions?page=1&pageSize=10' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw'
```
### 3. 获取会话详情接口
```bash
curl --location --request GET 'http://127.0.0.1:8080/api/ai/session/test_sess_123?page=1&pageSize=20' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw'
```
### 4. 获取消息引用信息接口
```bash
curl --location --request GET 'http://127.0.0.1:8080/api/ai/message/2/references' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw'
```
### 5. 获取会话引用信息接口
```bash
curl --location --request GET 'http://127.0.0.1:8080/api/ai/session/test_sess_123/references' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw'
```
### 6. 删除单个会话接口
```bash
curl --location --request DELETE 'http://127.0.0.1:8080/api/ai/session/test_sess_123' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw'
```
### 7. 批量删除会话接口
```bash
curl --location --request DELETE 'http://127.0.0.1:8080/api/ai/sessions' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzEyNTI0NywiZXhwIjoxNzc3MTMyNDQ3fQ.dXgYqS5WUB6EOgFSSYfKdwc-EnXfUFuWssd92GMy3HOItqyvTe-jpWa1yu2Yac8MHuXeSsZjZfvMxX8G4WF0Iw' \
--header 'Content-Type: application/json' \
--data-raw '[
"test_sess_123",
"test_sess_456"
]'
```
## 注意事项
1. **Token有效期**:token有效期为2小时(7200秒),过期后需要重新登录获取
2. **会话ID**:测试时需要使用实际存在的会话ID
3. **消息ID**:获取引用信息时需要使用实际存在的消息ID
4. **权限验证**:确保测试使用的账号有权限访问对应的会话
5. **环境配置**:确保服务运行在 `http://127.0.0.1:8080`
## 测试流程
1. 首先执行登录命令获取token
2. 将获取到的token替换到各个测试命令中
3. 按照顺序执行测试命令,验证接口功能
4. 检查响应结果是否符合预期
## 故障排查
- **401错误**:token无效或过期,需要重新登录
- **403错误**:权限不足,检查用户是否有权限访问该资源
- **400错误**:参数错误,检查请求参数是否正确
- **500错误**:服务器内部错误,查看服务器日志获取详细信息

View File

@@ -1,440 +0,0 @@
# AI接口规范文档
## 1. 接口概览
| 接口路径 | 方法 | 功能描述 |
|---------|------|----------|
| `/ai/chat/stream` | POST | AI流式问答,实时返回处理过程 |
| `/ai/chat/stop` | POST | 中断指定会话的AI流式问答进程 |
| `/ai/sessions` | GET | 获取用户会话列表,支持分页 |
| `/ai/session/{sessionId}` | GET | 获取指定会话的完整交互记录 |
| `/ai/message/{messageId}/references` | GET | 获取指定消息的引用信息 |
| `/ai/session/{sessionId}/references` | GET | 获取指定会话的所有引用信息 |
| `/ai/session/{sessionId}` | DELETE | 删除指定的会话及其相关信息 |
| `/ai/sessions` | DELETE | 批量删除多个会话及其相关信息 |
## 2. 认证方式
所有接口均需要在请求头中携带 `Authorization` 字段,格式为:
```
Authorization: Bearer {token}
```
其中 `{token}` 是用户登录后获取的JWT令牌。
## 3. 接口详情
### 3.1 AI流式问答接口
**接口路径**: `/ai/chat/stream`
**请求方法**: POST
**功能描述**: 向AI服务发起流式问答请求,实时返回处理过程,完成后自动保存到数据库
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| message | String | 是 | 用户提问内容 |
| id | String | 否 | 请求ID,用于追踪请求 |
| session_id | String | 否 | 会话ID,若不提供则自动生成 |
**请求示例**:
```json
{
"message": "公司的出差补助标准是什么?",
"id": "req_123456",
"session_id": "sess_123456"
}
```
**响应格式**: 流式SSE (Server-Sent Events)
**响应示例**:
```
event: message
data: {"type":"message","content":"根据公司政策,出差补助标准如下:"}
event: message
data: {"type":"message","content":"1. 一线城市:500元/天"}
event: message
data: {"type":"message","content":"2. 二线城市:350元/天"}
event: message
data: {"type":"message","content":"3. 三线城市:250元/天"}
event: complete
data: {"type":"complete","message":"回答完成"}
```
**错误响应**:
```
event: error
data: {"type":"error","message":"用户未登录"}
```
### 3.2 中断AI流式问答接口
**接口路径**: `/ai/chat/stop`
**请求方法**: POST
**功能描述**: 中断指定会话的AI流式问答进程,用于用户主动取消正在进行的AI回答
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionId | String | 是 | 要中断的会话ID |
**请求示例**:
```
POST /ai/chat/stop?sessionId=sess_123456
```
**响应格式**:
```json
// 成功
{
"success": true,
"message": "已中断AI回答"
}
// 失败(进程不存在或已结束)
{
"success": false,
"message": "未找到活跃的AI进程或已结束"
}
// 错误响应
{
"success": false,
"message": "用户未登录"
}
```
**中断后SSE事件**:
当中断成功时,前端会收到一个特殊的stop事件:
```
event: stop
data: {"type":"stopped","message":"用户主动中断"}
```
**使用场景**:
- 用户等待AI回答时间过长,想取消当前问题
- 用户问错问题了,想重新提问
- AI回答跑偏了,想中断重新组织语言
### 3.3 获取用户会话列表接口
**接口路径**: `/ai/sessions`
**请求方法**: GET
**功能描述**: 获取当前登录用户的所有会话列表,按创建时间倒序排列
**请求参数**:
| 参数名 | 类型 | 必选 | 默认值 | 描述 |
|--------|------|------|--------|------|
| page | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 10 | 每页大小 |
**请求示例**:
```
GET /ai/sessions?page=1&pageSize=10
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"sessionId": "sess_123456",
"userId": 1,
"summary": "用户询问公司政策相关问题",
"createTime": "2026-04-21 09:00:00"
}
],
"total": 1,
"page": 1,
"pageSize": 10,
"pages": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "用户未登录"
}
```
### 3.3 获取会话详情接口
**接口路径**: `/ai/session/{sessionId}`
**请求方法**: GET
**功能描述**: 获取指定会话的完整交互记录,包括用户提问与AI回答
**请求参数**:
| 参数名 | 类型 | 必选 | 默认值 | 描述 |
|--------|------|------|--------|------|
| sessionId | String | 是 | - | 会话ID |
| page | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 20 | 每页大小 |
**请求示例**:
```
GET /ai/session/sess_123456?page=1&pageSize=20
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"conversationId": 1,
"role": "assistant",
"content": "公司的出差补助标准是什么?",
"answer": "根据公司政策,出差补助标准如下:\n1. 一线城市:500元/天\n2. 二线城市:350元/天\n3. 三线城市:250元/天",
"messageType": "text",
"isFinished": 1,
"kbPaths": "[\"public_kb\"]",
"durationMs": 1250,
"createTime": "2026-04-21 09:01:30"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"pages": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权访问该会话"
}
```
### 3.4 获取消息引用信息接口
**接口路径**: `/ai/message/{messageId}/references`
**请求方法**: GET
**功能描述**: 获取指定消息的引用信息,包括文档来源、页码、片段内容等
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| messageId | Long | 是 | 消息ID |
**请求示例**:
```
GET /ai/message/2/references
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"messageId": 2,
"chunkId": "chunk_001",
"docPath": "uploads/policies/travel_policy.pdf",
"docName": "出差政策.pdf",
"page": 10,
"excerpt": "出差补助标准:一线城市500元/天,二线城市350元/天,三线城市250元/天。此标准适用于所有员工。",
"createTime": "2026-04-21 09:01:30"
}
],
"total": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "用户未登录"
}
```
### 3.5 获取会话引用信息接口
**接口路径**: `/ai/session/{sessionId}/references`
**请求方法**: GET
**功能描述**: 获取指定会话的所有引用信息,包括文档来源、页码、片段内容等
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionId | String | 是 | 会话ID |
**请求示例**:
```
GET /ai/session/sess_123456/references
```
**响应格式**:
```json
{
"success": true,
"data": [
{
"id": 1,
"messageId": 2,
"chunkId": "chunk_001",
"docPath": "uploads/policies/travel_policy.pdf",
"docName": "出差政策.pdf",
"page": 10,
"excerpt": "出差补助标准:一线城市500元/天,二线城市350元/天,三线城市250元/天。此标准适用于所有员工。",
"createTime": "2026-04-21 09:01:30"
}
],
"total": 1
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权访问该会话"
}
```
### 3.6 删除单个会话接口
**接口路径**: `/ai/session/{sessionId}`
**请求方法**: DELETE
**功能描述**: 删除指定的会话及其所有相关消息和引用信息
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionId | String | 是 | 会话ID |
**请求示例**:
```
DELETE /ai/session/sess_123456
```
**响应格式**:
```json
{
"success": true,
"message": "会话删除成功"
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权删除该会话"
}
```
### 3.7 批量删除会话接口
**接口路径**: `/ai/sessions`
**请求方法**: DELETE
**功能描述**: 批量删除多个会话及其所有相关消息和引用信息
**请求参数**:
| 参数名 | 类型 | 必选 | 描述 |
|--------|------|------|------|
| sessionIds | List<String> | 是 | 会话ID列表 |
**请求示例**:
```json
[
"sess_123456",
"sess_789012"
]
```
**响应格式**:
```json
{
"success": true,
"message": "会话批量删除成功",
"deletedCount": 2
}
```
**错误响应**:
```json
{
"success": false,
"message": "无权删除部分会话"
}
```
## 4. 权限控制
1. **用户认证**:所有接口均需要有效的JWT令牌
2. **会话访问权限**:用户只能访问和删除自己的会话
3. **批量操作验证**:批量删除时,所有会话必须都属于当前用户
## 5. 错误处理
| 错误类型 | 状态码 | 错误信息 | 说明 |
|---------|--------|----------|------|
| 未登录 | 401 | 用户未登录 | 缺少有效的Authorization头 |
| 无权限 | 403 | 无权访问该会话/无权删除该会话 | 会话不属于当前用户 |
| 参数错误 | 400 | 会话ID列表不能为空 | 请求参数不符合要求 |
| 服务器错误 | 500 | 操作失败: {具体错误信息} | 服务器内部错误 |
## 6. 最佳实践
1. **会话管理**:
- 每次对话使用相同的session_id以保持上下文
- 定期清理不需要的会话以节省存储空间
2. **请求优化**:
- 使用分页参数减少返回数据量
- 合理设置pageSize,避免一次请求过多数据
3. **错误处理**:
- 捕获并处理接口返回的错误信息
- 对网络异常和超时进行适当处理
4. **安全性**:
- 不要在前端存储敏感的会话信息
- 定期更新JWT令牌
## 7. 接口性能
1. **响应时间**:
- 会话列表和详情接口:< 500ms
- 引用信息接口:< 300ms
- 删除接口:< 200ms
2. **并发处理**:
- 支持同时处理多个请求
- 流式接口采用SSE技术,支持长时间连接
3. **数据量限制**:
- 单个会话最多存储1000条消息
- 批量删除最多支持100个会话
## 8. 版本控制
| 版本 | 变更内容 | 发布日期 |
|------|----------|----------|
| v1.0 | 初始版本,包含基础AI对话功能 | 2026-04-25 |
| v1.1 | 新增会话管理和引用信息接口 | 2026-04-25 |
## 9. 联系支持
- **技术支持**:tech-support@example.com
- **API文档**:https://api.example.com/docs
- **服务状态**:https://status.example.com
---
*本规范文档由系统自动生成,如有变更请以实际接口为准。*

View File

@@ -1,636 +0,0 @@
# 文件管理系统 API 接口文档
## 基础信息
- **Base URL**: `http://localhost:8080/api`
- **认证方式**: Bearer Token (在请求头中添加 `Authorization: Bearer {token}`)
- **响应格式**: JSON
## 通用响应结构
### 成功响应json { "code": 200, "message": "success", "data": {} }
***
## 1. 认证模块 (/auth)
### 1.1 用户登录
**接口地址**: `POST /auth/login`
**请求体**:json { "username": "string", "password": "string" }
**响应数据**:json { "code": 200, "message": "success", "data": { "token": "string", "refreshToken": "string", "expiresIn": 86400000, "userInfo": { "id": 1, "username": "string", "realName": "string", "email": "string", "phone": "string", "avatar": "string" } } }
***
### 1.2 用户登出
**接口地址**: `POST /auth/logout`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 1.3 获取当前用户信息
**接口地址**: `GET /auth/user/info`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": { "id": 1, "username": "string", "realName": "string", "email": "string", "phone": "string", "avatar": "string" } }
***
### 1.4 获取当前用户角色列表
**接口地址**: `GET /auth/user/roles`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": \["admin", "user"] }
***
### 1.5 获取当前用户主要角色
**接口地址**: `GET /auth/user/main-role`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": { "roleName": "admin" } }
***
## 2. 用户管理模块 (/api/users)
### 2.1 分页查询用户列表
**接口地址**: `GET /api/users`
**请求头**:Authorization: Bearer {token}
**查询参数**:
| 参数名 | 类型 | 必填 | 说明 |
| -------- | ------- | -- | ---------- |
| current | Integer | 否 | 当前页码,默认1 |
| size | Integer | 否 | 每页条数,默认10 |
| username | String | 否 | 用户名(模糊查询) |
| realName | String | 否 | 真实姓名(模糊查询) |
**响应数据**:json { "code": 200, "message": "success", "data": { "records": \[ { "id": 1, "username": "string", "realName": "string", "email": "string", "phone": "string", "avatar": "string", "status": 1, "deptId": 1, "deptName": "string", "roles": \[ { "id": 1, "roleName": "string", "roleCode": "string" } ], "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ], "total": 100, "size": 10, "current": 1, "pages": 10 } }
***
### 2.2 创建用户
**接口地址**: `POST /api/users`
**请求头**:Authorization: Bearer {token}
**请求体**:json { "username": "string", "password": "string", "realName": "string", "email": "string", "phone": "string", "deptId": 1, "roleIds": \[1, 2] }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 2.3 更新用户
**接口地址**: `PUT /api/users/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 用户ID |
**请求体**:json { "realName": "string", "email": "string", "phone": "string", "deptId": 1, "status": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 2.4 删除用户
**接口地址**: `DELETE /api/users/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 用户ID |
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 2.5 分配用户角色
**接口地址**: `PUT /api/users/{id}/roles`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 用户ID |
**请求体**:json \[1, 2, 3]
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 2.6 获取用户权限列表
**接口地址**: `GET /api/users/{id}/permissions`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 用户ID |
**响应数据**:json { "code": 200, "message": "success", "data": \["file:read", "file:write", "user:manage"] }
***
## 3. 角色管理模块 (/api/roles)
### 3.1 获取所有角色列表
**接口地址**: `GET /api/roles`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "roleName": "管理员", "roleCode": "admin", "description": "系统管理员", "status": 1, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ] }
### 3.2 创建角色
**接口地址**: `POST /api/roles`
**请求头**:Authorization: Bearer {token}
**请求体**:json { "roleName": "string", "roleCode": "string", "description": "string", "status": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 3.3 更新角色
**接口地址**: `PUT /api/roles/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 角色ID |
**请求体**:json { "roleName": "string", "roleCode": "string", "description": "string", "status": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 3.4 删除角色
**接口地址**: `DELETE /api/roles/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 角色ID |
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 3.5 分配角色权限
**接口地址**: `PUT /api/roles/{id}/permissions`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 角色ID |
**请求体**:json \[1, 2, 3]
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 3.6 获取角色权限列表
**接口地址**: `GET /api/roles/{id}/permissions`
**请求头**:**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 角色ID |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "permissionName": "文件读取", "permissionCode": "file:read", "permissionType": "menu", "parentId": 0, "path": "/files", "icon": "file", "sortOrder": 1 } ] }
***
## 4. 部门管理模块 (/api/departments)
### 4.1 获取部门列表
**接口地址**: `GET /api/departments`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "deptName": "技术部", "deptCode": "tech", "parentId": 0, "managerId": 1, "managerName": "张三", "description": "技术研发部门", "sortOrder": 1, "status": 1, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ] }
***
### 4.2 创建部门
**接口地址**: `POST /api/departments`
**请求头**:Authorization: Bearer {token}
**请求体**:json { "deptName": "string", "deptCode": "string", "parentId": 0, "managerId": 1, "description": "string", "sortOrder": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 4.3 更新部门
**接口地址**: `PUT /api/departments/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 部门ID |
**请求体**:json { "deptName": "string", "deptCode": "string", "parentId": 0, "managerId": 1, "description": "string", "sortOrder": 1, "status": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 4.4 删除部门
**接口地址**: `DELETE /api/departments/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 部门ID |
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 4.5 设置部门负责人
**接口地址**: `PUT /api/departments/{id}/manager`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 部门ID |
**请求体**:json { "userId": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 4.6 获取部门用户列表
**接口地址**: `GET /api/departments/{id}/users`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 部门ID |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "username": "string", "realName": "string", "email": "string", "phone": "string", "avatar": "string", "status": 1 } ] }
***
## 5. 文件管理模块 (/api/files)
### 5.1 上传文件
**接口地址**: `POST /api/files/upload`
**请求头**:Authorization: Bearer {token} Content-Type: multipart/form-data
**表单参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ---- | ---- | -- | ------ |
| file | File | 是 | 要上传的文件 |
**响应数据**:json { "code": 200, "message": "文件上传成功", "data": { "id": 1, "fileName": "example.pdf", "fileSize": 1024000, "fileType": "application/pdf", "filePath": "/uploads/example.pdf", "fileHash": "abc123", "uploaderId": 1, "uploaderName": "张三", "deptId": 1, "deptName": "技术部", "description": "文件描述", "downloadCount": 0, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } }
***
### 5.2 分页查询文件列表
**接口地址**: `GET /api/files/list`
**请求头**:Authorization: Bearer {token}
**查询参数**:
| 参数名 | 类型 | 必填 | 说明 |
| -------- | ------- | -- | --------- |
| current | Integer | 否 | 当前页码,默认1 |
| size | Integer | 否 | 每页条数,默认10 |
| fileName | String | 否 | 文件名(模糊查询) |
| deptId | Long | 否 | 部门ID |
**响应数据**:json { "code": 200, "message": "success", "data": { "records": \[ { "id": 1, "fileName": "example.pdf", "fileSize": 1024000, "fileType": "application/pdf", "filePath": "/uploads/example.pdf", "fileHash": "abc123", "uploaderId": 1, "uploaderName": "张三", "deptId": 1, "deptName": "技术部", "description": "文件描述", "downloadCount": 0, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ], "total": 100, "size": 10, "current": 1, "pages": 10 } }
***
### 5.3 获取文件详情
**接口地址**: `GET /api/files/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 文件ID |
**响应数据**:json { "code": 200, "message": "success", "data": { "id": 1, "fileName": "example.pdf", "fileSize": 1024000, "fileType": "application/pdf", "filePath": "/uploads/example.pdf", "fileHash": "abc123", "uploaderId": 1, "uploaderName": "张三", "deptId": 1, "deptName": "技术部", "description": "文件描述", "downloadCount": 0, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } }
***
### 5.4 下载文件
**接口地址**: `GET /api/files/{id}/download`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 文件ID |
**响应**: 文件流(二进制数据)
***
### 5.5 更新文件信息
**接口地址**: `PUT /api/files/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 文件ID |
**请求体**:json { "fileName": "new-name.pdf", "description": "新的文件描述", "deptId": 1 }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 5.6 删除文件
**接口地址**: `DELETE /api/files/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 文件ID |
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 5.7 搜索文件
**接口地址**: `GET /api/files/search`
**请求头**:Authorization: Bearer {token}
**查询参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ------- | ------ | -- | ----- |
| keyword | String | 是 | 搜索关键词 |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "fileName": "example.pdf", "fileSize": 1024000, "fileType": "application/pdf", "filePath": "/uploads/example.pdf", "fileHash": "abc123", "uploaderId": 1, "uploaderName": "张三", "deptId": 1, "deptName": "技术部", "description": "文件描述", "downloadCount": 0, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ] }
***
### 5.8 获取我上传的文件
**接口地址**: `GET /api/files/my-uploads`
**请求头**:Authorization: Bearer {token}
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "fileName": "example.pdf", "fileSize": 1024000, "fileType": "application/pdf", "filePath": "/uploads/example.pdf", "fileHash": "abc123", "uploaderId": 1, "uploaderName": "张三", "deptId": 1, "deptName": "技术部", "description": "文件描述", "downloadCount": 0, "createTime": "2024-01-01T00:00:00", "updateTime": "2024-01-01T00:00:00" } ] }
***
## 6. 文件权限模块 (/api/file-permissions)
### 6.1 授予文件权限
**接口地址**: `POST /api/file-permissions`
**请求头**:Authorization: Bearer {token}
**请求体**:json { "fileId": 1, "userId": 2, "permissionType": 1, "expireTime": "2024-12-31T23:59:59" }
**字段说明**:
- `permissionType`: 1-只读,2-读写
- `expireTime`: 过期时间(可选)
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 6.2 撤销文件权限
**接口地址**: `DELETE /api/file-permissions/{id}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 权限ID |
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
### 6.3 获取文件权限列表
**接口地址**: `GET /api/file-permissions/file/{fileId}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | -- | ---- |
| fileId | Long | 是 | 文件ID |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "fileId": 1, "userId": 2, "userName": "李四", "permissionType": 1, "permissionTypeName": "只读", "grantUserId": 1, "grantUserName": "张三", "expireTime": "2024-12-31T23:59:59", "createTime": "2024-01-01T00:00:00" } ] }
***
### 6.4 更新权限过期时间
**接口地址**: `PUT /api/file-permissions/{id}/expire`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| --- | ---- | -- | ---- |
| id | Long | 是 | 权限ID |
**请求体**:json { "expireTime": "2024-12-31T23:59:59" }
**响应数据**:json { "code": 200, "message": "success", "data": null }
***
## 7. 操作日志模块 (/api/logs)
### 7.1 分页查询操作日志
**接口地址**: `GET /api/logs`
**请求头**:Authorization: Bearer {token}
**查询参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ------------- | ------- | -- | --------- |
| current | Integer | 否 | 当前页码,默认1 |
| size | Integer | 否 | 每页条数,默认10 |
| operationType | String | 否 | 操作类型 |
**响应数据**:json { "code": 200, "message": "success", "data": { "records": \[ { "id": 1, "userId": 1, "userName": "张三", "operationType": "UPLOAD", "operationDesc": "上传文件 example.pdf", "fileId": 1, "fileName": "example.pdf", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0...", "createTime": "2024-01-01T00:00:00" } ], "total": 100, "size": 10, "current": 1, "pages": 10 } }
***
### 7.2 获取用户操作日志
**接口地址**: `GET /api/logs/user/{userId}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | -- | ---- |
| userId | Long | 是 | 用户ID |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "userId": 1, "userName": "张三", "operationType": "UPLOAD", "operationDesc": "上传文件 example.pdf", "fileId": 1, "fileName": "example.pdf", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0...", "createTime": "2024-01-01T00:00:00" } ] }
***
### 7.3 获取文件操作日志
**接口地址**: `GET /api/logs/file/{fileId}`
**请求头**:Authorization: Bearer {token}
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | -- | ---- |
| fileId | Long | 是 | 文件ID |
**响应数据**:json { "code": 200, "message": "success", "data": \[ { "id": 1, "userId": 1, "userName": "张三", "operationType": "UPLOAD", "operationDesc": "上传文件 example.pdf", "fileId": 1, "fileName": "example.pdf", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0...", "createTime": "2024-01-01T00:00:00" } ] }
***
## 8. 问题处理模块 (/api/questions)
### 8.1 上传并处理文件(AI处理)
**接口地址**: `POST /api/questions/upload`
**请求头**:Content-Type: multipart/form-data
**表单参数**:
| 参数名 | 类型 | 必填 | 说明 |
| ----- | ------- | -- | -------- |
| files | File\[] | 是 | 要上传的多个文件 |
**响应数据**:json { "code": 200, "message": "文件上传并处理成功", "data": { "fileCount": 2, "aiResult": "AI处理结果字符串" } }
***
## 错误码说明
| 错误码 | 说明 |
| --- | --------------- |
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权(Token无效或过期) |
| 403 | 权限不足 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
***
## 注意事项
1. 所有需要认证的接口都需要在请求头中携带 `Authorization: Bearer {token}`
2. 文件上传接口使用 `multipart/form-data` 格式
3. 分页查询默认返回第1页,每页10条记录
4. 时间格式统一使用 ISO 8601 格式:`yyyy-MM-ddTHH:mm:ss`
5. 文件大小限制和允许的文件类型由后端配置决定

View File

@@ -1,357 +0,0 @@
文件管理系统 API 文档
基础信息
Base URL: http://localhost:8080/api
认证方式: JWT Bearer Token
Token Header: Authorization: Bearer {token}
Swagger UI: http://localhost:8080/api/swagger-ui.html
API Docs: http://localhost:8080/api/v3/api-docs
1. 认证管理 (/auth)
1.1 用户登录
POST /auth/login
描述: 使用用户名和密码登录系统
请求体:{
"username": "string",
"password": "string"
}
限流: 60秒内最多5次请求(基于IP)
1.2 用户登出
POST /auth/logout
Headers:
Authorization: Bearer {token}
1.3 获取用户信息
GET /auth/user/info
Headers:
Authorization: Bearer {token}
1.4 获取用户角色
GET /auth/user/roles
Headers:
Authorization: Bearer {token}
1.5 获取主要角色
GET /auth/user/main-role
Headers:
Authorization: Bearer {token}
2. 用户管理 (/user)
2.1 分页查询用户
GET /user/page
Query Parameters:
pageNum: 页码 (默认: 1)
pageSize: 每页大小 (默认: 10)
username: 用户名 (可选)
deptId: 部门ID (可选)
2.2 查询用户详情
GET /user/{id}
Path Parameters:
id: 用户ID
2.3 创建用户
POST /user
请求体:{
"username": "newuser",
"realName": "新用户",
"email": "user@example.com",
"phone": "13800138001",
"deptId": 1,
"userType": "普通用户",
"status": 1
}
2.4 更新用户
PUT /user
请求体:{
"id": 1,
"realName": "更新后的名字",
"email": "newemail@example.com",
"phone": "13800138002"
}
2.5 删除用户
DELETE /user/{id}
Path Parameters:
id: 用户ID
2.6 重置密码
PUT /user/{id}/reset-password
Path Parameters:
id: 用户ID
说明: 重置密码为默认密码 123456
2.7 更新用户状态
PUT /user/{id}/status
Path Parameters:
id: 用户ID
Query Parameters:
status: 状态 (1:启用, 0:禁用)
2.8 分配用户角色
PUT /user/{id}/roles
Path Parameters:
id: 用户ID
请求体:[1, 2, 3]
说明: 角色ID列表
3. 部门管理 (/department)3.1 查询部门列表
GET /department
3.2 创建部门
POST /department
请求体:{
"deptName": "新部门",
"parentId": 1,
"orderNum": 1
}
3.3 更新部门
PUT /department/{id}
Path Parameters:
id: 部门ID
请求体:{
"deptName": "更新后的部门名",
"orderNum": 2
}
3.4 删除部门
DELETE /department/{id}
Path Parameters:
id: 部门ID
3.5 设置部门管理员
PUT /department/{id}/manager
Path Parameters:
id: 部门ID
Query Parameters:
managerId: 管理员用户ID
3.6 查询部门用户
GET /department/{id}/users
Path Parameters:
id: 部门ID
4. 角色管理 (/role)
4.1 查询角色列表
GET /role
4.2 创建角色
POST /role
请求体:{
"roleName": "新角色",
"roleCode": "NEW_ROLE",
"description": "角色描述"
}
4.3 更新角色
PUT /role/{id}
Path Parameters:
id: 角色ID
请求体:{
"roleName": "更新后的角色名",
"description": "更新后的描述"
}
4.4 删除角色
DELETE /role/{id}
Path Parameters:
id: 角色ID
4.5 分配权限
PUT /role/{id}/permissions
Path Parameters:
id: 角色ID
请求体:[1, 2, 3]
说明: 权限ID列表
4.6 查询角色权限
GET /role/{id}/permissions
Path Parameters:
id: 角色ID
5. 文件管理 (/file)
5.1 分页查询文件
GET /file/page
Headers:
Authorization: Bearer {token}
Query Parameters:
pageNum: 页码 (默认: 1)
pageSize: 每页大小 (默认: 10)
fileName: 文件名 (可选)
deptId: 部门ID (可选)
5.2 查询文件详情
GET /file/{id}
Path Parameters:
id: 文件ID
5.3 上传文件
POST /file/upload
Headers:
Authorization: Bearer {token}
Content-Type: multipart/form-data
Form Data:
file: 文件对象
限制:
文件大小: 最大 10MB
允许格式: .doc, .docx, .pdf, .txt, .jpg, .jpeg, .png, .gif, .webp
限流: 60秒内最多10次请求(基于用户)
5.4 RAG流式问答
POST /file/rag/stream
Headers:
Authorization: Bearer {token}
Accept: text/event-stream
请求体:5.4 RAG流式问答
POST /file/rag/stream
Headers:
Authorization: Bearer {token}
Accept: text/event-stream
5.5 下载文件
GET /file/download/{id}
Path Parameters:
id: 文件ID
响应: 文件二进制流
Headers:
Content-Disposition: attachment; filename*=UTF-8''{filename}
Content-Type: application/octet-stream
5.6 获取文件内容
GET /file/content/{id}
Path Parameters:
id: 文件ID
说明: 供后端服务使用,返回文件二进制内容
5.7 获取文件详细信息
GET /file/info/{id}
Path Parameters:
id: 文件ID
说明: 获取文件的所有信息包括内容,供后端服务使用
5.8 删除文件
DELETE /file/{id}
Path Parameters:
id: 文件ID
6. 文件权限管理 (/file/permission)
6.1 授权文件权限
POST /file/permission
Headers:
Authorization: Bearer {token}
请求体:{
"fileId": 1,
"targetUserId": 2,
"permissionType": "read",
"expireTime": "2024-12-31 23:59:59"
}
说明:
permissionType: 权限类型 (read:只读, write:读写)
expireTime: 过期时间 (可选)
6.2 撤销文件权限
DELETE /file/permission/{id}
Path Parameters:
id: 权限ID
6.3 查询文件权限列表
GET /file/permission/file/{fileId}
Path Parameters:
fileId: 文件ID
Headers:
Authorization: Bearer {token}
6.4 更新权限过期时间
PUT /file/permission/{id}/expire
Path Parameters:
id: 权限ID
请求体:{
"expireTime": "2025-12-31 23:59:59"
}
7. 日志管理 (/log)
7.1 分页查询日志
GET /log/page
Query Parameters:
pageNum: 页码 (默认: 1)
pageSize: 每页大小 (默认: 10)
username: 用户名 (可选)
operationType: 操作类型 (可选)
8. 第三方接口 (/third-party)
8.1 接收JSON数据
POST /third-party/json
请求体:{
"data": {
"key1": "value1",
"key2": "value2"
},
"fileName": "custom_file"
}
说明: 接收第三方系统的JSON数据并生成TXT文件
8.2 调用第三方接口
POST /third-party/call
Query Parameters:
filePath: 文件路径
userId: 用户ID
说明: 根据文件路径和用户ID调用第三方接口并保存返回数据
通用响应格式
所有接口均使用统一的响应格式:{
"code": 200,
"message": "success",
"data": {}
}
常见状态码:
200: 成功
400: 请求参数错误
401: 未授权(Token无效或过期)
403: 权限不足
404: 资源不存在
500: 服务器内部错误
环境变量配置
变量名
说明
默认值
DB_USERNAME
数据库用户名
root
DB_PASSWORD
数据库密码
123456
REDIS_HOST
Redis主机
localhost
REDIS_PORT
Redis端口
6379
REDIS_PASSWORD
Redis密码
(空)
JWT_SECRET
JWT密钥
your-secret-key-for-jwt-token-generation-2024-change-in-production
DIFY_API_URL
Dify API地址
http://172.20.10.2/v1/workflows/run
DIFY_API_KEY
Dify API密钥
app-0iWrwSYdTczCmRmtVNhfjWnq
X_KEY
X-Key
password123
CORS_ALLOWED_ORIGINS
CORS允许的源
*
SWAGGER_ENABLED
是否启用Swagger
true
这份API文档涵盖了系统中所有的接口,包括认证、用户管理、部门管理、角色管理、文件管理、文件权限管理、日志管理和第三方接口。你可以直接在浏览器中访问 Swagger UI (http://localhost:8080/api/swagger-ui.html) 进行在线测试和调试。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>制度文件管理学习AI智能体</title>
<script type="module" crossorigin src="./assets/index-C4E43nTL.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-vk6ahhKu.css">
<script type="module" crossorigin src="./assets/index-BTWfMALa.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CsV1hzMV.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,34 @@
-- =============================================================
-- 数据库迁移脚本:question 表补充缺失列
-- 版本: V1.1
-- 日期: 2026-06-08
-- 说明: question表缺少 exclude_stems 和 is_deleted 列,
-- 导致 MyBatis-Plus 全字段查询时抛出 SQL 异常
-- (Unknown column 'exclude_stems' / 'is_deleted')
-- 对应实体字段: Question.excludeStems, Question.isDeleted
-- =============================================================
-- 1. 添加 exclude_stems 列(对应实体 field: excludeStems)
-- 用于 AI 出题时对题干内容进行去重
ALTER TABLE `question`
ADD COLUMN `exclude_stems` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
NULL DEFAULT NULL COMMENT '题干内容,用于AI去重'
AFTER `document_id`;
-- 2. 添加 is_deleted 列(对应实体 field: isDeleted)
-- 用于逻辑删除标记
ALTER TABLE `question`
ADD COLUMN `is_deleted` int NOT NULL DEFAULT 0
COMMENT '软删除标志:0表示正常,1表示已删除'
AFTER `updated_at`;
-- 3. 为 is_deleted 添加索引(优化按删除状态查询的性能)
ALTER TABLE `question`
ADD INDEX `idx_question_is_deleted`(`is_deleted` ASC) USING BTREE;
-- =============================================================
-- 回滚脚本(如需回滚请执行以下语句)
-- =============================================================
-- ALTER TABLE `question` DROP COLUMN `exclude_stems`;
-- ALTER TABLE `question` DROP COLUMN `is_deleted`;
-- ALTER TABLE `question` DROP INDEX `idx_question_is_deleted`;

View File

@@ -61,6 +61,11 @@ function processSseLine(line, callbacks, context) {
const { onStart, onThinking, onSearching, onSources, onChunk, onFinish, onError } = callbacks
const { sessionId, startTime } = context
// 记录关键事件日志(不记录频繁的 chunk 事件避免刷屏)
if (eventType !== 'chunk') {
console.log(`[SSE] 收到事件: ${eventType}`, eventType === 'sources' ? `(${(parsed.sources || []).length} 条来源)` : '')
}
switch (eventType) {
case 'start':
case 'connected':
@@ -125,6 +130,7 @@ function processSseLine(line, callbacks, context) {
return false
}
} catch (e) {
console.warn('[SSE] 解析行失败:', e.message, '原始数据:', data?.substring(0, 100))
if (data && data !== '[DONE]') {
onChunk?.({ content: data })
}
@@ -314,6 +320,33 @@ export const aiAPI = {
}
},
/**
* 中断流式问答 - 通知后端清理资源
* POST /api/ai/chat/stop?sessionId=xxx
*/
stopStream: async (sessionId) => {
const token = localStorage.getItem('token')
if (!token) throw new Error('用户未登录')
if (!sessionId) {
console.warn('[aiAPI] stopStream: sessionId 为空,跳过调用后端停止接口')
return null
}
try {
const response = await fetch(`${BASE_URL}/ai/chat/stop?sessionId=${encodeURIComponent(sessionId)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
})
const result = await response.json()
console.log('[aiAPI] 中断问答响应:', result)
return result
} catch (error) {
console.warn('[aiAPI] 调用中断接口失败(不影响前端本地停止):', error.message)
return null
}
},
getSessions: async (params = {}) => {
const token = localStorage.getItem('token')

View File

@@ -108,15 +108,28 @@ export const examAPI = {
},
generateQuestions: (params) => {
if (!params.file_path) throw new Error('文件路径不能为空')
const requestBody = {
file_path: params.file_path,
collection: params.collection || 'public_kb',
question_count: params.question_count || 0,
question_types: params.question_types || {},
difficulty: validateDifficulty(params.difficulty),
request_id: params.request_id || `gen_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const hasFileId = params.file_id != null
const hasFilePath = !!params.file_path
if (!hasFileId && !hasFilePath) throw new Error('file_id 或 file_path 不能为空')
const requestBody = {}
// 按文档规范:推荐使用 file_id(自动查询向量库)
if (hasFileId) {
requestBody.file_id = Number(params.file_id)
}
// 使用 file_path 时必须提供 collection
if (hasFilePath) {
requestBody.file_path = params.file_path
requestBody.collection = params.collection || 'public_kb'
}
requestBody.question_types = params.question_types || {}
if (params.question_scores) requestBody.question_scores = params.question_scores
requestBody.difficulty = validateDifficulty(params.difficulty)
requestBody.request_id = params.request_id || `gen_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
return examClient.post('/exam/generate', requestBody, { timeout: 280000 }).then(handleExamResponse)
},
@@ -161,28 +174,28 @@ export const examAPI = {
}
const answers = params.answers.map((answer, idx) => {
if (!answer.questionId) throw new Error(`第${idx + 1}个答案的questionId不能为空`)
if (!answer.questionType) throw new Error(`第${idx + 1}个答案的questionType不能为空`)
if (!answer.studentAnswer && answer.studentAnswer !== 0) throw new Error(`第${idx + 1}个答案的studentAnswer不能为空`)
if (!answer.questionId && !answer.question_id) throw new Error(`第${idx + 1}个答案的question_id不能为空`)
if (!answer.questionType && !answer.question_type) throw new Error(`第${idx + 1}个答案的question_type不能为空`)
return {
questionId: String(answer.questionId),
questionType: answer.questionType,
questionContent: answer.questionContent || {},
studentAnswer: String(answer.studentAnswer),
maxScore: answer.maxScore != null ? Number(answer.maxScore) : 5
question_id: String(answer.questionId || answer.question_id),
question_type: answer.questionType || answer.question_type,
question_content: answer.questionContent || answer.question_content || {},
student_answer: answer.studentAnswer != null ? answer.studentAnswer : (answer.student_answer != null ? answer.student_answer : ''),
max_score: answer.maxScore != null ? Number(answer.maxScore) : (answer.max_score != null ? Number(answer.max_score) : 5)
}
})
const isBlank = answers.length === 0
const requestBody = {
requestId: params.requestId || `grade_${Date.now()}`,
type: params.type || 'practice',
recordId: params.recordId || null,
request_id: params.requestId || params.request_id || `grade_${Date.now()}`,
answer_type: params.type || params.answer_type || 'practice',
record_id: params.recordId || params.record_id || null,
answers
}
console.log('[examAPI] 批改答案 - 参数:', { type: requestBody.type, recordId: requestBody.recordId, answersCount: answers.length })
console.log('[examAPI] 批改答案 - 参数:', { answer_type: requestBody.answer_type, record_id: requestBody.record_id, answersCount: answers.length })
console.log('[examAPI] 发送完整 payload:', JSON.stringify(requestBody, null, 2)?.substring(0, 1000))
if (isBlank) console.log('[examAPI] ⚠️ 空白试卷提交')
return examClient.post('/exam/grade', requestBody, { timeout: 120000 }).then(handleExamResponse)
@@ -191,13 +204,19 @@ export const examAPI = {
queryUserAnswers: (params) => {
const hasPaperId = params.paper_id && String(params.paper_id).trim()
const hasSessionId = params.session_id && String(params.session_id).trim()
const hasRecordId = params.record_id && String(params.record_id).trim()
if (!hasPaperId && !hasSessionId) throw new Error('paper_id 或 session_id 不能为空')
if (hasPaperId && hasSessionId) throw new Error('paper_id 和 session_id 不能同时提供')
if (!hasPaperId && !hasSessionId && !hasRecordId) throw new Error('paper_id、session_id 或 record_id 不能为空')
if ([hasPaperId, hasSessionId, hasRecordId].filter(Boolean).length > 1) throw new Error('只能提供 paper_id、session_id、record_id 中的一个')
const requestBody = {}
if (hasPaperId) requestBody.paper_id = String(params.paper_id)
if (hasSessionId) requestBody.session_id = String(params.session_id)
if (hasRecordId) requestBody.record_id = String(params.record_id)
else if (hasPaperId) requestBody.paper_id = String(params.paper_id)
else if (hasSessionId) requestBody.session_id = String(params.session_id)
if (params.answer_type) requestBody.answer_type = params.answer_type
if (params.page != null) requestBody.page = Number(params.page)
if (params.page_size != null) requestBody.page_size = Number(params.page_size)
return examClient.post('/exam/answers/query', requestBody, { timeout: 30000 }).then(handleExamResponse)
},
@@ -274,9 +293,9 @@ export const examAPI = {
})
const cleanAnswers = answers.map(a => ({
questionId: String(a.questionId),
questionType: a.questionType,
answer: a.answer
questionId: String(a.questionId || a.question_id),
questionType: a.questionType || a.question_type,
answer: a.studentAnswer != null ? a.studentAnswer : (a.student_answer != null ? a.student_answer : a.answer || '')
}))
console.log('[examAPI] 正式考试提交(14.2格式) - recordId:', recordId, '题目数:', cleanAnswers.length)
@@ -320,15 +339,18 @@ export const examAPI = {
publishPaper: (paperId, params = {}) => {
if (!paperId) throw new Error('试卷ID不能为空')
// 直接转发 params 的字段,与后端 ExamPublishRequest 保持字段名一致
const body = {
deptId: params.deptId || null,
deptIds: Array.isArray(params.deptIds) && params.deptIds.length > 0 ? params.deptIds : null,
deptId: params.deptId ?? null,
deptIds: params.deptIds || [],
userIds: params.userIds || [],
excludeUserIds: params.excludeUserIds || [],
publishTime: params.publishTime || null,
deadline: params.deadline || null
}
console.log('[examAPI] 发布试卷 - 参数:', { paperId, deptIds: body.deptIds, userIds: body.userIds?.length })
// 移除所有 null 字段(只传有值的字段),避免 LocalDateTime 反序列化问题
Object.keys(body).forEach(k => { if (body[k] === null) delete body[k] })
console.log('[examAPI] 发布试卷 - 参数:', { paperId, ...body })
return examClient.post(`/admin/exam/paper/${encodeURIComponent(paperId)}/publish`, body, { timeout: 30000 }).then(handleExamResponse)
},
@@ -344,7 +366,7 @@ export const examAPI = {
if (Array.isArray(params.file_ids) && params.file_ids.length > 0) requestBody.file_ids = params.file_ids.filter(id => id != null).map(Number)
console.log('[examAPI] 预览试卷详情 - 参数:', requestBody)
return examClient.post('/admin/exam/paper/preview/detail', requestBody, { timeout: 120000 }).then(handleExamResponse)
return examClient.post('/admin/exam/paper/preview', requestBody, { timeout: 120000 }).then(handleExamResponse)
},
getPaperAssignment: (paperId) => {

View File

@@ -64,7 +64,13 @@ export const fileAPI = {
return apiClient.get('/file/page', { params })
},
uploadFile: (formData, onProgress) => {
uploadFile: (formData, onProgress, options = {}) => {
if (options.autoVectorize) {
formData.append('autoVectorize', 'true')
}
if (options.collectionName) {
formData.append('collectionName', options.collectionName)
}
return apiClient.post('/file/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'

View File

@@ -219,6 +219,48 @@ export const ragAPI = {
return apiClient.get(url, { params: { limit } }).then(handleRagResponse)
},
// ========== 智能向量化(自动识别知识库) ==========
// 文档: POST /collection/vectorize/smart?fileId=xxx — 自动根据文件部门ID选择向量库
/** 单文件智能向量化(异步提交,立即返回任务状态) */
smartVectorizeFile: (fileId) => {
if (fileId === null || fileId === undefined || fileId === '') {
const err = new Error('文件ID不能为空')
err.code = 'INVALID_PARAM'
throw err
}
const numId = Number(fileId)
if (isNaN(numId) || numId <= 0) {
const err = new Error('文件ID必须是有效的正整数: ' + fileId)
err.code = 'INVALID_PARAM'
throw err
}
console.log('[ragAPI] 智能向量化请求: fileId=', numId)
return apiClient.post('/collection/vectorize/smart', null, {
params: { fileId: numId },
timeout: 30000
}).then(handleRagResponse).catch(err => {
if (err.code === 'ECONNABORTED' || err.originalCode === 'ECONNABORTED') {
console.log('[ragAPI] 智能向量化任务已提交:', { fileId: numId })
return { submitted: true, fileId: numId, message: '智能向量化任务已提交,正在后台处理', status: 'VECTORIZING', collection: '' }
}
console.error('[ragAPI] 智能向量化失败:', { fileId: numId, code: err.code, message: err.message, status: err.status })
throw err
})
},
/** 批量智能向量化(异步提交,每个文件自动匹配对应部门向量库) */
smartVectorizeBatch: (fileIds) => {
const idsStr = Array.isArray(fileIds) ? fileIds.join(',') : String(fileIds || '')
if (!idsStr) throw new Error('文件ID列表不能为空')
return apiClient.post('/collection/vectorize/batch/smart', null, {
params: { fileIds: idsStr },
timeout: 60000
}).then(handleRagResponse)
},
// ========== 传统向量化(需手动指定知识库) ==========
vectorizeFile: (collectionName, fileId) => {
return apiClient.post(`/collection/${encodeURIComponent(collectionName)}/vectorize`, null, {
params: { fileId },
@@ -339,6 +381,26 @@ export const ragAPI = {
search: (params) => {
return apiClient.post('/rag/search', params).then(handleRagResponse)
},
cascadeDelete: (fileId) => {
return apiClient.post(`/collection/cascade-delete/${fileId}`, {}, { timeout: 60000 }).then(handleRagResponse)
},
devectorize: (collectionName, fileId) => {
return apiClient.post(`/collection/${collectionName}/devectorize`, null, {
params: { fileId },
timeout: 120000
}).then(handleRagResponse)
},
generateExamForFile: (params) => {
const { collectionName, ...rest } = params
// 按文档5.2.3规范:所有参数作为 query param 传递
return apiClient.post(`/collection/${collectionName}/generate-exam`, null, {
params: rest,
timeout: 180000
}).then(handleRagResponse)
}
}

View File

@@ -32,6 +32,7 @@
v-model="selectedDoc"
@change="onDocSelected"
@collection-change="onCollectionChange"
:show-drop-zone="false"
placeholder="先选择知识库,再选择制度文件..."
/>
<div v-if="selectedCollectionName" class="collection-info">
@@ -3089,24 +3090,34 @@ const handleGenerateQuestions = async () => {
if (val > 0) activeTypes[key] = val
})
const filePath = selectedDocData.value.pdfUrl || selectedDocData.value.filePath || selectedDocData.value.path
// 优先使用文档自带的 collectionName,否则使用用户选择的知识库
const collectionName = selectedDocData.value.collectionName || selectedCollectionName.value || 'public_kb'
const doc = selectedDocData.value
let fileId = doc?.id || doc?.fileId
// 向量库的文件:真正的 file_id 可能嵌套在 sourceData 中
if (!fileId && doc?.sourceData) {
fileId = doc.sourceData.metadata?.file_id || doc.sourceData.file_id || doc.sourceData.id
}
console.log('📋 出题参数详情:')
console.log(' - selectedDocData:', JSON.stringify(selectedDocData.value, null, 2))
console.log(' - filePath (原始):', filePath)
console.log(' - collectionName:', collectionName)
console.log(' - selectedDocData:', JSON.stringify(doc, null, 2))
console.log(' - fileId:', fileId)
console.log(' - questionTypes:', JSON.stringify(activeTypes))
const params = {
file_path: filePath,
collection: collectionName,
question_types: activeTypes,
difficulty: difficulty.value,
request_id: `exam_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
if (fileId) {
params.file_id = Number(fileId)
} else if (doc?.pdfUrl || doc?.filePath || doc?.path) {
params.file_path = doc.pdfUrl || doc.filePath || doc.path
params.collection = selectedCollectionName.value || 'public_kb'
} else {
message.error('无法获取文件信息,请重新选择文件后重试')
return
}
generateProgress.value = 40
console.log('🚀 开始调用生成题目API,参数:', JSON.stringify(params, null, 2))
@@ -3114,16 +3125,12 @@ const handleGenerateQuestions = async () => {
const result = await examAPI.generateQuestions(params)
console.log('📥 题目生成API返回结果:', result)
console.log('📥 结果类型:', typeof result)
console.log('📥 结果结构:', {
hasResult: !!result,
requestId: result?.requestId || result?.request_id,
message: result?.message,
hasQuestions: !!(result && result.questions),
questionsCount: result?.questions?.length || 0,
dataFileId: result?.data?.fileId,
success: result?.success,
error: result?.error,
errorCode: result?.errorCode,
total: result?.total,
allKeys: result ? Object.keys(result) : []
})

View File

@@ -31,8 +31,8 @@
</div>
</div>
<!-- ===== 新增:增强型上传区域 ===== -->
<div class="enhanced-upload-section" v-if="step === 2" @click.stop>
<!-- ===== 增强型上传区域(可配置隐藏) ===== -->
<div class="enhanced-upload-section" v-if="showDropZone" @click.stop>
<DropZone
@files-selected="handleFilesSelected"
@error="handleValidationError"
@@ -93,53 +93,8 @@
<!-- 右侧列表区 -->
<div class="file-list-area">
<!-- ===== 第一步:选择向量库 ===== -->
<div v-if="step === 1" class="collection-list">
<div class="collection-header">
<h4>▣ 选择知识库</h4>
<span class="collection-hint">请先选择一个知识库(向量库),再从中选择文档</span>
</div>
<!-- 加载中 -->
<div v-if="collectionLoading" class="loading-state">
<LoadingOutlined spin />
<span>正在加载知识库列表...</span>
</div>
<!-- 向量库列表 -->
<template v-else-if="collections.length > 0">
<div
v-for="col in collections"
:key="col.name"
class="collection-item"
@click="selectCollection(col.name)"
>
<DatabaseOutlined class="collection-icon" :class="col.visibility" />
<div class="collection-info">
<div class="collection-name">{{ col.displayName || col.name }}</div>
<div class="collection-meta">
<span class="visibility-tag" :class="col.visibility === 'public' ? 'public' : 'private'">
{{ col.visibility === 'public' ? '公开' : col.visibility === 'private' ? '私密' : '知识库' }}
</span>
<span>{{ col.count || 0 }} 个文档</span>
<span v-if="col.description" class="desc-text">{{ col.description }}</span>
</div>
</div>
<RightOutlined class="collection-arrow" />
</div>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<InboxOutlined class="empty-icon" />
<p>暂无可用知识库</p>
<p class="empty-hint">请确保RAG服务正常运行并已创建知识库</p>
</div>
</div>
<!-- ===== 第二步:选择文档(或普通模式)===== -->
<template v-if="step === 2">
<template v-if="viewMode === 'files'">
<!-- ===== 文件列表(直接展示) ===== -->
<template v-if="viewMode === 'files'">
<div class="file-list" ref="listRef">
<template v-if="displayGroups.length > 0">
<div
@@ -204,24 +159,14 @@
</div>
</div>
</template>
</template> <!-- 结束 step === 2 -->
</div>
</div>
<!-- 底部操作栏 -->
<div class="panel-footer">
<button
v-if="step === 2"
class="back-btn"
@click="backToCollectionSelect"
>
<LeftOutlined /> 返回选择知识库
</button>
<span class="footer-hint" v-if="step === 1">请先选择一个知识库</span>
<span class="footer-hint" v-else-if="viewMode === 'files'">点击文件查看所有版本</span>
<span class="footer-hint" v-if="viewMode === 'files'">点击文件查看所有版本</span>
<span class="footer-hint" v-else>选择一个版本确认</span>
<span class="footer-total" v-if="viewMode === 'files' && step === 2">共 {{ mergedGroups.length }} 个文件</span>
<span class="footer-total" v-else-if="step === 1">共 {{ collections.length }} 个知识库</span>
<span class="footer-total" v-if="viewMode === 'files'">共 {{ mergedGroups.length }} 个文件</span>
</div>
</div>
</div>
@@ -356,7 +301,8 @@ const props = defineProps({
modelValue: [String, Number],
placeholder: { type: String, default: '请选择制度文件' },
showAllOption: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true }
allowClear: { type: Boolean, default: true },
showDropZone: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'change', 'collection-change', 'vectorized', 'file-uploaded'])
@@ -377,7 +323,7 @@ const selectedGroup = ref(null)
const collections = ref([]) // 向量库列表
const selectedCollection = ref('') // 当前选中的向量库
const collectionLoading = ref(false) // 加载向量库列表中
const step = ref(1) // 当前步骤:1=选向量库,2=选文档
const step = ref(2) // 当前步骤:直接使用文件列表模式
// 向量化相关状态
const isVectorizing = ref(false)
@@ -706,53 +652,53 @@ const loadVectorizeCollections = async () => {
}
}
// 确认执行向量化
// 确认执行向量化(使用智能向量化接口,自动识别知识库)
const confirmVectorize = async () => {
if (!vectorizeTargetVersion.value) {
message.error('请先选择要向量化的文件版本')
return
}
if (!vectorizeSelectedCollection.value) {
message.error('请先选择目标知识库')
return
}
isVectorizing.value = true
vectorizingFileId.value = vectorizeTargetVersion.value.id
try {
console.log('🚀 开始向量化文件:')
console.log(' - 文件ID:', vectorizeTargetVersion.value.id)
console.log(' - 文件名:', vectorizeTargetVersion.value.rawFileName || selectedGroup.value?.title)
console.log(' - 目标知识库:', vectorizeSelectedCollection.value)
console.log(' - 用户角色:', userRole.value, '(管理员:', isAdmin.value, ')')
const fileId = vectorizeTargetVersion.value.id
const fileName = vectorizeTargetVersion.value.rawFileName || selectedGroup.value?.title
console.log('🚀 开始智能向量化文件:')
console.log(' - 文件ID:', fileId)
console.log(' - 文件名:', fileName)
message.loading({
content: `正在提交文件向量化任务到 "${vectorizeSelectedCollection.value}"...`,
content: '正在提交智能向量化任务,系统将自动匹配知识库...',
key: 'vectorizing',
duration: 0
})
const result = await ragAPI.submitVectorizeTask(vectorizeSelectedCollection.value, vectorizeTargetVersion.value.id)
// 使用智能向量化接口,自动根据文件部门ID匹配知识库
const result = await ragAPI.smartVectorizeFile(fileId)
message.destroy('vectorizing')
// 从响应中提取系统分配的知识库名称
const assignedCollection = result.collection || result.data?.collection || '系统默认'
if (result.submitted) {
message.success({
content: `文件向量化任务已提交到「${vectorizeSelectedCollection.value}」,正在后台处理...`,
content: `智能向量化任务已提交!系统已自动识别知识库「${assignedCollection}」,正在后台处理...`,
duration: 4
})
} else {
message.success(`文件向量化成功!已添加到知识库:${vectorizeSelectedCollection.value}`)
message.success(`文件向量化成功!已添加到知识库:${assignedCollection}`)
}
vectorizeModalVisible.value = false
emit('vectorized', {
fileId: vectorizeTargetVersion.value.id,
collectionName: vectorizeSelectedCollection.value,
fileName: vectorizeTargetVersion.value.rawFileName || selectedGroup.value?.title
fileId,
collectionName: assignedCollection,
fileName
})
} catch (error) {
@@ -902,18 +848,9 @@ const loadFilesFromFileSystem = async () => {
}
}
// 统一的加载入口
// 统一的加载入口:直接调用文件列表接口
const loadFiles = async () => {
// 两步流程:第一步加载向量库列表,第二步加载文档列表
if (step.value === 1) {
// 第一步:加载向量库列表
await loadCollections()
} else if (step.value === 2) {
// 第二步:加载已选向量库的文档列表
if (selectedCollection.value) {
await loadDocumentsFromCollection(selectedCollection.value)
}
}
await loadFilesFromFileSystem()
}
const saveRecent = (id) => {

View File

@@ -1428,19 +1428,24 @@ const handleBatchFileChange = (nextTargetKeys) => {
}
const vectorizeBatch = async () => {
if (!batchSelectedFiles.value.length || !selectedCollection.value) return
if (!batchSelectedFiles.value.length) return
vectorizing.value = true
vectorizeResult.value = null
try {
const res = await ragAPI.vectorizeBatch(selectedCollection.value.name, batchSelectedFiles.value)
const successCount = res?.successCount || res?.successFiles?.length || 0
// 使用智能批量向量化接口,每个文件自动匹配对应部门向量库
const res = await ragAPI.smartVectorizeBatch(batchSelectedFiles.value)
const submittedCount = res?.submittedCount || res?.successCount || res?.submittedFiles?.length || batchSelectedFiles.value.length
const failedCount = res?.failedCount || res?.failedFiles?.length || 0
const batchId = res?.batchId || ''
vectorizeResult.value = {
success: true,
message: `批量向量化完成:成功 ${successCount} 个,失败 ${failedCount} 个`,
chunkCount: res?.successFiles?.reduce((sum, f) => sum + (f.chunkCount || 0), 0) || 0
message: batchId
? `批量向量化任务已提交(批次: ${batchId}):已提交 ${submittedCount} 个,失败 ${failedCount} 个`
: `批量向量化完成:成功 ${submittedCount} 个,失败 ${failedCount} 个`,
submitted: !!batchId,
details: res?.submittedFiles || []
}
message.success(`批量向量化完成:成功 ${successCount},失败 ${failedCount}`)
message.success(`批量向量化任务已提交:${submittedCount} 个文件正在后台处理`)
showBatchVectorize.value = false
batchSelectedFiles.value = []
loadDocuments()
@@ -1472,17 +1477,20 @@ const viewDocumentChunks = async (doc) => {
}
const vectorizeSingle = async () => {
if (!vectorizeFileId.value || !selectedCollection.value) return
if (!vectorizeFileId.value) return
vectorizing.value = true
vectorizeResult.value = null
try {
const res = await ragAPI.submitVectorizeTask(selectedCollection.value.name, vectorizeFileId.value)
// 使用智能向量化接口,自动根据文件部门ID匹配知识库
const res = await ragAPI.smartVectorizeFile(vectorizeFileId.value)
if (res.submitted) {
vectorizeResult.value = { success: true, message: '向量化任务已提交,正在后台处理...', submitted: true }
message.success('向量化任务已提交')
const collection = res.collection || res.data?.collection || '系统默认'
vectorizeResult.value = { success: true, message: `智能向量化任务已提交到「${collection}」,正在后台处理...`, submitted: true }
message.success(`向量化任务已提交(自动识别知识库:${collection})`)
} else {
const collection = res.collection || res.data?.collection || selectedCollection.value?.name || '系统默认'
vectorizeResult.value = { success: true, message: res?.message || '向量化成功', chunkCount: res?.chunk_count || res?.chunks?.length || 0 }
message.success('向量化完成')
message.success(`向量化完成(知识库:${collection})`)
}
loadDocuments()
} catch (e) {

View File

@@ -129,10 +129,22 @@
</template>
<template v-else-if="message.status === 'streaming' || message.content">
<div class="streaming-output">
<span v-html="formatMessage(message.content)"></span>
<span class="streaming-cursor" v-if="message.isStreaming"></span>
</div>
<template v-for="(segment, segIdx) in getContentWithImages(message.content, message.images)" :key="segIdx">
<div v-if="segment.type === 'text'" class="streaming-output">
<span v-html="formatMessage(segment.content)"></span>
<span class="streaming-cursor" v-if="message.isStreaming && segIdx === getContentWithImages(message.content, message.images).length - 1"></span>
</div>
<div v-else-if="segment.type === 'image'" class="chat-image-item" @click="previewImage(segment.image)">
<img
:src="getImageDisplayUrl(segment.image)"
:alt="segment.image.description || segment.image.alt || '相关图片'"
:title="segment.image.description || segment.image.source || ''"
loading="lazy"
@error="handleImageError"
@load="handleImageLoad(segment.image)"
/>
</div>
</template>
</template>
</template>
@@ -142,8 +154,8 @@
<div v-else-if="segment.type === 'image'" class="chat-image-item" @click="previewImage(segment.image)">
<img
:src="getImageDisplayUrl(segment.image)"
:alt="segment.image.alt || segment.image.caption || segment.image.description || '相关图片'"
:title="segment.image.description || segment.image.caption || segment.image.doc_name || ''"
:alt="segment.image.description || segment.image.alt || '相关图片'"
:title="segment.image.description || segment.image.source || ''"
loading="lazy"
@error="handleImageError"
@load="handleImageLoad(segment.image)"
@@ -170,27 +182,33 @@
<transition name="expand">
<div v-if="expandedReferences[message.id]" class="references-list">
<template v-for="(group, groupIndex) in getGroupedReferences(message.id)" :key="groupIndex">
<div class="ref-group">
<div class="ref-group-header">
<div class="ref-group" :class="{ 'group-collapsed': collapsedGroups[group.filename] }">
<div class="ref-group-header" @click="toggleGroupCollapse(group.filename)">
<RightOutlined v-if="collapsedGroups[group.filename]" class="group-collapse-icon" />
<DownOutlined v-else class="group-collapse-icon" />
<FileTextOutlined class="ref-file-icon" />
<span class="ref-group-filename">{{ group.filename }}</span>
<span class="ref-group-count">{{ group.items.length }} 处</span>
</div>
<div
v-for="(ref, refIndex) in group.items"
:key="refIndex"
class="reference-item"
:class="{ 'highlighted': highlightedRef === ref.id }"
@click="navigateToFileReader(ref)"
>
<div class="ref-item-header">
<span class="ref-location" v-if="ref.location"><EnvironmentOutlined /> {{ ref.location }}</span>
<span class="ref-score" v-if="ref.score">{{ ref.score.toFixed(1) }}%</span>
<transition name="group-expand">
<div v-show="!collapsedGroups[group.filename]" class="ref-group-body">
<div
v-for="(ref, refIndex) in group.items"
:key="refIndex"
class="reference-item"
:class="{ 'highlighted': highlightedRef === ref.id }"
@click="navigateToFileReader(ref)"
>
<div class="ref-item-header">
<span class="ref-location" v-if="ref.location"><EnvironmentOutlined /> {{ ref.location }}</span>
<span class="ref-score" v-if="ref.score">{{ ref.score.toFixed(1) }}%</span>
</div>
<div class="ref-context" v-if="ref.context">
"{{ ref.context }}"
</div>
</div>
</div>
<div class="ref-context" v-if="ref.context">
"{{ ref.context }}"
</div>
</div>
</transition>
</div>
</template>
</div>
@@ -254,36 +272,38 @@
</div>
</div>
<div class="input-wrapper">
<textarea
v-model="inputValue"
placeholder="输入您的问题..."
class="chat-textarea"
ref="textareaRef"
@keydown="handleKeydown"
@input="autoResize"
:disabled="isLoading"
rows="1"
/>
<div class="input-actions">
<button
v-if="isLoading"
class="stop-btn"
@click="handleCancelRequest"
title="停止生成"
>
<StopOutlined />
</button>
<button
v-else
class="send-btn"
:class="{ 'has-text': inputValue.trim() }"
@click="handleSend"
:disabled="!inputValue.trim()"
title="发送"
>
<ArrowUpOutlined />
</button>
<div class="chat-input-area">
<div class="input-wrapper">
<textarea
v-model="inputValue"
placeholder="输入您的问题..."
class="chat-textarea"
ref="textareaRef"
@keydown="handleKeydown"
@input="autoResize"
:disabled="isLoading"
rows="1"
/>
<div class="input-actions">
<button
v-if="isLoading"
class="stop-btn"
@click="handleCancelRequest"
title="停止生成"
>
<StopOutlined />
</button>
<button
v-else
class="send-btn"
:class="{ 'has-text': inputValue.trim() }"
@click="handleSend"
:disabled="!inputValue.trim()"
title="发送"
>
<ArrowUpOutlined />
</button>
</div>
</div>
</div>
</div>
@@ -712,6 +732,7 @@ const faqCollapsed = ref(false)
const activeTooltip = ref(null)
const expandedReferences = ref({})
const collapsedGroups = ref({}) // 按文档名折叠:{ "1.docx": true }
const highlightedRef = ref(null)
const messageReferences = ref({})
@@ -738,48 +759,39 @@ let typewriterActive = false
let navigateRef = null
if (typeof window !== 'undefined') {
// 按文档规范:点击 [引用] 链接时,查找对应引用信息并导航到文件阅读器
window.handleReferenceClick = (element) => {
const refId = element.dataset.refId
if (highlightedRef.value === refId) {
highlightedRef.value = null
} else {
highlightedRef.value = refId
}
if (navigateRef) {
const filename = element.dataset.filename || ''
const location = element.dataset.location || ''
const chunkId = element.dataset.chunkId
if (!chunkId) return
// 解码并获取引用上下文(用于关键词提取和高亮)
let context = ''
if (element.dataset.context) {
// HTML实体解码
context = element.dataset.context
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim()
// 从 chunkId 解析文档名和索引(格式:{docName}_{chunkIndex})
const lastUnderscore = chunkId.lastIndexOf('_')
const docName = lastUnderscore > 0 ? chunkId.substring(0, lastUnderscore) : chunkId
const chunkIndex = lastUnderscore > 0 ? chunkId.substring(lastUnderscore + 1) : ''
// 查找引用信息
let refInfo = null
for (const msgId of Object.keys(messageReferences.value)) {
const refs = messageReferences.value[msgId]
if (!refs) continue
const found = refs.find(r => {
const rChunkId = r.chunkId || r.id || ''
return rChunkId === chunkId || rChunkId.endsWith(chunkId) || r.filename === docName
})
if (found) {
refInfo = found
break
}
}
// 传递完整的ref对象(包含context),确保能触发内容高亮
if (navigateRef) {
navigateRef({
filename,
location,
context: context || undefined // 有context时才传递
filename: refInfo?.filename || docName,
location: refInfo?.location || (chunkIndex ? `第${chunkIndex}页` : ''),
context: refInfo?.context || refInfo?.preview || refInfo?.excerpt || ''
})
}
}
window.showReferenceTooltip = (element) => {
const refId = element.dataset.refId
const filename = element.dataset.filename
const location = element.dataset.location
activeTooltip.value = { refId, filename, location, element }
}
window.hideReferenceTooltip = () => {
activeTooltip.value = null
}
}
const sessions = ref([])
@@ -888,10 +900,26 @@ const downloadImage = (image) => {
const getImageDisplayUrl = (image) => {
if (!image) return ''
const imageId = image.id || image.imageId || image.image_id
// 优先使用 blob URL(带认证加载的缓存)
if (imageId && imageBlobUrls.value[imageId]) {
return imageBlobUrls.value[imageId]
}
return resolveImageUrl(image.url || image.src || image)
// 如果有 URL 字段且 imageId 不包含文件扩展名,直接解析
const rawUrl = image.url || image.src || ''
if (rawUrl) {
// 若 imageId 带扩展名(如 49d2910b148d.jpg),说明后端返回的 url 路径格式有误,跳过
if (!imageId || !/\.\w+$/.test(String(imageId))) {
return resolveImageUrl(rawUrl)
}
}
// 没有 URL 字段或 URL 路径有误,但有 imageId,尝试 Nginx 静态资源路径
if (imageId) {
// 清理可能的文件扩展名
const cleanId = String(imageId).replace(/\.\w+$/, '')
const ext = String(imageId).includes('.') ? String(imageId).match(/\.(\w+)$/)?.[1] : 'jpg'
return `/files/img/${cleanId}.${ext}`
}
return ''
}
// 解析图片URL
@@ -922,7 +950,8 @@ const getContentWithImages = (content, images) => {
}
const segments = []
const imageRelevanceScores = parsedImages.map(img => {
const desc = (img.description || img.caption || img.doc_name || '').toLowerCase()
// 优先使用后端返回的 preview(包含图片周围文档上下文),回退到 description / alt / source
const desc = (img.preview || img.description || img.alt || img.source || '').toLowerCase()
return { image: img, desc, scores: [] }
})
nonEmptyParagraphs.forEach((para, pIdx) => {
@@ -935,8 +964,8 @@ const getContentWithImages = (content, images) => {
if (paraLower.includes(word)) score += 3
})
}
const docName = (imgInfo.image.doc_name || '').toLowerCase().replace(/\.\w+$/, '')
if (docName && paraLower.includes(docName)) score += 5
const source = (imgInfo.image.source || '').toLowerCase().replace(/\.\w+$/, '')
if (source && paraLower.includes(source)) score += 5
score += (pIdx + 1) / (nonEmptyParagraphs.length + 1) * 0.5
imgInfo.scores.push({ pIdx, score })
})
@@ -1012,18 +1041,21 @@ const handleImageLoad = (image) => {
// 带认证的图片加载(解决 <img> 标签无法携带 Authorization 头的问题)
const loadImageWithAuth = async (image) => {
const imageId = image.id || image.imageId
let imageId = image.id || image.imageId
if (!imageId || imageBlobUrls.value[imageId] || imageLoadingMap.value[imageId]) return
imageLoadingMap.value[imageId] = true
// 清理可能的文件扩展名(后端返回的 id 可能带 .jpg)
const rawId = String(imageId)
const cleanId = rawId.replace(/\.\w+$/, '')
imageLoadingMap.value[rawId] = true
try {
const response = await imageAPI.getImageData(imageId)
const response = await imageAPI.getImageData(cleanId)
const blob = response.data
const blobUrl = URL.createObjectURL(blob)
imageBlobUrls.value[imageId] = blobUrl
imageBlobUrls.value[rawId] = blobUrl
} catch (e) {
console.error('❌ 加载图片失败:', imageId, e)
console.error('❌ 加载图片失败:', rawId, e)
} finally {
imageLoadingMap.value[imageId] = false
imageLoadingMap.value[rawId] = false
}
}
@@ -1122,6 +1154,33 @@ const finalizeMessage = (aiMessageId, result, displayContent, trimmedInput) => {
processSessionAndFinish(result, trimmedInput, displayContent)
}
// 从会话 API 异步加载消息图片(流式结束时 SSE 可能未携带 images)
const loadMessageImagesFromSession = async (aiMessageId, sessionId) => {
try {
const result = await aiAPI.getSessionDetail(sessionId, { page: 1, pageSize: 100 })
if (result.data && Array.isArray(result.data)) {
// 找最后一条 AI 回复消息
const aiMessages = result.data.filter(msg => msg.role === 'assistant')
const lastAi = aiMessages[aiMessages.length - 1]
if (lastAi && lastAi.images) {
const sessionImages = safeParseImages(lastAi.images)
if (sessionImages.length > 0) {
const msgIndex = messages.value.findIndex(m => m.id === aiMessageId)
if (msgIndex !== -1) {
const msg = messages.value[msgIndex]
if (!msg.images || msg.images.length === 0) {
messages.value[msgIndex] = { ...msg, images: sessionImages }
sessionImages.forEach(img => loadImageWithAuth(img))
}
}
}
}
}
} catch (e) {
console.warn('⚠️ 从会话加载图片失败:', e)
}
}
const cleanSearchKeyword = (raw) => {
if (!raw || typeof raw !== 'string') return ''
let text = raw.trim()
@@ -1515,43 +1574,24 @@ const formatMessage = (content) => {
let formatted = content.replace(/\n/g, '<br>')
// 按文档规范:将 [ref:chunk_id] 替换为可点击的引用链接
// chunk_id 格式为 {docName}_{chunkIndex},如 "文档名_86"
formatted = formatted.replace(
/\[ref:([^\]]+)\]/g,
(match, refInfo) => {
const refId = `ref_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const parts = refInfo.split('_')
const filename = parts[0] || '未知文件'
const location = parts.slice(1).join('_') || ''
// ✨ 智能提取引用上下文(终极版:双向提取 + 内容质量评分)
const matchIndex = formatted.indexOf(match)
// 核心算法:双向提取 + 智能评分选择最佳引用文本
const context = extractBestContext(formatted, matchIndex, match.length)
// 对纯文本context进行HTML实体编码,确保可以安全放入data属性
const encodedContext = context
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.substring(0, 200) // 限制长度防止属性过长
return `<span class="reference-tag" data-ref-id="${refId}"
data-filename="${filename}"
data-location="${location}"
data-context="${encodedContext}"
onclick="window.handleReferenceClick(this)"
onmouseenter="window.showReferenceTooltip(this)"
onmouseleave="window.hideReferenceTooltip()">
📎<sup class="ref-index">${filename}</sup>
</span>`
(match, chunkId) => {
return `<a class="ref-link" data-chunk-id="${chunkId.replace(/"/g, '&quot;')}" onclick="window.handleReferenceClick(this)">[引用]</a>`
}
)
formatted = formatted.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
formatted = formatted.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 将 URL 转换为可点击链接
formatted = formatted.replace(
/(https?:\/\/[^\s<>"'))]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
)
return formatted
}
@@ -1563,21 +1603,21 @@ const parseReferencesFromContent = (content) => {
let match
while ((match = regex.exec(content)) !== null) {
const refInfo = match[1]
const parts = refInfo.split('_')
const filename = parts[0] || '未知文件'
const location = parts.slice(1).join('_') || ''
const chunkId = match[1].trim()
// chunk_id 格式为 {docName}_{chunkIndex},使用 lastIndexOf 正确分割
const lastUnderscore = chunkId.lastIndexOf('_')
const filename = lastUnderscore > 0 ? chunkId.substring(0, lastUnderscore) : chunkId
const location = lastUnderscore > 0 ? chunkId.substring(lastUnderscore + 1) : ''
// ✨ 智能提取引用上下文(终极版:双向提取 + 内容质量评分)
// ✨ 智能提取引用上下文
const matchIndex = match.index
// 使用统一的智能算法
const context = extractBestContext(content, matchIndex, match[0].length)
references.push({
id: `ref_${references.length + 1}`,
filename,
location,
chunkId: chunkId,
filename: filename || '未知文档',
location: location ? `第${location}页` : '',
context: context.trim(),
fullRef: match[0]
})
@@ -1606,11 +1646,14 @@ const getTooltipPosition = () => {
}
const getMessageReferences = (messageId) => {
// 1. 优先从 messageReferences 存储中获取(由 onSources SSE 事件填充)
if (messageReferences.value[messageId] && messageReferences.value[messageId].length > 0) {
return messageReferences.value[messageId]
}
const msg = messages.value.find(m => m.id === messageId)
// 2. 从消息内容中解析 [ref:xxx] 标记
if (msg?.content) {
const parsedRefs = parseReferencesFromContent(msg.content)
if (parsedRefs.length > 0) {
@@ -1618,6 +1661,19 @@ const getMessageReferences = (messageId) => {
}
}
// 3. 后备方案:从消息的 sources 字段转换(由 onFinish/result.sources 填充)
if (msg?.sources && msg.sources.length > 0) {
return msg.sources.map((source, idx) => ({
id: source.id || source.chunkId || source.chunk_id || `ref_${messageId}_${idx}`,
chunkId: source.chunkId || source.chunk_id || '',
filename: source.doc_name || source.source || source.filename || source.title || source.name || '未知文档',
location: source.page ? `第${source.page}页` : (source.section || source.location || source.table || ''),
context: source.excerpt || source.content?.stem || source.content_stem || source.preview || source.context || (typeof source.content === 'string' ? source.content : '') || '',
preview: source.preview || '',
score: source.score || source.relevance_score || null
}))
}
return []
}
@@ -1634,6 +1690,11 @@ const getGroupedReferences = (messageId) => {
return Object.values(groups)
}
// 切换引用分组的折叠状态
const toggleGroupCollapse = (filename) => {
collapsedGroups.value[filename] = !collapsedGroups.value[filename]
}
const scrollToBottom = async () => {
await nextTick()
if (chatMessagesRef.value) {
@@ -1784,10 +1845,15 @@ const handleSend = async () => {
newSources.forEach(source => {
const refItem = {
id: source.id || source.chunk_id || `ref_${aiMessageId}_${messageReferences.value[aiMessageId].length + 1}`,
filename: source.filename || source.title || source.name || source.source || '未知文档',
location: source.page ? `第${source.page}页` : (source.location || source.table || ''),
context: source.content || source.context || source.preview || source.excerpt || '',
id: source.id || source.chunkId || source.chunk_id || `ref_${aiMessageId}_${messageReferences.value[aiMessageId].length + 1}`,
chunkId: source.chunkId || source.chunk_id || '',
// 文档字段映射:docName → source / doc_name
filename: source.doc_name || source.source || source.filename || source.title || source.name || '未知文档',
// 文档字段映射:page → page, section → section
location: source.page ? `第${source.page}页` : (source.section || source.location || source.table || ''),
// 文档字段映射:excerpt → content.stem, preview 由后端自动填充
context: source.excerpt || source.content?.stem || source.content_stem || source.preview || source.context || (typeof source.content === 'string' ? source.content : '') || '',
preview: source.preview || '',
score: source.score || source.relevance_score || null,
rawData: source
}
@@ -1827,25 +1893,79 @@ const handleSend = async () => {
console.log('🖼️ [DEBUG] onFinish 收到的 result:', result)
console.log('🖼️ [DEBUG] result.images:', result.images)
const finalContent = result.answer || accumulatedContent || '(空回复)'
// 优先使用 result.answer(后端完整回答),回退到 accumulatedContent(前端拼接的chunks)
const displayContent = result.answer || accumulatedContent || '(空回复)'
if (result.sources && result.sources.length > 0) {
currentSources.value = result.sources
}
// 收到 finish 事件后立即停止打字机,用 result.answer 作为最终内容
// 避免 accumulatedContent 与 result.answer 不一致导致内容跳变
stopTypewriter()
// 优先使用 citations(finish 事件中的权威引用数据,已写入数据库)
// 收到 citations 后替换 onSources 填充的临时数据,确保引用来源与刷新后一致
if (result.citations && result.citations.length > 0) {
const citationMap = {}
result.citations.forEach((c, i) => {
citationMap[c.chunk_id] = i + 1
// 保存 onSources 已填充的引用数据(含 context),用于后备
const existingRefs = (messageReferences.value[aiMessageId] || []).reduce((map, ref) => {
map[ref.chunkId] = ref
return map
}, {})
messageReferences.value[aiMessageId] = result.citations.map((citation, idx) => {
const chunkId = citation.chunk_id || citation.chunkId || ''
const existing = existingRefs[chunkId]
// 优先使用 citation 中的 excerpt/content.stem/preview
// 若 citation 缺少 context 数据,则复用 onSources 阶段已填充的 context
let context = citation.excerpt || citation.content?.stem || citation.preview || ''
if (!context && existing?.context) {
context = existing.context
}
return {
id: chunkId || `ref_${aiMessageId}_${idx}`,
chunkId,
filename: citation.source || citation.doc_name || '未知文档',
location: citation.page ? `第${citation.page}页` : (citation.section || ''),
context,
preview: citation.preview || existing?.preview || '',
score: citation.score || null,
rawData: citation
}
})
const processedContent = finalContent.replace(/\[ref:([^\]]+)\]/g, (match, chunkId) => {
const num = citationMap[chunkId]
return num ? `[${num}]` : ''
})
if (processedContent !== finalContent) {
accumulatedContent = processedContent
}
// 后备方案:使用 sources(SSE 事件中的候选数据,未写入数据库)
else if (result.sources && result.sources.length > 0) {
if (!messageReferences.value[aiMessageId] || messageReferences.value[aiMessageId].length === 0) {
messageReferences.value[aiMessageId] = result.sources.map((source, idx) => ({
id: source.id || source.chunkId || source.chunk_id || `ref_${aiMessageId}_${idx}`,
chunkId: source.chunkId || source.chunk_id || '',
filename: source.doc_name || source.source || source.filename || source.title || source.name || '未知文档',
location: source.page ? `第${source.page}页` : (source.section || source.location || source.table || ''),
context: source.excerpt || source.content?.stem || source.content_stem || source.preview || source.context || (typeof source.content === 'string' ? source.content : '') || '',
preview: source.preview || '',
score: source.score || source.relevance_score || null
}))
}
}
const parsedImages = safeParseImages(result.images)
// 将 citations 中 chunkType='image' 的 preview 补充到图片数据中,用于图片定位
if (parsedImages.length > 0 && result.citations && result.citations.length > 0) {
const imageCitations = result.citations.filter(c => c.chunk_type === 'image' && c.preview)
if (imageCitations.length > 0) {
parsedImages.forEach(img => {
const imgId = img.image_id || img.imageId || ''
const imgSource = img.source || ''
const imgPage = img.page ? String(img.page) : ''
for (const cit of imageCitations) {
const citSource = cit.source || ''
const citSourceBase = citSource.replace(/\.\w+$/, '')
const citPage = cit.page ? String(cit.page) : ''
if ((imgId && cit.chunk_id === imgId) ||
(imgSource && (imgSource === citSource || imgSource.replace(/\.\w+$/, '') === citSourceBase)) ||
(imgPage && citPage && imgPage === citPage && imgSource && imgSource.replace(/\.\w+$/, '') === citSourceBase)) {
img.preview = cit.preview
break
}
}
})
}
}
if (parsedImages.length > 0) {
currentImages.value = parsedImages
console.log('🖼️ [DEBUG] 设置 currentImages:', currentImages.value)
@@ -1855,19 +1975,12 @@ const handleSend = async () => {
currentTables.value = result.tables
}
const displayContent = accumulatedContent || finalContent
// 立即用 result.answer 设置最终内容,不再等待打字机
finalizeMessage(aiMessageId, result, displayContent, trimmedInput)
if (!typewriterActive && typewriterBuffer.value.length === 0) {
finalizeMessage(aiMessageId, result, displayContent, trimmedInput)
} else {
const waitForComplete = () => {
if (!typewriterActive && typewriterBuffer.value.length === 0) {
finalizeMessage(aiMessageId, result, displayContent, trimmedInput)
} else {
setTimeout(waitForComplete, 100)
}
}
waitForComplete()
// 如果流完成时没有收到图片,从会话 API 异步加载
if ((!result.images || safeParseImages(result.images).length === 0) && result.sessionId) {
loadMessageImagesFromSession(aiMessageId, result.sessionId)
}
},
@@ -1890,7 +2003,8 @@ const handleSend = async () => {
...messages.value[msgIndex],
isStreaming: false,
isFinished: true,
status: 'stopped'
status: 'stopped',
images: currentImages.value.length > 0 ? [...currentImages.value] : messages.value[msgIndex].images
}
}
} else {
@@ -1911,7 +2025,8 @@ const handleSend = async () => {
isFinished: true,
status: 'interrupted',
isInterrupted: true,
canRetry: true
canRetry: true,
images: currentImages.value.length > 0 ? [...currentImages.value] : messages.value[msgIndex].images
}
}
message.warning('AI回答生成中断,已显示部分内容,可点击重新生成', 5)
@@ -1987,6 +2102,12 @@ const updateSessionTitle = (content) => {
const handleCancelRequest = () => {
if (abortController.value) {
// 按文档规范:通知后端清理资源
const currentSession = activeSessionId.value ? sessions.value.find(s => s.id === activeSessionId.value) : null
if (currentSession?.sessionId) {
aiAPI.stopStream(currentSession.sessionId).catch(() => {})
}
abortController.value.abort()
stopTypewriter()
isLoading.value = false
@@ -2663,10 +2784,71 @@ const loadSessionDetailFromAPI = async (sessionId) => {
content: msg.content
}))
// 重建引用数据(从消息内容中的 [ref:...] 标签解析 + 从 localStorage 恢复缓存)
// 重建引用数据:通过 API 接口获取每条 AI 消息的引用列表
const rebuiltRefs = {}
const imagePreviewMap = {} // msgId -> { imageId -> preview }
const aiMessages = mappedMessages.filter(msg => msg.type === 'ai')
// 并行获取所有 AI 消息的引用数据
const refPromises = aiMessages.map(async (msg) => {
try {
const refResult = await aiAPI.getMessageReferences(msg.id)
if (refResult.data && Array.isArray(refResult.data) && refResult.data.length > 0) {
rebuiltRefs[msg.id] = refResult.data.map((ref, idx) => ({
id: ref.id || ref.chunkId || `ref_${msg.id}_${idx}`,
chunkId: ref.chunkId || '',
filename: ref.docName || '未知文档',
location: ref.page ? `第${ref.page}页` : (ref.section || ''),
context: ref.excerpt || ref.content?.stem || ref.preview || '',
preview: ref.preview || '',
score: ref.score || null,
rawData: ref
}))
// 提取 chunkType='image' 的引用的 preview,用于图片定位
const imageRefs = refResult.data.filter(r => r.chunkType === 'image')
if (imageRefs.length > 0) {
imagePreviewMap[msg.id] = {}
imageRefs.forEach(r => {
// 用 chunkId 或 docName+page 作为 key 匹配图片
if (r.preview) {
imagePreviewMap[msg.id][r.chunkId] = r.preview
}
})
}
}
} catch (e) {
console.warn(`⚠️ 获取消息 ${msg.id} 引用失败:`, e.message)
}
})
await Promise.allSettled(refPromises)
// 将引用接口返回的图片 preview 补充到消息的 images 数据中
mappedMessages.forEach(msg => {
if (msg.type === 'ai' && msg.content && msg.content.includes('[ref:')) {
if (msg.type === 'ai' && msg.images && msg.images.length > 0 && imagePreviewMap[msg.id]) {
const previewMap = imagePreviewMap[msg.id]
msg.images = msg.images.map(img => {
// 尝试多种方式匹配:chunkId、image_id+page、source+page
const imgId = img.image_id || img.imageId || ''
const imgSource = img.source || ''
const imgPage = img.page || ''
let matchedPreview = ''
for (const [chunkId, preview] of Object.entries(previewMap)) {
const chunkSource = chunkId.includes('_') ? chunkId.substring(0, chunkId.lastIndexOf('_')) : chunkId
if (chunkId === imgId || chunkSource === imgSource ||
(imgSource && chunkSource.includes(imgSource.replace(/\.\w+$/, ''))) ||
(imgPage && chunkId.includes(imgPage))) {
matchedPreview = preview
break
}
}
return matchedPreview ? { ...img, preview: matchedPreview } : img
})
}
})
// 后备:对没有获取到引用数据的消息,从内容中解析 [ref:xxx] 标记
mappedMessages.forEach(msg => {
if (msg.type === 'ai' && !rebuiltRefs[msg.id] && msg.content && msg.content.includes('[ref:')) {
const parsed = parseReferencesFromContent(msg.content)
if (parsed.length > 0) {
rebuiltRefs[msg.id] = parsed
@@ -3009,6 +3191,12 @@ defineExpose({
flex-shrink: 0;
}
.sidebar-footer .chat-input-area {
padding: 0;
background: transparent;
border-top: none;
}
.sidebar-footer .input-wrapper {
max-width: 100%;
background: var(--bg-container);
@@ -3729,6 +3917,19 @@ defineExpose({
line-height: 1.7;
}
.streaming-output a,
.content-paragraph a,
.message-content-detail a {
color: var(--color-primary, #1677ff);
text-decoration: underline;
cursor: pointer;
}
.streaming-output a:hover,
.content-paragraph a:hover,
.message-content-detail a:hover {
opacity: 0.8;
}
.ai-status.thinking {
display: flex;
align-items: center;
@@ -3794,49 +3995,56 @@ defineExpose({
color: var(--text-primary);
}
.chat-input-area {
padding: 16px 24px 24px;
background: var(--bg-card);
flex-shrink: 0;
}
.input-wrapper {
max-width: 680px;
margin: 0 auto 24px;
background: #2d2d2d;
border: 1px solid rgba(120, 120, 128, 0.36);
border-radius: 14px;
padding: 10px 16px;
max-width: 768px;
margin: 0 auto;
background: var(--bg-container);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 16px;
padding: 12px 16px;
display: flex;
align-items: flex-end;
gap: 8px;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.input-wrapper:hover {
border-color: rgba(140, 140, 148, 0.5);
border-color: rgba(0, 0, 0, 0.18);
}
.input-wrapper:focus-within {
border-color: rgba(100, 160, 240, 0.6);
box-shadow: 0 0 0 2px rgba(64, 130, 230, 0.08);
border-color: rgba(0, 0, 0, 0.25);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08), 0 0 0 2px rgba(33, 37, 41, 0.04);
}
.chat-textarea {
width: 100%;
border: none;
background: transparent;
font-size: 14px;
line-height: 1.6;
font-size: 15px;
line-height: 1.5;
resize: none;
outline: none;
color: #e5e5e5;
color: var(--text-primary);
font-family: inherit;
min-height: 22px;
min-height: 24px;
max-height: 200px;
padding: 4px 0;
}
.chat-textarea::placeholder {
color: #8b8b8b;
color: #9ca3af;
}
.chat-textarea:disabled {
opacity: 0.45;
opacity: 0.5;
cursor: not-allowed;
}
@@ -3844,43 +4052,43 @@ defineExpose({
display: flex;
align-items: center;
flex-shrink: 0;
margin-bottom: 1px;
margin-bottom: 2px;
}
.stop-btn {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(80, 80, 88, 0.35);
color: #c7c7cc;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 8px;
font-size: 13px;
border-radius: 50%;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.55);
color: #fecaca;
background: #e5e7eb;
transform: scale(1.05);
}
.send-btn {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(60, 60, 65, 0.38);
color: #6b6b70;
background: #e5e7eb;
color: #9ca3af;
border: none;
border-radius: 8px;
font-size: 13px;
border-radius: 50%;
font-size: 14px;
cursor: pointer;
transition: all 0.18s ease;
transition: all 0.2s ease;
flex-shrink: 0;
}
@@ -3890,8 +4098,9 @@ defineExpose({
}
.send-btn.has-text:hover:not(:disabled) {
filter: brightness(1.1);
transform: scale(1.04);
background: var(--color-primary);
filter: brightness(1.08);
transform: scale(1.05);
}
.send-btn:disabled {
@@ -4560,6 +4769,10 @@ defineExpose({
.chat-messages {
padding: var(--space-md);
}
.chat-input-area {
padding: var(--space-md);
}
}
/* ========== 会话详情模态框样式 ========== */
@@ -4824,26 +5037,23 @@ defineExpose({
/* ==================== 引用组件样式 ==================== */
/* 引用标签(内联) - 使用 :deep 穿透 scoped,适配 v-html 渲染 */
:deep(.reference-tag) {
display: inline-flex;
align-items: center;
/* 引用链接标签(内联)- 按文档规范使用 .ref-link */
:deep(.ref-link) {
display: inline;
cursor: pointer;
color: var(--color-primary);
font-size: var(--font-xs);
transition: color var(--transition-fast);
vertical-align: middle;
}
:deep(.reference-tag:hover) {
color: var(--color-primary-hover);
color: var(--color-primary, #1677ff);
font-size: var(--font-sm);
text-decoration: underline;
text-decoration-style: dotted;
vertical-align: baseline;
margin: 0 2px;
white-space: nowrap;
}
:deep(.ref-index) {
margin-left: 2px;
font-weight: var(--weight-medium);
font-size: var(--font-xs);
:deep(.ref-link:hover) {
color: var(--color-primary-hover, #4096ff);
text-decoration: underline;
text-decoration-style: solid;
}
/* 引用来源卡片 */
@@ -4926,11 +5136,24 @@ defineExpose({
.ref-group-header {
display: flex;
align-items: center;
gap: var(--space-md);
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-container);
border-bottom: 1px solid var(--border-light);
cursor: default;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast);
}
.ref-group-header:hover {
background: var(--bg-hover);
}
.group-collapse-icon {
font-size: 11px;
color: var(--text-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.ref-group-filename {
@@ -4945,6 +5168,16 @@ defineExpose({
margin-left: auto;
}
/* 折叠状态:隐藏底部边框 */
.group-collapsed .ref-group-header {
border-bottom: none;
}
/* 引用项容器 */
.ref-group-body {
overflow: hidden;
}
.ref-group .reference-item {
cursor: pointer;
padding: var(--space-sm) var(--space-md);
@@ -5146,9 +5379,28 @@ defineExpose({
max-height: 500px;
}
/* 分组折叠动画 */
.group-expand-enter-active,
.group-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.group-expand-enter-from,
.group-expand-leave-to {
opacity: 0;
max-height: 0;
}
.group-expand-enter-to,
.group-expand-leave-from {
opacity: 1;
max-height: 800px;
}
/* 响应式设计 */
@media (max-width: 768px) {
:deep(.reference-tag) {
:deep(.ref-link) {
font-size: var(--font-xs);
}

File diff suppressed because it is too large Load Diff

View File

@@ -696,12 +696,7 @@ defineExpose({
.docx-toolbar,
.xlsx-toolbar,
.text-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: none !important;
}
.image-toolbar {

View File

@@ -51,15 +51,14 @@
</div>
<div v-else class="paper-list">
<div v-for="paper in pendingPapers" :key="paper.id" class="paper-item">
<div class="paper-header">
<div v-for="paper in pendingPapers" :key="paper.id" class="paper-item paper-item--compact">
<div class="paper-row-main">
<h3 class="paper-title">{{ paper.title }}</h3>
<span class="paper-status pending">待考试</span>
</div>
<div class="paper-meta">
<span>题目数: {{ paper.questionCount || 0 }} 题</span>
<span>截止时间: {{ paper.deadline }}</span>
<span>时长: {{ paper.duration || 60 }} 分钟</span>
<span>{{ paper.questionCount || 0 }}题 · {{ paper.duration || 60 }}分钟</span>
<span v-if="paper.deadline">截止 {{ paper.deadline }}</span>
</div>
<div class="paper-actions">
<button class="btn btn-primary btn-sm" @click="startExam(paper.id)">开始考试</button>
@@ -294,28 +293,18 @@
<div
v-for="result in completedResults"
:key="result.id"
class="result-item"
class="result-item result-item--compact"
@click="viewExamDetail(result.recordId, result.paperId)"
>
<div class="result-header">
<h3>{{ result.paperTitle }}</h3>
<div class="paper-row-main">
<h3 class="paper-title">{{ result.paperTitle }}</h3>
<span :class="['grade-badge', getGradeClass(result.score)]">{{ result.score }}分</span>
</div>
<div class="result-details">
<div class="detail-item">
<span class="label">正确率:</span>
<span class="value">{{ result.accuracy }}%</span>
</div>
<div class="detail-item">
<span class="label">用时:</span>
<span class="value">{{ result.timeUsed }}分钟</span>
</div>
<div class="detail-item">
<span class="label">考试时间:</span>
<span class="value">{{ result.completedAt }}</span>
</div>
<div class="result-meta">
<span>正确率 {{ result.accuracy || 0 }}%</span>
<span>{{ result.timeUsed || 0 }}分钟</span>
<span>{{ result.completedAt }}</span>
</div>
<div class="result-click-hint">点击查看详情</div>
</div>
</div>
</ContentCard>
@@ -347,9 +336,9 @@
</div>
<div class="rr-score-section">
<span class="rr-total-score">{{ examDetailData.totalScore }}</span>
<span class="rr-max-score">/ {{ examDetailData.totalMaxScore }} 分</span>
<span class="rr-max-score">/ {{ examDetailData.totalMaxScore || '-' }} 分</span>
<span v-if="examDetailData.scoreRate" class="rr-rate-badge">
得分率 {{ Math.round(Number(examDetailData.scoreRate) * 100) }}%
得分率 {{ Math.round(Number(examDetailData.scoreRate)) }}%
</span>
</div>
</div>
@@ -398,7 +387,7 @@
<div class="rrq-score-row">
<span class="score-label">得分:</span>
<span :class="['score-value', currentDetailQuestion.isCorrect === false ? 'text-wrong' : 'text-correct']">{{ currentDetailQuestion.score }} / {{ currentDetailQuestion.maxScore }} 分</span>
<span :class="['score-value', currentDetailQuestion.isCorrect === false ? 'text-wrong' : 'text-correct']">{{ currentDetailQuestion.score }} / {{ currentDetailQuestion.maxScore ?? '-' }} 分</span>
</div>
<div v-if="getQuestionAnalysis(currentDetailQuestion)" class="rrq-analysis">
@@ -500,15 +489,13 @@
<div class="form-section">
<h4 class="section-title">难度设置</h4>
<div class="form-row">
<div class="form-item difficulty-item">
<div class="difficulty-slider-wrap">
<span class="diff-label" :class="{ active: selftestConfig.difficulty <= 2 }">简单</span>
<input type="range" class="difficulty-slider" min="1" max="5" v-model.number="selftestConfig.difficulty" />
<span class="diff-label" :class="{ active: selftestConfig.difficulty >= 4 }">困难</span>
</div>
<div class="difficulty-value">
难度等级: <strong>{{ selftestConfig.difficulty }}</strong>
</div>
<div class="form-item">
<label class="form-label">难度等级</label>
<select v-model.number="selftestConfig.difficulty" class="form-select">
<option :value="1">简单</option>
<option :value="2">中等</option>
<option :value="3">困难</option>
</select>
</div>
</div>
</div>
@@ -622,7 +609,11 @@ const toggleMark = (idx) => {
}
const selectSingle = (opt) => {
if (currentQuestion.value) currentQuestion.value.studentAnswer = opt.key || opt.label || ''
if (currentQuestion.value) {
const val = opt.key || opt.label || ''
currentQuestion.value.studentAnswer = val
console.log('[答题] 单选已选择:', val, '题目ID:', (currentQuestion.value.questionId || '').substring(0, 8))
}
}
const isMultiOptSelected = (opt) => {
@@ -890,6 +881,9 @@ const isCorrectAnswer = (opt, q) => {
const formatAnswer = (answer, questionType) => {
if (answer === null || answer === undefined) return '未作答'
// 处理 Python None 序列化为字符串的情况
if (typeof answer === 'string' && answer.trim().toLowerCase() === 'none') return '未作答'
if (typeof answer === 'string' && answer.trim().toLowerCase() === 'null') return '未作答'
if (Array.isArray(answer)) {
if (answer.length === 0) return '未作答'
return answer.join(', ')
@@ -1457,6 +1451,64 @@ const formatAnswer = (answer, questionType) => {
flex-wrap: wrap;
}
/* 紧凑型待考试/成绩单列表样式 */
.paper-item--compact,
.result-item--compact {
padding: 12px 14px !important;
gap: 6px !important;
}
.paper-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.paper-row-main .paper-title {
font-size: 13px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.paper-row-main .paper-status,
.paper-row-main .grade-badge {
flex-shrink: 0;
font-size: 11px;
padding: 1px 8px;
}
.result-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #868E96;
flex-wrap: wrap;
}
.result-meta span::before {
content: "·";
margin-right: 12px;
color: #CED4DA;
}
.result-meta span:first-child::before {
display: none;
}
.paper-item--compact .paper-meta {
margin-bottom: 0 !important;
font-size: 11px;
gap: 12px;
}
.paper-item--compact .paper-actions {
margin-top: 4px;
}
.taking-header {
display: flex;
justify-content: space-between;
@@ -1747,53 +1799,25 @@ const formatAnswer = (answer, questionType) => {
.form-row .form-item .form-input:focus {
border-color: #228BE6;
}
.difficulty-item {
.form-select {
width: 100%;
height: 36px;
padding: 6px 12px;
border: 1px solid #DEE2E6;
border-radius: 6px;
font-size: 13px;
color: #212529;
background: #FFFFFF;
transition: border-color 0.15s ease;
cursor: pointer;
}
.difficulty-slider-wrap {
display: flex;
align-items: center;
gap: 16px;
}
.diff-label {
font-size: 12px;
color: #868E96;
white-space: nowrap;
transition: color 0.15s ease;
}
.diff-label.active {
color: #228BE6;
font-weight: 600;
}
.difficulty-slider {
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #E9ECEF;
border-radius: 3px;
.form-select:focus {
border-color: #212529;
outline: none;
cursor: pointer;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
}
.difficulty-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #228BE6;
border: 2px solid #FFFFFF;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.difficulty-value {
font-size: 12px;
color: #495057;
margin-top: 8px;
text-align: center;
}
.difficulty-value strong {
font-size: 16px;
color: #228BE6;
.form-select:hover {
border-color: #CED4DA;
}
.form-actions {
text-align: center;

View File

@@ -46,6 +46,7 @@
v-model="selectedDoc"
@change="onDocSelected"
@collection-change="onCollectionChange"
:show-drop-zone="false"
placeholder="先选择知识库,再选择制度文件..."
/>
@@ -171,6 +172,16 @@
</div>
</div>
<div v-if="q.type === 'fill_blank'" class="question-answer-section">
<div class="answer-label">参考答案:</div>
<div class="answer-content">{{ q.answer || '未设置' }}</div>
</div>
<div v-if="q.type === 'subjective'" class="question-answer-section">
<div class="answer-label">参考答案:</div>
<div class="answer-content">{{ q.answer || '未设置' }}</div>
</div>
<div class="question-actions">
<button
class="q-action-btn approve"
@@ -334,6 +345,8 @@ const {
questionTypeOptions,
questionTypeCounts,
totalQuestionCount,
apiTotalCount,
resetApiTotalCount,
examTypeStats,
onDocSelected,
onCollectionChange,
@@ -356,7 +369,9 @@ const {
const canStartTask = computed(() => {
const hasFile = selectedDocData.value && (selectedDocData.value.id || selectedDocData.value.filePath || selectedDocData.value.pdfUrl)
const hasCount = totalQuestionCount.value > 0
// 检查用户配置的题型数量总和(而非 apiTotalCount,后者是生成成功后才有的统计值)
const userConfiguredCount = Object.values(questionTypeCounts).reduce((sum, c) => sum + (Number(c) || 0), 0)
const hasCount = userConfiguredCount > 0
return hasFile && hasCount && !isGenerating.value
})
@@ -365,11 +380,14 @@ const startTaskGeneration = async () => {
message.warning('请先选择文件')
return
}
if (totalQuestionCount.value <= 0) {
if (totalQuestionCount.value <= 0 && totalQuestionCount.value !== '-') {
message.warning('请至少设置一种题型数量')
return
}
// 重置统计为"暂未生成"
resetApiTotalCount()
const fileData = { ...selectedDocData.value, collectionName: selectedCollectionName.value }
const typeCounts = {}
questionTypeOptions.forEach(opt => {
@@ -384,7 +402,16 @@ const startTaskGeneration = async () => {
totalQuestionCount.value
)
loadPendingQuestions()
// 从已完成任务的 resultCount 同步题目总数(与 TaskItem 数据源一致)
const completedTask = tasks.value
.filter(t => t.status === 'completed')
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))[0]
if (completedTask?.resultCount != null && completedTask.resultCount > 0) {
apiTotalCount.value = completedTask.resultCount
}
// 刷新待审批列表
loadPendingQuestions(true)
}
const pageSize = 10
@@ -404,19 +431,42 @@ watch(generatedQuestions, () => {
currentPage.value = 1
})
const loadPendingQuestions = async () => {
const loadPendingQuestions = async (forceRefresh = false) => {
try {
const res = await questionAPI.getPendingQuestions()
const rawQuestions = res?.data?.data || res?.data?.list || res?.data || []
if (Array.isArray(rawQuestions) && rawQuestions.length > 0) {
const pendingOnly = rawQuestions.filter(q => !q.status || q.status === 'pending' || q.status === 'UNGENERATED')
console.log(`[GeneratePanel] getPendingQuestions 返回 ${rawQuestions.length} 条,过滤后 pending: ${pendingOnly.length} 条`)
// 只在 generatedQuestions 为空时才加载;已有数据时不覆盖(避免覆盖刚生成的题目)
if (generatedQuestions.value.length === 0) {
generatedQuestions.value = transformQuestionData(pendingOnly)
console.log(`[GeneratePanel] 自动加载 ${generatedQuestions.value.length} 条待审核题目`)
const res = await questionAPI.pageQuestions({
status: 'pending',
pageNum: 1,
pageSize: 999
})
const raw = res?.data || res
let list = []
if (Array.isArray(raw)) {
list = raw
} else {
const rawData = raw?.data || raw
list = rawData?.list || rawData?.records || rawData?.questions || (Array.isArray(rawData) ? rawData : [])
}
if (Array.isArray(list) && list.length > 0) {
console.log(`[GeneratePanel] pageQuestions(status=pending) 返回 ${list.length} 条`)
const newQuestions = transformQuestionData(list)
if (forceRefresh || generatedQuestions.value.length === 0) {
generatedQuestions.value = newQuestions
console.log(`[GeneratePanel] ${forceRefresh ? '强制刷新' : '首次加载'} ${generatedQuestions.value.length} 条待审核题目`)
} else {
console.log(`[GeneratePanel] 已有 ${generatedQuestions.value.length} 条题目,跳过自动加载`)
// 增量合并:按 question_id 去重,避免重复
const existingIds = new Set(generatedQuestions.value.map(q => q.questionId || q.question_id))
const added = newQuestions.filter(q => !existingIds.has(q.questionId || q.question_id))
if (added.length > 0) {
generatedQuestions.value = [...generatedQuestions.value, ...added]
console.log(`[GeneratePanel] 增量合并 ${added.length} 条新题目(总计 ${generatedQuestions.value.length} 条)`)
} else {
console.log(`[GeneratePanel] 无新题目需要合并`)
}
}
} else {
if (forceRefresh) {
generatedQuestions.value = []
}
}
} catch (error) {
@@ -631,26 +681,52 @@ const saveEdit = async () => {
try {
const q = editingQuestion.value
const isChoice = q.type === 'single_choice' || q.type === 'multiple_choice'
const correctOpts = editForm.options.filter(o => o.isCorrect).map(o => o.key)
const answerPayload = isChoice
? editForm.options.filter(o => o.isCorrect).map(o => o.key).join(',')
: editForm.answer
// 答案:统一转为字符串(与 POST /api/question 创建接口格式一致)
let answerStr
if (isChoice) {
answerStr = correctOpts.join(',')
} else if (q.type === 'fill_blank') {
answerStr = splitBlankAnswers(editForm.answer).join(',')
} else {
answerStr = editForm.answer || ''
}
const updateData = {
// 严格匹配创建接口的数据结构
const contentObj = {
stem: editForm.stem,
difficulty: editForm.difficulty,
answer: answerPayload
data: {},
answer: answerStr
}
if (isChoice && editForm.options.length > 0) {
updateData.options = editForm.options.map(o => ({ key: o.key, text: o.text }))
contentObj.data.options = editForm.options.map(o => ({ key: o.key, content: o.text }))
} else if (q.type === 'true_false') {
contentObj.data.options = [
{ key: 'T', content: '正确' },
{ key: 'F', content: '错误' }
]
} else if (q.type === 'fill_blank') {
const blanks = splitBlankAnswers(editForm.answer)
if (blanks.length > 0) contentObj.data.blank_count = blanks.length
}
const updateData = {
question_type: q.type,
difficulty: editForm.difficulty,
content: contentObj
}
if (q.score != null) updateData.score = q.score
if (q.document_name || q.documentName) updateData.document_name = q.document_name || q.documentName
console.log('[保存] 发送 payload:', JSON.stringify(updateData, null, 2))
await questionAPI.updateQuestion(q.id, updateData)
q.stem = editForm.stem
q.difficulty = editForm.difficulty
q.answer = answerPayload
q.answer = answerStr
if (isChoice) {
q.options = editForm.options.map(o => ({ key: o.key, text: o.text }))
}
@@ -658,8 +734,15 @@ const saveEdit = async () => {
message.success('修改已保存')
closeEditModal()
} catch (error) {
console.error('保存失败:', error)
message.error('保存失败: ' + (error.message || '请联系管理员'))
const serverError = error.response?.data
console.error('[保存] 更新题目失败:', {
status: error.response?.status,
statusText: error.response?.statusText,
serverData: serverError,
message: error.message
})
const errMsg = serverError?.message || serverError?.error || error.message || '请联系管理员'
message.error('保存失败: ' + errMsg)
} finally {
savingEdit.value = false
}
@@ -695,6 +778,7 @@ const getTypeLabel = (type) => {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 16px;
}
.result-panel :deep(.content-card) {
@@ -1156,6 +1240,33 @@ const getTypeLabel = (type) => {
margin-top: 8px;
}
/* 填空/简答题答案显示 */
.question-answer-section {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
background: #F8F9FA;
border-radius: 6px;
border-left: 3px solid #1C7ED6;
}
.answer-label {
font-size: 13px;
font-weight: 600;
color: #1C7ED6;
white-space: nowrap;
flex-shrink: 0;
}
.answer-content {
font-size: 13px;
color: #212529;
line-height: 1.6;
word-break: break-word;
}
.option-item {
display: flex;
align-items: center;

View File

@@ -163,10 +163,10 @@
<div v-for="(q, i) in composeResult.questions" :key="i" class="q-card">
<div class="q-header">
<span class="q-num">{{ i + 1 }}</span>
<span class="q-type">{{ formatQType(q.questionType || q.type) }}</span>
<span class="q-type">{{ formatQType(q.question_type) }}</span>
<span class="q-diff">难度 {{ q.difficulty || '-' }}</span>
</div>
<div class="q-stem">{{ q.stem || q.question_stem || q.content?.stem || '无题干' }}</div>
<div class="q-stem">{{ q.content?.stem || '无题干' }}</div>
</div>
</div>
</div>
@@ -623,7 +623,7 @@ const totalCount = computed(() =>
)
const totalPaperScore = computed(() =>
(composeResult.value?.questions || []).reduce((sum, q) => sum + (q.score || q.maxScore || q.max_score || 5), 0)
(composeResult.value?.questions || []).reduce((sum, q) => sum + (Number(q.score) || 0), 0)
)
const increment = key => { if (composeForm[key] < 50) composeForm[key]++ }
@@ -698,83 +698,23 @@ const handleGeneratePaper = async () => {
console.log('[组卷] 请求参数:', JSON.stringify(params))
const result = await examAPI.generatePaper(params)
console.log('[组卷] 原始返回结构 keys:', result ? Object.keys(result) : 'null/undefined')
console.log('[组卷] 原始返回:', JSON.stringify(result).substring(0, 800))
// 兼容多种字段名
let questions = result?.questions || result?.paperQuestions || result?.questionList ||
result?.items || result?.question_list || []
const paperId = result?.paper_id || result?.paperId || result?.paper?.id ||
result?.paper?.paper_id || result?.id || null
const paperTitle = result?.paper_title || result?.paperTitle ||
result?.paper?.title || result?.title || ''
// API返回标准snake_case字段:questions, paper_id, paper_title, total_score, question_count
const questions = Array.isArray(result?.questions) ? result.questions : []
const paperId = result?.paper_id || null
const paperTitle = result?.paper_title || ''
const totalScore = result?.total_score || 0
console.log('[组卷] 解析后 - paperId:', paperId, 'questions:', Array.isArray(questions) ? questions.length : 'not array')
console.log('[组卷] 解析结果 - paperId:', paperId, 'questions:', questions.length, 'totalScore:', totalScore)
// Fallback: if generate returned 0 questions, try fetching from question bank
// 无论 paperId 是否存在都尝试从题库补充
if (!Array.isArray(questions) || questions.length === 0) {
console.log('[组卷] 生成接口返回 0 题,尝试从题库补充...')
const typeMap = [
{ key: 'single_choice_count', type: 'single_choice' },
{ key: 'multiple_choice_count', type: 'multiple_choice' },
{ key: 'true_false_count', type: 'true_false' },
{ key: 'fill_blank_count', type: 'fill_blank' },
{ key: 'subjective_count', type: 'subjective' }
]
if (!Array.isArray(questions)) questions = []
for (const tm of typeMap) {
const need = composeForm[tm.key]
if (need <= 0) continue
try {
// 第一次尝试:带难度筛选
const qParams = {
pageNum: 1,
pageSize: need,
questionType: tm.type,
difficulty: composeForm.difficulty
}
console.log(`[组卷] 题库查询 [${tm.type}] params:`, JSON.stringify(qParams))
const res = await questionAPI.pageQuestions(qParams)
const body = res?.data || {}
const innerData = body?.data
console.log(`[组卷] 题库查询 [${tm.type}] body关键字段:`, {
hasData: !!innerData,
isArray: Array.isArray(innerData),
keys: innerData && typeof innerData === 'object' && !Array.isArray(innerData) ? Object.keys(innerData).slice(0, 8) : 'N/A'
})
let rawList = innerData?.list || innerData?.records || innerData?.questions ||
(Array.isArray(innerData) ? innerData : null) ||
body?.list || body?.records || body?.questions || []
let qList = Array.isArray(rawList) ? rawList : []
console.log(`[组卷] 题库查询 [${tm.type}]: 获取 ${qList.length} 题`)
// 第二次尝试:去掉难度筛选重试
if (qList.length === 0 && composeForm.difficulty !== undefined) {
console.log(`[组卷] 题库查询 [${tm.type}] 带难度=${composeForm.difficulty}返回0题,尝试不限制难度重试...`)
const retryParams = { pageNum: 1, pageSize: need, questionType: tm.type }
const retryRes = await questionAPI.pageQuestions(retryParams)
const retryBody = retryRes?.data || {}
const retryInner = retryBody?.data
rawList = retryInner?.list || retryInner?.records || retryInner?.questions ||
(Array.isArray(retryInner) ? retryInner : null) ||
retryBody?.list || retryBody?.records || retryBody?.questions || []
qList = Array.isArray(rawList) ? rawList : []
console.log(`[组卷] 重试 [${tm.type}](不限难度): 获取 ${qList.length} 题`)
}
if (qList.length > 0) {
questions = questions.concat(qList.slice(0, need))
}
} catch (e) {
console.warn(`[组卷] 查询题库 [${tm.type}] 失败:`, e.message?.substring(0, 80))
}
}
if (questions.length === 0) {
const hint = allFiles.value.length > 0
? `题库中暂无匹配的题目。请先在「生成题目」页面中为文件生成题目,然后再来组卷。`
: '数据库中暂无题目。请先在「生成题目」页面中为制度文件生成题目。'
message.warning(hint, 6)
}
composeResult.value = { questions, paperId, paperTitle }
composeResult.value = { questions, paperId, paperTitle, totalScore }
if (questions.length > 0) {
message.success(`组卷成功!${questions.length} 道题目,可保存为草稿`)
@@ -799,10 +739,11 @@ const handleSavePaper = async () => {
message.warning('请先生成试卷')
return
}
const ids = (r.questions || []).map(q => q.question_id || q.questionId || q.id).filter(Boolean)
// generatePaper接口返回的题目使用snake_case: question_id
const ids = r.questions.map(q => q.question_id).filter(Boolean)
console.log('[保存] 题目IDs:', ids.slice(0, 5), `...共${ids.length}个`)
if (ids.length === 0) {
console.error('[保存] 无法提取题目ID,原始题目数据:', JSON.stringify((r.questions || []).slice(0, 2)))
console.error('[保存] 无法提取题目ID,原始题目数据:', JSON.stringify(r.questions.slice(0, 2)))
message.error('无法提取题目ID')
return
}
@@ -815,16 +756,16 @@ const handleSavePaper = async () => {
const savedPaper = await examAPI.savePaper(saveData)
console.log('[保存] savePaper 返回:', savedPaper ? Object.keys(savedPaper).join(',') : 'null')
// 构建纸卷对象并直接注入草稿列表
// 构建纸卷对象并直接注入草稿列表(savePaper接口返回camelCase)
let newPaper = null
if (savedPaper) {
newPaper = {
...savedPaper,
id: savedPaper.id || savedPaper.paperId || savedPaper.paper_id,
id: savedPaper.id || savedPaper.paperId,
title: savedPaper.title || saveData.title,
questionCount: ids.length,
totalScore: savedPaper.totalScore || savedPaper.total_score || 0,
createdAt: savedPaper.createdAt || savedPaper.created_at || new Date().toISOString(),
totalScore: savedPaper.totalScore || r.totalScore || 0,
createdAt: savedPaper.createdAt || new Date().toISOString(),
published: false,
status: 'draft'
}
@@ -833,13 +774,13 @@ const handleSavePaper = async () => {
id: 'local_' + Date.now(),
title: saveData.title,
questionCount: ids.length,
totalScore: 0,
totalScore: r.totalScore || 0,
createdAt: new Date().toISOString(),
published: false,
status: 'draft'
}
}
if (!drafts.value.some(d => (d.id || d.paperId || d.paper_id) === newPaper.id)) {
if (!drafts.value.some(d => d.id === newPaper.id)) {
drafts.value.unshift(newPaper)
}
console.log('[保存] 已直接注入草稿列表,当前草稿数:', drafts.value.length, 'id:', newPaper.id)

View File

@@ -388,7 +388,8 @@ const {
handleSearch,
handleReset,
handleTableChange,
handleDeleteQuestion
handleDeleteQuestion,
changeLocalPage
} = useQuestionBank()
onMounted(() => {
@@ -644,8 +645,9 @@ const editQuestion = async (record) => {
try {
const res = await questionAPI.getQuestionById(record.id)
console.log('[编辑] 详情接口原始响应 topKeys:', Object.keys(res?.data || res || {}).slice(0, 12))
const rawDetail = res?.data || res
console.log('[编辑] 详情接口原始响应:', JSON.stringify(rawDetail, null, 2))
console.log('[编辑] 详情接口原始响应 topKeys:', Object.keys(rawDetail || {}).slice(0, 12))
const hasQuestionData = rawDetail
&& (rawDetail.id || rawDetail.stem || rawDetail.content
|| (rawDetail.data && (rawDetail.data.id || rawDetail.data.stem || rawDetail.data.content || rawDetail.data.options)))
@@ -681,40 +683,80 @@ const saveEdit = async () => {
savingEdit.value = true
try {
const rec = editingRecord.value
const isChoice = rec.type === 'single_choice' || rec.type === 'multiple_choice'
const correctOpts = editForm.options.filter(o => o.isCorrect).map(o => o.key)
const answerPayload = editingRecord.value.type === 'single_choice' || editingRecord.value.type === 'multiple_choice'
? correctOpts.join(',')
: editForm.answer
const updateData = {
question_type: editingRecord.value.type,
difficulty: editForm.difficulty,
content: { stem: editForm.stem },
options: editForm.options.map(o => ({ key: o.key, text: o.text })),
answer: answerPayload
// 答案:所有题型统一转为字符串(与 POST /api/question 创建接口格式一致)
// 后端读取返回数组,但写入时要求字符串
let answerStr
if (isChoice) {
answerStr = correctOpts.join(',')
} else if (rec.type === 'fill_blank') {
// 填空题:用逗号分隔多个答案
answerStr = splitBlankAnswers(editForm.answer).join(',')
} else {
answerStr = editForm.answer || ''
}
console.log('[保存] 更新题目 - id:', editingRecord.value.id, 'type:', editingRecord.value.type)
await questionAPI.updateQuestion(editingRecord.value.id, updateData)
// 严格匹配创建接口的数据结构(参考完整curl测试手册 13.2 创建题目)
const contentObj = {
stem: editForm.stem,
data: {},
answer: answerStr
}
if (isChoice && editForm.options.length > 0) {
contentObj.data.options = editForm.options.map(o => ({ key: o.key, content: o.text }))
} else if (rec.type === 'true_false') {
contentObj.data.options = [
{ key: 'T', content: '正确' },
{ key: 'F', content: '错误' }
]
} else if (rec.type === 'fill_blank') {
const blanks = splitBlankAnswers(editForm.answer)
if (blanks.length > 0) contentObj.data.blank_count = blanks.length
}
const updateData = {
question_type: rec.type,
difficulty: editForm.difficulty,
content: contentObj
}
// 透传创建接口中的额外字段
if (rec.score != null) updateData.score = rec.score
if (rec.document_name || rec.documentName) updateData.document_name = rec.document_name || rec.documentName
console.log('[保存] 更新题目 - id:', rec.id, 'type:', rec.type)
console.log('[保存] 发送 payload:', JSON.stringify(updateData, null, 2))
await questionAPI.updateQuestion(rec.id, updateData)
editingRecord.value.stem = editForm.stem
editingRecord.value.difficulty = editForm.difficulty
editingRecord.value.answer = answerPayload
editingRecord.value.answer = answerStr
if (editForm.options.length > 0) {
editingRecord.value.options = [...editForm.options]
}
const idx = questions.value.findIndex(q => q.id === editingRecord.value.id)
if (idx !== -1) {
questions.value[idx] = { ...questions.value[idx], stem: editForm.stem, difficulty: editForm.difficulty, answer: answerPayload }
questions.value[idx] = { ...questions.value[idx], stem: editForm.stem, difficulty: editForm.difficulty, answer: answerStr }
if (editForm.options.length > 0) questions.value[idx].options = [...editForm.options]
}
message.success('题目已保存')
editModalVisible.value = false
} catch (error) {
console.error('[保存] 更新题目失败:', error)
message.error('保存失败: ' + (error.message || '未知错误'))
const serverError = error.response?.data
console.error('[保存] 更新题目失败:', {
status: error.response?.status,
statusText: error.response?.statusText,
serverData: JSON.stringify(serverError, null, 2),
message: error.message
})
const errMsg = serverError?.message || serverError?.error || error.message || '未知错误'
message.error('保存失败: ' + errMsg)
} finally {
savingEdit.value = false
}
@@ -777,21 +819,9 @@ const rejectQuestion = async (record) => {
const onPageChange = async (page, pageSize) => {
console.log(`[分页] @change 触发: page=${page}, pageSize=${pageSize}`)
console.log(`[分页] 变更前 pagination.current=${pagination.current}, pageSize=${pagination.pageSize}`)
const oldPage = pagination.current
pagination.current = page
if (pageSize) pagination.pageSize = pageSize
console.log(`[分页] 变更后 pagination.current=${pagination.current}, pageSize=${pagination.pageSize}`)
console.log(`[分页] 准备调用 loadQuestions...`)
try {
await loadQuestions()
console.log(`[分页] loadQuestions 完成, questions.length=${questions.value?.length || 0}`)
} catch (err) {
console.error('[分页] loadQuestions 异常:', err)
}
// 搜索模式走前端本地分页,正常模式走后端分页
await changeLocalPage(page, pageSize)
console.log(`[分页] 变更后 current=${pagination.current}, pageSize=${pagination.pageSize}, questions.length=${questions.value?.length || 0}`)
}
</script>

View File

@@ -58,7 +58,16 @@
</span>
<span class="task-exam-status" v-if="task.examStatus && task.examStatus !== task.status">{{ task.examStatus }}</span>
</div>
<div class="task-message"> {{ task.processMessage }} </div>
<div class="task-message">
<!-- 生成中:展示步骤状态 processStepMessage -->
<template v-if="task.status === 'generating' || task.status === 'pending'">
{{ task.processStepMessage || task.processMessage }}
</template>
<!-- 已完成/失败:展示生成结果 processMessage(支持多行) -->
<template v-else>
<pre class="task-result-text">{{ task.processMessage }}</pre>
</template>
</div>
<div class="task-bar-indicator" v-if="task.status === 'generating' || task.status === 'pending'">
<div class="bar-track"><div class="bar-fill" :class="{ pulse: task.status === 'generating' }"></div></div>
</div>
@@ -224,9 +233,18 @@ const clearAll = () => { emit('clear-all') }
font-size: 11.5px;
color: #6C757D;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-result-text {
font-family: inherit;
font-size: 11.5px;
color: #495057;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 120px;
overflow-y: auto;
}
.task-bar-indicator { margin-top: 6px; }

View File

@@ -6,7 +6,7 @@
<span class="uti-size">{{ formatFileSize(task.fileSize) }}</span>
</div>
<!-- 双状态条:单行紧凑排列 -->
<!-- 向量化状态 -->
<div class="uti-statuses">
<span :class="['uti-tag', vectorStatus.color]">
<LoadingOutlined v-if="vectorStatus.animating" class="spin" />
@@ -16,17 +16,8 @@
{{ vectorStatus.label }}
</span>
<span :class="['uti-tag', examStatus.color]">
<LoadingOutlined v-if="examStatus.animating" class="spin" />
<CheckCircleOutlined v-else-if="examStatus.status === 'success'" />
<CloseCircleOutlined v-else-if="examStatus.status === 'error'" />
<MinusCircleOutlined v-else-if="examStatus.status === 'pending'" />
<QuestionCircleOutlined v-else />
{{ examStatus.label }}
</span>
<button
v-if="(vectorStatus.status === 'error' && vectorStatus.message) || (examStatus.status === 'error' && examStatus.message)"
v-if="vectorStatus.status === 'error' && vectorStatus.message"
class="uti-detail"
@click="toggleError"
>详情</button>
@@ -44,8 +35,8 @@
</div>
<!-- 错误消息 -->
<div v-if="showError && (vectorStatus.message || examStatus.message)" class="uti-error">
{{ vectorStatus.message || examStatus.message }}
<div v-if="showError && vectorStatus.message" class="uti-error">
{{ vectorStatus.message }}
</div>
</div>
</template>
@@ -54,14 +45,12 @@
import { ref } from 'vue'
import {
FileTextOutlined, LoadingOutlined, CheckCircleOutlined,
CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined,
QuestionCircleOutlined, ReloadOutlined
CloseCircleOutlined, ClockCircleOutlined, ReloadOutlined
} from '@ant-design/icons-vue'
defineProps({
task: { type: Object, required: true },
vectorStatus: { type: Object, required: true },
examStatus: { type: Object, required: true }
vectorStatus: { type: Object, required: true }
})
const emit = defineEmits(['retry'])

View File

@@ -36,7 +36,7 @@
<template v-else>
<UploadTaskItem
v-for="t in paginatedTasks" :key="t.id"
:task="t" :vector-status="parseVectorStatus(t)" :exam-status="parseExamStatus(t)"
:task="t" :vector-status="parseVectorStatus(t)"
/>
<div v-if="totalFilteredPages > 1" class="utp-page">
@@ -62,8 +62,7 @@ defineProps({
totalFilteredPages: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
polling: { type: Boolean, default: false },
parseVectorStatus: { type: Function, required: true },
parseExamStatus: { type: Function, required: true }
parseVectorStatus: { type: Function, required: true }
})
defineEmits(['refresh', 'update:statusFilter', 'update:searchQuery', 'update:currentPage'])

View File

@@ -44,10 +44,10 @@ const _isFinished = (record) => {
const _getBlacklistKey = () => 'exam_finished_record_blacklist'
const _loadBlacklist = () => {
try { return new Set(JSON.parse(localStorage.getItem(_getBlacklistKey()) || '[]')) } catch { return new Set() }
try { return new Set(JSON.parse(sessionStorage.getItem(_getBlacklistKey()) || '[]')) } catch { return new Set() }
}
const _saveBlacklist = (set) => {
try { localStorage.setItem(_getBlacklistKey(), JSON.stringify([...set])) } catch {}
try { sessionStorage.setItem(_getBlacklistKey(), JSON.stringify([...set])) } catch {}
}
const _addToBlacklist = (recordId, realStatus, paperTitle, score = null) => {
const bl = _loadBlacklist()
@@ -65,13 +65,7 @@ const _isFinished = (record) => {
return false
}
const _cleanOldBlacklist = () => {
const bl = _loadBlacklist()
const now = Date.now()
const maxAge = 7 * 24 * 60 * 60 * 1000
const cleaned = [...bl].filter(e => {
try { return now - JSON.parse(e).ts < maxAge } catch { return false }
})
_saveBlacklist(new Set(cleaned))
// sessionStorage 在标签页关闭后自动清除,无需额外清理
}
const _mapToCompletedResult = (r) => ({
@@ -141,20 +135,46 @@ const _isFinished = (record) => {
}
data = data.filter(r => !_isFinished(r))
const blacklistedInMain = data.filter(r => _isBlacklisted(r.id || r.paperId || r.recordId || r.record_id))
// 黑名单过滤:仅当 API 状态也确认已结束时才过滤,避免跨浏览器/跨会话不一致
const blacklistedInMain = data.filter(r => {
if (!_isBlacklisted(r.id || r.paperId || r.recordId || r.record_id)) return false
// 双重确认:API 状态也必须指示已结束
if (_isFinished(r)) return true
const fullText = JSON.stringify(r).toLowerCase()
if (/\b(graded|completed|submitted)\b/.test(fullText)) return true
console.warn(`[待考试] ⚠️ 记录 ${r.id || r.paperId} 在黑名单但API状态为 "${_getStatus(r)}",跳过黑名单过滤`)
return false
})
if (blacklistedInMain.length > 0) {
console.warn(`[待考试] 🚫 主API黑名单过滤: 排除 ${blacklistedInMain.length} 条已提交/已结束记录`)
console.warn(`[待考试] 🚫 黑名单过滤: 排除 ${blacklistedInMain.length} 条已结束记录`)
leakedFinished.push(...blacklistedInMain)
}
data = data.filter(r => !_isBlacklisted(r.id || r.paperId || r.recordId || r.record_id))
data = data.filter(r => {
if (!_isBlacklisted(r.id || r.paperId || r.recordId || r.record_id)) return true
if (_isFinished(r)) return false
const fullText = JSON.stringify(r).toLowerCase()
if (/\b(graded|completed|submitted)\b/.test(fullText)) return false
return true // 黑名单但API状态不匹配 → 不过滤
})
if (data.length > 0) {
console.log('[待考试] 主API命中, 首条keys:', Object.keys(data[0]))
// 诊断:检查题数字段
const first = data[0]
console.log('[待考试] 🔍 题数字段诊断:', {
questionCount: first.questionCount,
question_count: first.question_count,
questionNum: first.questionNum,
questions: first.questions?.length,
questionIds: first.questionIds?.length,
question_ids: first.question_ids?.length,
fieldList: Object.keys(first).filter(k => k.toLowerCase().includes('question') || k.toLowerCase().includes('count') || k.toLowerCase().includes('num'))
})
pendingPapers.value = data.map(p => ({
id: p.id || p.paperId || p.paper_id || p.recordId || p.record_id,
paperId: p.paperId || p.paper_id || p.id,
title: p.title || p.paperTitle || p.paper_title || p.name || p.paperName || '未命名试卷',
questionCount: p.questionCount || p.question_count || p.questionNum || p.questions?.length || 0,
questionCount: p.questionCount || p.question_count || p.questionNum || p.questions?.length || p.questionIds?.length || p.question_ids?.length || 0,
totalScore: p.totalScore || p.total_score || p.score || 0,
duration: p.duration || p.timeLimit || p.time_limit || 60,
deadline: p.deadline || p.deadline_time || p.endTime || p.end_time || '',
@@ -188,7 +208,7 @@ const _isFinished = (record) => {
id: p.id || p.paperId || p.paper_id || p.recordId || p.record_id,
paperId: p.paperId || p.paper_id || p.id,
title: p.title || p.paperTitle || p.paper_title || p.name || p.paperName || '未命名试卷',
questionCount: p.questionCount || p.question_count || p.questionNum || p.questions?.length || 0,
questionCount: p.questionCount || p.question_count || p.questionNum || p.questions?.length || p.questionIds?.length || p.question_ids?.length || 0,
totalScore: p.totalScore || p.total_score || p.score || 0,
duration: p.duration || p.timeLimit || p.time_limit || 60,
deadline: p.deadline || p.deadline_time || p.endTime || p.end_time || '',
@@ -223,10 +243,22 @@ const _isFinished = (record) => {
pendingRecords = pendingRecords.filter(r => !_isFinished(r))
}
const blacklistedRecords = pendingRecords.filter(r => _isBlacklisted(r.id || r.recordId))
const blacklistedRecords = pendingRecords.filter(r => {
if (!_isBlacklisted(r.id || r.recordId)) return false
if (_isFinished(r)) return true
const fullText = JSON.stringify(r).toLowerCase()
if (/\b(graded|completed|submitted)\b/.test(fullText)) return true
return false
})
if (blacklistedRecords.length > 0) {
console.warn(`[待考试] 🚫 黑名单过滤: 排除 ${blacklistedRecords.length} 条已知已结束记录(后端状态与列表不一致)`)
pendingRecords = pendingRecords.filter(r => !_isBlacklisted(r.id || r.recordId))
console.warn(`[待考试] 🚫 黑名单过滤: 排除 ${blacklistedRecords.length} 条已结束记录(后端状态与列表不一致)`)
pendingRecords = pendingRecords.filter(r => {
if (!_isBlacklisted(r.id || r.recordId)) return true
if (_isFinished(r)) return false
const fullText = JSON.stringify(r).toLowerCase()
if (/\b(graded|completed|submitted)\b/.test(fullText)) return false
return true
})
}
if (pendingRecords.length > 0) {
@@ -518,17 +550,23 @@ const _isFinished = (record) => {
if (isSelfTest.value) {
const questions = currentTakingPaper.value.questions || []
try {
const formattedAnswers = questions.map(q => ({
questionId: q.questionId || q.id,
questionType: q.type,
questionContent: {
stem: q.stem,
options: q.options,
answer: q.answer
},
studentAnswer: q.studentAnswer != null ? String(q.studentAnswer) : '',
maxScore: q.maxScore || q.score || 5
}))
const formattedAnswers = questions.map(q => {
const optionsData = (q.options || []).map(opt => ({
key: opt.key || opt.label || '',
content: opt.content || opt.text || ''
}))
return {
questionId: q.questionId || q.id,
questionType: q.type,
questionContent: {
stem: q.stem || '',
data: { options: optionsData },
answer: q.answer != null ? q.answer : ''
},
studentAnswer: q.studentAnswer != null ? String(q.studentAnswer) : '',
maxScore: q.maxScore || q.score || 5
}
})
const result = await examAPI.gradeAnswers({
answers: formattedAnswers,
@@ -543,8 +581,8 @@ const _isFinished = (record) => {
paperId: null,
paperTitle: currentTakingPaper.value.title || '自测卷',
totalScore: result.totalScore ?? result.total_score ?? 0,
totalMaxScore: result.totalMaxScore ?? result.total_max_score ?? 100,
scoreRate: result.scoreRate ?? result.score_rate ?? 0,
totalMaxScore: result.totalMaxScore ?? result.total_max_score ?? null,
scoreRate: result.scoreRate ?? result.score_rate ?? null,
submitTime: new Date().toLocaleString(),
questions: questions.map((q, idx) => {
const r = resultQuestions[idx] || {}
@@ -555,7 +593,7 @@ const _isFinished = (record) => {
studentAnswer: q.studentAnswer ?? '',
correctAnswer: q.answer || '',
score: r.score ?? r.studentScore ?? 0,
maxScore: r.maxScore ?? r.totalScore ?? q.maxScore ?? q.score ?? 5,
maxScore: r.maxScore ?? r.totalScore ?? q.maxScore ?? q.score ?? null,
isCorrect: r.isCorrect ?? r.is_correct ?? r.correct ?? null,
feedback: r.feedback || ''
}
@@ -578,9 +616,12 @@ const _isFinished = (record) => {
const questions = currentTakingPaper.value.questions || []
const paperId = currentTakingPaper.value.paperId || ''
// 按 4.9 规范构建完整答案 payload:包含 studentAnswer / content / maxScore
const formattedAnswers = questions.map(q => {
const raw = q.studentAnswer
const qType = q.type
const qId = q.questionId || q.id
let answer
if (qType === 'true_false') {
@@ -610,16 +651,47 @@ const _isFinished = (record) => {
}
}
// 构建题目内容对象(后端批改需要)
const optionsData = (q.options || []).map(opt => ({
key: opt.key || opt.label || '',
content: opt.content || opt.text || ''
}))
const questionContent = {
stem: q.stem || '',
data: { options: optionsData },
answer: q.answer || ''
}
return {
questionId: q.questionId || q.id,
questionId: qId,
questionType: qType,
answer
answer,
studentAnswer: raw, // 原始值,确保后端能存储用户作答
maxScore: q.maxScore || q.score || 5,
questionContent: questionContent
}
}).filter(a => a.questionId && a.questionType)
// 诊断:输出每题原始 studentAnswer 值,确认用户是否真的作了答
console.log('[提交] 📋 每题作答状态:')
questions.forEach((q, idx) => {
const raw = q.studentAnswer
const qid = q.questionId || q.id
const hasAnswer = raw != null && raw !== '' &&
!(Array.isArray(raw) && raw.length === 0)
console.log(` [${idx + 1}] ID=${(qid || '').substring(0, 8)} type=${q.type} studentAnswer=${JSON.stringify(raw)} ${hasAnswer ? '✅已答' : '❌未答'}`)
})
console.log('[提交] 当前考试数据 keys:', Object.keys(currentTakingPaper.value).join(','))
console.log('[提交] 发送 recordId:', recordId, 'paperId:', paperId, '答案数:', formattedAnswers.length, '首项:', JSON.stringify(formattedAnswers[0])?.substring(0, 80))
console.log('[提交] 完整答案列表:', JSON.stringify(formattedAnswers.map(a => ({ questionId: a.questionId, questionType: a.questionType, answer: a.answer }))))
console.log('[提交] 发送 recordId:', recordId, 'paperId:', paperId, '答案数:', formattedAnswers.length)
console.log('[提交] 首项完整结构:', JSON.stringify(formattedAnswers[0])?.substring(0, 200))
console.log('[提交] 完整答案列表:', JSON.stringify(formattedAnswers.map(a => ({
questionId: a.questionId,
questionType: a.questionType,
answer: a.answer,
studentAnswer: a.studentAnswer,
hasContent: !!a.questionContent?.stem
}))))
const isBlankSubmission = formattedAnswers.length === 0 || formattedAnswers.every(a => {
const ans = a.answer
@@ -667,15 +739,22 @@ const _isFinished = (record) => {
}
try {
console.log('[提交] 调用 /exam/record/{recordId}/grade (14.2 接口) ...')
const response = await examAPI.submitFormalExam({ recordId, answers: formattedAnswers })
// 使用 4.9 规范:通用接口 + answerType=formal_exam + recordId(完整格式,含 studentAnswer/content/maxScore)
console.log('[提交] 调用 /exam/grade (4.9 通用接口, formal_exam) ...')
const response = await examAPI.gradeAnswers({
type: 'formal_exam',
requestId: `formal_${recordId}`,
recordId,
paperId,
answers: formattedAnswers
})
console.log('[提交] ✅ 提交成功, 响应:', JSON.stringify(response)?.substring(0, 500))
message.success('提交成功')
const respTitle = currentTakingPaper.value?.title || '已提交试卷'
const respPaperId = currentTakingPaper.value?.paperId || paperId
const respScore = response?.totalScore != null ? Number(response.totalScore) : (response?.score != null ? Number(response.score) : null)
const respMaxScore = response?.totalMaxScore != null ? Number(response.totalMaxScore) : (response?.maxScore != null ? Number(response.maxScore) : 100)
const respMaxScore = response?.totalMaxScore != null ? Number(response.totalMaxScore) : (response?.maxScore != null ? Number(response.maxScore) : null)
const respScoreRate = response?.scoreRate ?? response?.score_rate ?? null
const respTimeSpent = response?.timeSpent ?? response?.time_spent ?? null
const respSubmitTime = response?.submitTime ?? response?.submittedAt ?? response?.completedAt ?? new Date().toISOString()
@@ -700,7 +779,7 @@ const _isFinished = (record) => {
} catch (err) {
const submittedRecordId = recordId
const submittedTitle = currentTakingPaper.value?.title || ''
_logFullError('submitFormalExam(14.2)', err)
_logFullError('gradeAnswers(4.9 formal_exam)', err)
const origErr = err?.originalError || err
const response = origErr?.response || err?.response
const statusText = response ? ` (HTTP ${response.status})` : ''
@@ -753,23 +832,31 @@ const _isFinished = (record) => {
if (!s) return true
return !_EXCLUDED_STATUSES.includes(s)
})
.map(r => ({
...r,
id: r.recordId || r.id,
recordId: r.recordId || r.id,
paperId: r.paperId || r.paper_id,
answerType: r.answerType || r.answer_type || 'formal_exam',
paperTitle: r.paperTitle || r.paperName || '试卷',
score: r.totalScore != null ? Number(r.totalScore) : (r.score != null ? Number(r.score) : 0),
totalMaxScore: r.totalMaxScore || r.total_max_score || 100,
accuracy: r.scoreRate != null
? Math.round(Number(r.scoreRate) * 100)
: (r.totalScore && (r.totalMaxScore || r.total_max_score)
? Math.round((Number(r.totalScore) / Number(r.totalMaxScore || r.total_max_score || 100)) * 100)
: 0),
timeUsed: r.timeSpent != null ? Math.floor(Number(r.timeSpent) / 60) : (r.duration || 0),
completedAt: r.submitTime || r.completedAt || r.submittedAt || r.updatedAt || ''
}))
.map(r => {
// 后端 scoreRate 已是百分比(如85表示85%),直接取整即可;fallback 用 totalScore/totalMaxScore*100
let acc = 0
if (r.scoreRate != null && Number(r.scoreRate) > 0) {
acc = Math.round(Number(r.scoreRate))
} else if (r.totalScore != null && Number(r.totalScore) > 0) {
const max = Number(r.totalMaxScore || r.total_max_score || 100)
acc = max > 0 ? Math.round((Number(r.totalScore) / max) * 100) : 0
}
console.log('[成绩单] 映射:', { id: r.recordId, scoreRate: r.scoreRate, totalScore: r.totalScore, totalMaxScore: r.totalMaxScore, computedAccuracy: acc })
return {
...r,
id: r.recordId || r.id,
recordId: r.recordId || r.id,
paperId: r.paperId || r.paper_id,
answerType: r.answerType || r.answer_type || 'formal_exam',
paperTitle: r.paperTitle || r.paperName || '试卷',
score: r.totalScore != null ? Number(r.totalScore) : (r.score != null ? Number(r.score) : 0),
totalMaxScore: r.totalMaxScore || r.total_max_score || 100,
accuracy: acc,
timeUsed: r.timeSpent != null ? Math.floor(Number(r.timeSpent) / 60) : (r.duration || 0),
completedAt: r.submitTime || r.completedAt || r.submittedAt || r.updatedAt || ''
}
})
} catch (error) {
console.warn('加载成绩单失败:', error.message || error)
@@ -805,7 +892,7 @@ const _isFinished = (record) => {
answerType: 'formal_exam',
paperTitle: blTitle || '已提交试卷',
score: blScore != null ? blScore : 0,
totalMaxScore: 100,
totalMaxScore: null,
accuracy: blScore != null ? Math.round((blScore / 100) * 100) : 0,
timeUsed: 0,
completedAt: new Date(blTs).toLocaleString(),
@@ -901,16 +988,30 @@ const _isFinished = (record) => {
}
}
// ===== 第一优先:获取考试记录详情(包含完整答案数据)=====
const adminRecordData = await fetchWithLog('getAdminExamRecordDetail(主源)', () => examAPI.getAdminExamRecordDetail(recordId))
// ===== 第二优先:获取正式考试成绩(含每题得分和答案)=====
scoreData = await fetchWithLog('getFormalExamResult', () => examAPI.getFormalExamResult(recordId))
if (!scoreData) {
scoreData = await fetchWithLog('getExamResult', () => examAPI.getExamResult(recordId))
}
if (paperId) {
userAnswerData = await fetchWithLog('queryUserAnswers', () => examAPI.queryUserAnswers({ paper_id: paperId }))
if (!userAnswerData) {
userAnswerData = await fetchWithLog('getAdminExamRecordDetail', () => examAPI.getAdminExamRecordDetail(recordId))
// ===== 第三优先:按记录ID查询用户答题数据(而非按试卷ID)=====
if (!adminRecordData) {
userAnswerData = await fetchWithLog('queryUserAnswers(record_id)', () => examAPI.queryUserAnswers({ record_id: recordId }))
// 回退:按试卷ID查询
if (!userAnswerData && paperId) {
userAnswerData = await fetchWithLog('queryUserAnswers(paper_id)', () => examAPI.queryUserAnswers({ paper_id: paperId }))
}
} else {
// adminRecordData 本身就是完整的用户答题数据,直接使用
userAnswerData = adminRecordData
console.log('[考试详情] 使用 adminRecordData 作为 userAnswerData')
}
// ===== 获取试卷题目列表 =====
if (paperId) {
paperQuestions = await fetchWithLog('getPaperDetail', () => examAPI.getPaperDetail(paperId))
if (!paperQuestions && !paperQuestions?.questions) {
console.log('[考试详情] getPaperDetail 失败, 回退到 getAdminPaperDetail ...')
@@ -919,15 +1020,31 @@ const _isFinished = (record) => {
}
console.log('[考试详情] 数据收集完成:', {
hasAdminRecord: !!adminRecordData,
hasScoreData: !!scoreData,
hasUserAnswerData: !!userAnswerData,
hasPaperQuestions: !!paperQuestions,
adminRecordKeys: adminRecordData ? Object.keys(adminRecordData) : [],
scoreKeys: scoreData ? Object.keys(scoreData) : [],
userAnswerKeys: userAnswerData ? Object.keys(userAnswerData) : [],
paperQuestionKeys: paperQuestions ? Object.keys(paperQuestions) : []
})
// 详细诊断:输出 adminRecordData 的完整结构
if (adminRecordData) {
console.log('[考试详情] 🔍 adminRecordData 完整结构:', JSON.stringify(adminRecordData, null, 2)?.substring(0, 2000))
// 检查 questions 字段
if (adminRecordData.questions && Array.isArray(adminRecordData.questions)) {
console.log('[考试详情] 🔍 adminRecordData.questions 数量:', adminRecordData.questions.length)
if (adminRecordData.questions[0]) {
console.log('[考试详情] 🔍 adminRecordData.questions[0] 所有key:', Object.keys(adminRecordData.questions[0]))
console.log('[考试详情] 🔍 adminRecordData.questions[0] 结构:', JSON.stringify(adminRecordData.questions[0], null, 2)?.substring(0, 800))
}
}
}
const rawAnswers = []
// 从 scoreData 提取答案
if (scoreData?.answers) {
const sAnswers = Array.isArray(scoreData.answers) ? scoreData.answers : []
rawAnswers.push(...sAnswers)
@@ -936,6 +1053,8 @@ const _isFinished = (record) => {
console.log('[考试详情] scoreData.answers[0] 所有key:', Object.keys(sAnswers[0]))
}
}
// 从 userAnswerData(含adminRecordData)提取答案
if (userAnswerData?.answers) {
const uAnswers = Array.isArray(userAnswerData.answers) ? userAnswerData.answers : []
rawAnswers.push(...uAnswers)
@@ -943,6 +1062,63 @@ const _isFinished = (record) => {
console.log('[考试详情] userAnswerData.answers[0] 完整结构:', JSON.stringify(uAnswers[0], null, 2)?.substring(0, 500))
}
}
// 从 userAnswerData.questions 提取每题的答案信息(adminRecordData 可能以 questions 形式返回更丰富的数据)
if (userAnswerData?.questions && Array.isArray(userAnswerData.questions)) {
const uq = Array.isArray(userAnswerData.questions) ? userAnswerData.questions : []
console.log('[考试详情] userAnswerData.questions 数量:', uq.length)
if (uq.length > 0) {
console.log('[考试详情] userAnswerData.questions[0] 所有key:', Object.keys(uq[0]))
// 将每个 question 转换为 answer 格式,补充到 rawAnswers
uq.forEach(q => {
const qid = q.questionId || q.question_id || q.id
if (!qid) return
// 检查该 question 是否已有答案数据(避免重复)
const existingIdx = rawAnswers.findIndex(a =>
(a.questionId || a.question_id || a.id) === qid
)
const answerObj = {
questionId: qid,
// 学生答案字段(多种可能名称)
userAnswer: q.userAnswer ?? q.studentAnswer ?? q.student_answer ?? q.submittedAnswer ?? null,
// 正确答案字段
correctAnswer: q.correctAnswer ?? q.correct_answer ?? q.answer ?? q.standardAnswer ?? null,
// 分数字段
score: q.score ?? q.gotScore ?? q.got_score ?? null,
maxScore: q.maxScore ?? q.max_score ?? q.fullScore ?? null,
isCorrect: q.isCorrect ?? q.is_correct ?? null,
feedback: q.feedback ?? '',
// 题目内容
content: q.content || {},
stem: q.stem || '',
options: q.options || [],
questionType: q.questionType || q.question_type || q.type || ''
}
if (existingIdx >= 0) {
// 合并:用 question 的丰富数据补充已有的 sparse answer
const existing = rawAnswers[existingIdx]
Object.keys(answerObj).forEach(k => {
if (answerObj[k] != null && (existing[k] == null || existing[k] === '')) {
existing[k] = answerObj[k]
}
})
} else {
rawAnswers.push(answerObj)
}
})
console.log('[考试详情] 从 userAnswerData.questions 补充后 rawAnswers 总数:', rawAnswers.length)
}
}
// 额外:从 adminRecordData 的其他可能字段提取
if (adminRecordData && !rawAnswers.length) {
for (const key of ['answerList', 'question_answers', 'resultAnswers', 'gradedAnswers', 'submittedAnswers']) {
if (Array.isArray(adminRecordData[key])) {
rawAnswers.push(...adminRecordData[key])
console.log(`[考试详情] 从 adminRecordData.${key} 提取 ${adminRecordData[key].length} 条`)
break
}
}
}
const answersMap = {}
rawAnswers.forEach(a => {
@@ -971,15 +1147,23 @@ const _isFinished = (record) => {
aContent = { ...aContent, data: { ...(aContent.data || {}), options: extractedOptions } }
}
const extractedCorrect = a.correctAnswer || a.correct_answer || a.rightAnswer || a.standard_answer || aContent?.answer || ''
let rawStudent = a.userAnswer ?? a.user_answer ?? a.studentAnswer ?? a.student_answer ?? a.myAnswer ?? null
const extractedStudent = (rawStudent === null || rawStudent === undefined || String(rawStudent).trim().toLowerCase() === 'null') ? '' : String(rawStudent)
// Python None 序列化为字符串 "None" 的处理
const _stripNone = (v) => {
if (v === null || v === undefined) return ''
const s = String(v).trim()
if (!s || s.toLowerCase() === 'none' || s.toLowerCase() === 'null') return ''
return s
}
const extractedCorrect = _stripNone(a.correctAnswer) || _stripNone(a.correct_answer) || _stripNone(a.rightAnswer) || _stripNone(a.standard_answer) || _stripNone(aContent?.answer) || ''
let rawStudent = a.userAnswer ?? a.user_answer ?? a.studentAnswer ?? a.student_answer ?? a.myAnswer ?? a.submittedAnswer ?? null
const extractedStudent = _stripNone(rawStudent)
const rawFeedbackText = a.feedback || a.comment || a.remark || ''
let correctFromFb = ''
if (rawFeedbackText && !extractedCorrect) {
const m = rawFeedbackText.match(/(?:正确答案|答案|标准答案)[::]\s*(.+)/)
if (m) correctFromFb = m[1].trim()
if (m) correctFromFb = _stripNone(m[1].trim())
}
Object.assign(answersMap[qid], {
@@ -988,7 +1172,7 @@ const _isFinished = (record) => {
studentAnswer: extractedStudent,
correctAnswer: extractedCorrect || correctFromFb,
score: a.score ?? a.studentScore ?? a.got_score ?? answersMap[qid].score ?? 0,
maxScore: a.maxScore ?? a.max_score ?? a.totalScore ?? a.totalScore ?? a.full_score ?? answersMap[qid].maxScore ?? 5,
maxScore: a.maxScore ?? a.max_score ?? a.totalScore ?? a.full_score ?? answersMap[qid].maxScore ?? null,
isCorrect: a.isCorrect ?? a.is_correct ?? a.correct ?? a.isRight ?? answersMap[qid].isCorrect ?? null,
feedback: rawFeedbackText,
content: aContent,
@@ -996,6 +1180,54 @@ const _isFinished = (record) => {
})
})
// 从数据库查询用户提交的答案(替代本地缓存)
console.log('[考试详情] 🔍 调用 queryUserAnswers 查询数据库中的用户答案, recordId:', recordId)
try {
const userAnswerRes = await examAPI.queryUserAnswers({
record_id: recordId,
answer_type: 'formal_exam'
})
console.log('[考试详情] 🔍 queryUserAnswers 返回:', JSON.stringify(userAnswerRes)?.substring(0, 500))
// 解析返回的用户答案数据
let dbAnswers = []
if (Array.isArray(userAnswerRes)) {
dbAnswers = userAnswerRes
} else if (userAnswerRes?.answers && Array.isArray(userAnswerRes.answers)) {
dbAnswers = userAnswerRes.answers
} else if (userAnswerRes?.data) {
dbAnswers = Array.isArray(userAnswerRes.data) ? userAnswerRes.data : (userAnswerRes.data?.answers || [])
}
if (dbAnswers.length > 0) {
let restoredCount = 0
dbAnswers.forEach(dbAns => {
const qid = dbAns.questionId || dbAns.question_id
if (!qid || !answersMap[qid]) return
// 从多个可能的字段名提取用户答案
let rawStudent = dbAns.student_answer ?? dbAns.studentAnswer ?? dbAns.user_answer ?? dbAns.answer ?? null
let restored = ''
if (typeof rawStudent === 'boolean') {
restored = rawStudent ? 'true' : 'false'
} else if (Array.isArray(rawStudent)) {
restored = rawStudent.length > 0 ? rawStudent : ''
} else if (rawStudent != null && rawStudent !== undefined && String(rawStudent).trim()) {
restored = String(rawStudent).trim()
}
if (restored) {
answersMap[qid].studentAnswer = restored
restoredCount++
}
})
console.log(`[考试详情] ✅ 从数据库恢复 ${restoredCount} 题的用户答案`)
} else {
console.log('[考试详情] ⚠️ queryUserAnswers 返回空数据,无用户答案记录')
}
} catch (queryErr) {
console.warn('[考试详情] ⚠️ queryUserAnswers 查询失败:', queryErr.message)
}
console.log('[考试详情] answersMap构建完成, 条数:', Object.keys(answersMap).length)
if (Object.keys(answersMap).length > 0) {
const firstKey = Object.keys(answersMap)[0]
@@ -1009,6 +1241,38 @@ const _isFinished = (record) => {
})
}
// 从 paperQuestions 的题目中提取正确答案(试卷详情可能包含每题的答案)
if (paperQuestions?.questions && Array.isArray(paperQuestions.questions)) {
console.log('[考试详情] 🔍 检查 paperQuestions.questions 是否含答案数据...')
paperQuestions.questions.forEach((pq, idx) => {
const qid = pq.questionId || pq.question_id || pq.id
if (!qid) return
// 从题目 content 中提取 answer
const pqContent = pq.content || {}
const contentAnswer = pqContent?.answer
|| pqContent?.data?.answer
|| (typeof pqContent === 'string' ? (() => { try { return JSON.parse(pqContent)?.answer } catch { return null } })() : null)
// 从题目顶层字段提取
const topAnswer = pq.correctAnswer || pq.correct_answer || pq.answer || pq.standardAnswer
const mergedAnswer = topAnswer || contentAnswer
if (mergedAnswer && qid && answersMap[qid]) {
const existing = answersMap[qid]
if (!existing.correctAnswer || existing.correctAnswer === '' || existing.correctAnswer === 'None') {
console.log(`[考试详情] 🔍 从 paperQuestions.questions[${idx}] 补充 correctAnswer:`, String(mergedAnswer).substring(0, 50))
existing.correctAnswer = String(mergedAnswer)
}
}
})
}
// 最终答案诊断:输出每个题目的最终答案状态
console.log('[考试详情] ✅ 答案提取最终状态:')
Object.keys(answersMap).forEach(qid => {
const a = answersMap[qid]
console.log(` [${qid.substring(0, 8)}...] studentAnswer="${a.studentAnswer}" correctAnswer="${a.correctAnswer}" score=${a.score} isCorrect=${a.isCorrect}`)
})
const questionList = []
if (paperQuestions?.questions && Array.isArray(paperQuestions.questions)) {
questionList.push(...paperQuestions.questions)
@@ -1044,8 +1308,10 @@ const _isFinished = (record) => {
qOptions = answer.content.options
}
const rawStudent = answer.studentAnswer ?? q.studentAnswer ?? q.userAnswer ?? q.student_answer ?? ''
const rawCorrect = answer.correctAnswer ?? q.correctAnswer ?? q.correct_answer ?? q.answer ?? qContent?.answer ?? q.content?.answer ?? ''
// 提取学生答案:优先从 answersMap,再从 question 本身
// 支持多种字段名变体(snake_case / camelCase)
const rawStudent = answer.studentAnswer ?? q.studentAnswer ?? q.userAnswer ?? q.student_answer ?? q.user_answer ?? q.myAnswer ?? q.submittedAnswer ?? ''
const rawCorrect = answer.correctAnswer ?? q.correctAnswer ?? q.correct_answer ?? q.answer ?? q.standardAnswer ?? q.rightAnswer ?? qContent?.answer ?? q.content?.answer ?? ''
const qType = q.questionType || q.question_type || answer.questionType || q.type || ''
const computedCorrect = computeIsCorrect(rawStudent, rawCorrect, qType)
@@ -1063,7 +1329,7 @@ const _isFinished = (record) => {
studentAnswer: rawStudent,
correctAnswer: rawCorrect,
score: answer.score ?? 0,
maxScore: answer.maxScore ?? q.score ?? answer.totalScore ?? 5,
maxScore: answer.maxScore ?? q.score ?? answer.totalScore ?? null,
isCorrect: finalIsCorrect,
feedback: answer.feedback || q.feedback || ''
}
@@ -1097,7 +1363,7 @@ const _isFinished = (record) => {
studentAnswer: rawStudent,
correctAnswer: rawCorrect,
score: a.score ?? 0,
maxScore: a.maxScore ?? 5,
maxScore: a.maxScore ?? null,
isCorrect: finalIsCorrect,
feedback: a.feedback || ''
}
@@ -1153,11 +1419,27 @@ const _isFinished = (record) => {
if (qid) allQuestionIds.add(qid)
})
console.log('[考试详情] 题目ID汇总 - questions:', questions.length, '条, answersMap:', Object.keys(answersMap).length, '个, 总ID数:', allQuestionIds.size, 'IDs:', [...allQuestionIds])
// 仅对缺少题干/选项的题目进行详情补充请求,避免不必要的 500 错误
const needsEnrichment = (qid) => {
const existQ = questions.find(q => q.questionId === qid)
if (!existQ) return true // 不在列表中的需要获取
if (!existQ.stem && !existQ.content?.stem) return true // 缺少题干
if (existQ.questionType === 'single_choice' || existQ.questionType === 'multiple_choice' || existQ.questionType === 'true_false') {
if (!existQ.options || existQ.options.length === 0) return true // 选择题缺少选项
}
return false
}
const enrichIds = [...allQuestionIds].filter(needsEnrichment)
const skipIds = [...allQuestionIds].filter(qid => !needsEnrichment(qid))
if (skipIds.length > 0) {
console.log(`[考试详情] 跳过 ${skipIds.length} 个已完整题目的详情请求:`, skipIds)
}
if (allQuestionIds.size > 0) {
const idList = [...allQuestionIds]
console.log('[考试详情] 开始获取题目详情,共', allQuestionIds.size, '题, ID列表:', idList)
console.log('[考试详情] 题目ID汇总 - questions:', questions.length, '条, answersMap:', Object.keys(answersMap).length, '个, 需补充:', enrichIds.length, '跳过:', skipIds.length, '总ID数:', allQuestionIds.size)
if (enrichIds.length > 0) {
const idList = enrichIds
console.log('[考试详情] 开始获取题目详情,共', enrichIds.length, '题, ID列表:', idList)
console.log('[考试详情] === 进入题目详情获取分支,questionAPI类型:', typeof questionAPI, 'keys:', Object.keys(questionAPI || {}))
let fullResults = []
@@ -1223,7 +1505,7 @@ const _isFinished = (record) => {
}
})
console.log('[考试详情] 题目详情获取完成,成功:', Object.keys(fullQuestionMap).length, '/', allQuestionIds.size)
console.log('[考试详情] 题目详情获取完成,成功:', Object.keys(fullQuestionMap).length, '/', enrichIds.length)
if (Object.keys(fullQuestionMap).length > 0) {
questions = questions.map(q => {
@@ -1287,32 +1569,36 @@ const _isFinished = (record) => {
const apiTotalMaxScore = merged.totalMaxScore != null ? Number(merged.totalMaxScore) : (merged.total_max_score != null ? Number(merged.total_max_score) : null)
const apiScoreRate = merged.scoreRate != null ? Number(merged.scoreRate) : (merged.score_rate != null ? Number(merged.score_rate) : null)
const computedFromQuestions = questions.reduce((acc, q) => {
acc.sumScore += (q.score != null ? Number(q.score) : 0)
acc.sumMaxScore += (q.maxScore != null ? Number(q.maxScore) : 0)
return acc
}, { sumScore: 0, sumMaxScore: 0 })
let finalTotalScore = apiTotalScore
// 严格使用接口返回值计算总分/满分/得分率
let finalTotalScore = apiTotalScore ?? 0
let finalTotalMaxScore = apiTotalMaxScore
let finalScoreRate = apiScoreRate
if (computedFromQuestions.sumMaxScore > 0 && (!apiTotalMaxScore || apiTotalMaxScore <= 0)) {
finalTotalMaxScore = computedFromQuestions.sumMaxScore
// 仅当接口未返回 totalMaxScore 时,才从题目 maxScore 之和回退(并记录警告)
if (finalTotalMaxScore == null) {
const sumMaxScore = questions.reduce((s, q) => s + (q.maxScore != null ? Number(q.maxScore) : 0), 0)
if (sumMaxScore > 0) {
finalTotalMaxScore = sumMaxScore
console.warn('[考试详情] ⚠️ 接口未返回totalMaxScore,使用题目maxScore之和:', sumMaxScore)
} else {
console.warn('[考试详情] ⚠️ 接口和题目均无maxScore数据')
}
}
if (computedFromQuestions.sumScore > 0 && (!apiTotalScore || apiTotalScore <= 0 || Math.abs(apiTotalScore - computedFromQuestions.sumScore) > computedFromQuestions.sumMaxScore * 0.5)) {
finalTotalScore = computedFromQuestions.sumScore
}
if ((!apiScoreRate && apiScoreRate !== 0) && finalTotalMaxScore > 0) {
finalScoreRate = finalTotalScore / finalTotalMaxScore
// scoreRate:优先接口,其次根据 totalScore/totalMaxScore 计算
if ((finalScoreRate == null || finalScoreRate === 0) && finalTotalMaxScore > 0 && finalTotalScore > 0) {
finalScoreRate = Math.round((finalTotalScore / finalTotalMaxScore) * 100) / 100
}
const sumScore = questions.reduce((s, q) => s + (q.score != null ? Number(q.score) : 0), 0)
const sumMaxScore = questions.reduce((s, q) => s + (q.maxScore != null ? Number(q.maxScore) : 0), 0)
console.log('[考试详情] 分数交叉验证:', {
'接口totalScore': apiTotalScore,
'接口totalMaxScore': apiTotalMaxScore,
'接口scoreRate': apiScoreRate,
'题目score之和': computedFromQuestions.sumScore,
'题目maxScore之和': computedFromQuestions.sumMaxScore,
'题目score之和': sumScore,
'题目maxScore之和': sumMaxScore,
'最终totalScore': finalTotalScore,
'最终totalMaxScore': finalTotalMaxScore,
'最终scoreRate': finalScoreRate
@@ -1322,8 +1608,8 @@ const _isFinished = (record) => {
recordId,
paperId: merged.paperId || merged.paper_id || paperId,
paperTitle: merged.paperTitle || merged.paper_title || merged.paperName || '考试详情',
totalScore: finalTotalScore ?? 0,
totalMaxScore: finalTotalMaxScore ?? 100,
totalScore: finalTotalScore,
totalMaxScore: finalTotalMaxScore ?? 0,
scoreRate: finalScoreRate ?? 0,
submitTime: merged.submitTime || merged.submit_time || merged.completedAt || '',
questions

View File

@@ -28,13 +28,22 @@ export function useGenerateState() {
subjective: 0
})
// 题目总数:与出题任务 resultCount 统一,默认 null 表示"暂未生成"
const apiTotalCount = ref(null)
const totalQuestionCount = computed(() => {
return Object.values(questionTypeCounts).reduce((sum, count) => sum + count, 0)
// 优先使用实际加载的题目列表长度(最准确),其次用任务resultCount
if (generatedQuestions.value.length > 0) return generatedQuestions.value.length
if (apiTotalCount.value !== null && apiTotalCount.value > 0) return apiTotalCount.value
return '-'
})
// 重置为未生成状态(开始新任务时调用)
const resetApiTotalCount = () => { apiTotalCount.value = null }
const examTypeStats = computed(() => [
{ value: totalQuestionCount.value, label: '题目总数' },
{ value: generatedQuestions.value.length, label: '已生成' },
{ value: generatedQuestions.value.length || '-', label: '已生成' },
{ value: difficulty.value === 1 ? '简单' : difficulty.value === 2 ? '中等' : '困难', label: '难度等级' }
])
@@ -78,30 +87,36 @@ export function useGenerateState() {
if (val > 0) activeTypes[key] = val
})
const filePath = selectedDocData.value?.pdfUrl || selectedDocData.value?.filePath || selectedDocData.value?.path
const collectionName = selectedDocData.value?.collectionName || selectedCollectionName.value || 'public_kb'
if (!filePath) {
console.error('❌ [useGenerateState] 文件路径为空! selectedDocData:', JSON.stringify(selectedDocData.value))
throw new Error('无法获取文件路径,请重新选择文件后重试')
const doc = selectedDocData.value
let fileId = doc?.id || doc?.fileId
// 向量库的文件:真正的 file_id 可能嵌套在 sourceData 中
if (!fileId && doc?.sourceData) {
fileId = doc.sourceData.metadata?.file_id || doc.sourceData.file_id || doc.sourceData.id
}
console.log(' - selectedDocData 完整对象:', JSON.stringify(selectedDocData.value, null, 2))
console.log(' - 文件路径:', filePath)
console.log(' - 知识库:', collectionName)
console.log(' - selectedDocData 完整对象:', JSON.stringify(doc, null, 2))
console.log(' - 文件ID:', fileId)
console.log(' - 题型配置:', JSON.stringify(activeTypes, null, 2))
console.log(' - 题目总数:', totalQuestionCount.value)
console.log(' - 难度等级:', difficulty.value)
const params = {
file_path: filePath,
collection: collectionName,
question_count: totalQuestionCount.value,
question_types: activeTypes,
difficulty: difficulty.value,
request_id: `gen_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
if (fileId) {
params.file_id = Number(fileId)
} else if (doc?.pdfUrl || doc?.filePath || doc?.path) {
params.file_path = doc.pdfUrl || doc.filePath || doc.path
params.collection = selectedCollectionName.value || 'public_kb'
} else {
console.error('❌ [useGenerateState] 无法获取文件ID或文件路径!', JSON.stringify(doc))
throw new Error('无法获取文件信息,请重新选择文件后重试')
}
console.log('📤 [useGenerateState] 完整请求参数:', JSON.stringify(params, null, 2))
const result = await examAPI.generateQuestions(params)
@@ -139,6 +154,7 @@ export function useGenerateState() {
if (initialQuestions) {
console.log('✅ [useGenerateState] 同步模式:接口直接返回题目,无需轮询')
generatedQuestions.value = transformQuestionData(initialQuestions)
apiTotalCount.value = generatedQuestions.value.length
message.success(`成功生成 ${generatedQuestions.value.length} 道题目`)
console.log(`\n📋 题目详情:`)
generatedQuestions.value.forEach((q, i) => {
@@ -160,7 +176,6 @@ export function useGenerateState() {
console.log(`⏳ [useGenerateState] RequestId: ${requestId},开始轮询等待...`)
const fileId = selectedDocData.value?.id
console.log(`🔍 [useGenerateState] 获取到的 fileId: ${fileId} (类型: ${typeof fileId})`)
if (!fileId) {
console.warn('⚠️ [useGenerateState] fileId 为空,将无法监控文件状态!')
@@ -262,6 +277,7 @@ export function useGenerateState() {
if (matchCount > 0) {
found = true
generatedQuestions.value = transformQuestionData(matchedQs)
apiTotalCount.value = generatedQuestions.value.length
message.destroy('generating')
message.success(`成功生成 ${generatedQuestions.value.length} 道题目`)
break
@@ -361,6 +377,7 @@ export function useGenerateState() {
console.log(`\n✅ 最终过滤结果: 方法=${filterMethod}, 题目数=${filteredQuestions.length}`)
generatedQuestions.value = transformQuestionData(filteredQuestions)
apiTotalCount.value = generatedQuestions.value.length
console.log(`\n✅ [useGenerateState] 数据转换完成:`)
console.log(` - 输入: ${filteredQuestions.length} 条`)
@@ -443,6 +460,7 @@ export function useGenerateState() {
if (fallbackQuestions && fallbackQuestions.length > 0) {
console.log(`✅ [兜底] 成功获取 ${fallbackQuestions.length} 道题目`)
generatedQuestions.value = transformQuestionData(fallbackQuestions)
apiTotalCount.value = generatedQuestions.value.length
message.success(`成功生成 ${generatedQuestions.value.length} 道题目`)
} else if (lastFileStatus?.examStatus === 'GENERATING') {
message.warning({ content: '生成任务仍在进行中,请稍后手动刷新查看结果', duration: 10 })
@@ -517,18 +535,31 @@ export function useGenerateState() {
if (rawOptions[0] && typeof rawOptions[0] === 'object' && rawOptions[0].key) {
options = rawOptions.map(opt => ({
key: String(opt.key),
text: opt.content || opt.text || ''
text: opt.content || opt.text || '',
isCorrect: false
}))
} else {
const optionKeys = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
options = rawOptions.map((opt, idx) => ({
key: optionKeys[idx] || String.fromCharCode(65 + idx),
text: typeof opt === 'object' ? (opt.content || opt.text || '') : String(opt)
text: typeof opt === 'object' ? (opt.content || opt.text || '') : String(opt),
isCorrect: false
}))
}
}
// 将 answer 映射到选项的 isCorrect 字段(选择题)
const rawType = q.questionType || q.question_type || q.type || ''
const typeStr = String(rawType).toLowerCase()
const rawAnswer = q.answer !== undefined ? q.answer : (contentObj.answer || '')
if ((typeStr === 'single_choice' || typeStr === 'multiple_choice') && rawAnswer && options.length > 0) {
const answerKeys = String(rawAnswer).toUpperCase().split(/[,,]/).map(s => s.trim())
options.forEach(opt => {
if (answerKeys.includes(opt.key)) {
opt.isCorrect = true
}
})
}
return {
id: q.questionId || q.question_id || q.id || `gen_q_${qIdx}`,
@@ -539,7 +570,7 @@ export function useGenerateState() {
score: Number(q.score) || Number(q.questionScore) || 5,
stem: rawStem,
options: options,
answer: q.answer !== undefined ? q.answer : (contentObj.answer || ''),
answer: rawAnswer,
status: q.status || 'pending',
documentId: q.documentId || q.document_id || '',
documentName: q.documentName || q.document_name || '',
@@ -558,6 +589,8 @@ export function useGenerateState() {
questionTypeOptions,
questionTypeCounts,
totalQuestionCount,
apiTotalCount,
resetApiTotalCount,
examTypeStats,
onDocSelected,
onCollectionChange,

View File

@@ -5,7 +5,10 @@ import { fileAPI } from '../../../api/file.js'
export function useQuestionBank() {
const questions = ref([])
// 搜索模式下的全量数据缓存(用于前端本地分页)
const allQuestionsCache = ref([])
const loading = ref(false)
const isSearchMode = ref(false) // 是否处于搜索/筛选状态
const pagination = reactive({
current: 1,
pageSize: 10,
@@ -131,18 +134,53 @@ const sortByPending = (arr) => {
})
}
/** 判断当前是否处于搜索/筛选模式 */
function checkSearchMode() {
return !!(
(searchForm.keyword && searchForm.keyword.trim()) ||
(searchForm.type) ||
(searchForm.difficulty !== '' && searchForm.difficulty != null) ||
(searchForm.status)
)
}
/** 前端本地分页:从全量缓存中截取当前页数据 */
function applyLocalPagination() {
if (!isSearchMode.value || allQuestionsCache.value.length === 0) return
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
questions.value = allQuestionsCache.value.slice(start, end)
pagination.total = allQuestionsCache.value.length
}
/** 搜索模式下切换页码(前端本地分页,不请求后端) */
const changeLocalPage = (page, size) => {
if (!isSearchMode.value) {
// 非搜索模式:走正常后端分页
pagination.current = page
if (size) pagination.pageSize = size
loadQuestions()
return
}
// 搜索模式:前端本地分页
if (size) pagination.pageSize = size
pagination.current = page
applyLocalPagination()
}
const loadQuestions = async () => {
loading.value = true
questions.value = []
// 判断是否搜索模式
isSearchMode.value = checkSearchMode()
const requestParams = {
pageNum: pagination.current,
pageSize: pagination.pageSize,
pageNum: 1,
// 搜索模式加载全量数据用于前端分页
pageSize: isSearchMode.value ? Math.max(pagination.pageSize * 20, 500) : pagination.pageSize,
sortBy: 'status',
sortOrder: 'asc'
}
if (searchForm.keyword) {
requestParams.keyword = searchForm.keyword
}
if (searchForm.type) {
requestParams.questionType = searchForm.type
}
@@ -161,41 +199,73 @@ const loadQuestions = async () => {
const raw = res?.data || res
let list = []
if (Array.isArray(raw)) {
list = raw
questions.value = list.map(enrichQuestion)
sortByPending(questions.value)
pagination.total = list.length
list = raw.map(enrichQuestion)
} else {
const rawData = raw?.data || raw
const rawList = rawData?.list || rawData?.records || rawData?.questions || (Array.isArray(rawData) ? rawData : null)
list = Array.isArray(rawList) ? rawList : []
list = Array.isArray(rawList) ? rawList.map(enrichQuestion) : []
}
if (list.length > 0 || raw?.success !== false) {
questions.value = list.map(enrichQuestion)
// documentId 过滤(始终在后端结果上二次过滤)
if (searchForm.documentId) {
list = list.filter(q => !q.documentId || String(q.documentId) === String(searchForm.documentId))
}
if (searchForm.documentId) {
questions.value = questions.value.filter(q =>
!q.documentId || String(q.documentId) === String(searchForm.documentId)
)
pagination.total = questions.value.length
} else {
sortByPending(questions.value)
pagination.total = rawData?.total || rawData?.count || raw?.total || raw?.count || list.length
}
// 搜索关键词过滤(只保留匹配的题目)
if (searchForm.keyword && searchForm.keyword.trim()) {
const kw = searchForm.keyword.trim().toLowerCase()
list = list.filter(q => matchKeyword(q, kw))
}
// 排序:pending 优先
sortByPending(list)
if (list.length > 0) {
if (isSearchMode.value) {
// ★ 搜索模式:缓存全量数据,前端本地分页
allQuestionsCache.value = list
applyLocalPagination()
} else {
questions.value = []
pagination.total = 0
// 正常模式:直接使用后端返回的分页数据
questions.value = list
pagination.total = Array.isArray(raw)
? list.length
: (raw?.data?.total || raw?.data?.count || raw?.total || raw?.count || list.length)
}
} else {
questions.value = []
allQuestionsCache.value = []
pagination.total = 0
}
} catch (error) {
console.error('加载题目列表失败:', error)
message.error('加载题目时发生错误: ' + (error.message || '未知错误'))
questions.value = []
allQuestionsCache.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
/** 判断题目是否匹配关键词(仅搜索题干 stem 字段) */
function matchKeyword(question, kw) {
if (!kw) return false
// 1. 检查 excludeStems 纯文本字段
if (question.excludeStems && question.excludeStems.toLowerCase().includes(kw)) return true
// 2. 仅检查 content JSON 中的 stem(题干)字段,不匹配选项/答案/解析
if (question.content) {
const c = typeof question.content === 'string'
? (function() { try { return JSON.parse(question.content) } catch(e) { return null } })()
: question.content
// 提取 stem 题干文本
const stem = c?.stem || c?.title || c?.question || ''
if (stem && String(stem).toLowerCase().includes(kw)) return true
}
// 3. 检查已 enrich 的 stem 字段(enrichQuestion 提取过一次)
if (question.stem && String(question.stem).toLowerCase().includes(kw)) return true
return false
}
const handleSearch = () => {
pagination.current = 1
@@ -237,6 +307,7 @@ const loadQuestions = async () => {
questions,
loading,
pagination,
isSearchMode,
searchForm,
questionTypeOptions,
difficultyOptions,
@@ -247,6 +318,7 @@ const loadQuestions = async () => {
handleSearch,
handleReset,
handleTableChange,
handleDeleteQuestion
handleDeleteQuestion,
changeLocalPage
}
}

View File

@@ -28,6 +28,7 @@ export function useTaskManager() {
status: 'pending',
examStatus: '',
processMessage: '等待中...',
processStepMessage: '',
createdAt: new Date().toLocaleTimeString(),
updatedAt: new Date().toLocaleTimeString(),
pollingStopped: false
@@ -66,25 +67,37 @@ export function useTaskManager() {
if (val > 0) activeTypes[key] = val
})
const filePath = task.filePath
const collection = fileData.collectionName || 'public_kb'
if (!filePath) {
updateTask(taskId, { status: 'failed', examStatus: 'FAILED', processMessage: '文件路径无效' })
return
// 优先使用 file_id(从多个可能的数据源获取)
let fileId = task.fileId || fileData.id || fileData.fileId
// 向量库的文件:真正的 file_id 可能嵌套在 sourceData 中
if (!fileId && fileData.sourceData) {
fileId = fileData.sourceData.metadata?.file_id || fileData.sourceData.file_id || fileData.sourceData.id
}
const params = {
file_path: filePath,
collection,
question_count: totalCount,
question_types: activeTypes,
difficulty,
request_id: `gen_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
request_id: task.id || `gen_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
if (fileId) {
params.file_id = Number(fileId)
console.log(`[useTaskManager] 使用 file_id=${params.file_id} 调用生成接口`)
} else if (task.filePath) {
// 没有 file_id 时退回到 file_path + collection
params.file_path = task.filePath
params.collection = fileData.collectionName || 'public_kb'
console.log('[useTaskManager] 使用 file_path + collection 调用生成接口')
} else {
updateTask(taskId, { status: 'failed', examStatus: 'FAILED', processMessage: '无法获取文件ID或文件路径' })
return
}
updateTask(taskId, { processMessage: `请求生成 ${totalCount} 道题目...` })
console.log('[useTaskManager] 生成请求参数:', JSON.stringify(params))
const result = await examAPI.generateQuestions(params)
let initialQuestions = null
@@ -169,8 +182,20 @@ export function useTaskManager() {
const examStatus = fileInfo.examStatus || fileInfo.exam_status || ''
const processMsg = fileInfo.processMessage || fileInfo.process_message || ''
const processStepMsg = fileInfo.processStepMessage || fileInfo.process_step_message || ''
updateTask(taskId, { examStatus, processMessage: processMsg || `正在处理 (${pollCount})...` })
// processStepMessage 展示生成过程中的步骤状态
// processMessage 展示生成完成后的详细结果
if (processStepMsg) {
updateTask(taskId, { examStatus, processStepMessage: processStepMsg })
}
if (examStatus === 'GENERATED' || examStatus === 'COMPLETED' || examStatus === 'FAILED') {
// 完成或失败时,展示完整的结果信息
updateTask(taskId, { examStatus, processMessage: processMsg || processStepMsg || '处理完成' })
} else {
// 生成过程中,展示步骤状态
updateTask(taskId, { examStatus, processMessage: processStepMsg || processMsg || `正在处理 (${pollCount})...` })
}
if (examStatus === 'FAILED') {
updateTask(taskId, { status: 'failed', examStatus: 'FAILED', processMessage: processMsg || '生成失败' })
@@ -204,7 +229,8 @@ export function useTaskManager() {
updateTask(taskId, {
status: 'completed',
examStatus: examStatus,
processMessage: `生成完成:共 ${count} 题`,
// 使用接口返回的 processMessage(含题型统计、AI分析等完整结果)
processMessage: processMsg || `生成完成:共 ${count} 题`,
resultCount: count
})
if (count > 0) {

View File

@@ -157,25 +157,9 @@ export const AdvancedPDFReader: React.FC<AdvancedPDFReaderProps> = ({
return (
<div style={containerStyle} className={className}>
<Toolbar
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomReset={handleZoomReset}
onExport={handleExport}
onImport={handleImport}
scale={scale}
readOnly={readOnly}
/>
{/* 工具栏已隐藏 — 仅保留阅读区域 */}
{!readOnly && (
<div style={colorPickerContainerStyle}>
<span style={colorPickerLabelStyle}>高亮颜色:</span>
<ColorPicker
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
</div>
)}
{/* 颜色选择器已隐藏 */}
<div style={contentStyle}>
<Document

View File

@@ -694,14 +694,10 @@ defineExpose({
min-height: 100%;
}
.annotation-sidebar,
.sidebar-toggle-button,
.action-buttons-container {
position: fixed;
bottom: var(--space-3xl);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-sm);
z-index: 1001;
display: none !important;
}
.action-button {

View File

@@ -1,11 +1,5 @@
.pdf-reader-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--bg-container);
border-bottom: 1px solid var(--border-light);
gap: var(--space-md);
display: none !important;
}
.pdf-reader-toolbar .toolbar-group {
@@ -37,9 +31,7 @@
}
.color-picker-container {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm);
display: none !important;
}
.color-picker-btn {

View File

@@ -3465,4 +3465,15 @@ onUnmounted(() => {
display: none; /* 移动端隐藏文字,只显示图标 */
}
}
/* 精简阅读器顶部栏:仅保留返回列表和文件名 */
.reader-header .toolbar-group,
.reader-header .toolbar-divider {
display: none !important;
}
/* 隐藏批注侧边栏 */
.sidebar {
display: none !important;
}
</style>

View File

@@ -1,393 +0,0 @@
# 出题做题生成流程示意图
> **版本**: v2.0 (2026-05-10 更新)
> **状态**: 与代码实现已对齐
---
## 一、模块总览
```
┌─────────────────────────────────────────────────────────────────────┐
│ 考察与训练 (ExamModule) │
├──────────┬──────────┬──────────────┬──────────────┬────────────────┤
│ 🤖 AI出题 │ 📚 题库管理 │ 📋 智能组卷 │ 📝 试卷考核 │ 🎯 互动训练 │
│ (管理员) │ (管理员) │ (管理员) │ (全员) │ (全员) │
│ exam:ai │ exam:bank│ exam:paper │ exam:exam │ exam:train │
└──────────┴──────────┴──────────────┴──────────────┴────────────────┘
```
### 各模块功能说明
| 模块 | 权限码 | 功能描述 | 目标用户 |
|-----|--------|---------|---------|
| AI出题 | `exam:ai` | 选择文档→AI生成题目 | 管理员/有权限用户 |
| 题库管理 | `exam:bank` | 题目审核/编辑/删除 | 管理员 |
| 智能组卷 | `exam:paper` | 配置参数→生成试卷→**编辑+发布** | 管理员 |
| 试卷考核 | `exam:exam` | 待考试/答题/成绩/错题本/**自测组卷** | 全员 |
| 互动训练 | `exam:train` | 闯关模式/每日一练/错题本(本地) | 全员 |
---
## 二、题目生成流程(AI出题)
```
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ 上传文档 │ ──→│ 文件向量化处理 │ ──→│ AI题目生成 │ ──→│ 题目预览 │
│ │ │ │ │ │ │ │
└──────────┘ └──────────────┘ └──────────────┘ └────┬─────┘
│
┌─────────────────────────────────────────┘
↓
┌──────────────┐
│ 管理员审核操作 │
└──────┬───────┘
│
┌──────────┼──────────┐
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ 采纳通过 │ │ 编辑修改 │ │ 删除丢弃 │
└───┬────┘ └───┬────┘ └────────┘
↓ ↓
┌────────┐ ┌────────┐
│ 进入题库 │→ │ 进入题库 │
└────────┘ └────────┘
```
### 相关API接口
| 序号 | 接口路径 | 方法 | 说明 |
|-----|---------|------|------|
| 1 | `/api/file/upload` | POST | 上传制度文件 |
| 2 | `/api/collections/{name}/vectorize` | POST | 文件向量化处理 |
| 3 | `/api/exam/generate` | POST | AI智能出题接口 |
| 4 | `/api/question/review/{questionId}` | PUT | 审核题目(采纳/拒绝) |
---
## 三、试卷生成流程(双路径)
### 路径A:管理员 — 智能组卷(可编辑 + 可发布)
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 选择关联文档 │ ──→│ 配置试卷参数 │ ──→│ 调用组卷API │ ──→│ 预览编辑试卷 │
│ (可选) │ │ │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────┬───────┘
│
┌────────────────────────────────┘
↓
┌──────────────────────┐
│ 管理员操作面板 │
│ ├─ 编辑题目(题干/选项) │
│ ├─ 调整分值/顺序 │
│ └─ 发布试卷给指定用户 │
└──────────┬───────────┘
│
┌───────────────┼───────────────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 保存草稿 │ │ 发布试卷 │ │ 删除试卷 │
└──────────┘ └────┬─────┘ └──────────┘
│
↓ 用户收到通知
┌──────────────────────┐
│ 试卷考核-待考试列表 │
└──────────────────────┘
```
### 路径B:用户 — 自测组卷(仅生成,直接答题)
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 配置参数 │ ──→│ 调用组卷API │ ──→│ 生成成功 │ ──→│ 直接进入答题 │
│ (题型/难度) │ │ │ │ │ │ (不可编辑) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
### 两条路径对比
| 维度 | 路径A:管理员智能组卷 | 路径B:用户自测组卷 |
|-----|-------------------|------------------|
| 入口 | 「智能组卷」标签页 | 「试卷考核→自测组卷」子标签 |
| API | `POST /api/exam/paper/generate` | 同一接口 |
| 可选文件 | ✅ 关联制度文件筛选 | ✅ 可选关联 |
| 生成后行为 | 进入编辑预览 | **直接进入答题页面** |
| 编辑题目 | ✅ 题干/选项/答案/分值 | ❌ 不支持 |
| 发布功能 | ✅ 发布给指定部门/用户 | ❌ 仅自己使用 |
| 数据持久化 | ✅ 存入后端试卷表 | ❌ 前端暂存,关闭即失 |
| 权限要求 | `exam:paper` | `exam:exam` |
### 相关API接口
| 序号 | 接口路径 | 方法 | 说明 |
|-----|---------|------|------|
| 1 | `/api/exam/paper/generate` | POST | 从题库动态生成试卷 |
| 2 | `/admin/exam/paper/{paperId}/publish` | POST | 管理员发布试卷(含deadline/target) |
| 3 | `/api/exam/my/papers` | GET | 获取我的试卷列表 |
| 4 | `/api/exam/paper/{paperId}` | GET | 获取试卷详情 |
---
## 四、答题考试流程(试卷考核)
### 核心流程图
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 获取待考试 │ ────→ │ 选择试卷 │ ────→ │ 启动考试记录 │
│ 列表 │ │ │ │ /record/start│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
↓ ↓ ↓
┌──────────────┐ ┌──────────────────────────────────────────┐
│ GET │ │ 答题界面 │
│ /my/papers │ │ ┌─────────────────────────────────────┐ │
└──────────────┘ │ │ 第 N 题 [题型] ★ 分值 │ │
│ │ 题目内容 │ │
│ │ ○ A. 选项内容 │ │
│ │ ○ B. 选项内容 │ │
│ │ ○ C. 选项内容 │ │
│ │ ○ D. 选项内容 │ │
│ └─────────────────────────────────────┘ │
│ │
│ [上一题] ●●●○●● [下一题] [提交试卷] │
└──────────────┬───────────────────────────┘
│
┌───────────────────┼───────────────────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 自动保存 │ │ 提交试卷 │ │ 退出确认 │
│ /save │ │ /submit │ │ │
└──────────┘ └────┬─────┘ └──────────┘
│
↓
┌──────────────────────┐
│ 后端AI批改处理 │
│ (自动触发) │
└──────────┬───────────┘
│
↓
┌──────────────────────┐
│ 显示成绩单 │
│ 总分/得分率/每题详情 │
└──────────┬───────────┘
│
↓
┌──────────────────────┐
│ 错题自动收集入库 │
│ → 错题本可查看/重做 │
└──────────────────────┘
```
### 答题流程详细步骤与API对应关系
| 步骤 | 操作 | API接口 | 方法 | 说明 |
|-----|------|---------|------|------|
| 1 | 获取待考试列表 | `/api/exam/my/papers` | GET | 返回当前用户的已发布试卷 |
| 2 | 选择并进入试卷 | - | 前端切换视图 | 加载试卷数据到答题组件 |
| 3 | **启动考试记录** | `/api/exam/record/{recordId}/start` | POST | 创建考试记录,开始计时 |
| 4 | 答题中(自动保存) | `/api/exam/record/{recordId}/save` | PUT | 定期保存当前答案进度 |
| 5 | 提交试卷 | `/api/exam/record/{recordId}/submit` | POST | 提交最终答案,触发后端AI批改 |
| 6 | 查看成绩结果 | `/api/exam/record/{recordId}/result` | GET | 获取AI批改后的成绩和错题信息 |
| 7 | 错题自动收集 | 后端自动处理 | - | 批改后自动将错题录入错题库 |
> **注意**:当后端尚未实现 record 系列 API 时,前端会优雅降级:
> - start/save 失败时继续前端答题模式
> - submit 失败时降级为直接调用 `/exam/grade` 进行批改
### 支持的题型及答题方式
| 题型 | question_type | 答题控件 | 答案格式 |
|-----|--------------|---------|---------|
| 单选题 | `single_choice` | Radio单选框 | 字符串 `"A"` |
| 多选题 | `multiple_choice` | Checkbox多选框 | 数组 `["A","C"]` |
| 判断题 | `true_false` | 是/否双选 | 字符串 `"true"`/`"false"` |
| 填空题 | `fill_blank` | 文本输入框 | 字符串 |
| 简答题 | `subjective` | 多行文本域 | 字符串 |
---
## 五、错题系统(两套独立机制)
### 架构对比
```
┌─────────────────────┐ ┌─────────────────────┐
│ 互动训练-错题本 │ │ 试卷考核-错题本 │
│ (trainTab='wrong') │ │ (examTab='wrong') │
├─────────────────────┤ ├─────────────────────┤
│ 数据来源: 前端缓存 │ │ 数据来源: 后端API │
│ 存储位置: trainingData│ │ 存储位置: 数据库表 │
│ .wrongQuestions │ │ wrong_questions │
│ │ │ │
│ 触发场景: │ │ 触发场景: │
│ • 闯关模式答错 │ │ • 试卷考核提交后 │
│ • 每日一练答错 │ │ • 后端自动收集 │
│ │ │ │
│ 操作能力: │ │ 操作能力: │
│ • 查看解析(前端mock) │ │ • 查看解析(API数据) │
│ • 重做(重置关卡) │ │ • 重做(/redo API) │
│ • 无持久化 │ │ • 收藏(/toggle API) │
└─────────────────────┘ └─────────────────────┘
```
### 试卷考核错题本API
| 操作 | API | 方法 | 说明 |
|-----|-----|------|------|
| 获取错题列表 | `/api/wrong-questions/list` | POST | 分页查询,支持collection过滤 |
| 收藏/取消收藏 | `/api/wrong-questions/collection/toggle` | POST | 传入question_id |
| 重做错题 | `/api/wrong-questions/redo` | POST | 传入question_ids数组 |
---
## 六、互动训练流程
```
┌─────────────────────────────────────────────────────────────┐
│ 互动训练 (exam:train) │
├──────────┬──────────┬──────────────────────────────────────┤
│ 🏆 闯关 │ 📅 每日一练│ ❌ 错题本(本地缓存) │
│ checkpoint│ daily │ wrong │
└──────────┴──────────┴──────────────────────────────────────┘
闯关模式流程:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 选择关卡 │ → │ 组卷API │ → │ 答题提交 │ → │ 批改评分 │
│ (5个预设) │ │ generate │ │ grade │ │ 更新关卡 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
┌──────────┴──────────┐
↓ ↓
┌──────────┐ ┌──────────┐
│ 通过升级 │ │ 错题缓存 │
│ 下一关 │ │ 本地存储 │
└──────────┘ └──────────┘
```
### 互动训练关卡配置(预设固定关卡)
| 关卡ID | 名称 | 难度 | 单选 | 多选 | 判断 | 简答 | 状态 |
|-------|------|------|------|------|------|------|------|
| 1 | 入门挑战 | ⭐ | 3 | 0 | 2 | 0 | in-progress |
| 2 | 基础巩固 | ⭐⭐ | 4 | 2 | 2 | 0 | pending |
| 3 | 进阶提升 | ⭐⭐⭐ | 4 | 3 | 3 | 0 | pending |
| 4 | 高级挑战 | ⭐⭐⭐⭐ | 3 | 3 | 2 | 2 | pending |
| 5 | 大师试炼 | ⭐⭐⭐⭐⭐ | 4 | 4 | 2 | 2 | pending |
---
## 七、完整API接口清单
### 7.1 题目管理
| 接口 | 路径 | 方法 | 权限 | 说明 |
|-----|------|------|------|------|
| AI出题 | `/api/exam/generate` | POST | exam:ai | 根据文档生成题目 |
| 审核题目 | `/api/question/review/{id}` | PUT | exam:bank | 采纳/拒绝题目 |
### 7.2 试卷管理
| 接口 | 路径 | 方法 | 权限 | 说明 |
|-----|------|------|------|------|
| 生成试卷 | `/api/exam/paper/generate` | POST | exam:paper 或 exam:exam | 从题库动态组卷 |
| 我的试卷 | `/api/exam/my/papers` | GET | exam:exam | 获取用户试卷列表 |
| 试卷详情 | `/api/exam/paper/{paperId}` | GET | exam:paper 或 exam:exam | 获取试卷完整内容 |
| 发布试卷 | `/admin/exam/paper/{paperId}/publish` | POST | exam:paper | 管理员发布试卷 |
### 7.3 答题考试(record-based)
| 接口 | 路径 | 方法 | 权限 | 说明 |
|-----|------|------|------|------|
| 启动考试 | `/api/exam/record/{recordId}/start` | POST | exam:exam | 开始考试计时 |
| 保存答案 | `/api/exam/record/{recordId}/save` | PUT | exam:exam | 答题中途自动保存 |
| 提交试卷 | `/api/exam/record/{recordId}/submit` | POST | exam:exam | 提交最终答案 |
| 成绩查询 | `/api/exam/record/{recordId}/result` | GET | exam:exam | 获取批改成绩和错题 |
### 7.4 批改与查询
| 接口 | 路径 | 方法 | 权限 | 说明 |
|-----|------|------|------|------|
| 批改答案 | `/api/exam/grade` | POST | exam:exam | AI批改(降级备用) |
| 查询答题记录 | `/api/exam/answers/query` | POST | exam:exam | 按paperId/sessionId查询 |
### 7.5 错题管理
| 接口 | 路径 | 方法 | 权限 | 说明 |
|-----|------|------|------|------|
| 错题列表 | `/api/wrong-questions/list` | POST | exam:exam | 分页获取错题 |
| 收藏切换 | `/api/wrong-questions/collection/toggle` | POST | exam:exam | 收藏/取消收藏错题 |
| 重做错题 | `/api/wrong-questions/redo` | POST | exam:exam | 重新练习错题 |
---
## 八、数据库核心表结构
### exam_paper(试卷表)
| 字段名 | 类型 | 说明 |
|-------|------|------|
| id | BIGINT | 主键 |
| uuid | VARCHAR(36) | UUID唯一标识 |
| title | VARCHAR(200) | 试卷标题 |
| total_score | INT | 总分 |
| question_count | INT | 题目数量 |
| difficulty | INT | 整体难度(1-5) |
| status | TINYINT | 状态(0=草稿,1=已发布,2=已归档) |
| creator_id | BIGINT | 创建者(管理员) |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
### exam_record(考试记录表)
| 字段名 | 类型 | 说明 |
|-------|------|------|
| id | BIGINT | 主键 |
| uuid | VARCHAR(36) | UUID唯一标识 |
| paper_id | BIGINT | 关联试卷ID |
| user_id | BIGINT | 考试用户ID |
| status | TINYINT | 状态(0=未开始,1=进行中,2=已交卷,3=已批改) |
| score | DECIMAL(5,2) | 得分 |
| max_score | INT | 满分 |
| started_at | DATETIME | 开始时间 |
| submitted_at | DATETIME | 提交时间 |
| graded_at | DATETIME | 批改完成时间 |
### wrong_questions(错题表)
| 字段名 | 类型 | 说明 |
|-------|------|------|
| id | BIGINT | 主键 |
| question_id | BIGINT | 关联题目ID |
| user_id | BIGINT | 用户ID |
| student_answer | TEXT | 学生答案 |
| correct_answer | TEXT | 正确答案 |
| is_correct | TINYINT | 是否正确(0=错误) |
| error_count | INT | 错误次数 |
| last_wrong_at | DATETIME | 最近错误时间 |
---
## 九、管理员 vs 用户 功能矩阵
| 功能 | 管理员 | 普通用户 |
|-----|:-----:|:-------:|
| AI出题 | ✅ | ✅(有权限) |
| 题库审核 | ✅ | ❌ |
| 智能组卷(生成) | ✅ | ❌ |
| 智能组卷(编辑) | ✅ | ❌ |
| 智能组卷(发布) | ✅ | ❌ |
| 试卷考核-待考试 | ❌ | ✅ |
| 试卷考核-答题 | ✅(测试) | ✅ |
| 试卷考核-成绩单 | ✅ | ✅ |
| 试卷考核-错题本(API) | ✅ | ✅ |
| 试卷考核-自测组卷 | ✅ | ✅ |
| 互动训练-闯关 | ✅ | ✅ |
| 互动训练-每日一练 | ✅ | ✅ |
| 互动训练-错题本(本地) | ✅ | ✅ |
---
*文档更新日期: 2026-05-10*
*与 ExamModule.vue v2.0 + exam.js v2.0 对齐*

File diff suppressed because it is too large Load Diff

View File

@@ -1,471 +0,0 @@
# 制度文件管理学习AI智能体页面设计文档
## 文档说明
本文档详细描述了制度文件管理学习AI智能体的页面设计方案,基于功能需求规格文档,涵盖了6个核心模块、24个具体功能点的页面布局、交互设计、视觉设计等内容,为前端开发提供详细的设计指导。
## 设计原则
1. **用户中心**:以用户需求为中心,提供直观、易用的界面
2. **功能完整**:覆盖所有功能点,确保系统功能完整可用
3. **视觉统一**:保持一致的视觉风格,提升用户体验
4. **响应式设计**:适配不同屏幕尺寸,确保在各种设备上的良好显示
5. **性能优化**:优化页面加载速度,提升系统响应性能
6. **可访问性**:确保系统对所有用户(包括残障用户)都可访问
## 整体页面结构
系统采用经典的三栏布局,包括:
1. **顶部导航栏**:系统标题、用户信息、全局搜索、消息通知等
2. **左侧菜单栏**:模块导航、功能菜单、快捷入口等
3. **右侧主内容区**:功能操作、数据展示、表单输入等
4. **底部状态栏**:版权信息、系统版本、技术支持等
### 页面布局尺寸
| 区域 | 宽度 | 高度 | 备注 |
|------|------|------|------|
| 顶部导航栏 | 100% | 60px | 固定高度 |
| 左侧菜单栏 | 240px | 100% | 可折叠,折叠后宽度为64px |
| 右侧主内容区 | 剩余宽度 | 100% | 自适应宽度 |
| 底部状态栏 | 100% | 40px | 固定高度 |
## 导航系统
### 1. 模块级导航
位于左侧菜单栏,包含6个核心模块:
- 制度文件查看和学习
- 制度文件管理
- 知识问答
- 考察和训练
- 纲要学习(思维导图)
- 综合看板
### 2. 功能级导航
位于各模块内部,根据功能点组织子菜单:
- **制度文件查看和学习**:智能PDF阅读器、权限精细化管理、新岗人员学习地图、语音朗读、关联推荐
- **制度文件管理**:版本管理、OA系统深度对接、知识库自动同步、题库智能维护
- **知识问答**:多轮智能对话、答案溯源、问答质量闭环、会话管理
- **考察和训练**:AI智能出题、题库管理、智能组卷、AI自动阅卷、互动式训练
- **纲要学习**:自动化纲要生成、导图学习功能
- **综合看板**:数据可视化、智能预警、决策支持
### 3. 面包屑导航
位于主内容区顶部,显示当前页面的层级路径,方便用户了解当前位置和返回上级页面。
### 4. 快捷导航
位于顶部导航栏,提供常用功能的快速访问入口,如全局搜索、消息通知、用户设置等。
## 模块页面设计
### 0. 通用流程设计
#### 0.1 用户注册/登录流程
**页面布局**:
- 登录页:账号输入框、密码输入框、验证码输入框、登录按钮、忘记密码链接、OA单点登录按钮
- 注册页:用户名输入框、密码设置、确认密码、邮箱输入、手机输入、验证码输入、注册按钮
- 忘记密码页:账号输入、验证码输入、新密码设置、确认新密码、提交按钮
**交互设计**:
- 普通登录:用户输入账号密码 → 系统验证 → 登录成功进入系统
- OA单点登录:用户点击OA登录按钮 → 跳转至OA认证页面 → 认证成功后自动登录系统
- 注册流程:用户填写注册信息 → 系统验证 → 发送邮箱/手机验证码 → 用户输入验证码 → 注册成功
- 忘记密码:用户输入账号 → 系统发送验证码 → 用户输入验证码 → 设置新密码 → 密码重置成功
#### 0.2 制度文件上传流程
**页面布局**:
- 上传页面:文件选择按钮、文件拖拽区域、文件信息预览、制度分类选择、版本说明输入、上传按钮
- 上传进度:进度条、上传状态提示
- 上传结果:成功/失败提示、查看文件按钮
**交互设计**:
- 用户选择/拖拽文件 → 系统验证文件格式 → 显示文件信息 → 用户填写版本说明 → 点击上传 → 系统处理文件 → 生成版本号 → 上传成功提示
#### 0.3 权限管理审批流程
**页面布局**:
- 权限申请页:申请人信息、申请文件、申请权限类型、申请理由、提交按钮
- 审批页面:待审批列表、审批详情、审批意见输入、同意/拒绝按钮
- 审批记录:审批历史、审批状态、审批人、审批时间
**交互设计**:
- 权限申请:用户提交权限申请 → 系统发送审批通知给直属领导 → 领导审批 → 审批结果通知申请人
- 跨部门授权:系统管理员配置跨部门权限 → 相关部门负责人确认 → 权限生效
#### 0.4 系统配置流程
**页面布局**:
- 系统设置页:基本配置、邮件配置、短信配置、预警配置、存储配置
- 配置项列表:配置名称、当前值、操作(编辑)
- 配置编辑:输入框、选择器、保存按钮
**交互设计**:
- 管理员进入系统设置 → 选择配置类别 → 编辑配置项 → 保存配置 → 系统应用新配置
#### 0.5 数据备份/恢复流程
**页面布局**:
- 备份管理页:备份列表、备份时间、备份大小、操作(恢复/删除)
- 备份设置:备份周期、备份路径、自动备份开关
- 手动备份按钮:立即备份
- 恢复确认:恢复前确认弹窗、恢复进度、恢复结果
**交互设计**:
- 自动备份:系统按设定周期自动执行备份 → 生成备份记录
- 手动备份:管理员点击立即备份 → 系统执行备份 → 显示备份结果
- 数据恢复:管理员选择备份文件 → 确认恢复 → 系统执行恢复 → 显示恢复结果
#### 0.6 系统监控/日志流程
**页面布局**:
- 监控面板:系统状态、CPU使用率、内存使用率、磁盘使用率、在线用户数
- 日志查询:时间范围选择、日志类型选择、关键词搜索、日志列表
- 异常告警:告警列表、告警级别、告警时间、处理状态
**交互设计**:
- 实时监控:系统实时采集监控数据 → 展示监控面板 → 异常时触发告警
- 日志查询:管理员设置查询条件 → 系统检索日志 → 展示日志列表
- 告警处理:管理员查看告警 → 处理告警 → 记录处理结果
### 1. 制度文件查看和学习
**页面布局**:
- **顶部工具栏**:
- 文件信息区:制度名称、版本号、生效日期、密级标识
- 阅读工具区:目录切换按钮、搜索框、缩放控制(50%-200%)、页面旋转、视图模式切换(单页/双页/连续滚动)
- 批注工具区:高亮工具(多种颜色)、下划线、删除线、文本批注、便签批注、图形批注(矩形、圆形、箭头)、手写批注(钢笔、水彩笔、智能笔)
- AI工具区:智能总结按钮、全文总结按钮、词句解释开关、引文提示开关
- 功能按钮区:语音朗读、导出笔记、打印、下载、分享
- **左侧边栏**(可切换标签页):
- 目录标签:文档目录树(可展开/收起,支持点击跳转)
- 缩略图标签:页面缩略图预览(支持点击跳转)
- 批注标签:批注列表(显示所有批注,支持点击定位)
- 笔记标签:笔记汇总(按文档汇总所有笔记,支持搜索和导出)
- **中间主区域**:
- PDF内容区:文件内容展示(支持缩放、滚动、页面切换)
- AI总结浮窗:右键或悬停时显示段落智能总结
- 词句解释弹窗:划词选中后弹出解释说明
- 引文提示浮窗:悬停引文时显示引用信息
- **右侧边栏**:
- 权限配置区:角色列表、菜单树、数据范围、文件权限设置
- 关联推荐区:"相关推荐"侧边栏(展示推荐项:标题、摘要、标签)
- AI全文总结区:显示文章摘要、核心要点、关键结论
- **底部状态栏**:
- 阅读进度:当前页/总页数、阅读进度百分比、阅读时长统计
- 导航控制:上一页/下一页按钮、页面跳转输入框、前进/后退按钮
- 同步状态:多设备同步状态指示
**交互设计**:
- 智能PDF阅读器功能:
- **基础阅读功能**:点击目录项快速跳转到对应章节、搜索关键词后高亮显示匹配内容、支持页面缩放(50%-200%)、页面旋转、单页/双页/连续滚动视图切换
- **批注与高亮功能**:支持鼠标选择文本进行高亮(多种颜色)、下划线、删除线标记;支持添加文本批注、便签批注、图形批注(矩形、圆形、箭头);支持手写批注(钢笔、水彩笔、智能笔)
- **AI智能辅助功能**:
- **智能总结**:右键点击段落自动生成要点总结,鼠标悬停自动弹出对应段落总结
- **全文总结**:自动提取文章摘要、核心要点、关键结论
- **词句解释**:划词选中生僻词汇或专业术语,自动弹出解释说明
- **引文提示**:鼠标悬停在引文上自动显示引用信息,无需翻页
- **笔记管理功能**:所有批注和笔记自动汇总到笔记本,支持按文档查看所有笔记,支持笔记搜索和导出
- **阅读进度功能**:阅读进度自动保存,支持多设备同步阅读位置,显示当前页/总页数、阅读进度百分比
- **文档导航功能**:支持缩略图预览快速跳转、页面跳转输入、前进/后退导航
- 权限精细化管理功能:点击角色名称查看/编辑权限、勾选菜单树和组织树进行权限配置、批量授权功能支持选择多个用户和文件、权限变更实时生效(延迟≤5秒)
- 新岗人员学习地图功能:点击阶段卡片展开/收起该阶段任务、点击任务项开始学习或查看详情、学习进度实时同步、到期任务自动提醒
- 语音朗读功能:点击播放按钮开始朗读、调整语速和音调、选择朗读范围(当前段落、当前章节、全文)、支持后台播放
- 关联推荐功能:点击推荐项跳转到对应制度文件、滚动加载更多推荐内容、推荐结果可配置开关
### 2. 制度文件管理
**页面布局**:
- 顶部:制度文件名称、操作按钮(上传新版本、对比版本、回滚版本、扫描受影响题目、生成新题)、OA对接配置(接口地址、认证方式、同步策略)、同步配置(同步策略、调度时间、订阅管理)
- 左侧:版本列表(版本号、创建时间、创建人、变更说明、操作按钮)、受影响题目列表(题目ID、内容、关联制度、原版本、状态)
- 中间:同步日志(同步时间、同步内容、状态、错误信息)、同步记录(制度名称、变更类型、同步时间、状态、推送用户数)
- 右侧:对比视图(左右两栏展示不同版本差异,高亮显示新增/删除/修改)、AI生成题目预览(题型、题目内容、答案、解析、操作)
- 底部:反馈工单列表(工单编号、来源制度、反馈内容、处理状态)、订阅列表(用户姓名、订阅制度、接收方式)、试卷模板列表(模板名称、关联制度版本、题目分布、更新时间)
**交互设计**:
- 版本管理功能:点击版本号查看该版本详情、选择两个版本进行对比、点击回滚按钮回滚到指定版本、上传新版本时自动递增版本号
- OA系统深度对接功能:配置OA接口参数、查看同步日志和状态、处理反馈工单、支持增量更新
- 知识库自动同步功能:配置同步策略(全量/增量)、查看同步记录和状态、管理用户订阅、制度变更后自动推送更新提醒
- 题库智能维护功能:扫描制度变更影响的题目、审核AI生成的新题目、更新试卷模板、批量处理受影响题目
### 3. 知识问答
**页面布局**:
- 顶部:上下文提示(显示当前对话轮数)、答案区域(回答正文)、反馈按钮(点赞、点踩)
- 左侧:会话列表(会话标题、创建时间、最后更新时间、消息数)
- 中间:对话窗口(历史消息、输入框、发送按钮)、分析报告(报告周期、高频问题Top N、低分答案列表、未命中问题列表)
- 右侧:跳转按钮(点击来源后跳转到阅读器对应位置)、向量库管理(知识库状态、索引统计、重建索引按钮)
- 底部:引用来源列表(制度名称、条款号、片段)、置信度标签、FAQ管理(FAQ列表、支持增删改查)、学习报告(提问总数、高频知识点、薄弱环节、推荐学习内容)
**交互设计**:
- RAG向量数据库功能:用户提问 → 混合检索(关键词+语义+结构化) → 获取相关文本块 → 组装上下文 → 调用大模型生成答案 → 返回
- 多轮智能对话功能:点击会话列表项切换会话、在输入框中输入问题并发送、支持追问和多轮对话、会话历史自动保存
- 答案溯源功能:点击引用来源跳转到原文、查看答案的置信度评分、低置信度答案提示人工复核
- 问答质量闭环功能:对答案进行点赞/点踩反馈、查看质量分析报告、管理FAQ列表、定期生成报告并优化问答质量
- 会话管理功能:搜索和删除会话、回顾历史问答记录、查看个人学习报告、基于报告推荐学习内容
### 4. 考察和训练
**页面布局**:
- 顶部:出题配置(选择制度、题型、数量、难度等级)、筛选条件(按制度、题型、难度、状态、标签)、组卷配置(选择模板、或手动设置各题型数量、难度比例、关联制度)、考试结果页(总分、用时、各题得分)、训练模式选择(闯关模式、每日一练、错题本)
- 左侧:题目列表(题目ID、内容、题型、难度、关联制度、标签、状态、操作)、闯关地图(关卡列表、通关状态、得分)
- 中间:生成结果(题目预览、答案、解析、关联知识点)、试卷预览(题目列表、总分、建议时长)、主观题详情(考生答案、标准答案、AI评语)
- 右侧:统计图表(总题量、题型分布、使用率、正确率趋势)、每日一练(推荐题目、每日刷新)
- 底部:操作按钮(采纳、修改、重新生成、保存至题库、生成试卷、保存模板、发布考试、导出)、过期题目提示(高亮显示,支持批量处理)、申诉按钮(对主观题评分有异议时可提交人工复核)、错题本(错题列表、可重练、查看解析)
**交互设计**:
- AI智能出题功能:配置出题参数、查看AI生成的题目、编辑和审核题目、保存题目至题库
- 题库管理功能:增删改查题目、批量导入导出题目、查看题目统计数据、处理过期题目
- 智能组卷功能:配置组卷条件、生成试卷并预览、调整题目顺序、保存模板和发布考试
- AI自动阅卷功能:查看考试成绩和各题得分、查看主观题评语和得分点/失分点、提交申诉进行人工复核、支持同义词匹配(如“三日”=“3天”)
- 互动式训练功能:选择训练模式、进行闯关练习、完成每日一练、重练错题并查看解析
### 5. 纲要学习(思维导图)
**页面布局**:
- 顶部:操作按钮(生成思维导图、导出图片、导出PDF、全屏、重置视图、查看详情、添加笔记、编辑节点、分享)
- 中间:思维导图视图(可拖拽、缩放、展开/收起的节点图)
- 右侧:节点信息(节点标题、核心要点)、节点详情弹窗(节点对应原文片段、跳转原文链接)
- 底部:笔记区域(在节点旁显示笔记图标,点击可查看/编辑)
**交互设计**:
- 自动化纲要生成功能:点击“生成思维导图”按钮解析文件、拖拽和缩放思维导图、展开/收起节点、导出思维导图为图片或PDF
- 导图学习功能:点击节点查看详情和原文片段、添加和编辑笔记、自定义思维导图节点、分享思维导图
### 6. 综合看板
**页面布局**:
- 顶部:筛选条件(时间范围、组织范围)、预警规则配置(学习预警阈值、更新预警提前天数、风险预警问题频次阈值)、报告模板配置(报告名称、周期、包含内容)
- 中间:全局概览(卡片、趋势折线图)、预警看板(预警类型、预警内容、级别、时间、操作)、报告列表(报告名称、生成时间、状态、操作)
- 右侧:组织架构视图(树形结构,点击部门显示学习完成率、考试通过率、活跃度排名)
- 底部:制度维度分析(制度热度排行榜、冷门制度列表、宣贯效果评估)、处理记录(已处理预警的历史记录)、推送设置(接收邮箱、推送时间)
**交互设计**:
- 数据可视化功能:切换时间范围和组织范围、下钻查看部门详情、导出图表和数据、实时刷新数据(延迟≤10秒)
- 智能预警功能:配置预警规则、查看预警信息和详情、处理预警事项、查看处理历史记录
- 决策支持功能:配置报告模板、生成和下载报告、设置报告推送、查看历史报告
## 响应式设计
### 1. 桌面端(≥1200px)
- 完整三栏布局
- 左侧菜单栏展开状态
- 所有功能完整显示
### 2. 平板端(768px-1199px)
- 左侧菜单栏可折叠
- 主内容区自适应宽度
- 部分表格和图表调整为横向滚动
### 3. 移动端(<768px)
- 左侧菜单栏隐藏(可通过汉堡菜单展开)
- 顶部导航栏简化
- 内容垂直堆叠
- 表单和按钮适配触摸操作
## 页面交互设计
### 1. 通用交互
- **悬停效果**:按钮、链接、卡片等元素悬停时显示反馈
- **点击效果**:按钮点击时显示按压效果
- **加载状态**:数据加载时显示加载动画
- **错误提示**:操作失败时显示错误提示
- **成功提示**:操作成功时显示成功提示
- **确认对话框**:重要操作前显示确认对话框
### 2. 特殊交互
- **拖拽排序**:支持拖拽调整题目顺序、节点位置等
- **批量操作**:支持选择多个项目进行批量处理
- **实时搜索**:输入关键词时实时显示搜索结果
- **自动保存**:编辑内容时自动保存
- **键盘快捷键**:常用操作支持键盘快捷键
## 页面元素设计
### 1. 按钮
| 类型 | 样式 | 用途 |
|------|------|------|
| 主要按钮 | 蓝色背景,白色文字 | 主要操作,如提交、保存、确认 |
| 次要按钮 | 白色背景,蓝色边框和文字 | 次要操作,如取消、返回 |
| 危险按钮 | 红色背景,白色文字 | 危险操作,如删除、回滚 |
| 文本按钮 | 无背景,蓝色文字 | 辅助操作,如查看详情、编辑 |
| 图标按钮 | 无背景,仅图标 | 空间有限时的操作,如搜索、刷新 |
### 2. 表单元素
| 类型 | 样式 | 用途 |
|------|------|------|
| 输入框 | 白色背景,灰色边框,聚焦时蓝色边框 | 文本输入 |
| 下拉框 | 白色背景,灰色边框,右侧下拉箭头 | 选择选项 |
| 复选框 | 方形,未选中时灰色边框,选中时蓝色背景和白色对勾 | 多选 |
| 单选框 | 圆形,未选中时灰色边框,选中时蓝色背景和白色圆点 | 单选 |
| 日期选择器 | 输入框+日历图标,点击弹出日历 | 选择日期 |
| 上传组件 | 虚线边框,点击或拖拽上传文件 | 文件上传 |
### 3. 卡片
- 白色背景,轻微阴影
- 圆角设计(4px-8px)
- 顶部标题栏
- 底部操作栏(可选)
### 4. 表格
- 白色背景
- 表头灰色背景
- 行交替颜色(白色和浅灰色)
- 悬停行高亮
- 支持排序、筛选、分页
### 5. 图表
- 使用现代图表库(如ECharts)
- 配色方案统一
- 支持交互(如悬停提示、点击下钻)
- 响应式调整
## 配色方案
### 1. 主色调
- **主色**:蓝色 (#1890FF) - 代表专业、信任、科技
- **辅助色**:绿色 (#52C41A) - 代表成功、进步
- **警告色**:黄色 (#FAAD14) - 代表警告、提醒
- **错误色**:红色 (#F5222D) - 代表错误、危险
### 2. 中性色
- **背景色**:白色 (#FFFFFF)、浅灰色 (#F5F5F5)、中灰色 (#E8E8E8)
- **文字色**:深灰色 (#333333)、中灰色 (#666666)、浅灰色 (#999999)
- **边框色**:浅灰色 (#E8E8E8)、中灰色 (#D9D9D9)
### 3. 功能色
- **链接色**:蓝色 (#1890FF)
- **高亮色**:蓝色 (#E6F7FF)
- **禁用色**:浅灰色 (#BFBFBF)
## 字体方案
### 1. 字体选择
- **主要字体**:微软雅黑、宋体(中文)
- **备选字体**:Arial、Helvetica(英文)
### 2. 字体大小
| 级别 | 大小 | 用途 |
|------|------|------|
| 标题1 | 20px | 页面标题 |
| 标题2 | 16px | 模块标题 |
| 标题3 | 14px | 区域标题 |
| 正文 | 14px | 正文内容 |
| 小字体 | 12px | 辅助文字、提示信息 |
### 3. 字体粗细
- **粗体**:600 - 标题、强调文字
- **常规**:400 - 正文内容
- **细体**:300 - 辅助文字
## 图标方案
### 1. 图标库
- 使用 Ant Design Icons 或 Font Awesome
- 保持图标风格统一
### 2. 图标分类
| 类别 | 示例图标 | 用途 |
|------|----------|------|
| 导航图标 | 菜单、首页、设置 | 导航菜单 |
| 操作图标 | 编辑、删除、添加、搜索 | 功能操作 |
| 状态图标 | 成功、警告、错误、信息 | 状态提示 |
| 功能图标 | 文件、PDF、Word、Excel | 功能标识 |
### 3. 图标尺寸
- **导航图标**:20px × 20px
- **操作图标**:16px × 16px
- **状态图标**:14px × 14px
- **小图标**:12px × 12px
## 页面加载与性能优化
### 1. 加载策略
- **懒加载**:图片、非首屏内容懒加载
- **按需加载**:模块按需加载,减少初始加载时间
- **预加载**:预加载可能需要的资源
### 2. 性能优化
- **代码分割**:将代码分割为多个小块,按需加载
- **资源压缩**:压缩CSS、JS、图片等资源
- **缓存策略**:合理设置缓存,减少重复请求
- **CDN加速**:使用CDN加速静态资源
### 3. 响应时间
- 页面加载时间:≤3秒
- 操作响应时间:≤1秒
- 数据加载时间:≤2秒(大数据除外)
## 附录
### 1. 页面设计规范
- 所有页面遵循统一的设计规范
- 保持视觉一致性和用户体验一致性
- 确保所有功能点都有对应的页面设计
### 2. 前端技术栈
- **框架**:React / Vue
- **UI库**:Ant Design / Element UI
- **图表库**:ECharts
- **状态管理**:Redux / Vuex
- **路由**:React Router / Vue Router
- **构建工具**:Webpack / Vite
### 3. 设计工具
- **原型设计**:Figma / Sketch / Axure
- **UI设计**:Figma / Sketch
- **图标设计**:Iconfont / Sketch
### 4. 交付物
- 页面设计文档(本文档)
- 高保真原型图
- UI设计稿
- 前端代码实现
## 版本历史
| 版本 | 修订日期 | 修订内容 | 修订人 |
|------|----------|----------|--------|
| V1.2 | 2026-04-01 | 1. 重新整合各模块结构,将次级功能作为大功能中的小功能,使模块结构更加清晰
2. 优化页面布局和交互设计描述,确保功能完整性和一致性
3. 增强知识问答模块的RAG向量数据库功能描述
4. 完善通用流程设计,确保系统流程的完整性 | 系统设计团队 |
| V1.1 | 2026-04-01 | 1. 整合次级功能到主级功能描述中,使每个模块成为完整功能单元
2. 添加通用流程设计部分,包括用户注册/登录、文件上传、权限审批、系统配置、数据备份/恢复、系统监控/日志等流程
3. 在知识问答模块中添加RAG向量数据库功能
4. 更新功能级导航,使用更准确的功能名称 | 系统设计团队 |
| V1.0 | 2026-04-01 | 初始版本 | 系统设计团队 |

View File

@@ -1,305 +0,0 @@
toolName: view\_files
status: success
filePath: d:\Users\19211\Desktop\demo\QAModule.vue
我来查看一下 `aiAPI.sendMessage` 的实现,这很可能是问题所在。
toolName: file\_search
status: success
file\_pattern: \*\*/ai.js
toolName: file\_search
status: success
file\_pattern: **/api/**/\*.{js,ts}
toolName: search\_by\_regex
status: success
query: aiAPI
toolName: search\_by\_regex
status: success
query: sendMessage
toolName: search\_by\_regex
status: success
query: import.\*aiAPI
toolName: file\_search
status: success
file\_pattern: **/api/**
toolName: search\_by\_regex
status: success
query: export.\*sendMessage
toolName: view\_folder
status: success
d:\Users\19211\Desktop\demo
toolName: file\_search
status: success
file\_pattern: \*\*/\*.vue
toolName: search\_by\_regex
status: success
query: @/api
toolName: file\_search
status: success
file\_pattern: \*\*/\*.js
toolName: file\_search
status: success
file\_pattern: \*\*/\*.ts
toolName: view\_files
status: success
filePath: d:\Users\19211\Desktop\demo\前端示例.md
我发现问题了。您的 `QAModule.vue` 代码本身有打字机效果的实现逻辑,但问题很可能是 **`aiAPI.sendMessage`** **没有真正实现流式读取**。
让我查看一下后端 `FileService` 的 `streamFromRagService` 方法,了解它返回的数据格式:
toolName: search\_by\_regex
status: success
query: streamFromRagService
toolName: search\_by\_regex
status: success
query: streamFromRagService
toolName: search\_by\_regex
status: success
query: streamFromRagService
toolName: view\_files
status: success
filePath: d:\Users\19211\Desktop\demo\src\main\java\top\tqx\demo\_1\service\impl\FileServiceImpl.java
我找到问题根源了!您的代码中 **`@/api/ai`** **文件不存在**,所以我无法看到 `aiAPI.sendMessage` 的具体实现。但根据代码分析,问题很可能出在 **`aiAPI.sendMessage`** **没有真正实现流式读取**。
## 问题诊断
后端 `streamFromRagService` 是**真正的 SSE 流式输出**(第521行 `emitter.send(event)`),问题在于**前端 API 层没有正确解析 SSE 流**。
您需要创建一个 `@/api/ai.js` 文件来实现正确的流式读取。以下是修复后的代码:
```javascript
// src/api/ai.js 或 @/api/ai.js
import request from '@/utils/request'
export default {
sendMessage(params, onStart, onMessage, onSource, onReference, onError, onComplete) {
const controller = new AbortController()
// 1. 先发送请求
fetch('/ai/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(params),
signal: controller.signal
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
// 2. 开始读取流
function read() {
reader.read().then(({ done, value }) => {
if (done) {
// 处理缓冲区中剩余的数据
if (buffer.trim()) {
try {
const data = JSON.parse(buffer.trim())
if (data.type === 'content' && onMessage) {
onMessage(data.content)
}
} catch (e) {}
}
if (onComplete) onComplete({})
return
}
// 解码并添加到缓冲区
buffer += decoder.decode(value, { stream: true })
// 处理每一行(处理不完整的数据行)
const lines = buffer.split('\n')
buffer = lines.pop() // 保留最后未完成的一行
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6).trim()
if (!dataStr || dataStr === '[DONE]') continue
try {
const data = JSON.parse(dataStr)
switch (data.type) {
case 'content':
// 关键:每个字符块都立即调用回调!
if (onMessage) onMessage(data.content)
break
case 'source':
case 'sources':
if (onSource) onSource(data)
break
case 'reference':
if (onReference) onReference(data)
break
case 'session_id':
if (onComplete) onComplete({ sessionId: data.session_id })
break
case 'error':
if (onError) onError(data.message)
break
case 'finish':
case 'result':
// 收集最终结果
if (onComplete) onComplete({
answer: data.answer || '',
sources: data.sources || []
})
break
default:
// 其他事件类型也处理
if (data.answer && onMessage) {
// 有些服务把完整答案放在 answer 字段
onMessage(data.answer)
}
}
} catch (e) {
// JSON 解析失败,忽略该行
}
}
}
// 继续读取
read()
})
}
read()
})
.catch(error => {
if (error.name === 'AbortError') {
// 请求被取消
} else if (onError) {
onError(error.message)
}
})
return controller
},
cancelRequest(controller) {
if (controller) {
controller.abort()
}
},
getSessions(params) {
return request.get('/ai/sessions', { params })
},
getSessionDetail(sessionId, params) {
return request.get(`/ai/session/${sessionId}`, { params })
},
deleteSession(sessionId) {
return request.delete(`/ai/session/${sessionId}`)
}
}
```
## 关键点说明
### 1. 为什么"一下子输出出来"?
问题在于 **`aiAPI.sendMessage`** **可能没有按 chunk 逐字调用** **`onMessage`**,而是:
- 要么收集完所有数据后一次性传给前端
- 要么每次收到多个字符才调用一次 `onMessage`
### 2. 修复方法
确保 **`onMessage`** **回调是每个字符块都被调用**,而不是批量处理:
```javascript
// ✅ 正确:每个chunk立即传递
case 'content':
if (onMessage) onMessage(data.content) // 可能是1个字符,也可能是多个
break
// ❌ 错误:收集后一次性传递
case 'content':
buffer += data.content
if (buffer.length >= 10) { // 等积累到一定量才发送
onMessage(buffer)
buffer = ''
}
break
```
### 3. 打字机效果的问题
即使 `onMessage` 被正确调用,您代码中的打字机效果 `startTypingEffect` **每次只添加 1-3 个字符**(第864行),这意味着如果 `onMessage` 一次收到超过 3 个字符,就会出现跳跃感。
建议检查 `onMessage` 回调是否真的被**逐字调用**:
```javascript
// 在 QAModule.vue 的 handleSend 中添加调试:
onMessage: (contentChunk) => {
currentAIMessage.value += contentChunk
console.log(`📥 chunk长度: ${contentChunk.length}, 内容: "${contentChunk}"`)
// ...
}
```
如果调试日志显示 `chunk长度` 经常大于 3,那打字效果就会显得不流畅。
您需要确认 `@/api/ai.js` 文件的存在和实现,或者如果有其他 API 文件路径,请告诉我它的位置。

View File

@@ -1,345 +0,0 @@
# 后端问题诊断报告
> 生成时间:2026-05-31
> 涉及模块:考察训练系统 - 考试提交 / AI批改服务
> 问题级别:**P1 - 高(功能不可用)**
---
## 一、问题描述
### 1.1 现象
用户在正式考试中提交试卷(含空白答案或已作答答案)时,前端收到 **HTTP 500 错误**,考试记录无法正常结束。
### 1.2 复现步骤
1. 进入「考察和训练」→ 选择试卷 → 开始考试
2. 不作答(或部分作答)任何题目
3. 点击「提交试卷」按钮
4. 前端控制台输出错误日志(见下方)
5. 页面提示「提交失败」或「系统异常」
### 1.3 前端控制台错误日志
```
[提交] ⚠️ 空白试卷提交 — recordId: fa779367-bebb-48e7-af75-68e582c8ae7b 总题数: 5
[examAPI] submit(空白提交) - recordId: fa779367-bebb-48e7-af75-68e582c8ae7b
[提交] submit(结束考试) 失败:
message: 系统异常
无response对象(可能是网络错误/CORS)
[examAPI] 正式考试提交批改 - recordId: fa779367-bebb-48e7-af75-68e582c8ae7b 题目数: 5
[提交] gradeExamRecord(空答案) 失败:
message: 提交失败: Service层批改题目失败: 调用AI服务失败:
POST请求失败: 500 INTERNAL SERVER ERROR:
"{"error_code":"GRADE_ERROR",
"message":"'NoneType' object has no attribute 'get'",
"status":"failed",
"status_code":5021,
"success":false}"
[提交] 所有2种提交方式均失败
```
---
## 二、根因分析
### 2.1 直接原因:Python AI 批改服务崩溃
后端 Java 服务在处理考试提交时,调用 Python AI 服务进行自动批改。Python 服务在接收到**空答案数据**时发生 `TypeError`:
```
'NoneType' object has no attribute 'get'
```
**错误位置**:Python AI 批改服务的答案解析/评分逻辑中
**触发条件**:`answers` 参数为空列表、空字典或 `None` 时,代码对 `None` 对象调用了 `.get()` 方法
### 2.2 根本原因:所有提交接口均强制调用 AI 批改
| 接口 | HTTP方法 | 路径 | 内部行为 | 受影响 |
|------|---------|------|---------|--------|
| `submitExam()` | POST | `/admin/exam/paper/{recordId}/submit` | → `gradeAnswersForRecord()` → **调用Python AI** | ✅ 全部用户 |
| `gradeAnswers()` | POST | `/admin/exam/paper/{recordId}/grade` | → **调用Python AI** | ✅ 全部用户 |
| `gradeAnswersForRecord()` | POST | `/exam/record/{recordId}/grade` | → **调用Python AI** | ✅ 全部用户 |
**关键发现**:后端**不存在**"仅更新状态、不触发AI批改"的纯提交接口。`submit` 接口虽然名称为"提交",但内部实际执行的是完整的AI批改流程。**
### 2.3 数据流图
```
┌──────────┐ POST /record/{id}/submit ┌─────────────────────┐
│ 前端 │ ──────────────────────────────→ │ AdminExamController │
│ 提交 │ body: {} (空白) │ .submitExam() │
└──────────┘ └──────────┬──────────┘
│
examService.gradeAnswersForRecord()
│
┌─────────▼──────────┐
│ Java Service层 │
│ 组装answers参数 │
└─────────┬──────────┘
│
HTTP POST (内部调用)
│
┌─────────▼──────────┐
│ Python AI 批改服务 │
│ │
│ answers = None/[] │ ← 空值传入
│ ↓ │
│ data.get('xxx') │ ← 💥 TypeError!
│ 'NoneType' object │
│ has no attribute │
│ 'get' │
└─────────┬──────────┘
│
返回 HTTP 500 + error_code=5021
│
┌─────────▼──────────┐
│ AdminExamController │
│ catch(Exception) │
│ return Result.error │
│ (500, e.getMessage())│
└─────────┬──────────┘
│
HTTP 500 { code:500, message:"..." }
│
axios拦截器包装 Error("系统异常")
│
前端显示 "提交失败: 系统异常"
```
---
## 三、影响范围
### 3.1 功能影响
| 功能 | 影响 | 说明 |
|------|------|------|
| 正式考试提交(空白卷) | ❌ 完全不可用 | 所有题型答案为空时必现 |
| 正式考试提交(有答案) | ⚠️ 可能不可用 | 取决于Python服务是否对所有题型都做了空值保护 |
| 日常练习提交 | ⚠️ 可能受影响 | 同样走AI批改流程 |
| 试卷发布 | ⚠️ 需单独验证 | 发布接口不涉及AI批改,但当前也报错(需进一步排查) |
### 3.2 用户影响
- **全部角色用户**受影响(系统管理员、部门管理员、普通用户)
- 无法完成考试流程闭环(开始考试 → 提交 → 查看成绩)
- 已开始的考试记录卡在 `not_started` 或中间状态
---
## 四、修复方案
### 方案A:Python AI 服务空值防御(推荐 - 快速修复)
**修改文件**:Python AI 批改服务入口文件(具体路径待确认)
**修复内容**:在接收 `answers` 参数后立即做空值检查
```python
# === 修复前 ===
def grade_answers(questions, answers):
for q in questions:
ans = answers.get(q['id']) # ← answers=None时崩溃
score = calculate_score(ans)
# === 修复后 ===
def grade_answers(questions, answers):
# 防御性检查:空值直接返回0分
if not answers or not isinstance(answers, (dict, list)):
return {
"score": 0,
"total_score": 0,
"details": [],
"success": True,
"message": "空白试卷,无需批改"
}
if isinstance(answers, dict) and len(answers) == 0:
return {"score": 0, "total_score": 0, "details": [], "success": True}
if isinstance(answers, list) and all(
a is None or a == '' or a.get('answer', '') == ''
for a in answers if isinstance(a, dict)
):
return {"score": 0, "total_score": 0, "details": [], "success": True}
# 正常批改逻辑...
for q in questions:
ans = answers.get(q['id']) if isinstance(answers, dict) else None
if ans is None or ans == '':
continue # 跳过无答案的题目
score = calculate_score(ans)
```
**优点**:
- 改动最小,只修改Python侧
- 不影响现有正常答题的批改逻辑
- 空白试卷自动得0分,符合业务预期
**缺点**:
- 未解决架构问题(submit接口仍强依赖AI服务)
---
### 方案B:新增纯提交接口(推荐 - 完整修复)
**修改文件**:
- `AdminExamController.java` — 新增接口
- `ExamPaperService.java` / `ExamPaperServiceImpl.java` — 新增方法
#### B.1 后端新增接口
```java
// AdminExamController.java - 新增
@Operation(summary = "结束考试(不批改)", description = "仅更新考试状态为已完成,不做AI批改")
@PostMapping("/admin/exam/paper/{recordId}/finish")
public Result<Void> finishExam(
@PathVariable String recordId,
HttpServletRequest httpRequest) {
try {
Long userId = getCurrentUserId(httpRequest);
ExamRecord record = examRecordMapper.selectById(recordId);
if (record == null) {
return Result.error(404, "考试记录不存在");
}
// 仅更新状态,不调用AI批改
record.setStatus("completed");
record.setScore(0); // 空白卷默认0分
record.setSubmitTime(LocalDateTime.now());
record.setUpdatedAt(LocalDateTime.now());
examRecordMapper.updateById(record);
log.info("考试结束(未批改): recordId={}, userId={}", recordId, userId);
return Result.success();
} catch (Exception e) {
log.error("结束考试失败: {}", e.getMessage(), e);
return Result.error(500, e.getMessage());
}
}
```
#### B.2 前端对接
```javascript
// useExam.js - 空白试卷优先使用finish接口
if (isBlankSubmission) {
_variants.push({
label: 'finishExam(结束考试)',
fn: () => examAPI.finishExam(recordId) // 新增API
})
_variants.push({
label: 'submit(结束考试)',
fn: () => examAPI.submit(recordId) // 兜底
})
} else {
// 有答案的正常走AI批改
_variants.push({
label: 'gradeExamRecord(批改)',
fn: () => examAPI.gradeExamRecord(recordId, formattedAnswers)
})
// ...
}
```
```javascript
// exam.js API层新增
finishExam: (recordId) => {
if (!recordId) throw new Error('考试记录ID不能为空')
return examClient.post(`/admin/exam/paper/${encodeURIComponent(recordId)}/finish`, {}, { timeout: 15000 })
.then(handleExamResponse)
},
```
**优点**:
- 彻底解耦"提交"与"批改"
- 空白试卷不再触发AI服务
- 符合单一职责原则
- 可后续异步补批改
**缺点**:
- 需要前后端配合改动
- 空白卷暂无成绩明细(可后续补充手动打分功能)
---
### 方案C:Java Service 层增加空值保护(折中方案)
**修改文件**:`ExamServiceImpl.java` 的 `gradeAnswersForRecord()` 方法
```java
@Override
public ExamRecordResponse gradeAnswersForRecord(String recordId, ExamSaveRequest request, Long userId, String answerType) {
// 新增:前置空值检查
List<AnswerItem> answers = request.getAnswers();
if (answers == null || answers.isEmpty() ||
answers.stream().allMatch(a -> a.getAnswer() == null || a.getAnswer().isBlank())) {
log.info("检测到空白试卷,跳过AI批改: recordId={}", recordId);
// 直接更新状态为已完成,分数为0
ExamRecord record = examRecordMapper.selectOne(
new LambdaQueryWrapper<ExamRecord>().eq(ExamRecord::getRecordId, recordId));
if (record != null) {
record.setStatus("completed");
record.setScore(0);
record.setSubmitTime(LocalDateTime.now());
examRecordMapper.updateById(record);
}
return ExamRecordResponse.builder()
.recordId(recordId)
.score(0)
.totalScore(0)
.status("completed")
.build();
}
// 原有AI批改逻辑...
}
```
**优点**:
- 只改Java层,不动Python
- 对前端透明,无需前端改动
- 快速上线
**缺点**:
- submit接口语义仍混乱(名为提交实为批改)
- 未从根本上解决问题
---
## 五、推荐实施计划
| 优先级 | 方案 | 工作量 | 建议 |
|--------|------|--------|------|
| **P0 - 立即** | **方案C** | 30min | 先恢复基本功能 |
| **P1 - 本周** | **方案A** | 1h | Python侧彻底修复 |
| **P2 - 迭代** | **方案B** | 3h | 架构优化 |
**建议先实施方案C快速止血,再按方案A+方案B逐步完善。**
---
## 六、相关附件
### 6.1 涉及的后端文件清单
| 文件 | 路径 | 作用 |
|------|------|------|
| `AdminExamController.java` | `demo/src/main/java/top/tqx/demo_1/controller/` | 考试管理REST接口 |
| `ExamController.java` | `demo/src/main/java/top/tqx/demo_1/controller/` | 用户侧考试接口 |
| `ExamPaperService.java` | `demo/src/main/java/top/tqx/demo_1/service/` | 考试业务接口定义 |
| `ExamPaperServiceImpl.java` | `demo/src/main/java/top/tqx/demo_1/service/impl/` | 考试业务实现(含AI调用) |
| Python AI批改服务 | 待确认路径 | 自动评分核心逻辑 |
### 6.2 涉及的前端文件清单(已优化)
| 文件 | 路径 | 已完成的优化 |
|------|------|------------|
| `useExam.js` | `src/components/exam/composables/` | 空白试卷优先走submit;增强错误诊断日志 |
| `PaperManagementPanel.vue` | `src/components/exam/` | 发布错误增强诊断(originalError回溯) |

View File

@@ -0,0 +1,448 @@
# 后端问题诊断报告:题目编辑保存 HTTP 500
> 生成时间:2026-06-08
> 涉及接口:`PUT /api/question/{id}`(更新题目)
> 问题级别:**P1 - 高(功能不可用)**
> 报告范围:后端全链路深度分析
---
## 一、错误现象
### 1.1 前端控制台日志
```
[保存] 更新题目 - id: e6bb2566-90a8-4651-b553-083a1d695b3c type: single_choice
[保存] 发送 payload: { "question_type": "single_choice", "difficulty": 2, "content": { ... } }
[error] [保存] 更新题目失败: {
status: 500,
statusText: "",
serverData: { code: 500, message: "服务器内部错误", success: false },
message: "Request failed with status code 500"
}
```
### 1.2 用户操作路径
题库管理与审批 → 题目列表 → 点击某题的「编辑」按钮 → 修改内容 → 点击「保存修改」→ HTTP 500
---
## 二、完整调用链路
```
┌─────────────────────────────────────────────────────────────────────┐
│ 前端 QuestionBankPanel.vue │
│ saveEdit() │
│ rec.id = UUID (如 "e6bb2566-...") │
│ updateData = { question_type, difficulty, content:{stem,data,answer} } │
│ │
│ PUT /api/question/{UUID} body: updateData │
└───────────────────────────────┬─────────────────────────────────────┘
│ HTTP PUT
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 QuestionController.java (L91-122) │
│ @PutMapping("/{id}") │
│ public Result<Question> updateQuestion(@PathVariable String id, │
│ @RequestBody QuestionUpdateRequest request) │
│ │
│ ① isNumeric(id)? NO → 走UUID分支 │
│ ② LambdaQueryWrapper.eq(Question::getQuestionId, id) │
│ ③ existing = questionService.getOne(wrapper) │
│ ④ questionService.updateQuestion(existing.getId(), request) │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 QuestionServiceImpl.java (L194-247) │
│ question = getById(id) // 从DB加载完整对象 │
│ │
│ if (request.getQuestionType() != null) │
│ → setQuestionType(...) │
│ if (request.getContent() != null) │
│ → setContent(objectMapper.valueToTree(content)) ⚠️ 崩溃点A │
│ if (request.getScore() != null) │
│ → setScore(...) │
│ // ... 其他字段 ... │
│ │
│ setUpdatedAt(LocalDateTime.now()) │
│ updateById(question) ⚠️ 崩溃点B (写DB) │
└───────────────────────────────┬─────────────────────────────────────┘
│ MyBatis-Plus UPDATE SQL
▼
┌─────────────────────────────────────────────────────────────────────┐
│ MySQL question 表 │
│ content json NOT NULL ← JacksonTypeHandler 序列化 │
│ score decimal(5,2) NOT NULL ← 精度/范围约束 │
│ knowledge_base_path varchar(500) ← 长度约束 │
└───────────────────────────────┬─────────────────────────────────────┘
│ SQL执行异常
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 后端 GlobalExceptionHandler.java (L84-108) │
│ @ExceptionHandler(RuntimeException.class) │
│ → log.error("运行时异常: type={}, message={}", ...) │
│ → return Result.error(500, "服务器内部错误") ← 吞掉真实信息! │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 三、根因分析:所有可能的崩溃点(按风险等级排序)
### 崩溃点 #1 [致命级] JacksonTypeHandler 序列化 JsonNode 异常
**位置**: `QuestionServiceImpl.java:207` + `Question.java:63-64`
**代码**:
```java
// Question.java L63-64
@TableField(typeHandler = JacksonTypeHandler.class)
private JsonNode content; // Entity类型: JsonNode
// QuestionUpdateRequest.java L22-23
@JsonProperty("content")
private Map<String, Object> content; // DTO类型: Map
// QuestionServiceImpl.java L206-207
if (request.getContent() != null) {
question.setContent(objectMapper.valueToTree(request.getContent()));
// ↑ Map<String,Object> → JsonNode 转换
}
// QuestionServiceImpl.java L44 — 裸ObjectMapper,无任何配置!
private final ObjectMapper objectMapper = new ObjectMapper();
```
**崩溃机制**:
```
updateById(question)
→ MyBatis遇到 @TableField(typeHandler=JacksonTypeHandler)
→ JacksonTypeHandler.setParameter() 内部调用:
objectMapper.writeValueAsString(jsonNode)
→ 如果JsonNode包含非法值 → JsonProcessingException
→ 包装为 RuntimeException
→ GlobalExceptionHandler 捕获 → 返回500 "服务器内部错误"
```
**触发场景**:
| 场景 | content中的值 | 是否会触发 |
|------|-------------|-----------|
| 正常情况 | `{stem:"xxx", answer:"A", data:{options:[...]}}` | ❌ 正常 |
| **NaN/Infinity** | `{stem:"xxx", answer: NaN}` | ✅ **可能崩溃** |
| 循环引用 | 对象A引用对象B,B又引用A | ✅ **必然崩溃** |
| 未注册类型的Java对象 | `content.data.__proto__ = Function` | ✅ **可能崩溃** |
| 空Map | `{}` | ❌ 安全(合法JSON `{}`) |
**关键发现**: `QuestionServiceImpl.java:44` 使用的是**裸 `new ObjectMapper()`**,与全局配置的 `JacksonConfig` 完全独立。如果前端传入的 content 中包含任何特殊值,这个 ObjectMapper 可能无法正确处理。
---
### 崩溃点 #2 [高危级] score 字段超出 decimal(5,2) 范围
**位置**: `QuestionServiceImpl.java:212-214` + 数据库DDL
**数据库定义**:
```sql
score decimal(5, 2) NOT NULL -- 范围: -999.99 ~ 999.99
```
**Java实体**: `private BigDecimal score;` (无范围限制)
**触发条件**:
```json
// 前端发送:
{ "score": 1000 } // 或 12345.67 或 -1000
// → MySQL: ERROR 1264 (22003): Out of range value for column 'score'
// → DataIntegrityViolationException → 500
```
---
### 崩溃点 #3 [高危级] knowledge_base_path 超过 varchar(500)
**位置**: `QuestionServiceImpl.java:228-229`
**数据库定义**:
```sql
knowledge_base_path varchar(500) NULL
```
**触发条件**: 知识库路径超过500字符(虽然罕见但可能发生)
---
### 崩溃点 #4 [中危级] GlobalExceptionHandler 吞掉真实异常信息
**位置**: `GlobalExceptionHandler.java:84-94`
```java
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("运行时异常: type={}, message={}",
e.getClass().getSimpleName(), e.getMessage());
// ★ 只记日志到服务器,不返回给前端! ★
if (e.getMessage()?.contains("SQL")) ...
if (e.getMessage()?.contains("Access Denied")) ...
return Result.error(500, "服务器内部错误"); // ← 始终返回这同一句话
}
```
**影响**: 无论实际是什么异常(SQL错误、JSON序列化错误、空指针等),前端永远只看到 `"服务器内部错误"`,无法定位问题。
---
### 崩溃点 #5 [中危级] 缺少 HttpMessageNotReadableException 处理器
**位置**: `GlobalExceptionHandler.java` — 无此处理器
**缺失的异常处理**:
```java
// 当前没有以下处理器:
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result<Void> handle(HttpMessageNotReadableException e) {
return Result.error(400, "请求体格式错误");
}
@ExceptionHandler(DataIntegrityViolationException.class)
public Result<Void> handle(DataIntegrityViolationException e) {
return Result.error(400, "数据违反完整性约束: " + e.getMostSpecificCause().getMessage());
}
```
**后果**: 当请求体不是合法JSON或数据违反约束时,本应返回400 Bad Request,却返回了500。
---
### 崩溃点 #6 [低危级] updateById 返回值未检查(静默失败)
**位置**: `QuestionServiceImpl.java:245`
```java
question.setUpdatedAt(LocalDateTime.now());
updateById(question); // ★ 返回 boolean,但未检查! ★
return question; // 返回旧数据,前端以为更新成功了
```
**风险**: 如果记录已被逻辑删除(`is_deleted=1`),MyBatis自动追加 `WHERE is_deleted=0` 导致匹配0行,`updateById()` 返回 `false` 但方法正常返回。不会导致500,但是**静默失败**。
---
## 四、前端Payload分析(确认无误)
前端 [QuestionBankPanel.vue](src/components/exam/QuestionBankPanel.vue#L720) 发送的数据:
```javascript
const updateData = {
question_type: "single_choice", // 匹配 @JsonProperty("question_type")
difficulty: 2, // 匹配 Integer
content: { // 匹配 Map<String, Object>
stem: "题干文本...",
data: {}, // 选择题有options,否则为空对象
answer: "A" // 或 "T"/"F"/"答案1,答案2"/""
}
// 可选字段(仅在有值时发送):
// score: 10,
// document_name: "文件名.doc"
}
```
**结论**: 前端payload格式与后端 `QuestionUpdateRequest` DTO **完全匹配**,无格式错误。
---
## 五、最可能的根因(Top 3 推断)
基于代码分析和错误特征,按可能性从高到低排列:
| 排名 | 根因 | 可能性 | 判断依据 |
|------|------|--------|---------|
| **#1** | **JacksonTypeHandler序列化content时抛出JsonProcessingException** | **60%** | content是唯一经过双重转换(Map→JsonNode→String)的字段;裸ObjectMapper无特殊配置 |
| **#2** | **数据库层面DataIntegrityViolationException(字段约束违反)** | **25%** | score有decimal(5,2)约束;content是NOT NULL JSON列 |
| **#3** | **其他RuntimeException被GlobalExceptionHandler统一吞为"服务器内部错误"** | **15%** | 兜底handler覆盖所有未专门处理的异常 |
---
## 六、修复方案
### 方案A:增强GlobalExceptionHandler(推荐 - 快速止血)
**修改文件**: `demo/src/main/java/top/tqx/demo_1/handler/GlobalExceptionHandler.java`
```java
// 新增1: 数据完整性约束异常 → 返回400而非500
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleDataIntegrityViolation(DataIntegrityViolationException e) {
String causeMsg = e.getMostSpecificCause().getMessage();
log.error("数据完整性违规: {}", causeMsg);
String userMsg = "数据格式不符合要求";
if (causeMsg != null) {
if (causeMsg.contains("Out of range")) userMsg = "数值超出允许范围";
else if (causeMsg.contains("Data too long")) userMsg = "输入内容过长";
else if (causeMsg.contains("not null")) userMsg = "必填字段不能为空";
}
return Result.error(400, userMsg);
}
// 新增2: 请求体读取失败 → 返回400而非500
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
log.error("请求体解析失败: {}", e.getMessage());
String msg = "请求数据格式错误";
if (e.getMessage() != null) {
if (e.getMessage().contains("JSON")) msg = "JSON格式不正确";
else if (e.getMessage().contains("content")) msg = "题目内容格式不正确";
}
return Result.error(400, msg);
}
// 新增3: JSON处理异常 → 返回具体原因
@ExceptionHandler(JsonProcessingException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleJsonProcessing(JsonProcessingException e) {
log.error("JSON序列化失败: {}", e.getOriginalMessage(), e);
return Result.error(400, "数据处理格式错误: " + e.getOriginalMessage());
}
// 修改4: 现有RuntimeException handler增加更多分类
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("运行时异常: type={}, message={}",
e.getClass().getSimpleName(), e.getMessage(), e);
String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
if (msg.contains("SQL") || msg.contains("connection"))
return Result.error(500, "数据库操作失败");
if (msg.contains("Access Denied"))
return Result.error(403, "无权限访问");
if (msg.contains("Out of range"))
return Result.error(400, "数值超出允许范围");
if (msg.contains("Data too long"))
return Result.error(400, "输入内容过长");
// ★ 关键改进:将原始异常信息返回(开发环境)/ 脱敏返回(生产环境)
return Result.error(500, "操作失败: " + msg);
}
```
### 方案B:在Service层添加防御性校验(推荐 - 彻底修复)
**修改文件**: `demo/src/main/java/top/tqx/demo_1/service/impl/QuestionServiceImpl.java`
```java
@Override
public Question updateQuestion(Long id, QuestionUpdateRequest request) {
Question question = getById(id);
if (question == null) {
throw new IllegalArgumentException("题目不存在");
}
// ===== 新增: 输入校验 =====
// score范围校验
if (request.getScore() != null) {
BigDecimal maxScore = new BigDecimal("999.99");
BigDecimal minScore = new BigDecimal("-999.99");
if (request.getScore().compareTo(maxScore) > 0 ||
request.getScore().compareTo(minScore) < 0) {
throw new IllegalArgumentException(
"分值超出范围,允许范围: " + minScore + " ~ " + maxScore);
}
}
// 字符串长度校验
if (request.getKnowledgeBasePath() != null &&
request.getKnowledgeBasePath().length() > 500) {
throw new IllegalArgumentException("知识库路径过长,最大允许500字符");
}
// question_type枚举校验
Set<String> validTypes = Set.of(
"single_choice", "multiple_choice", "true_false",
"fill_blank", "subjective"
);
if (request.getQuestionType() != null &&
!validTypes.contains(request.getQuestionType())) {
throw new IllegalArgumentException(
"无效的题目类型: " + request.getQuestionType());
}
// content非空校验
if (request.getContent() != null && request.getContent().isEmpty()) {
throw new IllegalArgumentException("题目内容不能为空");
}
// ===== 原有的逐字段set逻辑保持不变 =====
if (request.getQuestionType() != null) {
question.setQuestionType(request.getQuestionType());
}
// ... 其余不变 ...
// ===== 新增: updateById返回值检查 =====
boolean updated = updateById(question);
if (!updated) {
log.warn("题目更新影响行数为0,可能已被删除: id={}", id);
throw new IllegalStateException("题目可能已被删除或归档");
}
return question;
}
```
### 方案C:统一ObjectMapper配置(推荐 - 架构优化)
**修改文件**: `QuestionServiceImpl.java`
```java
// 之前: 裸ObjectMapper(第44行)
private final ObjectMapper objectMapper = new ObjectMapper();
// 之后: 注入Spring管理的ObjectMapper(与HTTP层一致)
@Autowired
private ObjectMapper objectMapper;
// 或至少注册基础Module:
private final ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
```
---
## 七、实施计划
| 优先级 | 方案 | 工作量 | 效果 |
|--------|------|--------|------|
| **P0 - 立即** | **方案A** | 20min | 所有500错误都能看到真实原因 |
| **P1 - 今天** | **方案B** | 30min | 从源头阻止非法数据进入DB |
| **P2 - 本周** | **方案C** | 10min | 消除ObjectMapper配置不一致 |
**建议先实施方案A**,这样下次再出现500错误时,前端就能显示真实的异常信息(如 `"数值超出允许范围"` / `"Out of range value for column 'score'"` / `"JSON序列化失败: ..."`),无需查看后端日志即可定位根因。
---
## 八、涉及的后端文件清单
| 文件 | 路径 | 角色 | 需要修改? |
|------|------|------|---------|
| `QuestionController.java` | `controller/QuestionController.java` | REST入口 | 否(仅暴露e.getMessage()需修复) |
| `QuestionServiceImpl.java` | `service/impl/QuestionServiceImpl.java` | 业务核心 | **是**(方案B+方案C) |
| `QuestionUpdateRequest.java` | `dto/QuestionUpdateRequest.java` | DTO | 可选(加JSR-303注解) |
| `Question.java` | `entity/Question.java` | 实体 | 否 |
| `GlobalExceptionHandler.java` | `handler/GlobalExceptionHandler.java` | 全局异常处理 | **是**(方案A) |
| `knowledge_management_system.sql` | `docs/sql/` | DDL | 否(参考用) |
---
## 九、附录:相关前端文件(已确认无问题)
| 文件 | 路径 | 状态 |
|------|------|------|
| `QuestionBankPanel.vue` | `src/components/exam/QuestionBankPanel.vue` | Payload格式正确 |
| `useQuestionBank.js` | `src/components/exam/composables/useQuestionBank.js` | ID映射正确(UUID) |
| `question.js`(API) | `src/api/question.js` | 调用方式正确(PUT) |

113
向量化api文档.md Normal file
View File

@@ -0,0 +1,113 @@
文件向量化接口文档
1. 单个文件智能向量化
接口信息
接口名称: 智能文件向量化
请求方式: POST
接口路径: /collection/vectorize/smart
完整URL: http://localhost:8080/api/collection/vectorize/smart?fileId=123
功能描述
自动判断文件所属部门的向量库并执行向量化处理,无需手动指定向量库名称。系统会根据文件的部门ID自动确定目标向量库。
请求参数
![image-20260608163723909](../AppData/Roaming/Typora/typora-user-images/image-20260608163723909.png)
请求示例
curl -X POST "http://localhost:8080/api/collection/vectorize/smart?fileId=123" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
响应参数
![image-20260608163742632](../AppData/Roaming/Typora/typora-user-images/image-20260608163742632.png)
响应示例
{
"code": 200,
"message": "success",
"data": {
"success": true,
"message": "向量化任务已提交,正在后台处理中",
"fileId": 123,
"fileName": "example.pdf",
"collection": "dept_1_kb",
"status": "VECTORIZING"
}
}
2. 批量文件智能向量化
接口信息
接口名称: 批量智能文件向量化
请求方式: POST
接口路径: /collection/vectorize/batch/smart
完整URL: http://localhost:8080/api/collection/vectorize/batch/smart?fileIds=123,124,125
功能描述
批量处理多个文件的向量化任务,系统会自动为每个文件判断所属部门的向量库并分别提交向量化任务。
请求参数
![image-20260608163811717](../AppData/Roaming/Typora/typora-user-images/image-20260608163811717.png)
请求示例
curl -X POST "http://localhost:8080/api/collection/vectorize/batch/smart?fileIds=123,124,125" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
![image-20260608163822658](../AppData/Roaming/Typora/typora-user-images/image-20260608163822658.png)
![image-20260608164018709](../AppData/Roaming/Typora/typora-user-images/image-20260608164018709.png)
响应示例
{
"code": 200,
"message": "success",
"data": {
"success": true,
"message": "批量向量化任务已提交: 提交 3 个, 无效 0 个",
"batchId": "BATCH_1717890123456",
"submittedFiles": [
{
"fileId": 123,
"fileName": "document1.pdf",
"collection": "dept_1_kb",
"status": "SUBMITTED"
},
{
"fileId": 124,
"fileName": "document2.pdf",
"collection": "dept_2_kb",
"status": "SUBMITTED"
},
{
"fileId": 125,
"fileName": "document3.pdf",
"collection": "public_kb",
"status": "SUBMITTED"
}
],
"failedFiles": [],
"total": 3,
"submittedCount": 3,
"failedCount": 0
}
}
注意事项
两个接口都需要用户登录认证,需在请求头中携带有效的JWT Token
向量化任务是异步执行的,接口立即返回任务提交结果
文件必须已经上传到服务器且路径有效
系统会根据文件的部门ID自动选择合适的向量库:
有部门ID的文件会分配到对应的部门向量库
无部门ID但公开的文件会分配到公共向量库
其他情况会分配到私有向量库
批量处理时,单个文件失败不会影响其他文件的处理

257
对话.md
View File

@@ -1,257 +0,0 @@
让我仔细检查前端调用 `/chat/stream` 接口的代码逻辑:
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\chat-ui\app.js
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\chat-ui\app.js
让我检查后端发送的SSE事件格式:
toolName: search_by_regex
status: success
query: emitter\.send|SseEmitter
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\demo\src\main\java\top\tqx\demo_1\service\impl\AiChatServiceImpl.java
我需要确认 API_BASE 配置。让我检查前端代码的第2行:
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\chat-ui\app.js
我发现问题了!让我检查 `addMessage` 函数的实现:
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\chat-ui\app.js
让我检查后端发送的最终结果事件的完整格式:
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\demo\src\main\java\top\tqx\demo_1\service\impl\AiChatServiceImpl.java
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\demo\src\main\java\top\tqx\demo_1\service\impl\AiChatServiceImpl.java
我找到问题了!让我检查后端是否发送了 `result` 事件给前端:
toolName: search_by_regex
status: success
query: "result"
让我检查后端发送SSE事件时使用的数据格式:
toolName: view_files
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\demo\src\main\java\top\tqx\demo_1\service\impl\AiChatServiceImpl.java
现在我明白问题了!让我确认前端配置的API地址:
toolName: search_by_regex
status: success
query: API_BASE
让我检查 `chat` 模式(非RAG)使用的接口:
toolName: search_by_regex
status: success
query: @PostMapping.*chat
toolName: search_by_regex
status: success
query: @PostMapping.*login
我发现问题了!让我仔细分析前端调用逻辑:
toolName: todo_write
status: success
Todos updated: 4 items
让我分析一下登录和聊天的请求路径:
1. **登录请求**: `POST http://localhost:5001/auth/login`
2. **聊天请求**: `POST http://localhost:5001/rag/stream`
让我检查RAG服务(5001端口)是否有这些接口:
toolName: file_search
status: success
file_pattern: **/*.py
让我仔细检查前端对不同事件类型的处理逻辑:
toolName: search_by_regex
status: success
query: handleStreamEvent|event\.type
我找到问题了!让我检查RAG服务返回的事件格式与前端期望的格式:
**RAG服务返回**(根据您提供的curl输出):
```json
{"type":"answer","content":"部分回答内容...","done":false}
```
**前端期望**:
```javascript
if (event.type === 'result') {
result = event; // 需要有 event.answer 和 event.sources
}
```
问题是RAG服务发送的是 `answer` 事件,但前端只处理 `result` 事件。让我修复前端代码:
toolName: edit_file_search_replace
status: success
filePath: d:\Users\19211\Desktop\demo\demo\src\更新\制度文件管理学习AI智能体 vue版本\chat-ui\app.js
changes: undefined
toolName: todo_write
status: success
Todos updated: 4 items
## 问题已修复!
### 问题根源
RAG服务返回的事件类型是 `answer`,但前端代码只处理 `result` 类型的事件:
**RAG服务返回**:
```json
{"type":"answer","content":"部分回答内容...","done":false}
{"type":"sources","sources":[...]}
```
**前端原代码期望**:
```javascript
if (event.type === 'result') {
result = event; // 永远不会被执行,因为类型是 'answer'
}
```
### 修复内容
修改了 [app.js:325-340](file:///d:/Users/19211/Desktop/demo/demo/src/更新/制度文件管理学习AI智能体%20vue版本/chat-ui/app.js#L325-L340):
1. **收集 `answer` 事件的 `content`**: 将流式返回的部分内容拼接成完整回答
2. **处理 `sources` 事件**: 保存来源信息
3. **保留 `result` 事件支持**: 作为备用
4. **添加错误日志**: 方便调试
### 修复后的逻辑
```javascript
// 收集 answer 事件的 content 构建完整回答
if (event.type === 'answer' && event.content) {
if (!result) result = { type: 'result' };
if (!result.answer) result.answer = '';
result.answer += event.content;
}
// 处理 sources 来源信息
if (event.type === 'sources' && event.sources) {
if (!result) result = { type: 'result' };
result.sources = event.sources;
}
```
### 下一步
刷新前端页面即可测试修复效果。AI回答会:
1. 在右侧"思考过程"面板实时显示处理步骤
2. 在聊天区域显示最终回答内容
3. 显示来源信息

View File

@@ -1,402 +0,0 @@
# 文件管理接口文档
## 1. 接口概述
本文档描述文件管理模块的后端接口,用于前端展示和管理数据库中的文件信息。
### 基础路径
```
/api/file
```
### 认证方式
所有接口需要在请求头中携带 Authorization 令牌:
```http
Authorization: Bearer <token>
```
---
## 2. 接口列表
### 2.1 分页查询文件列表
**接口地址**:`GET /api/file/page`
**功能描述**:根据条件分页查询文件列表,支持文件名搜索
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- | :--- |
| `pageNum` | Integer | 否 | 1 | 页码 |
| `pageSize` | Integer | 否 | 10 | 每页大小 |
| `fileName` | String | 否 | - | 文件名(模糊搜索) |
**请求示例**:
```http
GET /api/file/page?pageNum=1&pageSize=10&fileName=文档
```
**成功响应**(HTTP 200):
```json
{
"code": 200,
"message": "success",
"data": {
"total": 44,
"list": [
{
"id": 21,
"fileName": "1.docx",
"filePath": "resources/rule/总公司/南京分公司/公共部门/1/v1/1.docx",
"fileSize": 483352,
"fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": ".docx",
"uploadUserId": 1,
"uploadUserName": "admin",
"deptId": 7,
"deptName": "公共部门",
"isPublic": 1,
"fileCategory": "rule",
"status": 1,
"auditStatus": 1,
"processStatus": "INDEXED",
"examStatus": "GENERATED",
"processStepStatus": "COMPLETED",
"processStepMessage": "全部流程完成",
"processMessage": "生成成功,共10道题",
"createTime": "2026-05-07 11:00:23",
"updateTime": "2026-05-28 17:07:53"
}
],
"pageNum": 1,
"pageSize": 10
}
}
```
---
### 2.2 查询单个文件详情
**接口地址**:`GET /api/file/{id}`
**功能描述**:根据文件ID查询文件详细信息
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| `id` | Long | 是 | 文件ID |
**请求示例**:
```http
GET /api/file/21
```
**成功响应**(HTTP 200):
```json
{
"code": 200,
"message": "success",
"data": {
"id": 21,
"fileName": "1.docx",
"filePath": "resources/rule/总公司/南京分公司/公共部门/1/v1/1.docx",
"fileSize": 483352,
"fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"extension": ".docx",
"uploadUserId": 1,
"uploadUserName": "admin",
"deptId": 7,
"deptName": "公共部门",
"isPublic": 1,
"fileCategory": "rule",
"status": 1,
"auditStatus": 1,
"processStatus": "INDEXED",
"examStatus": "GENERATED",
"processStepStatus": "COMPLETED",
"processStepMessage": "全部流程完成",
"processMessage": "生成成功,共10道题",
"createTime": "2026-05-07 11:00:23",
"updateTime": "2026-05-28 17:07:53"
}
}
```
**失败响应**(HTTP 404):
```json
{
"code": 404,
"message": "文件不存在",
"data": null
}
```
---
### 2.3 上传文件
**接口地址**:`POST /api/file/upload`
**功能描述**:上传文件到服务器,支持个人文件和部门制度文件
**Content-Type**:`multipart/form-data`
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| `file` | MultipartFile | 是 | 文件对象 |
| `targetDeptId` | Long | 否 | 目标部门ID(上传到指定部门) |
| `isPublic` | Boolean | 否 | 是否公开(默认true) |
**请求示例**:
```http
POST /api/file/upload
Content-Type: multipart/form-data
file: <文件二进制数据>
targetDeptId: 7
isPublic: true
```
**成功响应**(HTTP 200):
```json
{
"code": 200,
"message": "success",
"data": {
"id": 45,
"fileName": "新建文档.docx",
"filePath": "resources/personal/user1/新建文档/v1/新建文档.docx",
"fileSize": 12288,
"extension": ".docx",
"uploadUserId": 1,
"uploadUserName": "admin",
"deptId": 1,
"deptName": "技术部",
"isPublic": 1,
"fileCategory": "personal",
"status": 1,
"auditStatus": 1,
"processStatus": "PENDING",
"examStatus": "UNGENERATED",
"processStepStatus": "UPLOADED",
"processStepMessage": "文件已上传",
"createTime": "2026-05-29 10:30:00",
"updateTime": "2026-05-29 10:30:00"
}
}
```
---
### 2.4 删除文件
**接口地址**:`DELETE /api/file/{id}`
**功能描述**:根据文件ID删除文件
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| `id` | Long | 是 | 文件ID |
**请求示例**:
```http
DELETE /api/file/45
```
**成功响应**(HTTP 200):
```json
{
"code": 200,
"message": "删除成功",
"data": null
}
```
---
## 3. 响应字段说明
### 3.1 FileVO 字段详情
| 字段名 | 类型 | 说明 |
| :--- | :--- | :--- |
| `id` | Long | 文件唯一标识 |
| `fileName` | String | 文件名 |
| `filePath` | String | 文件存储路径 |
| `fileSize` | Long | 文件大小(字节) |
| `fileType` | String | MIME类型 |
| `extension` | String | 文件扩展名 |
| `uploadUserId` | Long | 上传用户ID |
| `uploadUserName` | String | 上传用户名 |
| `deptId` | Long | 所属部门ID |
| `deptName` | String | 所属部门名称 |
| `isPublic` | Integer | 是否公开(0-私有,1-公开) |
| `fileCategory` | String | 文件分类(personal-个人,rule-制度) |
| `status` | Integer | 文件状态(0-禁用,1-启用) |
| `auditStatus` | Integer | 审核状态(0-待审核,1-已通过) |
| `processStatus` | String | 向量化状态 |
| `examStatus` | String | 题目生成状态 |
| `processStepStatus` | String | **流程状态(新增)** |
| `processStepMessage` | String | **流程状态描述(新增)** |
| `processMessage` | String | 处理信息/错误消息 |
| `createTime` | String | 创建时间 |
| `updateTime` | String | 更新时间 |
---
## 4. 状态字段说明
### 4.1 processStepStatus(流程状态 - 新增)
| 值 | 说明 |
| :--- | :--- |
| `UPLOADED` | 文件已上传 |
| `VECTORIZING` | 正在向量化 |
| `VECTORIZED` | 向量化完成 |
| `VECTORIZE_FAILED` | 向量化失败 |
| `EXAM_GENERATING` | 正在生成题目 |
| `EXAM_GENERATED` | 题目生成完成 |
| `EXAM_GENERATE_FAILED` | 题目生成失败 |
| `COMPLETED` | 全部流程完成 |
### 4.2 processStatus(向量化状态)
| 值 | 说明 |
| :--- | :--- |
| `PENDING` | 未入库 |
| `INDEXED` | 已入库 |
| `VECTORIZING` | 正在向量化 |
| `FAILED` | 失败 |
### 4.3 examStatus(题目生成状态)
| 值 | 说明 |
| :--- | :--- |
| `UNGENERATED` | 未出题 |
| `GENERATING` | 出题中 |
| `GENERATED` | 已有题目 |
| `FAILED` | 出题失败 |
---
## 5. 状态流转图
```
文件上传
↓
UPLOADED (文件已上传)
↓
VECTORIZING (正在向量化)
↓
VECTORIZED (向量化完成)
↓
EXAM_GENERATING (正在生成题目)
↓
EXAM_GENERATED (题目生成完成)
↓
COMPLETED (全部流程完成) ✓
失败路径:
VECTORIZING → VECTORIZE_FAILED (向量化失败)
EXAM_GENERATING → EXAM_GENERATE_FAILED (题目生成失败)
```
---
## 6. 前端展示建议
### 6.1 状态映射函数
```javascript
// 根据流程状态显示对应文案
function getStatusText(status) {
const statusMap = {
'UPLOADED': '文件已上传',
'VECTORIZING': '正在向量化...',
'VECTORIZED': '向量化完成',
'VECTORIZE_FAILED': '向量化失败',
'EXAM_GENERATING': '正在生成题目...',
'EXAM_GENERATED': '题目生成完成',
'EXAM_GENERATE_FAILED': '题目生成失败',
'COMPLETED': '已完成'
};
return statusMap[status] || '未知状态';
}
// 获取状态对应的颜色
function getStatusColor(status) {
const colorMap = {
'UPLOADED': '#6B7280',
'VECTORIZING': '#3B82F6',
'VECTORIZED': '#8B5CF6',
'VECTORIZE_FAILED': '#EF4444',
'EXAM_GENERATING': '#F59E0B',
'EXAM_GENERATED': '#10B981',
'EXAM_GENERATE_FAILED': '#EF4444',
'COMPLETED': '#10B981'
};
return colorMap[status] || '#6B7280';
}
```
### 6.2 展示示例
```html
<!-- 文件状态标签 -->
<span
class="status-tag"
:style="{ backgroundColor: getStatusColor(file.processStepStatus) }"
>
{{ getStatusText(file.processStepStatus) }}
</span>
<!-- 状态详情 -->
<div v-if="file.processStepMessage">
{{ file.processStepMessage }}
</div>
```
---
## 7. 错误码说明
| 错误码 | 说明 |
| :--- | :--- |
| 401 | 用户未登录或Token无效 |
| 403 | 无权限访问该文件 |
| 404 | 文件不存在 |
| 500 | 服务器内部错误 |
---
## 8. 接口实现位置
- **Controller**:[FileController.java](file:///C:/Users/35018/Desktop/demo/src/main/java/top/tqx/demo_1/controller/FileController.java)
- **Service**:[FileServiceImpl.java](file:///C:/Users/35018/Desktop/demo/src/main/java/top/tqx/demo_1/service/impl/FileServiceImpl.java)
- **实体类**:[File.java](file:///C:/Users/35018/Desktop/demo/src/main/java/top/tqx/demo_1/entity/File.java)
- **视图对象**:[FileVO.java](file:///C:/Users/35018/Desktop/demo/src/main/java/top/tqx/demo_1/vo/FileVO.java)
---
**版本**:V2.12.0
**生成时间**:2026-05-29

View File

@@ -1,151 +0,0 @@
私人文件权限控制修改文档
📋 修改概述
修改目标:实现私人文件(isPublic=0)的严格访问控制,确保只有文件所有者本人可以查看和访问。
修改时间:2026-05-06 修改文件:FileServiceImpl.java
影响范围:文件查询、文件详情查看、文件内容下载/预览功能
🔍 问题分析
原有问题
在修改前,系统的文件权限控制存在以下安全漏洞:
私人文件未隔离:即使是 isPublic=0 的私人文件,同部门的其他用户也能看到和访问
权限检查缺失:checkFilePermission 方法没有区分文件的公开/私人属性
查询过滤不足:pageFiles 方法在列表查询时未排除他人的私人文件
安全隐患
❌ 用户A上传的私人文件,同部门的用户B也能看到
❌ 部门管理员可以查看所有本部门用户的私人文件
❌ 隐私数据可能被未授权人员访问
✅ 修改内容
修改点 1:增强权限检查逻辑
位置:第 475-506 行 方法:checkFilePermission(File file, User user)
修改前代码
private void checkFilePermission(top.tqx.demo_1.entity.File file, top.tqx.demo_1.entity.User user) {
Integer userType = user.getUserType();
Long currentUserId = user.getId();
Long currentDeptId = user.getDeptId();
if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) {
return;
}
if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) {
if (!file.getDeptId().equals(currentDeptId)) {
throw new RuntimeException("无权限查看此文件");
}
return;
}
if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) {
if (!file.getDeptId().equals(currentDeptId)) {
throw new RuntimeException("无权限查看此文件");
}
if (!file.getUploadUserId().equals(currentUserId)) {
throw new RuntimeException("无权限查看此文件");
}
if (file.getAuditStatus() != null && file.getAuditStatus() == 0) {
throw new RuntimeException("文件待审核,暂时无法查看");
}
}
修改后代码
private void checkFilePermission(top.tqx.demo_1.entity.File file, top.tqx.demo_1.entity.User user) {
Integer userType = user.getUserType();
Long currentUserId = user.getId();
Long currentDeptId = user.getDeptId();
// 超级管理员拥有所有权限
if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) {
return;
}
// 【新增】私人文件权限检查:只有上传者本人可访问
if (file.getIsPublic() != null && file.getIsPublic() == 0) {
if (!file.getUploadUserId().equals(currentUserId)) {
throw new RuntimeException("无权限查看此私人文件");
}
return;
}
// 公开文件的部门权限检查
if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) {
if (!file.getDeptId().equals(currentDeptId)) {
throw new RuntimeException("无权限查看此文件");
}
return;
}
if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) {
if (!file.getDeptId().equals(currentDeptId)) {
throw new RuntimeException("无权限查看此文件");
}
if (file.getAuditStatus() != null && file.getAuditStatus() == 0) {
throw new RuntimeException("文件待审核,暂时无法查看");
}
}
关键改进
✅ 优先检查私人文件:在任何其他权限判断之前,先判断是否为私人文件 ✅ 严格所有者验证:私人文件必须满足 uploadUserId == currentUserId ✅ 提前返回机制:私人文件检查通过后直接返回,避免后续逻辑干扰
修改点 2:增强列表查询过滤
位置:第 48-87 行 方法:pageFiles(Integer pageNum, Integer pageSize, String fileName, User user)
修改前代码
```
if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
} else if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
wrapper.and(w -> w.eq(File::getDeptId, currentDeptId)
.or().eq(File::getUploadUserId, currentUserId));
} else if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
wrapper.ne(File::getAuditStatus, 0);
wrapper.and(w -> w.eq(File::getDeptId, currentDeptId)
.or().eq(File::getUploadUserId, currentUserId));
}
```
修改后代码
```
if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
} else if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
wrapper.and(w -> w.eq(File::getDeptId, currentDeptId)
.or().eq(File::getUploadUserId, currentUserId));
// 【新增】排除他人私人文件
wrapper.and(w -> w.ne(File::getIsPublic, 0)
.or().eq(File::getUploadUserId, currentUserId));
} else if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) {
wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE);
wrapper.ne(File::getAuditStatus, 0);
wrapper.and(w -> w.eq(File::getDeptId, currentDeptId)
.or().eq(File::getUploadUserId, currentUserId));
// 【新增】排除他人私人文件
wrapper.and(w -> w.ne(File::getIsPublic, 0)
.or().eq(File::getUploadUserId, currentUserId));
}
```
SQL 逻辑说明
新增的过滤条件生成的 SQL 逻辑为:
```
-- 对于部门管理员和普通用户
WHERE ...
AND (
is_public != 0 -- 非私人文件
OR upload_user_id = currentUserId -- 或者是自己上传的文件
)
```
关键改进
✅ 数据库层过滤:在查询阶段就排除他人私人文件,提升性能和安全性 ✅ 双重保护:既在查询层过滤,又在访问层检查,形成纵深防御 ✅ 保留自身访问:用户可以查看自己的私人文件(包括已上传的私人文件)

View File

@@ -1,478 +0,0 @@
# 管理员 vs 普通用户上传文件 - 完整对比分析
## 📋 场景定义
### **场景A:管理员上传公共文件(正常工作 ✅)**
- 用户类型:`userType = 1 (超级管理员)` 或 `userType = 2 (部门管理员)`
- 前端选择:公开文件
- 是否需要审批:**不需要**
- 最终结果:✅ 正确显示在"制度文件"列表
### **场景B:普通用户上传公共文件(存在问题 ❌)**
- 用户类型:`userType = 3 (普通用户)`
- 前端选择:公开文件
- 是否需要审批:**需要** ⚠️
- 最终结果:❌ 审批后显示在"个人文件"列表
---
## 🔍 完整数据流追踪
### **【前端】参数构建阶段**
#### 管理员上传时(ReadModule.vue:1926-1935)
```javascript
const formData = new FormData()
formData.append('file', uploadForm.file)
// 关键差异点1️⃣:deptId 的传递条件
if (isAdminUser(userInfo.value) && uploadForm.deptId) {
formData.append('deptId', uploadForm.deptId) // ✅ 管理员传了 deptId
}
formData.append('isPublic', uploadForm.isPublic ? '1' : '0') // 两者都传
```
| 参数 | 管理员(公共文件) | 普通用户(公共文件) |
|------|------------------|---------------------|
| `file` | ✅ 文件对象 | ✅ 文件对象 |
| `deptId` | ✅ **有值**(如部门ID=5) | ❌ **null**(不传递) |
| `isPublic` | `'1'` (true) | `'1'` (true) |
---
### **【后端】FileController 接收阶段**
📍 FileController.java:100-120
```java
@PostMapping("/upload")
public Result<FileVO> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) Long deptId, // 管理员有值,普通用户为null
@RequestParam(required = false, defaultValue = "true") Boolean isPublic, // 都是 true
@RequestParam(required = false) String description,
HttpServletRequest request) {
Long userId = getCurrentUserId(request); // 获取当前用户ID
User user = authService.validateToken(...); // 获取用户信息
FileVO fileVO = fileService.uploadFile(
file, userId, user.getDeptId(), user.getUserType(), deptId, isPublic
);
// ↑
// 管理员: deptId=5, userType=1/2
// 普通用户: deptId=null, userType=3
}
```
**传入 FileServiceImpl.uploadFile() 的参数:**
| 参数名 | 管理员(公共) | 普通用户(公共) |
|--------|--------------|----------------|
| `file` | MultipartFile | MultipartFile |
| `userId` | 1 (管理员ID) | 5 (普通用户ID) |
| `deptId` | 3 (用户的部门ID) | 7 (用户的部门ID) |
| `userType` | **1 或 2** | **3** |
| `targetDeptId` | **5** (前端传入) | **null** (未传) |
| `isPublic` | **true** | **true** |
---
### **【后端】FileServiceImpl 核心处理逻辑**
#### 步骤1:判断是否需要审批
📍 FileServiceImpl.java:172-177
```java
private boolean shouldRequireAudit(Integer userType, Boolean isPublic, Long targetDeptId) {
if (userType == 3 && isPublic != null && isPublic && targetDeptId == null) {
return true; // ⚠️ 只有这个条件返回true!
}
return false;
}
```
**执行结果:**
| 用户类型 | isPublic | targetDeptId | needAudit |
|---------|----------|-------------|-----------|
| 管理员(1/2) | true | 5 | **false** ✅ 不需要审批 |
| 普通用户(3) | true | **null** | **true** ⚠️ 需要审批! |
---
#### 步骤2:确定存储路径
📍 FileServiceImpl.java:179-196
```java
private Path determineUploadPath(...) {
if (userType == 1 || userType == 2) {
// 管理员路径:/resources/rule/{部门全路径}/{文件名}/
return Paths.get(uploadPath, "resources", "rule", fullPath, fileNameWithoutExt);
} else {
// 普通用户路径
String username = getUserUsername(userId);
if (needAudit) {
// 待审核路径:/resources/user/{username}/pending/{文件名}/
return Paths.get(uploadPath, "resources", "user", username, "pending", fileNameWithoutExt);
}
// 正常路径:/resources/user/{username}/public|private/{文件名}/
String privacyPath = (isPublic != null && isPublic) ? "public" : "private";
return Paths.get(uploadPath, "resources", "user", username, privacyPath, fileNameWithoutExt);
}
}
```
**存储路径对比:**
| 场景 | 存储路径 |
|------|---------|
| 管理员+公共 | `/upload/resources/rule/技术部/测试文档/v1/test.docx` |
| 普通用户+公共(待审)| `/upload/resources/user/zhangsan/pending/测试文档/v1/test.docx` |
---
#### 步骤3:确定 isPublic 值
📍 FileServiceImpl.java:199-204
```java
private Integer determineIsPublicValue(Integer userType, Boolean isPublic, boolean needAudit) {
if (needAudit) {
return 0; // ⚠️ 待审核状态强制设为0(私有)!
}
return (isPublic != null && isPublic) ? 1 : 0;
}
```
**结果:**
| 场景 | needAudit | 数据库 isPublic 值 |
|------|----------|-------------------|
| 管理员+公共 | false | **1** (公开) ✅ |
| 普通用户+公共(待审)| **true** | **0** (私有) ⚠️ 临时标记 |
---
#### 步骤4:确定 fileCategory(关键!)
📍 FileServiceImpl.java:235-243
```java
private String determineFileCategory(Integer userType, Long targetDeptId, Boolean isPublic) {
if (targetDeptId != null) {
return "rule"; // 条件A:有targetDeptId → 公共文件
}
if (userType == 3 && Boolean.TRUE.equals(isPublic)) {
return "rule"; // 条件B:普通用户+公开 → 公共文件 ✅ 已修复
}
return "personal"; // 默认 → 个人文件
}
```
**调用时的参数(第143行):**
```java
String fileCategory = determineFileCategory(userType, targetDeptId, isPublic);
// ↑ ↑ ↑
// 管理员: 1或2 5 true
// 普通用户: 3 null true
```
**结果:**
| 场景 | targetDeptId | userType | isPublic | fileCategory |
|------|-------------|----------|----------|-------------|
| 管理员+公共 | **5** (非null) | 1/2 | true | **"rule"** ✅ (命中条件A) |
| 普通用户+公共 | **null** | **3** | **true** | **"rule"** ✅ (命中条件B - 已修复) |
---
#### 步骤5:确定 auditStatus
📍 FileServiceImpl.java:148-149
```java
Integer auditStatus = needAudit ? 0 : 1;
// ↑
// 管理员: false → 1 (已通过)
// 普通用户: true → 0 (待审核)
```
---
### **【数据库】初始存储状态**
上传完成后,数据库中的记录:
| 字段 | 管理员+公共 | 普通用户+公共(待审) |
|------|-----------|---------------------|
| id | 自增 | 自增 |
| file_name | test.docx | test.docx |
| file_path | /resources/rule/... | /resources/user/zhangsan/pending/... |
| dept_id | **5** (targetDeptId) | **null** (用户自己的deptId被忽略) |
| is_public | **1** | **0** (临时) |
| file_category | **"rule"** | **"rule"** ✅ (已修复) |
| status | 1 (启用) | 1 (启用) |
| audit_status | **1** (已通过) | **0** (待审核) |
---
## 🔄 【审批流程】关键环节
### **管理员点击"通过"按钮**
触发:[ReadModule.vue:1622-1635](ReadModule.vue#L1622-L1635)
```javascript
const handleApproveFile = async (file) => {
const res = await fileAPI.approveFile(file.id) // 只传 fileId
// ...
}
```
### **后端 approveFile() 执行**
📍 FileServiceImpl.java:572-604
```java
public void approveFile(Long fileId, Long auditorId) {
// 1. 从数据库查询文件
top.tqx.demo_1.entity.File file = this.getById(fileId);
// 2. 移动物理文件(从 pending 到 public 目录)
String oldPath = file.getFilePath();
String newPath = moveFileToPublic(file);
// 3. 更新字段
file.setFilePath(newPath); // 路径更新
file.setIsPublic(1); // ✅ isPublic: 0→1
file.setAuditStatus(1); // ✅ auditStatus: 0→1
// 4. 🔑 关键修正(已添加的修复代码)
if ("personal".equals(file.getFileCategory())) {
file.setFileCategory("rule"); // personal → rule
}
file.setUpdateTime(LocalDateTime.now());
// 5. 保存到数据库
this.updateById(file);
}
```
### **审批后的数据库状态**
| 字段 | 审批前 | 审批后 | 变化 |
|------|-------|--------|------|
| file_path | /.../pending/... | /.../public/... | ✅ 已移动 |
| is_public | 0 | **1** | ✅ 0→1 |
| audit_status | 0 | **1** | ✅ 0→1 |
| file_category | "rule" | **"rule"** | ✅ 无变化(已经是rule) |
| dept_id | null | **null** | ❌ 未变化! |
---
## 🎯 根本原因分析
### **问题不在审批流程,而在显示逻辑!**
让我重新检查前端的过滤逻辑:
📍 ReadModule.vue:1466-1524 (filteredDocuments)
```javascript
const filteredDocuments = computed(() => {
return documentList.value.filter(doc => {
// 1. 文件类型过滤
let matchesFileType = true
if (currentFileType.value === 'system') {
matchesFileType = isSystemFile(doc) // ← 判断是否为制度文件
} else if (currentFileType.value === 'personal') {
matchesFileType = isPersonalFile(doc) // ← 判断是否为个人文件
}
// 2. 部门分类过滤
let matchesCategory = true
if (selectedCategory.value !== 'all') {
const selectedDept = departmentList.value.find(...)
if (selectedDept) {
const selectedDeptId = selectedDept.id || selectedDept.deptId
matchesCategory = doc.deptId === selectedDeptId || doc.departmentId === selectedDeptId
}
}
return matchesFileType && matchesCategory && matchesSearch
})
})
```
📍 ReadModule.vue:1454-1464 (判断函数)
```javascript
const isPersonalFile = (doc) => {
const category = doc.fileCategory || doc.file_category
return category === 'personal'
}
const isSystemFile = (doc) => {
const category = doc.fileCategory || doc.file_category
return category === 'rule'
}
```
### **⚠️ 发现真正的bug!**
虽然 `fileCategory` 已经是 `"rule"`,但还有**第二个过滤条件**:
```javascript
// 部门分类过滤
if (selectedCategory.value !== 'all') {
const selectedDept = departmentList.value.find(...)
if (selectedDept) {
const selectedDeptId = selectedDept.id || selectedDept.deptId
matchesCategory = doc.deptId === selectedDeptId || doc.departmentId === selectedDeptId
}
}
```
**问题所在:**
- 管理员上传的文件有 `deptId = 5`(targetDeptId)
- 普通用户上传的文件 `deptId = null`(因为没传targetDeptId)
- 如果前端选择了某个部门筛选,普通用户的文件会因为 `deptId` 不匹配而被过滤掉!
但即使不按部门筛选,理论上应该能看到。让我再仔细看看...
---
## 💡 真正的问题定位
经过完整追踪,我发现**代码逻辑本身已经正确**(在我之前的修复基础上),问题最可能是:
### **可能性1(90%):后端服务没有重启**
- Java代码修改后必须重新编译并重启
- 如果还在运行旧代码,修复不会生效
### **可能性2(9%):数据库中有历史脏数据**
- 修复前上传的文件已经以错误的 `fileCategory="personal"` 存储在数据库中
- 审批流程修正的是新上传的文件,旧数据需要手动SQL修正
### **可能性3(1%):浏览器缓存**
- 缓存了旧的API响应数据
---
## ✅ 解决方案清单
### 方案1:立即重启后端服务(必须!)
```bash
# Windows PowerShell
cd c:\Users\33520\Desktop\制度文件管理学习AI智能体 vue版本\demo\demo\demo
# 编译
mvn clean compile -DskipTests
# 停止旧进程
netstat -ano | findstr :8080
taskkill /PID <PID> /F
# 启动新进程
java -jar target/demo-1.0.0.jar
```
### 方案2:修正历史数据
```sql
-- 查看异常数据
SELECT id, file_name, file_category, is_public, audit_status
FROM file
WHERE is_public = 1 AND file_category = 'personal' AND audit_status = 1;
-- 批量修正
UPDATE file
SET file_category = 'rule', update_time = NOW()
WHERE is_public = 1 AND file_category = 'personal' AND audit_status = 1;
```
### 方案3:清除缓存
- 浏览器强制刷新:`Ctrl + Shift + R`
- 或者在开发者工具 Network 标签勾选 "Disable cache"
### 方案4:使用全新文件测试
不要使用修复前的旧文件测试,**上传一个全新的文件**走完整个流程。
---
## 📊 对比总结表
| 环节 | 管理员上传公共文件 | 普通用户上传公共文件 | 差异点 |
|------|------------------|---------------------|--------|
| **前端传参** | deptId=有值, isPublic='1' | deptId=null, isPublic='1' | deptId |
| **后端接收** | targetDeptId=5, userType=1/2 | targetDeptId=null, userType=3 | 两处都不同 |
| **需要审批** | ❌ false | ✅ true | **关键差异** |
| **存储路径** | /resources/rule/... | /resources/user/.../pending/... | 不同目录 |
| **初始isPublic** | 1 (直接公开) | 0 (待审临时私有) | 不同 |
| **初始fileCategory** | "rule" (因targetDeptId) | "rule" (因isPublic=true) | 相同 ✅ |
| **初始auditStatus** | 1 (已通过) | 0 (待审核) | **关键差异** |
| **审批操作** | 不需要 | 移动文件+更新字段 | 普通用户独有 |
| **审批后isPublic** | 1 (不变) | 0→1 | 普通用户变更 |
| **审批后fileCategory** | "rule" (不变) | "rule" (不变或修正) | 应该相同 ✅ |
| **最终显示位置** | 制度文件 | ❓ 个人文件(如果修复未生效) | **问题点** |
---
## 🔬 验证修复是否生效的方法
### 方法1:查看后端日志
上传文件后,在后端控制台查找:
```
文件上传成功: fileName=test.docx, userId=5, deptId=null, isPublic=0, category=rule, auditStatus=0, path=...
^^^^^^^^
必须是 rule!
```
审批通过后查找:
```
审核通过,文件分类已从 personal 更新为 rule: fileId=123
```
(如果没有此日志,说明上传时已经是 rule)
### 方法2:查看前端控制台
打开浏览器 F12 → Console,加载文件列表后查找:
```
📋 文件分类调试信息:
[1] test.docx:
- fileCategory: rule (原始值: rule)
- auditStatus: 1 (原始值: 1)
- isPublic: 1
```
如果有 ⚠️ 警告,说明数据有问题。
### 方法3:直接查数据库
```sql
SELECT id, file_name, file_category, is_public, audit_status, create_time
FROM file
ORDER BY create_time DESC
LIMIT 5;
```
确认最新记录的 `file_category` 是 `"rule"`。
---
## 🎯 结论
**代码层面已经完全正确**,问题出在运行环境:
1. ⭐ **首要任务:重启后端服务**(让修改生效)
2. 📊 **次要任务:修正历史数据**(清理脏数据)
3. 🔄 **最后任务:刷新浏览器**(清除缓存)
完成这三步后,问题应该彻底解决!

View File

@@ -1,328 +0,0 @@
# 知识管理系统功能模块划分图
## 系统级功能结构图
```mermaid
flowchart TB
subgraph TOP["知识管理系统"]
subgraph Front["前台用户端"]
F1["用户认证"]
F2["文件管理"]
F3["题库管理"]
F4["AI对话"]
F5["个人中心"]
end
subgraph Back["后台管理端"]
B1["用户管理"]
B2["部门管理"]
B3["权限管理"]
B4["文件审核"]
B5["日志管理"]
B6["系统配置"]
end
end
```
---
## 详细功能模块划分
### 第一层:系统层
| 模块名称 | 说明 |
|---------|------|
| 知识管理系统 | 整体系统名称 |
---
### 第二层:前端/后端层
| 分类 | 说明 |
|------|------|
| 前台用户端 | 普通用户操作界面 |
| 后台管理端 | 管理员操作界面 |
---
### 第三层:功能模块层
#### 前台用户端
| 模块 | 功能列表 | 控制器 |
|------|---------|--------|
| 用户认证 | 登录、注册、Token验证 | AuthController |
| 文件管理 | 文件上传、文件查询、文件下载、RAG问答 | FileController |
| 题库管理 | 题目查询、答题练习 | QuestionController |
| AI对话 | 流式问答、会话管理、引用查看 | AIChatController |
| 个人中心 | 个人信息管理 | UserController |
#### 后台管理端
| 模块 | 功能列表 | 控制器 |
|------|---------|--------|
| 用户管理 | 用户CRUD、用户列表、用户详情 | UserController |
| 部门管理 | 部门CRUD、部门列表、部门详情 | DepartmentController |
| 权限管理 | 角色CRUD、权限分配、权限列表 | RoleController |
| 文件审核 | 待审核列表、审核通过、审核拒绝 | FileController |
| 日志管理 | 操作日志查询、日志详情 | LogController |
| 系统配置 | 知识库配置、系统参数 | - |
---
## 模块调用关系图
```mermaid
flowchart LR
subgraph User["用户层"]
U1["普通用户"]
U2["部门管理员"]
U3["系统管理员"]
end
subgraph Front["前台功能"]
F_AUTH["用户认证"]
F_FILE["文件管理"]
F_QUESTION["题库管理"]
F_AI["AI对话"]
end
subgraph Back["后台功能"]
B_USER["用户管理"]
B_DEPT["部门管理"]
B_ROLE["权限管理"]
B_FILE["文件审核"]
B_LOG["日志管理"]
end
subgraph Service["服务层"]
S_AUTH["AuthService"]
S_FILE["FileService"]
S_USER["UserService"]
S_DEPT["DepartmentService"]
S_ROLE["RoleService"]
S_CHAT["ChatService"]
S_AI["AiChatService"]
end
subgraph Data["数据层"]
DB[("MySQL")]
KB[("知识库")]
end
U1 --> F_AUTH
U1 --> F_FILE
U1 --> F_QUESTION
U1 --> F_AI
U2 --> F_AUTH
U2 --> F_FILE
U2 --> B_DEPT
U2 --> B_FILE
U3 --> F_AUTH
U3 --> B_USER
U3 --> B_DEPT
U3 --> B_ROLE
U3 --> B_FILE
U3 --> B_LOG
F_AUTH --> S_AUTH
F_FILE --> S_FILE
F_AI --> S_AI
F_AI --> S_CHAT
B_USER --> S_USER
B_DEPT --> S_DEPT
B_ROLE --> S_ROLE
B_FILE --> S_FILE
B_LOG --> S_FILE
S_AUTH --> DB
S_FILE --> DB
S_USER --> DB
S_DEPT --> DB
S_ROLE --> DB
S_AI --> KB
S_CHAT --> DB
```
---
## 功能菜单树
```
知识管理系统
│
├── 前台用户端
│ ├── 用户认证
│ │ ├── 用户登录
│ │ ├── Token验证
│ │ └── 退出登录
│ │
│ ├── 文件管理
│ │ ├── 文件上传
│ │ ├── 文件查询
│ │ ├── 文件详情
│ │ ├── 文件更新
│ │ ├── 文件删除
│ │ └── RAG问答
│ │
│ ├── 题库管理
│ │ ├── 题目查询
│ │ ├── 题目详情
│ │ └── 答题练习
│ │
│ ├── AI对话
│ │ ├── 流式问答
│ │ ├── 会话列表
│ │ ├── 会话详情
│ │ ├── 引用查看
│ │ ├── 会话删除
│ │ └── 问答中断
│ │
│ └── 个人中心
│ ├── 个人信息
│ └── 修改密码
│
└── 后台管理端
├── 用户管理
│ ├── 用户列表
│ ├── 用户详情
│ ├── 添加用户
│ ├── 编辑用户
│ └── 删除用户
│
├── 部门管理
│ ├── 部门列表
│ ├── 部门详情
│ ├── 添加部门
│ ├── 编辑部门
│ └── 删除部门
│
├── 权限管理
│ ├── 角色列表
│ ├── 角色详情
│ ├── 添加角色
│ ├── 编辑角色
│ ├── 删除角色
│ ├── 权限列表
│ └── 权限分配
│
├── 文件审核
│ ├── 待审核列表
│ ├── 审核通过
│ └── 审核拒绝
│
├── 日志管理
│ ├── 日志列表
│ └── 日志详情
│
└── 系统配置
├── 知识库配置
└── 参数设置
```
---
## 接口权限矩阵
| 功能模块 | 普通用户 | 部门管理员 | 系统管理员 |
|---------|:-------:|:----------:|:----------:|
| **用户认证** | | | |
| 登录 | ✅ | ✅ | ✅ |
| 注册 | ✅ | ✅ | ✅ |
| **文件管理** | | | |
| 上传文件 | ✅ | ✅ | ✅ |
| 查询文件 | ✅ | ✅ | ✅ |
| 删除文件 | ✅ | ✅ | ✅ |
| RAG问答 | ✅ | ✅ | ✅ |
| **题库管理** | | | |
| 查询题目 | ✅ | ✅ | ✅ |
| 答题练习 | ✅ | ✅ | ✅ |
| **AI对话** | | | |
| 流式问答 | ✅ | ✅ | ✅ |
| 会话管理 | ✅ | ✅ | ✅ |
| **用户管理** | | | |
| 用户列表 | ❌ | ❌ | ✅ |
| 用户详情 | ❌ | ❌ | ✅ |
| 添加用户 | ❌ | ❌ | ✅ |
| 编辑用户 | ❌ | ❌ | ✅ |
| 删除用户 | ❌ | ❌ | ✅ |
| **部门管理** | | | |
| 部门列表 | ❌ | ✅ | ✅ |
| 部门详情 | ❌ | ✅ | ✅ |
| 添加部门 | ❌ | ❌ | ✅ |
| 编辑部门 | ❌ | ❌ | ✅ |
| 删除部门 | ❌ | ❌ | ✅ |
| **权限管理** | | | |
| 角色列表 | ❌ | ❌ | ✅ |
| 权限分配 | ❌ | ❌ | ✅ |
| **文件审核** | | | |
| 待审核列表 | ❌ | ✅ | ✅ |
| 审核通过 | ❌ | ✅ | ✅ |
| 审核拒绝 | ❌ | ✅ | ✅ |
| **日志管理** | | | |
| 日志列表 | ❌ | ❌ | ✅ |
| 日志详情 | ❌ | ❌ | ✅ |
---
## 技术架构图
```mermaid
flowchart TB
subgraph Client["客户端层"]
WEB["Web浏览器"]
APP["移动App"]
API["第三方系统"]
end
subgraph Gateway["网关层"]
GATEWAY["Spring Cloud Gateway"]
AUTH["认证中心"]
end
subgraph Service["业务服务层"]
USER_SVC["用户服务"]
FILE_SVC["文件服务"]
AI_SVC["AI服务"]
CHAT_SVC["对话服务"]
QUESTION_SVC["题库服务"]
end
subgraph Data["数据层"]
MYSQL["MySQL数据库"]
REDIS["Redis缓存"]
MINIO["MinIO对象存储"]
ES["Elasticsearch"]
end
subgraph External["外部服务"]
LLM["大模型服务"]
RAG["RAG服务"]
end
WEB --> GATEWAY
APP --> GATEWAY
API --> GATEWAY
GATEWAY --> AUTH
AUTH --> USER_SVC
GATEWAY --> USER_SVC
GATEWAY --> FILE_SVC
GATEWAY --> AI_SVC
GATEWAY --> CHAT_SVC
GATEWAY --> QUESTION_SVC
USER_SVC --> MYSQL
USER_SVC --> REDIS
FILE_SVC --> MYSQL
FILE_SVC --> MINIO
AI_SVC --> LLM
AI_SVC --> RAG
CHAT_SVC --> MYSQL
CHAT_SVC --> ES
QUESTION_SVC --> MYSQL
```

View File

@@ -1,267 +0,0 @@
# 文件审批流程调试指南
## 问题现象
普通用户上传公共文件 → 管理员审批通过 → 文件仍显示在"个人文件"列表而非"制度文件"列表
## 调试步骤
### 步骤1:检查后端是否使用了最新代码
#### 方法A:查看日志输出
在后端控制台(终端/命令行)查找以下日志:
**上传时应出现:**
```
文件上传成功: fileName=test.docx, userId=5, deptId=null, isPublic=0, category=rule, auditStatus=0, path=...
```
注意:`category` 应该是 `rule`(不是 `personal`)
**审批通过时应出现:**
```
审核通过,文件分类已从 personal 更新为 rule: fileId=123
```
或者如果没有触发(因为已经是 rule),则不会出现此日志
#### 方法B:直接查看源码
打开文件并确认以下修改存在:
**文件位置:** `demo/demo/demo/src/main/java/top/tqx/demo_1/service/impl/FileServiceImpl.java`
**检查点1 - 第235-243行(determineFileCategory方法):**
```java
private String determineFileCategory(Integer userType, Long targetDeptId, Boolean isPublic) {
if (targetDeptId != null) {
return "rule";
}
if (userType == 3 && Boolean.TRUE.equals(isPublic)) { // ← 这行必须存在
return "rule";
}
return "personal";
}
```
**检查点2 - 第143行(调用处):**
```java
String fileCategory = determineFileCategory(userType, targetDeptId, isPublic); // ← 必须传入isPublic参数
fileEntity.setFileCategory(fileCategory);
```
**检查点3 - 第595-598行(approveFile方法):**
```java
if ("personal".equals(file.getFileCategory())) {
file.setFileCategory("rule"); // ← 这段代码必须存在
log.info("审核通过,文件分类已从 personal 更新为 rule: fileId={}", fileId);
}
```
---
### 步骤2:重新编译部署后端
如果上述代码不存在或不确定,请执行:
```bash
# 1. 进入后端项目目录
cd demo/demo/demo
# 2. 清理并重新编译(跳过测试以加快速度)
mvn clean compile
# 或完整打包
mvn clean package -DskipTests
# 3. 重启后端服务
# 方式A:如果在IDE中运行 → 点击重新启动按钮
# 方式B:如果是jar包运行 → 先停止旧进程,再启动新进程
```
**Windows停止进程:**
```bash
# 查找Java进程
netstat -ano | findstr :8080
# 结束进程(替换PID为实际进程ID)
taskkill /PID <PID> /F
# 重新启动
java -jar target/demo-1.0.0.jar
```
---
### 步骤3:数据库数据验证与修正
连接到MySQL数据库,执行以下查询:
#### 3.1 查看当前文件状态
```sql
-- 查看最近上传的10个文件及其分类
SELECT
id,
file_name,
file_category,
is_public,
audit_status,
upload_user_id,
create_time
FROM file
ORDER BY create_time DESC
LIMIT 10;
```
**预期结果:**
| 场景 | file_category | is_public | audit_status |
|------|--------------|-----------|--------------|
| 管理员上传公共文件 | rule | 1 | 1 |
| 管理员上传个人文件 | personal | 0 | 1 |
| 普通用户上传公共(待审) | rule | 0 | 0 |
| 普通用户上传公共(已审) | rule | 1 | 1 |
| 普通用户上传个人文件 | personal | 0 | 1 |
#### 3.2 识别问题数据
```sql
-- 查找异常数据:标记为公开但分类为个人的已审批文件
SELECT
id,
file_name,
file_category,
is_public,
audit_status,
create_time
FROM file
WHERE is_public = 1
AND file_category = 'personal'
AND audit_status = 1;
```
如果有结果返回,说明这些是**需要修正的历史数据**。
#### 3.3 修正历史数据
```sql
-- 备份先(可选但推荐)
CREATE TABLE file_backup_20260506 AS SELECT * FROM file;
-- 批量修正
UPDATE file
SET file_category = 'rule',
update_time = NOW()
WHERE is_public = 1
AND file_category = 'personal'
AND audit_status = 1;
-- 验证修正结果
SELECT COUNT(*) as corrected_count
FROM file
WHERE is_public = 1
AND file_category = 'rule'
AND audit_status = 1;
```
---
### 步骤4:前端刷新与测试
#### 4.1 清除缓存
- **Chrome/Edge:** `Ctrl + Shift + Delete` → 选择"缓存的图片和文件" → 清除
- **快捷键:** `Ctrl + Shift + R`(强制刷新)
#### 4.2 完整测试流程
**测试场景A:普通用户上传公共文件(全新测试)**
1. 使用普通用户账号登录(userType=3)
2. 点击"上传文件"
3. 选择一个新文件
4. **勾选"公开文件"选项**
5. 点击"上传"
6. 预期提示:"文件已进入待审批状态"
7. 切换到管理员账号登录
8. 进入"审批管理"页面
9. 找到刚才上传的文件,点击"通过"
10. 预期提示:"文件审批通过"
11. **切换回普通用户账号**
12. 查看"制度文件"标签页
13. ✅ **预期:应该能看到刚上传的文件**
**测试场景B:普通用户上传个人文件**
1-5步同上,但第4步选择"个人文件"
12. 查看"个人文件"标签页
13. ✅ **预期:应该在个人文件列表中看到该文件**
---
### 步骤5:实时监控日志(进阶调试)
如果上述步骤仍未解决问题,开启详细日志:
#### 5.1 修改日志级别
在 `application.yml` 或 `application.properties` 中添加:
```yaml
logging:
level:
top.tqx.demo_1.service.impl.FileServiceImpl: DEBUG
```
#### 5.2 关键日志监控点
**上传时关注:**
```
DEBUG [FileServiceImpl] 文件上传成功: fileName=xxx, ..., category=xxx, ...
```
确认 `category` 值是否正确
**审批时关注:**
```
INFO [FileServiceImpl] 审核通过,文件分类已从 personal 更新为 rule: fileId=xxx
```
确认是否有此日志输出
**数据库查询时关注:**
在 `pageFiles()` 方法中添加临时日志:
```java
log.debug("查询文件列表 - 用户类型: {}, 文件总数: {}", userType, filePage.getTotal());
for (File f : filePage.getRecords()) {
log.debug(" 文件: ID={}, 名称={}, 分类={}, 是否公开={}",
f.getId(), f.getFileName(), f.getFileCategory(), f.getIsPublic());
}
```
---
## 常见问题FAQ
### Q1: 为什么我修改了代码但还是不生效?
**A:** 最可能的原因是后端服务没有重启。Java是编译型语言,修改源码后必须重新编译并重启服务才能生效。
### Q2: 数据库修正后还是看不到?
**A:** 可能是前端缓存。请强制刷新浏览器(Ctrl+Shift+R),或在开发者工具中禁用缓存。
### Q3: 新上传的文件正常,但旧的还是不对?
**A:** 这是正常的。旧数据需要手动执行SQL修正(参见步骤3.3)。新上传的文件会使用新的逻辑自动正确处理。
### Q4: 如何确认后端使用的是哪个版本的代码?
**A:** 在 `pom.xml` 中查看版本号,或在启动日志中找到类似:
```
Started DemoApplication in x.xxx seconds
```
确保是在你最后一次修改之后启动的。
### Q5: 审批通过了但文件路径没变?
**A:** 这是正常的。`moveFileToPublic()` 会移动物理文件并更新数据库中的路径字段。你可以检查:
```sql
SELECT file_path FROM file WHERE id = <文件ID>;
```
看路径是否从 `/pending/...` 变成了 `/public/...`
---
## 快速检查清单
- [ ] 后端代码已包含所有3个修复点(determineFileCategory、调用处、approveFile)
- [ ] 后端服务已重新编译并重启
- [ ] 数据库中无异常数据(is_public=1 且 file_category='personal')
- [ ] 浏览器缓存已清除
- [ ] 使用全新的文件进行测试(非旧数据)
- [ ] 测试了普通用户+公共文件、普通用户+个人文件、管理员+公共文件、管理员+个人文件四种场景
- [ ] 后端日志显示正确的 category 值
全部勾选 ✅ 后,问题应该已经解决!

300
题目更新接口文档.md Normal file
View File

@@ -0,0 +1,300 @@
# 题目更新接口文档
> 对应接口:`PUT /api/question/{id}`
> 功能说明:更新题目信息,支持部分字段更新(只传需要修改的字段即可)
> 接口版本:V1.0
> 更新日期:2026-06-08
---
## 目录
- [1. 接口定义](#1-接口定义)
- [2. 请求参数](#2-请求参数)
- [3. 请求示例](#3-请求示例)
- [4. 响应说明](#4-响应说明)
- [5. 错误码说明](#5-错误码说明)
- [6. 边界情况说明](#6-边界情况说明)
- [7. curl 测试命令](#7-curl-测试命令)
---
## 1. 接口定义
| 项目 | 内容 |
|------|------|
| **请求方法** | `PUT` |
| **接口路径** | `/api/question/{id}` |
| **Content-Type** | `application/json` |
| **认证方式** | Bearer Token (JWT) |
| **基础URL** | `http://localhost:8080`(开发环境) |
| **完整URL示例** | `PUT http://localhost:8080/api/question/378` |
### 1.1 路径参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | String | **是** | 题目ID,支持两种格式:<br>① 自增数字ID(如 `378`)<br>② UUID格式的 questionId(如 `e6bb2566-90a8-4651-b553-083a1d695b3c`) |
### 1.2 认证要求
需要在请求头中携带 JWT Token:
```
Authorization: Bearer <token>
```
Token 通过 `/api/auth/login` 接口登录获取。
---
## 2. 请求参数
所有请求字段均为**可选**(部分更新),只传需要修改的字段即可。
### 2.1 请求体结构
```json
{
"question_type": "single_choice",
"difficulty": 3,
"content": {
"stem": "题目标题",
"data": {},
"answer": "A"
},
"tags": ["数学", "代数"],
"score": 5.00,
"status": "pending",
"reviewer_comment": "审核意见",
"document_id": "uuid-string",
"document_name": "文件名.doc",
"knowledge_base_path": "public/dept_1",
"source_context": "原文上下文内容",
"chunk_ids": ["chunk_001", "chunk_002"],
"source_chunks_used": 3,
"page_numbers": [12, 15]
}
```
### 2.2 字段详情
| 字段 | 类型 | 必填 | 说明 | 校验规则 |
|------|------|------|------|---------|
| `question_type` | String | 否 | 题目类型 | 必须是以下之一:`single_choice`、`multiple_choice`、`true_false`、`fill_blank`、`subjective` |
| `difficulty` | Integer | 否 | 难度等级 | 1-5 |
| `content` | Object | 否 | 题目内容(JSON对象) | 不能为空的JSON对象 |
| `content.stem` | String | 视题型 | 题干 | 主观题必填 |
| `content.data` | Object | 视题型 | 额外数据(如选择题的选项列表) | - |
| `content.answer` | String | 视题型 | 答案 | 选择题为选项字母,判断题 "T"/"F" |
| `tags` | Array\<String\> | 否 | 知识点标签 | - |
| `score` | BigDecimal | 否 | 分值 | 范围:`-999.99` ~ `999.99`(数据库 decimal(5,2) 约束) |
| `status` | String | 否 | 审核状态 | `pending`、`approved`、`rejected` |
| `reviewer_comment` | String | 否 | 审核评论 | - |
| `document_id` | String | 否 | 关联文档ID | - |
| `document_name` | String | 否 | 文档名称 | - |
| `knowledge_base_path` | String | 否 | 知识库路径 | 最大500字符 |
| `source_context` | String | 否 | 原文上下文 | - |
| `chunk_ids` | Array\<String\> | 否 | 切片ID列表 | - |
| `source_chunks_used` | Integer | 否 | 使用的源切片数量 | - |
| `page_numbers` | Array\<Integer\> | 否 | 页码列表 | - |
---
## 3. 请求示例
### 3.1 修改题型和难度
```json
// PUT /api/question/378
{
"question_type": "multiple_choice",
"difficulty": 4
}
```
### 3.2 修改题目内容和分值
```json
// PUT /api/question/378
{
"content": {
"stem": "以下哪项是正确的?",
"data": {
"options": [
{ "label": "A", "text": "选项A" },
{ "label": "B", "text": "选项B" },
{ "label": "C", "text": "选项C" }
]
},
"answer": "B"
},
"score": 10.00
}
```
### 3.3 审核通过题目
```json
// PUT /api/question/378
{
"status": "approved",
"reviewer_comment": "内容正确,通过审核"
}
```
### 3.4 使用 UUID 更新
```json
// PUT /api/question/e6bb2566-90a8-4651-b553-083a1d695b3c
{
"difficulty": 5,
"score": 8.00
}
```
---
## 4. 响应说明
### 4.1 通用响应格式
```json
{
"code": 200,
"message": "操作成功",
"data": {},
"timestamp": "2026-06-08 10:00:00",
"success": true
}
```
### 4.2 更新成功响应(code=200)
```json
{
"code": 200,
"message": "题目更新成功",
"data": {
"id": 378,
"questionId": "41a890f2-c9b9-466f-b9f6-e0bd237329b7",
"questionType": "multiple_choice",
"difficulty": 4,
"tags": null,
"score": 5.00,
"version": "1.0",
"documentId": null,
"excludeStems": null,
"knowledgeBasePath": null,
"sourceContext": null,
"chunkIds": null,
"sourceChunksUsed": 0,
"examCount": 0,
"correctCount": 0,
"lastExamTime": null,
"pageNumbers": null,
"content": {
"stem": "以下哪项是正确的?",
"data": {
"options": [
{ "label": "A", "text": "选项A" },
{ "label": "B", "text": "选项B" }
]
},
"answer": "A"
},
"status": "pending",
"reviewerComment": null,
"createdAt": "2026-06-08 09:00:00",
"updatedAt": "2026-06-08 10:00:00",
"isDeleted": 0
},
"timestamp": "2026-06-08 10:00:00",
"success": true
}
```
### 4.3 data 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | Long | 自增主键ID |
| `questionId` | String | 题目UUID(全局唯一标识) |
| `questionType` | String | 题目类型 |
| `difficulty` | Integer | 难度等级 1-5 |
| `tags` | JsonNode | 知识点标签(JSON数组或null) |
| `score` | BigDecimal | 分值 |
| `version` | String | 版本号(默认"1.0") |
| `documentId` | String | 关联文档ID |
| `excludeStems` | String | 题干内容(AI去重用) |
| `knowledgeBasePath` | String | 知识库路径 |
| `sourceContext` | String | 原文上下文 |
| `chunkIds` | JsonNode | 切片ID列表 |
| `sourceChunksUsed` | Integer | 使用切片数 |
| `examCount` | Integer | 出题次数 |
| `correctCount` | Integer | 正确次数 |
| `lastExamTime` | DateTime | 最后作答时间 |
| `pageNumbers` | JsonNode | 页码列表 |
| `content` | JsonNode | 题目内容(JSON对象) |
| `status` | String | 审核状态 |
| `reviewerComment` | String | 审核评论 |
| `createdAt` | DateTime | 创建时间 |
| `updatedAt` | DateTime | 最后更新时间 |
| `isDeleted` | Integer | 逻辑删除标志(0=正常,1=已删除) |
---
## 5. 错误码说明
### 5.1 业务错误码
| HTTP状态码 | code | message | 触发场景 |
|-----------|------|---------|---------|
| **200** | 200 | 题目更新成功 | 更新成功 |
| **404** | 404 | 题目不存在: {id} | 指定ID的题目不存在 |
| **400** | 400 | 分值超出范围,允许范围: -999.99 ~ 999.99 | score 超出 decimal(5,2) 精度范围 |
| **400** | 400 | 知识库路径过长,最大允许500字符 | knowledge_base_path 超过500字符 |
| **400** | 400 | 无效的题目类型: {type} | question_type 不在枚举值内 |
| **400** | 400 | 题目内容不能为空 | content 传了空对象 `{}` |
| **400** | 400 | 题目可能已被删除或归档 | 记录已被逻辑删除,updateById 影响行数为0 |
| **400** | 400 | 题目不存在 | getById 返回 null |
### 5.2 异常错误码
| HTTP状态码 | code | message | 触发场景 |
|-----------|------|---------|---------|
| **500** | 500 | 更新题目失败: {异常信息} | 数据库异常、JSON序列化异常等(Controller 兜底catch) |
| **401** | 401 | 用户未登录 | 未携带 Token 或 Token 无效 |
| **403** | 403 | 无权限访问 | 当前用户无操作权限 |
---
## 6. 边界情况说明
### 6.1 ID 格式自动识别
接口自动识别传入的 `{id}` 格式:
- **纯数字**(如 `378`):按自增主键 `id` 查询
- **非数字**(如 `e6bb2566-...`):按 `question_id`(UUID)查询
- **非法格式**:抛出 `NumberFormatException`,返回 400
### 6.2 部分更新机制
接口支持**部分字段更新**(PATCH 语义),请求体中只需包含要修改的字段:
- 未传的字段保持原值不变
- 传了 `null` 的字段会被设为 `null`
- `content` 为完整替换,非合并更新
### 6.3 防御性校验
Service 层对以下输入进行校验:
| 校验项 | 拦截条件 | 错误信息 |
|--------|---------|---------|
| score 范围 | `score > 999.99 \|\| score < -999.99` | "分值超出范围,允许范围: -999.99 ~ 999.99" |
| 路径长度 | `knowledge_base_path.length > 500` | "知识库路径过长,最大允许500字符" |
| 题型枚举 | `question_type ∉ validTypes` | "无效的题目类型: {type}" |
| 内容非空 | `content != null && content.isEmpty()` | "题目内容不能为空" |
| 存在性检查 | `

View File

@@ -1,180 +0,0 @@
# 题目管理 API 文档
## 1. 上传文件并生成题目
**接口说明:** 上传文档文件,调用 AI 自动生成题目
**请求方式:** `POST`
**接口地址:** `/question/upload`
**Content-Type:** `multipart/form-data`
### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| -------------- | ------ | ---- | --------------------------------------- |
| file | File | 是 | 文档文件(支持 doc, docx, pdf, txt 等) |
| customFileName | String | 否 | 自定义文件名(可选) |
### 响应示例
json { "code": 200, "message": "操作成功", "data": "=== AI 处理结果 ===\n用户: admin\n文件路径: /path/to/file.docx\n\nAI 处理状态: 成功\n说明: 这是占位返回,请替换为实际的 AI 接口调用", "timestamp": "2026-04-23 08:30:00", "success": true }
> **注意:** 目前 `callAIInterface` 方法是占位符,需要对接 Dify 工作流才能实现真正的题目生成功能。
---
## 2. 分页查询题目
**接口说明:** 根据条件分页查询题目列表
**请求方式:** `GET`
**接口地址:** `/question/page`
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
| ------------ | ------- | ---- | ------ | ------------------------------------------------------------ |
| pageNum | Integer | 否 | 1 | 页码 |
| pageSize | Integer | 否 | 10 | 每页大小 |
| questionType | String | 否 | - | 题目类型:single_choice/multiple_choice/fill_blank/true_false/subjective |
| difficulty | Integer | 否 | - | 难度等级(1-5) |
| status | String | 否 | - | 审核状态:pending/approved/rejected |
### 响应示例
json { "code": 200, "message": "操作成功", "data": { "records": [ { "id": 21, "questionId": "q-001-sc-uuid-20260418", "questionType": "single_choice", "difficulty": 2, "tags": ["公司历史", "基本信息"], "score": 10.00, "version": "1.0", "documentId": "doc-001", "documentName": "公司介绍手册", "sourceContext": "根据文档第一段描述...", "pageNumbers": [1], "content": { "stem": "根据文档,公司成立的年份是?", "answer": "B", "explanation": "在文档第一页明确提到...", "data": { "options": [ {"key": "A", "content": "1998年"}, {"key": "B", "content": "2005年"}, {"key": "C", "content": "2010年"}, {"key": "D", "content": "2015年"} ] } }, "status": "pending", "reviewerComment": null, "createdAt": "2026-04-18 08:00:00", "updatedAt": "2026-04-18 00:49:27" } ], "total": 25, "size": 10, "current": 1, "pages": 3 }, "timestamp": "2026-04-23 08:30:00", "success": true }
---
## 3. 获取题目详情
**接口说明:** 根据题目 ID 获取详细信息
**请求方式:** `GET`
**接口地址:** `/question/{id}`
### 路径参数
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ----------- |
| id | Long | 是 | 题目自增 ID |
### 响应示例
json { "code": 200, "message": "操作成功", "data": { "id": 21, "questionId": "q-001-sc-uuid-20260418", "questionType": "single_choice", "difficulty": 2, "tags": ["公司历史", "基本信息"], "score": 10.00, "version": "1.0", "documentId": "doc-001", "documentName": "公司介绍手册", "sourceContext": "根据文档第一段描述...", "pageNumbers": [1], "content": {...}, "status": "pending", "reviewerComment": null, "createdAt": "2026-04-18 08:00:00", "updatedAt": "2026-04-18 00:49:27" }, "timestamp": "2026-04-23 08:30:00", "success": true }---
## 4. 获取待审核题目列表
**接口说明:** 获取所有待审核的题目
**请求方式:** `GET`
**接口地址:** `/question/pending`
### 响应示例
json { "code": 200, "message": "操作成功", "data": [ { "id": 21, "questionId": "q-001-sc-uuid-20260418", "questionType": "single_choice", "status": "pending", ... } ], "timestamp": "2026-04-23 08:30:00", "success": true }
---
## 5. 审核题目
**接口说明:** 审核通过或拒绝题目
**请求方式:** `PUT`
**接口地址:** `/question/review/{questionId}`
### 路径参数
| 参数名 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | --------- |
| questionId | String | 是 | 题目 UUID |
### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| --------------- | ------ | ---- | -------------------------------------------- |
| status | String | 是 | 审核状态:approved(通过)/ rejected(拒绝) |
| reviewerComment | String | 否 | 审核评论/意见 |
### 请求示例
PUT /question/review/q-001-sc-uuid-20260418?status=approved&reviewerComment=题目质量良好
### 响应示例
json { "code": 200, "message": "操作成功", "data": null, "timestamp": "2026-04-23 08:30:00", "success": true }
---
## 6. 按题型获取题目列表
**接口说明:** 获取指定题型的所有已审核通过的题目
**请求方式:** `GET`
**接口地址:** `/question/type/{questionType}`
### 路径参数
| 参数名 | 类型 | 必填 | 说明 |
| ------------ | ------ | ---- | ------------------------------------------------------------ |
| questionType | String | 是 | 题目类型:single_choice/multiple_choice/fill_blank/true_false/subjective |
### 响应示例
json { "code": 200, "message": "操作成功", "data": [ { "id": 21, "questionId": "q-001-sc-uuid-20260418", "questionType": "single_choice", "status": "approved", ... } ], "timestamp": "2026-04-23 08:30:00", "success": true }
---
## 7. 获取所有题型列表
**接口说明:** 获取系统支持的所有题目类型
**请求方式:** `GET`
**接口地址:** `/question/types`
### 响应示例
json { "code": 200, "message": "操作成功", "data": [ "single_choice", "multiple_choice", "fill_blank", "true_false", "subjective" ], "timestamp": "2026-04-23 08:30:00", "success": true }
---
## 附录
### 题目类型说明
| 类型值 | 中文名称 | 说明 |
| --------------- | -------- | ------------- |
| single_choice | 选择题 | 单选题 |
| multiple_choice | 多选题 | 多项选择题 |
| fill_blank | 填空题 | 填空题目 |
| true_false | 判断题 | 判断对错 |
| subjective | 简答题 | 主观题/问答题 |
### 审核状态说明
| 状态值 | 中文名称 | 说明 |
| -------- | -------- | -------------------- |
| pending | 待审核 | AI 生成后的初始状态 |
| approved | 已通过 | 审核通过,可对外展示 |
| rejected | 已拒绝 | 审核不通过 |
### 注意事项
1. **题目生成接口目前未完全实现**,`uploadAndProcessFile` 方法中的 `callAIInterface` 是占位符,需要对接 Dify 工作流 API
2. **按题型查询只返回已通过的题目**(status = "approved"),如需查看所有状态的题目,请使用分页查询接口并指定 status 参数
3. **时间格式统一为** `yyyy-MM-dd HH:mm:ss`
# AIChat模块流式接口
POST 'http://localhost:8080/api/ai/chat/stream' \
-H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3NzAwODgxOCwiZXhwIjoxNzc3MDE2MDE4fQ.mrvapro3L_vzYPxRtZPcEDQFAE2kuvpeMBV1vWhI9ho7lJZq0CnBrL1ZkhwOnDiXOZltLjor6vvFkuEb-pGEtg' \
-H 'Content-Type: application/json' \
-d '{"message": "三峡船闸是什么", "chat_history": []}' \
-H 'Accept: text/event-stream'