Files
leaudit-platform-backend/docs/交叉评查/交叉评查核心模块业务逻辑与Tab实现计划.md
T
wren adc1e0b8dc feat: 交叉评查后端优化 — 评查地区、文档评查统计、currentScore、错误提示
- GetUserTasks: 新增 task_regions CTE,从任务成员 sso_users.area 去重收集 evaluationRegion
- GetTaskDocuments: 新增 es LATERAL 子查询聚合 leaudit_rule_results 的 pass_count/warning_count/error_count/score_percent;path/uploadTime 改为从 leaudit_document_files 获取;新增 fileExt
- ReviewPointResultVO: 新增 currentScore 字段
- _loadReviewPointResults: SQL 新增 approved_delta LATERAL 子查询,currentScore = base_score + SUM(approved_deltas)
- CrossReviewTaskItemVO: 新增 evaluationRegion
- CrossReviewTaskDocumentVO: 新增 18 个评查统计字段 + path/uploadTime/fileExt
- 文档更新:交叉评查核心模块业务逻辑文档补充评查地区、评查统计、版本号本地化等章节
2026-05-15 14:15:29 +08:00

1855 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 交叉评查核心模块 — 业务逻辑与实现计划
## 目录
1. [数据库表结构](#1-数据库表结构)
2. [后端 API 全景](#2-后端-api-全景)
3. [计分与投票核心逻辑](#3-计分与投票核心逻辑)
4. [前端组件架构](#4-前端组件架构)
5. [数据流全景](#5-数据流全景)
6. [右侧栏「交叉意见」Tab 实现计划](#6-右侧栏交叉意见tab-实现计划)
---
## 1. 数据库表结构
所有表均位于 `fastapi_modules/fastapi_leaudit/models/`,通过 `_SCHEMA_BOOTSTRAP_STATEMENTS` 自举建表,带软删除支持(`delete_time TIMESTAMPTZ`)。
### 1.1 `leaudit_cross_review_tasks` — 交叉评查任务
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | INT PK | 任务 ID |
| `task_name` | VARCHAR | 任务名称 |
| `task_type` | VARCHAR | 任务类型(默认 "CITY" |
| `doc_type_id` | INT | 文档类型 ID |
| `doc_type_code` | VARCHAR | 文档类型编码 |
| `assigner_id` | INT | 创建人 / 指派者 |
| `status` | VARCHAR | 任务状态 |
| `create_time` | TIMESTAMPTZ | 创建时间 |
| `update_time` | TIMESTAMPTZ | 更新时间 |
| `delete_time` | TIMESTAMPTZ | 软删除时间 |
### 1.2 `leaudit_cross_review_task_members` — 任务成员
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | INT PK | |
| `task_id` | INT FK → tasks | 所属任务 |
| `user_id` | INT | 成员用户 ID |
| `member_role` | VARCHAR | 角色:`participant`(参与)/ `principal`(负责人) |
| `create_time` | TIMESTAMPTZ | |
| `delete_time` | TIMESTAMPTZ | |
> **负责人权限**`principal` 角色的成员 + `assigner_id` 创建者可执行 `CompleteTaskDocument`(完成评查)。
### 1.3 `leaudit_cross_review_task_documents` — 任务关联文档
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | INT PK | |
| `task_id` | INT FK → tasks | 所属任务 |
| `document_id` | INT | 关联的文档 IDFK → `leaudit_documents.id` |
| `audit_status` | INT | 评查状态:`0`=未评查,`1`=已评查(**文档×任务级别**,非按人) |
| `create_time` | TIMESTAMPTZ | |
| `delete_time` | TIMESTAMPTZ | |
> **评查状态逻辑**`CompleteTaskDocument` 将 `audit_status` 置为 1。当任务内全部文档的 `audit_status=1` 时,任务状态自动更新为 `completed`。
### 1.4 `leaudit_cross_review_proposals` — 交叉评查提案(意见)
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | INT PK | 提案 ID,后端返回为 `proposal_id` |
| `task_id` | INT FK → tasks | 所属任务 |
| `document_id` | INT FK → documents | 所属文档 |
| `rule_result_id` | INT | 评查点结果 ID`leaudit_review_point_audits.id` |
| `proposer_id` | INT | 提案人用户 ID |
| `proposed_score_delta` | NUMERIC(10,2) | 提议的分数调整量(负=扣分,正=加分) |
| `reason` | TEXT | 提案理由 / 审计意见文本 |
| `status` | VARCHAR | 状态:`pending` / `approved` / `rejected` / `cancelled` |
| `create_time` | TIMESTAMPTZ | |
| `update_time` | TIMESTAMPTZ | |
| `delete_time` | TIMESTAMPTZ | |
> **status 转换由 `_refresh_proposal_status()` 控制**,不可手动修改。
### 1.5 `leaudit_cross_review_votes` — 投票记录
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | INT PK | |
| `proposal_id` | INT FK → proposals | 所属提案 |
| `voter_id` | INT | 投票人用户 ID |
| `vote_type` | VARCHAR | `agree` / `disagree` / `cancel` |
| `create_time` | TIMESTAMPTZ | |
| `delete_time` | TIMESTAMPTZ | |
### 1.6 依赖的外部表
任务列表和文档列表的查询依赖以下外部表(非交叉评查专属):
| 表 | 用途 |
|----|------|
| `sso_users` | 用户信息;`area` 字段用于收集任务成员的评查地区 |
| `leaudit_documents` | 文档主表;含 `version_no`(全局版本号)、`version_group_key``current_run_id` |
| `leaudit_document_files` | 文档文件;第一个文件的 `local_path``created_at` 作为文档路径和上传时间 |
| `leaudit_rule_results` | 评查规则结果;聚合 `passed`/`risk`/`score`/`fail_message` 得出评查统计 |
| `leaudit_audit_runs` | 评查运行记录;通过 `d.current_run_id` 关联 |
> **评查地区数据来源**`GetUserTasks` 中 `task_regions` CTE 从 `leaudit_cross_review_task_members` JOIN `sso_users` 收集 `DISTINCT u.area`(排除 NULL 和空串),非从文档表获取。
> **评查统计数据来源**`GetTaskDocuments` 中 `es` LATERAL 子查询聚合 `leaudit_rule_results`,按 `risk` 分级:
> - `passed IS TRUE` → pass_count
> - `passed IS FALSE AND risk = 'high'` → error_count
> - `passed IS FALSE AND risk IN ('low','medium')` → warning_count
---
## 2. 后端 API 全景
**路由前缀**: `/api/v3/cross-review`
**控制器**: `fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py`
**服务实现**: `fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py`
### 2.1 任务管理
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/tasks` | 创建交叉评查任务 | `cross_review:task:create` |
| POST | `/tasks/query` | 查询当前用户的任务(分页) | `cross_review:task:read` |
| GET | `/tasks/{taskId}/progress` | 查询任务进度(完成数/总数) | `cross_review:progress:view` |
| GET | `/tasks/{taskId}/documents` | 查询任务文档列表(分页) | `cross_review:document:read` |
| POST | `/tasks/{taskId}/can-confirm` | 检查当前用户可否确认完成 | — |
| POST | `/tasks/{taskId}/documents/{docId}/complete` | 确认文档评查完成 | `cross_review:document:complete` |
| POST | `/tasks/{taskId}/documents/upload` | 向任务补传文档 | `cross_review:document:read` |
### 2.2 提案(意见)管理
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/proposals` | 创建提案(提出意见) | `cross_review:proposal:create` |
| POST | `/proposals/{proposalId}/votes` | 投票(agree/disagree/cancel | `cross_review:proposal:vote` |
| DELETE | `/proposals/{proposalId}` | 撤销提案(仅提案人) | `cross_review:proposal:delete` |
| GET | `/documents/{docId}/proposals` | 获取文档的意见列表(分页) | `cross_review:proposal:read` |
| GET | `/documents/{docId}/pending-votes` | 获取待投票摘要 | — |
### 2.3 请求 DTO`crossReviewDto.py`
**`CrossReviewTaskCreateDTO`**:
```python
taskName: str # 必填
taskType: str = "CITY"
docTypeId: int | None
docTypeCode: str | None
memberUserIds: list[int] # 参与者
principalUserIds: list[int] # 负责人
documentIds: list[int] # 初始挂载文档
```
**`CrossReviewProposalCreateDTO`**:
```python
reviewPointResultId: int # 必填 — 评查点结果 ID
documentId: int # 必填 — 文档 ID
evaluationPointId: int | None # 可选 — 评查点定义 ID
auditOpinion: str # 必填 — 意见文本
deductionScore: float # 必填 — 分数调整(负=扣分,正=加分)
```
**`CrossReviewProposalVoteDTO`**:
```python
voteType: str # "agree" | "disagree" | "cancel"
```
### 2.4 响应 VO`crossReviewVo.py`
**`CrossReviewProposalItemVO`** — 意见列表项:
```python
proposalId: int
evaluationPointName: str # 评查点名称
proposedScore: float # 提议分数调整
reason: str # 意见内容
proposer: str # 提案人姓名
votes: list[VoteItemVO] # 所有投票明细
agreeVoters: list[str] # 赞同者姓名
disagreeVoters: list[str] # 反对者姓名
pendingVoters: list[str] # 待投票者姓名
canVote: bool # 当前用户可否投票
problemMessage: str
proposerId: int
createdAt: datetime | None
status: str # pending | approved | rejected | cancelled
```
**`CrossReviewProposalPageVO`**:
```python
total: int; page: int; pageSize: int
items: list[CrossReviewProposalItemVO]
```
**`CrossReviewPendingVotesVO`**:
```python
hasPendingVotes: bool
pendingProposals: list[PendingProposalVO]
# PendingProposalVO: { evaluationPointName, pendingVotersNum }
```
**`CrossReviewTaskItemVO`** — 任务列表项:
```python
taskId: int
taskName: str
taskType: str
docTypeId: int | None
docTypeCode: str | None
status: str
progress: float
totalDocuments: int
completedDocuments: int
createdAt: datetime | None
evaluationRegion: list[str] # 评查地区 — 从任务成员 sso_users.area 去重收集
```
**`CrossReviewTaskDocumentVO`** — 任务文档列表项:
```python
documentId, name, documentNumber, typeId, typeName
processingStatus, versionNo, isLatestVersion
versionGroupKey, totalVersions
auditStatus # 0=未评查, 1=已评查(文档×任务级别)
createdAt, fileSize
path # 文件路径 — 来自 leaudit_document_files.local_path
uploadTime # 上传时间 — 来自 leaudit_document_files.created_at
# 以下为评查统计字段 — 聚合自 leaudit_rule_results
totalEvaluationPoints: int # 总评查点数
passCount: int # 通过数(passed IS TRUE
warningCount: int # 警告数(passed IS FALSE AND risk IN ('low','medium')
errorCount: int # 错误数(passed IS FALSE AND risk = 'high'
manualCount: int # 人工审核数(暂为 0
issueCount: int # 问题总数
warningMessages: list[str] # 警告消息
errorMessages: list[str] # 错误消息
issueMessages: list[str] # 问题消息(综合)
manualMessages: list[str] # 人工审核消息(暂为空)
finalScore: float # 最终得分(通过规则分数之和)
fullScore: float # 满分(所有规则分数之和)
scoreSummary: str # 得分摘要(如 "85.0/100.0"
scorePercent: float # 得分百分比(0-100
```
---
## 3. 计分与投票核心逻辑
### 3.1 计分公式
位置:`crossReviewServiceImpl.py:_calculate_current_score()` (line 1133)
```
current_score = base_score + SUM(approved_deltas)
```
**base_score 规则**:
| 条件 | base_score |
|------|-----------|
| `edit_audit_status = 1``override_result = TRUE` | 满分 (`rule_result.score`) |
| `edit_audit_status = 1``override_result = FALSE` | 0 |
| `rule_result.passed = TRUE`(机器评查通过) | 满分 |
| `rule_result.passed = FALSE`(机器评查未通过) | 0 |
**approved_delta**: 该评查点下所有 `status = 'approved'` 的提案的 `proposed_score_delta` 之和。
> `deductionScore` 可以是**负数**(扣分)或**正数**(加分)。
### 3.2 提案创建时的分数校验
位置:`crossReviewServiceImpl.py:CreateProposal()` (line 672)
| 条件 | 结果 |
|------|------|
| `deductionScore < 0``currentScore <= 0` | 拒绝:"当前分数已为 0,无法继续扣分" |
| `deductionScore > 0``currentScore >= fullScore` | 拒绝:"当前分数已为满分,无法继续加分" |
| 通过校验 | 创建提案 + 提案人自动投"赞同"票 |
### 3.3 投票机制:过半多数制
位置:`crossReviewServiceImpl.py:_refresh_proposal_status()` (line 1235)
```
threshold = floor(memberCount / 2) + 1
```
例如:5 个成员 → threshold = 34 个成员 → threshold = 3
**状态转换规则**
```
┌──────────┐
│ pending │
└────┬─────┘
┌──────────────┼──────────────┐
│ │ │
agreeCount >= disagreeCount >= agreeCount +
threshold threshold remaining < threshold
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ approved │ │ rejected │ │ rejected │
└──────────┘ └──────────┘ └──────────┘
cancelled: 仅提案人可在 pending 状态撤销
```
**关键规则**
- 提案人创建提案时自动投"赞同"票
- 投票类型:`agree` / `disagree` / `cancel`(撤销投票)
- 同一人只能投一票(后投覆盖前票)
- 状态变更后 `approved_delta` 自动计入/不计入 `_calculate_current_score()`
### 3.4 "完成评查"前置检查
`GetDocumentPendingVotes()` (line 844) → `checkProposalVotes()`
1. 解析文档所属 task_id
2. 校验当前用户是任务成员
3. 构建完整提案列表(最多 200 条)
4. 过滤 `status = 'pending'``pendingVoters.length > 0` 的提案
5. 返回 `hasPendingVotes` + 待投票提案列表
前端在「完成评查」按钮点击时先调用此接口,如果有待投票项则弹出警告确认框。
### 3.5 `updateCrossCheckingReviewResult` 与提案的叠加关系
```
最终分数 = updateCrossCheckingReviewResult 设置的 base_score
+ 所有 approved 提案的 proposed_score_delta 之和
```
两者**独立操作但叠加生效**
- `PATCH /api/v3/review-points/{id}/audit` → 修改 `edit_audit_status` / `override_result`,影响 base_score
- 提案 approved → 影响 delta
- 互不依赖,最终评分是两者之和
---
## 4. 前端组件架构
### 4.1 组件树
```
app/(audit)/cross-checking/
├── page.tsx # 交叉评查任务列表页(服务端组件)
├── result/
│ ├── page.tsx # 评查详情页(服务端组件,加载数据)
│ └── CrossCheckingResultClient.tsx # 评查详情页(客户端组件)
components/
├── cross-checking/
│ ├── DocumentListModal.tsx # 文档上传/管理弹窗
│ ├── ReviewPointsList.tsx # 评查点列表 + 意见列表(用于左侧 RulesDirectory 的 resultContent slot
│ └── CrossCheckingOpinionsPanel.tsx # 【新增】交叉意见 Tab 内容面板
├── reviews/
│ ├── leftColumn/
│ │ └── RulesDirectory.tsx # 左侧栏:评查规则目录
│ ├── rightColumn/
│ │ ├── DetailPanel.tsx # 右侧栏:详情面板(3 Tab + 动态扩展)
│ │ ├── ReviewPointDetailCard.tsx # 评查点详情卡片
│ │ └── FileInfoPanel.tsx # 文件信息面板
│ └── previewComponents/
│ ├── PdfPreviewTest.tsx # PDF 预览
│ ├── DocxPreviewTest.tsx # DOCX 预览
│ └── ComparePreview.tsx # 结构比对预览
lib/api/legacy/cross-checking/
├── cross-files.ts # 交叉评查文档 API
├── cross-files-upload.ts # 文档上传 API
└── cross-file-result.ts # 评查结果/意见/投票 API
```
### 4.2 页面三栏布局(当前状态 vs 目标状态)
**当前状态 — 按钮在底部栏 + Modal:**
```
┌──────────────────┬──────────────────────┬───────────────────────────┐
│ RulesDirectory │ Preview │ DetailPanel │
│ (22%) │ (48%) │ (30%) │
│ │ │ │
│ ├─ 规则树 │ PdfPreviewTest / │ ┌─────────────────────┐ │
│ │ ├─ 评查点1 │ DocxPreviewTest │ │ Tab: 评查结果 │ │
│ │ ├─ 评查点2 │ │ │ Tab: 抽取字段 (28) │ │
│ │ └─ ... │ + 目标页定位 │ │ Tab: 文件信息 │ │
│ │ │ + 高亮/bbox │ └─────────────────────┘ │
│ ├─ 统计摘要 │ │ │
│ │ (总数/通过/ │ │ Tab 内容区 │
│ │ 警告/错误) │ │ ├─ 评查结果: │
│ └─ 提案数 badge │ │ │ ReviewPointDetailCard│
│ │ │ ├─ 抽取字段: │
│ │ │ │ ExtractedFieldsPanel │
│ │ │ └─ 文件信息: │
│ │ │ FileInfoPanel │
│ │ │ │
│ │ │ ┌─────────────────────┐ │
│ │ │ │ 底部操作栏: │ │
│ │ │ │ [📥下载] │ │
│ │ │ │ [提出意见] ← 点开Modal│ │
│ │ │ │ [查看意见] ← 点开Modal│ │
│ │ │ │ [✓ 完成评查] │ │
│ │ │ └─────────────────────┘ │
└──────────────────┴──────────────────────┴───────────────────────────┘
```
**目标状态 — 提出意见/查看意见 移入第 4 个 Tab「交叉意见」:**
```
┌──────────────────┬──────────────────────┬───────────────────────────┐
│ RulesDirectory │ Preview │ DetailPanel │
│ (22%) │ (48%) │ (30%) │
│ │ │ │
│ ├─ 规则树 │ PdfPreviewTest / │ ┌─────────────────────┐ │
│ │ ├─ 评查点1 │ DocxPreviewTest │ │ Tab: 评查结果 │ │
│ │ ├─ 评查点2 │ │ │ Tab: 抽取字段 (28) │ │
│ │ └─ ... │ + 目标页定位 │ │ Tab: 文件信息 │ │
│ │ │ + 高亮/bbox │ │ Tab: 交叉意见 (3) ←NEW│ │
│ ├─ 统计摘要 │ │ └─────────────────────┘ │
│ └───────────── │ │ │
│ │ │ Tab: 交叉意见 内容区 │
│ │ │ ┌─────────────────────┐ │
│ │ │ │ [+ 提出意见] 按钮 │ │
│ │ │ │ │ │
│ │ │ │ 提出意见表单(可折叠) │ │
│ │ │ │ 评查点: xxx │ │
│ │ │ │ 满分 X | 已获得 Y │ │
│ │ │ │ [意见文本域] │ │
│ │ │ │ [分数调整 +/−] │ │
│ │ │ │ [取消] [提交意见] │ │
│ │ │ │ │ │
│ │ │ │ 意见列表 (分页) │ │
│ │ │ │ ├─ 意见卡片 1 │ │
│ │ │ │ │ 评查点名 · 状态 │ │
│ │ │ │ │ 意见内容... │ │
│ │ │ │ │ 提案人 · 扣分 │ │
│ │ │ │ │ [赞同][反对] │ │
│ │ │ │ ├─ 意见卡片 2 ... │ │
│ │ │ │ └─ 分页器 │ │
│ │ │ └─────────────────────┘ │
│ │ │ │
│ │ │ ┌─────────────────────┐ │
│ │ │ │ 底部操作栏(简化): │ │
│ │ │ │ [📥下载] │ │
│ │ │ │ [✓ 完成评查] │ │
│ │ │ └─────────────────────┘ │
└──────────────────┴──────────────────────┴───────────────────────────┘
```
**关键变化:**
| 元素 | 当前 | 目标 |
|------|------|------|
| Tab 数量 | 3 | 4+「交叉意见」) |
| 提出意见 | 底部按钮 → Modal | Tab 内联表单 |
| 查看意见 | 底部按钮 → Modal | Tab 内列表 |
| 投票操作 | Modal 内表格 | Tab 内卡片(含乐观更新) |
| 分数展示 | Modal 内文字 | Tab 表单分数状态条 |
| 底部栏 | [下载] [提出意见] [查看意见] [完成评查] | [下载] [完成评查] |
### 4.3 现有数据流(CrossCheckingResultClient
```
page.tsx (服务端)
├─ getUserSession() → userInfo, frontendJWT
├─ getReviewPoints_fromApi(fileId) → reviewData
│ ├─ reviewData.data → reviewPoints (可能是嵌套 {data, stats, reviewInfo})
│ ├─ reviewData.document → document
│ ├─ reviewData.stats → statistics
│ ├─ reviewData.reviewInfo → reviewInfo
│ ├─ reviewData.scoring_proposals → scoringProposals
│ └─ reviewData.comparison_document → 比对文档
└─ findIsProposer(taskId, userId) → isProposer
CrossCheckingResultClient (客户端)
├─ getNestedReviewPayload() → 解包嵌套响应
├─ getReviewPointsArray() → 提取评查点数组
├─ initialReviewData (useMemo)
│ ├─ fileInfo, reviewInfo, statistics
│ ├─ reviewPoints, aiAnalysis (mock)
│ └─ contractInfo (mock)
├─ 左侧: RulesDirectory (onRuleSelect → 切换 activeReviewPointResultId)
├─ 中间: PdfPreviewTest / DocxPreviewTest (定位到 targetPage)
├─ 右侧: DetailPanel
│ ├─ resultContent = <ReviewPointsList /> (评查点列表+意见)
│ ├─ bottomActions = [提出意见] [查看意见] (→ Modal)
│ ├─ isProposer = Boolean(isProposer && canCompleteDocument)
│ └─ completeButtonLabel = "完成评查"
└─ 弹窗: 提出意见 Modal / 查看意见 Modal
```
---
## 5. 数据流全景
### 5.1 任务创建 → 评查 → 完成流程
```
1. 创建任务
POST /api/v3/cross-review/tasks
→ 插入 leaudit_cross_review_tasks
→ 插入 leaudit_cross_review_task_members (参与者+负责人)
→ 插入 leaudit_cross_review_task_documents (初始文档)
→ 触发机器评查
2. 评查文档
GET /api/v3/review-points/{fileId}
→ documentServiceImpl.GetReviewPoints()
→ 加载 review_points (评查结果)
→ 加载 scoring_proposals (_loadScoringProposals)
→ 加载 comparison_document (比对文档)
3. 提出意见
POST /api/v3/cross-review/proposals
→ 校验 dedectionScore 边界 (_calculate_current_score)
→ 插入 leaudit_cross_review_proposals
→ 提案人自动投 agree (_vote_proposal)
→ _refresh_proposal_status → pending/approved/rejected
4. 投票
POST /api/v3/cross-review/proposals/{id}/votes
→ 插入/更新 leaudit_cross_review_votes
→ _refresh_proposal_status → 检查是否达到阈值
5. 完成评查
GET /api/v3/cross-review/documents/{id}/pending-votes (检查待投票)
POST /api/v3/cross-review/tasks/{taskId}/documents/{docId}/complete
→ 更新 leaudit_cross_review_task_documents.audit_status = 1
→ 检查任务下所有文档是否完成 → 更新任务状态
```
### 5.2 计分数据流(详细)
```
机器评查
├─ rule_result.score → 满分
├─ rule_result.passed → TRUE/FALSE
└─ rule_result.machineScore → AI 评分
人工审核(可选)
├─ PATCH /api/v3/review-points/{id}/audit
├─ edit_audit_status = 1
└─ override_result = TRUE/FALSE
提案投票
├─ approved 提案的 proposed_score_delta 求和
└─ 加到 base_score 上
current_score = base_score + SUM(approved_deltas)
```
### 5.3 提案生命周期
```
CreateProposal
├─ status = "pending"
├─ auto-vote: proposer → agree
└─ _refresh_proposal_status()
├─ agree >= threshold → "approved" (delta 计入 current_score)
├─ disagree >= threshold → "rejected"
├─ agree + remaining < threshold → "rejected"
└─ otherwise → keep "pending"
├─ VoteProposal (agree/disagree/cancel)
│ └─ _refresh_proposal_status()
└─ CancelProposal (仅提案人, only when "pending")
└─ status → "cancelled"
```
---
## 6. 右侧栏「交叉意见」Tab 实现计划
### 6.1 目标
-`DetailPanel` 新增第 4 个 Tab:**交叉意见**
- 意见列表 + 提出意见表单 + 投票操作直接嵌入面板
- 替代当前底部栏按钮 + Modal 方案
- 非交叉评查页面不受影响(slot 模式按需注入)
### 6.2 Step 1: 扩展 `DetailPanel`
**文件**: `components/reviews/rightColumn/DetailPanel.tsx`+25 行)
**改动**
1. 扩展 TabKey 类型和 props
```typescript
type TabKey = 'result' | 'fields' | 'info' | 'crossOpinions';
// 新增 props:
crossOpinionsContent?: React.ReactNode; // 交叉意见 tab 内容
crossOpinionsBadge?: number; // 意见数量 badge
```
2. 动态 TABS 数组(`useMemo`):
```typescript
const BASE_TABS = [
{ key: 'result' as const, label: '评查结果' },
{ key: 'fields' as const, label: '抽取字段' },
{ key: 'info' as const, label: '文件信息' },
];
const tabs = useMemo(() => {
if (crossOpinionsContent != null) {
return [...BASE_TABS, { key: 'crossOpinions' as TabKey, label: '交叉意见' }];
}
return BASE_TABS;
}, [crossOpinionsContent]);
```
3. 渲染内容:
```tsx
{activeTab === 'crossOpinions' && crossOpinionsContent}
```
4. Badge
```tsx
{tab.key === 'crossOpinions' && crossOpinionsBadge != null && crossOpinionsBadge > 0 && (
<span className="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px]
rounded-full bg-red-500 text-white text-[10px] px-1">
{crossOpinionsBadge}
</span>
)}
```
### 6.3 Step 2: 新建 `CrossCheckingOpinionsPanel`
**文件**: `components/cross-checking/CrossCheckingOpinionsPanel.tsx`(新建,~350 行)
**Props**
```typescript
interface CrossCheckingOpinionsPanelProps {
documentId: string | number;
// 当前选中评查点信息(用于表单自动关联 + 分数展示)
activeReviewPointId?: string | number | null;
activeReviewPointName?: string;
activeReviewPointScore?: number | null; // 满分
activeReviewPointMachineScore?: number | null; // AI 得分
activeReviewPointFinalScore?: number | null; // 人工调整后得分(null → fallback machineScore
// 认证
jwtToken?: string;
userInfo?: any;
// 权限
canCreateProposal: boolean;
canVoteProposal: boolean;
canDeleteProposal: boolean;
// 回调
onOpinionSubmitted?: (proposal: ScoringProposal) => void;
onNavigateToReviewPoint?: (pointId: string | number) => void;
}
```
**组件结构**
```
CrossCheckingOpinionsPanel
├── 头部
│ ├── "交叉意见" 标题 + 总数
│ └── [提出意见] 按钮(canCreateProposal 时显示)
├── 提出意见表单(可展开/折叠)
│ ├── 分数状态条:评查点名称 | 满分 X 分 | 已获得 Y 分
│ ├── 意见文本域
│ ├── 分数调整输入(负数扣分,正数加分)
│ ├── 前端边界校验提示
│ └── [取消] [提交意见]
├── 意见列表(分页)
│ └── 每条意见卡片:
│ ├── 头部:评查点名称(可点击→跳转 Tab) + 状态标签 + 时间
│ ├── 正文:意见内容
│ ├── 元信息:提案人 | 提议调整:+X/-X 分
│ ├── 投票区:
│ │ ├── 赞同者列表(绿色标签)
│ │ ├── 反对者列表(红色标签)
│ │ ├── 待投票者列表(灰色标签)
│ │ └── [赞同] [反对] 按钮(canVoteProposal && can_vote 时显示)
│ └── 操作:[撤销意见]canDeleteProposal && 当前用户是提案人 && status=pending
└── 空态:暂无意见
```
**状态管理**
```typescript
const [opinions, setOpinions] = useState<CrossCheckingOpinion[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ auditOpinion: '', deductionScore: 0 });
const [submitting, setSubmitting] = useState(false);
```
**数据加载策略**
- 懒加载:仅在 Tab 切换到 `crossOpinions` 时首次触发 `getCrossCheckingOpinions()`
- 提交意见后 → 刷新列表(重置到第 1 页)
- 投票后 → 乐观更新本地状态 + 失败时回滚
**提出意见提交逻辑**
```typescript
const handleSubmit = async () => {
// 1. 校验
if (!form.auditOpinion.trim()) { toastService.warning('请输入评查意见'); return; }
if (!activeReviewPointId) { toastService.warning('请先在左侧选择评查点'); return; }
// 2. 前端初步边界校验(后端做精确校验)
const currentScore = activeReviewPointFinalScore ?? activeReviewPointMachineScore ?? 0;
const fullScore = activeReviewPointScore ?? 0;
if (form.deductionScore < 0 && currentScore + form.deductionScore < 0) {
toastService.warning('扣分后分数不能低于 0'); return;
}
if (form.deductionScore > 0 && fullScore > 0 && currentScore + form.deductionScore > fullScore) {
toastService.warning('加分后分数不能超过满分'); return;
}
// 3. 调用 API
setSubmitting(true);
const result = await submitCrossCheckingOpinion({
reviewPointResultId: String(activeReviewPointId),
documentId: String(documentId),
evaluationPointId: null,
auditOpinion: form.auditOpinion,
deductionScore: form.deductionScore,
}, jwtToken, userInfo);
setSubmitting(false);
// 4. 处理结果
if (result.error) { toastService.error(result.error); return; }
toastService.success('意见提交成功 — 提案人自动投赞同票');
setShowForm(false);
setForm({ auditOpinion: '', deductionScore: 0 });
loadOpinions(); // 刷新列表
onOpinionSubmitted?.(result.data as any);
};
```
**投票逻辑**(乐观更新):
```typescript
const handleVote = async (proposalId: string | number, voteType: 'agree' | 'disagree') => {
// 乐观更新
const previous = opinions;
setOpinions(prev => prev.map(o =>
o.proposal_id === proposalId ? { ...o, can_vote: false } : o
));
const result = await performOpinionAction({
opinionId: proposalId, action: voteType
}, jwtToken, userInfo);
if (result.error) {
toastService.error(result.error);
setOpinions(previous); // 回滚
return;
}
loadOpinions(); // 刷新(获取后端 _refresh_proposal_status 后的状态)
};
```
### 6.4 Step 3: 修改 `CrossCheckingResultClient`
**文件**: `app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx`+35 / -80 行)
**改动**
1. 导入新组件:
```typescript
import { CrossCheckingOpinionsPanel } from "@/components/cross-checking/CrossCheckingOpinionsPanel";
```
2. 移除底部栏的「提出意见」「查看意见」按钮(从 `bottomActions` 中删除)
3. 新增 `crossOpinionsContent` 传入 DetailPanel
```typescript
<DetailPanel
// ... 现有 props ...
crossOpinionsContent={
<CrossCheckingOpinionsPanel
documentId={document.id}
activeReviewPointId={activeReviewPointResultId}
activeReviewPointName={activeReviewPoint?.name || activeReviewPoint?.title}
activeReviewPointScore={activeReviewPoint?.score ?? null}
activeReviewPointMachineScore={activeReviewPoint?.machineScore ?? null}
activeReviewPointFinalScore={activeReviewPoint?.finalScore ?? null}
jwtToken={jwtToken}
userInfo={userInfo}
canCreateProposal={canCreateProposal}
canVoteProposal={canVoteProposal}
canDeleteProposal={canDeleteProposal}
onOpinionSubmitted={handleOpinionSubmitted}
onNavigateToReviewPoint={(pointId) => {
setRightActiveTab('result');
handleReviewPointSelect(pointId);
}}
/>
}
crossOpinionsBadge={localScoringProposals.length}
/>
```
4. 移除 Modal 相关状态和 JSX(`showProposalModal`, `showOpinionList`, `opinionList`, `opinionListLoading`, `proposalForm`, `isSubmittingProposal`
### 6.5 文件改动汇总
| 文件 | 操作 | 改动量 |
|------|------|--------|
| `components/reviews/rightColumn/DetailPanel.tsx` | 修改 | +25 行 |
| `components/cross-checking/CrossCheckingOpinionsPanel.tsx` | **新建** | ~350 行 |
| `app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx` | 修改 | +35 / -80 行 |
**总计**: 1 新文件,2 修改,净增 ~330 行。后端无需改动。
### 6.6 兼容性
- `DetailPanel` 不传 `crossOpinionsContent` 时保持 3 Tab,行为完全不变
- `ReviewsTestClient` 零改动
- 权限控制:无 `cross_review:proposal:read` 权限时 Tab 仍显示但内容为空态提示
### 6.7 交互流程
```
用户打开交叉评查详情页
├─ 点击「交叉意见」Tab
│ ├─ 首次加载:GET /proposals?page=1&pageSize=10
│ ├─ 每条意见卡片:
│ │ ├─ 点击评查点名称 → 切换到「评查结果」Tab 并定位该评查点
│ │ ├─ 赞同/反对按钮(有投票权限 & can_vote=true 时显示)
│ │ └─ 撤销按钮(提案人 + pending 状态时显示)
│ └─ 点击「提出意见」展开内联表单
│ ├─ 显示:评查点名称 | 满分 X 分 | 已获得 Y 分
│ ├─ 填写意见文本 + 分数调整值
│ ├─ 前端边界校验
│ └─ 提交 → 后端精确校验 → 提案人自动投赞同票 → 刷新列表
└─ 底部栏:保持不变(下载 + 完成评查按钮)
```
---
## 7. 前端 API 封装参考
### 7.1 交叉意见相关(`cross-file-result.ts`
| 函数 | HTTP | 路径 | 用途 |
|------|------|------|------|
| `submitCrossCheckingOpinion` | POST | `/api/v3/cross-review/proposals` | 提交意见 |
| `getCrossCheckingOpinions` | GET | `/api/v3/cross-review/documents/{id}/proposals` | 获取意见列表(分页) |
| `performOpinionAction` | POST/DELETE | `/proposals/{id}/votes``/proposals/{id}` | 投票 / 撤销意见 |
| `checkProposalVotes` | GET | `/api/v3/cross-review/documents/{id}/pending-votes` | 检查待投票 |
| `confirmReviewResults` | POST | `/api/v3/cross-review/tasks/{taskId}/documents/{docId}/complete` | 完成评查 |
| `updateCrossCheckingReviewResult` | PATCH | `/api/v3/review-points/{id}/audit` | 更新评查结果状态 |
| `findIsProposer` | GET | `/api/v3/cross-review/tasks/{taskId}/can-confirm` | 检查是否负责人 |
### 7.2 任务列表与文档列表(`cross-files.ts`
| 函数 | HTTP | 路径 | 用途 |
|------|------|------|------|
| `getUserTaskDocuments` | POST | `/api/v3/cross-review/tasks/query` | 获取用户任务列表(V3 |
| `getTaskDocumentsWithVersions` | GET | `/api/v3/cross-review/tasks/{id}/documents` | 获取任务文档列表(含版本归纳) |
| `getCrossCheckingDocumentTypes` | — | — | 获取可选文档类型 |
| `getCrossCheckingTasks` | — | — | 任务列表包装(含前端筛选) |
### 7.3 版本号任务本地化
`d.version_no` 是文档的**全局**版本号(跨所有任务),前端在 `getTaskDocumentsWithVersions` 中做任务本地重映射:
```
同一 version_group_key 内:
1. 按原始 version_number 升序排列(保留全局先后顺序)
2. 重新分配 version_number = 1, 2, 3...
3. 同时更新 history_versions 中的 version_number
```
**示例**:文档全局版本 v11 → 任务内第 1 次上传 → 显示 V1;v12 → 第 2 次 → 显示 V2。
### 7.4 评查统计数据映射
前端 `getTaskDocumentsWithVersions` 将后端 V3 返回的 camelCase 字段映射为内部使用的 snake_case
```
API 返回 (JSON) → 前端类型 (CrossReviewDocumentWithVersion)
────────────────────────────────────────────────────────────
passCount → pass_count
warningCount → warning_count
errorCount → error_count
manualCount → manual_count
scorePercent → score_percent
warningMessages → warning_messages
errorMessages → error_messages
...
```
---
## 8. 补充:后端服务实现详解
### 8.1 `crossReviewServiceImpl.py` 完整方法签名
文件位置:`fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py`
```python
class CrossReviewServiceImpl(ICrossReviewService):
# ===== 任务管理 =====
async def CreateTask(self, CurrentUserId: int, dto: CrossReviewTaskCreateDTO)
-> CrossReviewTaskCreateVO:
"""创建任务: 插入 task → 插入 members → 挂载 documents → 触发评查"""
async def GetUserTasks(self, CurrentUserId: int, dto: CrossReviewTaskQueryDTO)
-> CrossReviewTaskPageVO:
"""
分页查询当前用户参与的任务。
SQL 含两个 CTE
- doc_stats: 统计每任务 total_documents / completed_documents
- task_regions: 从任务成员 JOIN sso_users 收集去重 area AS evaluationRegion
"""
async def GetTaskProgress(self, CurrentUserId: int, TaskId: int)
-> CrossReviewTaskProgressVO:
"""统计 completedDocuments / totalDocuments"""
async def GetTaskDocuments(self, CurrentUserId: int, TaskId: int,
dto: CrossReviewTaskDocumentQueryDTO)
-> CrossReviewTaskDocumentPageVO:
"""
分页查询任务下的文档列表。
含版本归纳:同一 version_group_key 的文档归为一组,totalVersions 为任务内计数。
含评查统计 LATERAL 子查询 (es):聚合 leaudit_rule_results 的
pass_count / warning_count / error_count / score_percent 等。
"""
async def CanConfirmTaskDocument(self, CurrentUserId: int, TaskId: int)
-> CrossReviewPermissionVO:
"""检查: assigner_id 或 member_role='principal' 才可完成评查"""
async def CompleteTaskDocument(self, CurrentUserId: int, TaskId: int,
DocumentId: int)
-> CrossReviewTaskCompleteVO:
"""标记文档 audit_status=1; 若全部文档完成则更新任务状态"""
async def UploadTaskDocument(self, CurrentUserId: int, TaskId: int,
file: UploadFile, ...)
-> CrossReviewTaskDocumentUploadVO:
"""上传新文档并挂载到任务"""
# ===== 提案 / 意见 =====
async def CreateProposal(self, CurrentUserId: int,
dto: CrossReviewProposalCreateDTO)
-> CrossReviewProposalCreateVO:
"""
创建提案流程:
1. 校验 deductionScore 边界 (_calculate_current_score)
2. 检查重复提案 (同人+同评查点+同文档)
3. INSERT leaudit_cross_review_proposals
4. 提案人自动投 agree (_vote_proposal)
5. _refresh_proposal_status → 返回状态
"""
async def VoteProposal(self, CurrentUserId: int, ProposalId: int,
dto: CrossReviewProposalVoteDTO)
-> CrossReviewProposalVoteVO:
"""
投票流程:
1. 校验提案存在且非 cancelled
2. 校验投票人是任务成员
3. UPSERT leaudit_cross_review_votes
4. _refresh_proposal_status → 可能变 approved/rejected
"""
async def CancelProposal(self, CurrentUserId: int, ProposalId: int)
-> CrossReviewProposalCancelVO:
"""仅提案人 + status=pending 时可撤销"""
async def GetDocumentProposals(self, CurrentUserId: int,
DocumentId: int, page: int, pageSize: int)
-> CrossReviewProposalPageVO:
"""
获取意见列表:
1. 解析 task_id
2. 加载所有成员列表 → 计算 pendingVoters
3. 加载投票记录 → 计算 agreeVoters/disagreeVoters
4. 判断 canVote (当前用户是否可投票)
5. 分页返回
"""
async def GetDocumentPendingVotes(self, CurrentUserId: int,
DocumentId: int)
-> CrossReviewPendingVotesVO:
"""过滤 status=pending 且 pendingVoters>0 的提案"""
# ===== 内部方法 =====
async def _calculate_current_score(self, session, ruleResultId, documentId)
-> float:
"""current_score = base_score + SUM(approved_deltas)"""
async def _refresh_proposal_status(self, session, proposalId) -> str:
"""过半多数制状态机"""
async def _vote_proposal(self, session, proposalId, voterId, voteType):
"""内部投票 (创建提案时自动调用)"""
```
### 8.2 `documentServiceImpl.py` 中的集成点
```python
# line 629
async def GetReviewPoints(self, CurrentUserId: int, DocumentId: int)
-> ReviewPointsAggregateVO:
"""
核心方法 — 返回评查详情聚合数据:
1. 加载 review_points (评查结果列表)
2. 加载 stats (通过/不通过/警告 统计)
3. 加载 reviewInfo (评查时间/模型/规则组)
4. 加载 document (文档元信息)
5. 加载 comparison_document (比对文档)
6. 调用 _loadScoringProposals() ← 交叉评查意见
"""
# line 2267
async def _loadScoringProposals(self, session, documentId) -> list[dict]:
"""
PRIMARY: 查询 leaudit_cross_review_proposals
映射: rule_result_id→evaluation_result_id,
proposed_score_delta→proposed_score,
create_time→created_at, update_time→updated_at
FALLBACK: 如果表不存在, 回退到旧 cross_scoring_proposals 表
"""
```
---
## 9. 补充:前端 ReviewPoint 类型定义
### 9.1 完整 ReviewPoint 接口
位置:`components/reviews/types.ts`(或 `components/reviews/index.ts` 导出)
```typescript
interface ReviewPoint {
id: string | number;
name?: string; // 评查点名称
title?: string; // 评查点标题(备选字段)
content?: Record<string, unknown>; // 抽取字段内容
contentPage?: Record<string, number>; // 字段所在页码
fieldPositions?: Record<string, {
bbox: [number, number, number, number]; // PDF 坐标
page_box: [number, number, number, number]; // 页面坐标
page_num: number; // 页码 (0-indexed)
confidence?: number; // OCR 置信度
match_method?: string; // 匹配方法
}>;
score?: number; // 满分 ← 用于意见表单分数展示
machineScore?: number; // AI 机器评查得分 ← 用于意见表单分数展示
finalScore?: number | null; // 人工调整后得分 ← 优先级最高
status?: string; // 评查状态
// ... 其他字段
}
```
### 9.2 ScoringProposal 接口(前端通用)
```typescript
// 定义在两个文件中,结构完全一致:
// a) lib/api/legacy/evaluation_points/reviews.ts line 165
// b) components/cross-checking/ReviewPointsList.tsx line 171
interface ScoringProposal {
id: string | number;
evaluation_result_id: string | number; // = rule_result_id (后端)
proposer_id: string | number;
proposed_score: number; // = proposed_score_delta (DB)
reason: string; // = reason (DB)
status: string; // pending | approved | rejected | cancelled
created_at: string;
updated_at: string;
document_id: string | number;
}
```
### 9.3 两个接口的对应关系
| 来源 | 字段名 | 含义 |
|------|--------|------|
| `getReviewPoints_fromApi``scoring_proposals` | `ScoringProposal[]` | 从评查详情 API 返回,数据来自 `_loadScoringProposals()` |
| `getCrossCheckingOpinions``opinions` | `CrossCheckingOpinion[]` | 从交叉评查专用 API 返回,含投票明细 + canVote |
| 后端 DB `proposed_score_delta` | NUMERIC(10,2) | 数据库存储 |
| 前端展示 | `proposed_score``deductionScore` | 负数=扣分,正数=加分 |
---
## 10. 补充:前端现有意见 UI 完整分析
### 10.1 ReviewPointsList 中的意见列表 Modal
位置:`components/cross-checking/ReviewPointsList.tsx` line 2932-3096
这是当前**左侧栏 RulesDirectory** 的 `resultContent` slot 中的意见列表。它和内联在 DetailPanel 中的用法不同:
- **ReviewPointsList** 渲染在左侧栏(22% 宽度),空间紧凑
- 意见列表以 Table 组件显示,列包括:
- `evaluation_point_name` — 评查点名称
- `problem_message` — 问题描述
- `reason` — 调整理由(截断 20 字符,tooltip 显示全文)
- `proposed_score` — 带颜色:绿色正数/红色负数,显示 ± 符号
- `votes` — 三组人名标签(绿=赞同,红=反对,灰=待投)
- `proposer` — 提案人
- `created_at` — 时间
- `opinion_status` — 已通过/未通过/投票中
- `operation` — 投票按钮 + 撤销按钮
- 浮动按钮显示提案总数 `scoringProposals.length`
- **提出意见表单**以 Modal 形式弹出
### 10.2 CrossCheckingResultClient 中的独立 Modal
位置:`app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx` line 786-871
与 ReviewPointsList 中的 Modal **完全独立**,是另一套实现:
- `showProposalModal` — 提出意见 Modalline 786-828
- 评查点名称(只读显示)
- 意见文本域
- 扣分分数输入
- `showOpinionList` — 查看意见 Modalline 830-871
- 简单的 table 展示
- 列:评查点、意见、扣分、状态、提交时间
- 无投票按钮、无分页
### 10.3 新 Tab 方案的设计决策
`CrossCheckingOpinionsPanel` 需要**统一并取代**以上两套独立的 Modal:
| 功能 | ReviewPointsList Modal | CrossCheckingResultClient Modal | 新 Tab 方案 |
|------|----------------------|-------------------------------|-----------|
| 意见列表 | Table + 投票 + 分页 | 简单 Table(无投票) | **合并:完整列表 + 投票** |
| 提出意见 | Modal 表单 | Modal 表单 | **内联表单(Tab 内展开)** |
| 分数展示 | 满分/已得分 | 无 | **完整分数状态条** |
| 位置 | 左侧栏弹出 | 右侧弹出 | **右侧 DetailPanel Tab** |
---
## 11. 补充:现有代码需要清理的部分
实现新 Tab 后,以下是 `CrossCheckingResultClient.tsx` 中需要移除的代码:
### 11.1 移除的状态变量
```typescript
// 这些将移入 CrossCheckingOpinionsPanel 内部
const [showProposalModal, setShowProposalModal] = useState(false); // line 366
const [proposalForm, setProposalForm] = useState({...}); // line 367
const [isSubmittingProposal, setIsSubmittingProposal] = useState(false);// line 368
const [showOpinionList, setShowOpinionList] = useState(false); // line 369
const [opinionList, setOpinionList] = useState<any[]>([]); // line 370
const [opinionListLoading, setOpinionListLoading] = useState(false); // line 371
```
### 11.2 移除的函数
```typescript
// handleOpenProposalModal (line 588-592) → 移入 Panel
// handleSubmitProposal (line 594-616) → 移入 Panel
// handleOpenOpinionList (line 618-631) → 移入 Panel
```
### 11.3 移除的 JSX
```typescript
// bottomActions 中的提出意见/查看意见按钮 (line 754-771)
// 提出意见 Modal (line 786-828)
// 查看意见 Modal (line 830-871)
```
### 11.4 保留的部分
```typescript
// handleOpinionSubmitted (line 584-586) → 保留,传给 Panel 作为回调
// handleConfirmResults (line 536-582) → 保留,完成评查按钮仍需要
// checkProposalVotes 调用 → 保留在 handleConfirmResults 中
// localScoringProposals 状态 → 保留,用于 badge 计数
```
---
## 12. 补充:Tab 懒加载实现细节
```typescript
// CrossCheckingResultClient.tsx 新增
const [crossOpinionsLoaded, setCrossOpinionsLoaded] = useState(false);
// 当用户切换到 crossOpinions tab 时标记已加载
const handleTabChange = (tab: TabKey) => {
setRightActiveTab(tab);
if (tab === 'crossOpinions') {
setCrossOpinionsLoaded(true);
}
};
// 只有在 tab 被访问过后才渲染 Panel(避免未访问就发起 API 请求)
<DetailPanel
activeTab={rightActiveTab}
onTabChange={handleTabChange} // 替代原来的 setRightActiveTab
// ...
crossOpinionsContent={
crossOpinionsLoaded ? (
<CrossCheckingOpinionsPanel ... />
) : null // 首次访问前不渲染,避免不必要的 API 调用
}
/>
```
---
## 13. 补充:完整交互时序图
```
用户点击左侧评查点
├─ RulesDirectory.onRuleSelect(pointId)
│ └─ setActiveReviewPointResultId(pointId)
│ └─ PdfPreview 定位到对应页 → 高亮框
│ └─ DetailPanel「评查结果」Tab 显示该点详情
│ └─ CrossCheckingOpinionsPanel 收到新的 activeReviewPointId
│ └─ 打开「提出意见」表单时自动关联新评查点
│ └─ 分数状态条更新为当前评查点的满分/已得分
用户点击「交叉意见」Tab
├─ setRightActiveTab('crossOpinions')
│ └─ 首次访问 → 懒加载 Panel → GET /proposals?page=1
│ └─ Panel 渲染意见列表
├─ 点击「提出意见」
│ └─ showForm = true
│ └─ 表单显示:评查点 {name} | 满分 {score} | 已获得 {currentScore}
│ └─ 填写意见 + 分数调整
│ └─ 点击「提交意见」
│ ├─ 前端边界校验
│ ├─ POST /api/v3/cross-review/proposals
│ │ ├─ 后端 _calculate_current_score 精确校验
│ │ ├─ INSERT leaudit_cross_review_proposals
│ │ ├─ 提案人自动投 agree
│ │ └─ _refresh_proposal_status → 返回状态
│ ├─ 成功 → 刷新列表 + 回调 onOpinionSubmitted
│ └─ 失败 → 显示后端错误消息
├─ 对某条意见投票
│ └─ 点击「赞同」或「反对」
│ ├─ 乐观更新:本地修改 can_vote = false
│ ├─ POST /api/v3/cross-review/proposals/{id}/votes
│ │ └─ 后端 _refresh_proposal_status → 可能达到阈值
│ ├─ 成功 → 刷新列表(获取最新投票状态)
│ └─ 失败 → 回滚本地状态 + 显示错误
└─ 点击评查点名称(意见卡片中)
└─ onNavigateToReviewPoint(pointId)
├─ setRightActiveTab('result')
└─ handleReviewPointSelect(pointId) → 定位 + 高亮
---
## 14. 补充:代码审查发现的遗漏逻辑与技术债
以下是在编写计划时对现有代码进行逐行审查后发现的**潜在问题**,需在实现时一并修复,避免技术债累积。
### 14.1 🐛 `handleSubmitProposal` 未更新 badge 计数
**位置**: `CrossCheckingResultClient.tsx` line 594-616
**根因分析**:
`handleSubmitProposal` 和 `handleOpinionSubmitted` 是两个独立编写的函数,开发时未建立调用关系。`handleOpinionSubmitted`line 584-586)仅通过 `onOpinionSubmitted` prop 从子组件 `ReviewPointsList` 接收回调,而 `handleSubmitProposal` 是本地 Modal 的提交逻辑,两者之间没有任何数据流通。
```
用户提交意见 → handleSubmitProposal → API → 成功 → toast + 关闭Modal
缺少: handleOpinionSubmitted
localScoringProposals 未更新
左侧 badge 数不变
右侧底部栏 badge 不变
```
**影响评估**:
- 用户提交意见成功后,左侧栏 `ReviewPointsList` 的浮动意见数不增加
- 底部栏 `DetailPanel` 的 badge 不更新
- **但下一轮轮询或页面刷新后数据会恢复正确**(非永久数据丢失)
- 用户体验:提交成功后看不到反馈,可能怀疑提交失败而重复提交
**修复方案**:
方案 A(最小改动): 在成功回调中构造 `ScoringProposal` 并调用 `handleOpinionSubmitted`
```typescript
toastService.success("意见提交成功");
setShowProposalModal(false);
// Fix 14.1: 通知父组件更新 badge
if (result.data?.data) {
handleOpinionSubmitted({
id: result.data.data.id,
evaluation_result_id: activeReviewPoint.id,
proposer_id: (userInfo as any)?.user_id ?? 0,
proposed_score: proposalForm.deductionScore,
reason: proposalForm.auditOpinion,
status: 'pending',
created_at: result.data.data.created_at,
updated_at: result.data.data.created_at,
document_id: document.id,
});
}
```
缺点: 依赖客户端构造数据,可能有字段不准确。
方案 B(新 Tab 方案采用): 提交后直接调用 `getCrossCheckingOpinions()` 刷新完整列表,从列表接口获取后端确认的完整数据。这是更稳健的做法,将在 `CrossCheckingOpinionsPanel` 中实现。
**边缘场景**:
- 网络延迟导致提交成功但列表刷新失败 → toast 提示"提交成功,但列表刷新失败,请手动刷新"
- 用户在提交后立即切换 Tab → 列表在后台刷新,切回时展示最新数据
---
### 14.2 🐛 `getCrossCheckingOpinions` 响应数据访问路径错误
**位置**: `CrossCheckingResultClient.tsx` line 625
**根因分析**:
这是典型的**API 封装层与消费者之间的契约不匹配**问题。
`getCrossCheckingOpinions` 函数(`cross-file-result.ts:240-298`)返回结构:
```
ApiResponse<{ opinions: CrossCheckingOpinion[], total: number }>
→ result = {
data: { opinions: [...], total: number } // 成功
error: string // 失败
}
```
但消费者代码(`CrossCheckingResultClient.tsx:625`)使用:
```typescript
(result.data as any)?.items || (result.data as any)?.data || []
```
**路径对比**:
| 消费者期望 | 实际返回 | 结果 |
|-----------|---------|------|
| `.data.items` | 不存在 | `undefined` |
| `.data.data` | 不存在(实际是 `.data.opinions` | `undefined` |
| fallback `[]` | — | **永远空数组** |
这说明**当前 Modal 的意见列表功能从未正常工作过**。因为 API 返回 `{ opinions, total }` 但代码访问 `.items || .data`,两者都访问不到,永远 fallback 到空数组。
**影响评估**:
- **CRITICAL**: 现有「查看意见」Modal 永远显示"暂无评查意见",即使文档下有多条意见
- 用户无法通过现有 UI 查看任何已提交的意见
- 但 ReviewPointsList 中独立的意见 Modal(使用 `loadOpinionListData`)不受影响,因为它直接从 `getCrossCheckingOpinions` 获取数据并正确访问 `.opinions`
**为什么有两个意见列表?**:
- `CrossCheckingResultClient` 的「查看意见」Modal → 使用 `handleOpenOpinionList` → 数据路径错误 → 永远为空
- `ReviewPointsList` 的意见列表 Modal → 使用 `loadOpinionListData` → 数据路径正确 → 正常展示
这是一个**重复造轮子导致的 bug**:同一个功能实现了两遍,一遍正确、一遍错误。
**修复方案**:
```typescript
// 正确访问路径
const opinionData = result.data as { opinions?: any[]; total?: number } | undefined;
setOpinionList(opinionData?.opinions || []);
```
由于新 Tab 方案会用 `CrossCheckingOpinionsPanel` 统一替代这两个独立 Modal,此 bug 将被彻底消除。
**边缘场景**:
- API 直接返回错误(result.error 有值)→ 已在 line 624 处理,不会走到 line 625
- result.data 为 null → `opinionData?.opinions` 短路回退到 `[]`
- 后端返回格式变更 → 使用 TypeScript 类型约束(`{ opinions?: ...; total?: ... }`),编译时即可发现
---
### 14.3 🐛 `submitCrossCheckingOpinion` 返回值不完整
**位置**: `cross-file-result.ts` line 200-208
**根因分析**:
后端 `CreateProposal` 接口(`crossReviewServiceImpl.py`)返回的 VO
```python
class CrossReviewProposalCreateVO:
proposalId: int # 仅返回 ID
createdAt: datetime | None # 仅返回创建时间
```
这是后端的设计决策:**创建接口只返回最小信息**(ID + 时间),完整提案数据需通过列表接口 `GET /documents/{id}/proposals` 获取。
前端 API 封装忠实反映了这个限制,只提取 `proposalId``createdAt`。但前端消费者(`handleOpinionSubmitted`)期望完整的 `ScoringProposal` 对象来更新本地状态。
```
后端 CreateProposal
→ 入 INSERT leaudit_cross_review_proposals
→ 自动投票
→ _refresh_proposal_status
→ 返回 { proposalId, createdAt } ← 只有两个字段
前端期望 ScoringProposal:
id, evaluation_result_id, proposer_id, proposed_score,
reason, status, created_at, updated_at, document_id
→ 8 个字段缺失!
```
**影响评估**:
- 14.1 的修复如果直接用返回值填充 `ScoringProposal`,会缺少 6 个字段(`evaluation_result_id`, `proposer_id`, `proposed_score`, `reason`, `status`, `document_id`
- badge 计数可以正常更新(只需要 `.length`),但意见对象的字段不完整
- 如果后续 UI 使用了这些缺失字段(如显示意见内容、状态标签),会出现 `undefined`
**修复方案**:
方案 A(当前过渡方案): 客户端根据表单输入数据 + 当前上下文构造完整的 `ScoringProposal`
```typescript
// 在 handleSubmitProposal 中构造 (已应用于 14.1 修复)
handleOpinionSubmitted({
id: result.data.data.id,
evaluation_result_id: activeReviewPoint.id, // 来自当前选中评查点
proposer_id: userInfo?.user_id ?? 0, // 来自登录信息
proposed_score: proposalForm.deductionScore, // 来自表单输入
reason: proposalForm.auditOpinion, // 来自表单输入
status: 'pending', // 新提案默认 pending
created_at: result.data.data.created_at, // 来自 API 返回
updated_at: result.data.data.created_at, // 创建时与 created_at 相同
document_id: document.id, // 来自当前文档
});
```
优点: 无需额外 API 调用,即时更新
缺点: 客户端数据可能与后端状态不一致(如后端 _refresh_proposal_status 已将状态变为 approved
方案 B(新 Tab 方案采用): 提交后直接调用 `loadOpinions()` 刷新完整列表
```typescript
// 在 CrossCheckingOpinionsPanel 中
const handleSubmit = async () => {
// ... 校验 ...
const result = await submitCrossCheckingOpinion({...});
if (result.error) { toastService.error(result.error); return; }
toastService.success('意见提交成功');
setShowForm(false);
await loadOpinions(); // 重新拉取完整列表,数据来自后端权威源
onProposalsChanged?.(currentOpinions); // 通知父组件同步 badge
};
```
优点: 数据 100% 准确,从后端权威源获取
缺点: 一次额外的 HTTP 请求
**边缘场景**:
- 提交成功但列表刷新失败 → 提交本身成功 + toast 成功提示 + 降级显示旧列表 + 手动刷新按钮
- 后端 _refresh_proposal_status 立即将提案变为 approved → 方案 B 能正确反映,方案 A 会显示为 pending(不一致)
- 并发提交(两个用户同时提交同一评查点)→ 后端有重复检查,第一个成功第二个返回错误
### 14.4 ⚠️ 撤销投票 UI 缺失
**ReviewPointsList 已有但新 Panel 计划遗漏**
**功能说明**:
当用户已经对某条意见投过票,后端 `can_vote` 变为 `false`,此时用户应看到「撤销投票」按钮而非「赞同/反对」按钮。撤销投票调用 `performOpinionAction({ action: 'withdraw_vote' })`,后端将投票记录标记为 `cancel`
**ReviewPointsList 中的完整投票按钮状态机** (line 3194-3241):
```
用户查看一条意见时:
1. 先判断 isProposer (当前用户 === proposal.proposer_id)
是 → 如果是 pending 状态,显示 [撤销意见] 按钮
2. 判断 can_vote:
true && canVoteProposal
→ 显示 [赞同] [反对] 两个按钮
false && !isProposer && canVoteProposal
→ 显示 [撤销投票] 按钮 ← 新 Panel 计划遗漏!
false && !isProposer && !canVoteProposal
→ 无操作按钮(只读)
```
**为什么需要「撤销投票」**:
- 用户可能改变了主意,想从赞同改为反对(或反之)
- 后端不支持直接修改投票类型,必须先撤销再重新投票
- 撤销后 `can_vote` 变回 `true`,赞同/反对按钮重新出现
**新 Panel 需要补充的 UI**:
```tsx
{/* 已投票且非提案人 → 撤销投票 */}
{!record.can_vote && !isProposer && canVoteProposal && (
<Button
className="bg-yellow-600 hover:bg-yellow-700 ..."
onClick={() => handleWithdrawAction(record.proposal_id, 'withdraw_vote')}
disabled={isPerforming(`${record.proposal_id}-withdraw_vote`)}
>
{isPerforming(`${record.proposal_id}-withdraw_vote`) ? '处理中...' : '撤销投票'}
</Button>
)}
```
**与新 Tab 方案的集成**:
- 新 Tab「交叉意见」中的每条意见卡片都需要实现此状态机
- 与 14.5(倒计时)和 14.6(操作状态管理)配合使用
---
### 14.5 ⚠️ 撤销操作安全倒计时
**ReviewPointsList 已有但新 Panel 计划遗漏**
**功能说明**:
`withdraw_vote`(撤销投票)和 `withdraw_opinion`(撤销意见)操作,ReviewPointsList 实现了 **3 秒倒计时确认**机制。这是因为撤销操作不可逆(尤其是撤销意见会删除整个提案),需要防止用户误点击。
**完整实现逻辑** (ReviewPointsList line 3150-3192):
```typescript
// 状态机
const [showModal, setShowModal] = useState<null | OpinionActionType>(null);
const [countdown, setCountdown] = useState(3);
const [counting, setCounting] = useState(false);
// 倒计时 Effect
useEffect(() => {
if (showModal && (showModal === 'withdraw_opinion' || showModal === 'withdraw_vote')
&& counting && countdown > 0) {
timer = setTimeout(() => setCountdown(c => c - 1), 1000);
} else if (countdown === 0) {
setCounting(false);
}
return () => clearTimeout(timer);
}, [showModal, counting, countdown]);
// 确认处理
const handleConfirm = () => {
if (showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') {
if (countdown === 0) { // 倒计时结束后才可确认
handleOpinionAction(record.proposal_id, showModal);
setShowModal(null);
setCountdown(3); // 重置倒计时
setCounting(false);
}
} else {
// 赞同/反对直接执行,不需要倒计时
handleOpinionAction(record.proposal_id, showModal!);
setShowModal(null);
}
};
```
**用户交互流程**:
```
1. 用户点击 [撤销投票] / [撤销意见]
2. 弹出确认 Modal
3. 按钮文本: "确认(3)" → (1秒后) → "确认(2)" → (1秒后) → "确认(1)" → (1秒后) → "确认"
4. 按钮在倒计时期间 disabled(灰色不可点击)
5. 倒计时到 0 后按钮启用,用户点击确认
6. 用户可随时点击 [取消] 关闭 Modal
```
**为什么赞同/反对不需要倒计时?**
- 赞同/反对可以通过再次操作来"修正"(先撤销再重新投票)
- 撤销意见是删除操作,不可逆
- 撤销投票虽可重新投票,但也是破坏性操作
**新 Panel 实现要点**:
```typescript
// 在 CrossCheckingOpinionsPanel 中
const [confirmModal, setConfirmModal] = useState<{
proposalId: string | number;
action: OpinionActionType;
} | null>(null);
const [confirmCountdown, setConfirmCountdown] = useState(3);
const handleWithdrawClick = (proposalId: string | number, action: OpinionActionType) => {
setConfirmModal({ proposalId, action });
setConfirmCountdown(3);
};
```
---
### 14.6 ⚠️ 操作进行中状态管理
**ReviewPointsList 已有但新 Panel 计划遗漏**
**功能说明**:
ReviewPointsList 使用 `performingAction` 状态追踪当前正在执行的异步操作,防止用户在 API 调用进行中重复点击。
**实现** (ReviewPointsList line 723-747):
```typescript
const [performingAction, setPerformingAction] = useState<string | null>(null);
const isPerforming = (actionKey: string) => performingAction === actionKey;
const handleOpinionAction = async (opinionId, action) => {
const actionKey = `${opinionId}-${action}`; // 例: "123-agree"
setPerformingAction(actionKey); // 标记进行中
try {
const response = await performOpinionAction(...);
if (response.error) { toastService.error(...); return; }
toastService.success(...);
await loadOpinionListData(...);
} catch (error) {
toastService.error(...);
} finally {
setPerformingAction(null); // 清除标记
}
};
```
**UI 层的双重保护**:
```tsx
<Button
disabled={isPerforming('123-agree')} // 1. 按钮 disabled
>
{isPerforming('123-agree') ? '处理中...' : '赞同'} // 2. 文案变化
</Button>
```
**为什么需要这个机制?**
- 防止用户在 API 调用中重复点击导致重复请求
- 提供视觉反馈(按钮变灰 + "处理中..."
- `actionKey``opinionId-action` 组合,可以同时在不同意见上执行不同操作而不互相阻塞
**新 Panel 需要复刻的实现**:
```typescript
const [performingAction, setPerformingAction] = useState<string | null>(null);
const isPerforming = (actionKey: string) => performingAction === actionKey;
// 在每条意见卡片的按钮中使用:
<button
disabled={isPerforming(`${opinion.proposal_id}-agree`)}
onClick={() => handleOpinionAction(opinion.proposal_id, 'agree')}
>
{isPerforming(`${opinion.proposal_id}-agree`) ? '处理中...' : '赞同'}
</button>
```
---
### 14.7 ⚠️ 表单在切换评查点时的行为
**场景分析**:
用户在「交叉意见」Tab 中已经打开了提出意见表单,正在填写。此时在左侧 RulesDirectory 中点击了另一个评查点,`activeReviewPointId` 发生变化。
**需要决策的行为**:
| 方案 | 表单状态 | 意见文本 | 分数调整 | 优点 | 缺点 |
|------|---------|---------|---------|------|------|
| A: 保留表单,更新关联 | 保持打开 | 保留 | 保留 | 操作流畅 | 用户可能混淆评查点 |
| B: 关闭表单 | 关闭 | 清空 | 清空 | 清晰无歧义 | 丢失用户输入 |
| C: 提示确认 | 弹确认框 | 保留/清空可选 | 保留/清空可选 | 最安全 | 多一步操作 |
**推荐方案 A(保留表单,更新关联)**:
理由:
1. 意见文本通常是通用的(如"该字段缺失,建议补充"),用户可能想在多个评查点上提相同意见
2. 分数调整可能也相同
3. 表单顶部实时更新评查点名称和分数,用户能清楚看到当前关联的是哪个评查点
**实现**:
```typescript
useEffect(() => {
if (showForm && activeReviewPointId) {
// 不关闭表单,不重置用户输入
// 仅更新表单头部的评查点名称和分数展示
// 用户能清楚看到关联评查点已变更
}
}, [activeReviewPointId, activeReviewPointScore, activeReviewPointFinalScore]);
// 表单头部实时反映当前评查点:
<div className="mb-3 text-[12px] text-slate-500 bg-white rounded border px-3 py-2">
{activeReviewPointName || '—'}
<span className="mx-2">|</span>
{activeReviewPointScore ?? '—'}
<span className="mx-2">|</span>
{activeReviewPointFinalScore ?? activeReviewPointMachineScore ?? '—'}
</div>
```
**边缘场景**:
- 用户尚未选择评查点就打开表单 → `activeReviewPointId` 为 null → 表单提交时校验拦截
- 切换评查点后 newScore 超出当前 deductionScore 范围 → 前端初步校验会拦截,后端精确校验兜底
---
### 14.8 ⚠️ 投票后 `localScoringProposals` 未同步
**根因分析**:
`localScoringProposals``CrossCheckingResultClient` 中的状态,来源有两个:
1. 页面初始化:从 `getReviewPoints_fromApi``scoring_proposals` 字段加载
2. 新意见提交:通过 `handleOpinionSubmitted` 追加
但是,当用户在**新 Tab「交叉意见」中执行投票操作**后:
- `CrossCheckingOpinionsPanel` 调用 `performOpinionAction` → 后端 `_refresh_proposal_status` → 提案状态可能变化
- `CrossCheckingOpinionsPanel` 刷新自己的 `opinions` 列表
-`CrossCheckingResultClient``localScoringProposals` **不知道这个变化**
**数据流断裂点**:
```
CrossCheckingOpinionsPanel (子组件)
├─ loadOpinions() → 拉取最新 opinions ← 数据新鲜
└─ 但未通知父组件!
CrossCheckingResultClient (父组件)
├─ localScoringProposals ← 数据陈旧!
│ ├─ 用于 DetailPanel badge (crossOpinionsBadge)
│ └─ 用于左侧栏 ReviewPointsList 意见计数
└─ 无法感知子组件的投票操作
```
**修复方案**:
新增 `onProposalsChanged` 回调,投票/撤销后由 Panel 向上通知:
```typescript
// CrossCheckingOpinionsPanelProps 新增:
onProposalsChanged?: (proposals: ScoringProposal[]) => void;
// 在 loadOpinions 完成后调用:
const loadOpinions = async () => {
// ... 获取数据 ...
const newOpinions = opinionData.opinions || [];
setOpinions(newOpinions);
// 向上同步:将 CrossCheckingOpinion[] 转为 ScoringProposal[]
const proposals: ScoringProposal[] = newOpinions.map(o => ({
id: o.proposal_id,
evaluation_result_id: 0, // CrossCheckingOpinion 无此字段
proposer_id: o.proposer_id,
proposed_score: o.proposed_score,
reason: o.reason,
status: o.status,
created_at: o.created_at,
updated_at: o.created_at,
document_id: documentId as string | number,
}));
onProposalsChanged?.(proposals);
};
```
**CrossCheckingResultClient 中的对接**:
```typescript
<CrossCheckingOpinionsPanel
// ... 其他 props ...
onProposalsChanged={(proposals) => {
setLocalScoringProposals(proposals);
}}
/>
```
**时序**:
```
用户投票赞同
→ handleOpinionAction(opinionId, 'agree')
→ POST /api/v3/cross-review/proposals/{id}/votes
→ 后端 _refresh_proposal_status
→ 前端 loadOpinions() 拉取最新列表
→ onProposalsChanged(最新列表)
→ setLocalScoringProposals(最新列表)
→ DetailPanel badge 自动更新
→ 左侧栏 意见计数自动更新
```
### 14.9 📋 现有代码需一并修复的汇总
| 问题 | 文件 | 严重度 | 修复方式 |
|------|------|--------|---------|
| 14.1 — 提交后 badge 不更新 | `CrossCheckingResultClient.tsx:609` | HIGH | 添加 `handleOpinionSubmitted` 调用 |
| 14.2 — 意见列表数据访问路径错误 | `CrossCheckingResultClient.tsx:625` | **CRITICAL** | 修正为 `.opinions` |
| 14.3 — submit 返回值不完整 | `cross-file-result.ts:200` | MEDIUM | 改为提交后 `loadOpinions()` 刷新 |
| 14.4 — 撤销投票 UI 缺失 | 新 Panel | HIGH | 补充撤销投票按钮逻辑 |
| 14.5 — 撤销安全倒计时 | 新 Panel | MEDIUM | 补充 3 秒倒计时确认 |
| 14.6 — 操作状态管理 | 新 Panel | MEDIUM | 补充 performingAction 状态 |
| 14.7 — 切换评查点 | 新 Panel | LOW | useEffect 处理 |
| 14.8 — 投票后 badge 同步 | 新 Panel + Client | MEDIUM | 新增 onProposalsChanged 回调 |
### 14.10 修正后的组件 Props(最终版)
综合以上发现,`CrossCheckingOpinionsPanelProps` 最终定义:
```typescript
interface CrossCheckingOpinionsPanelProps {
documentId: string | number;
// 评查点信息
activeReviewPointId?: string | number | null;
activeReviewPointName?: string;
activeReviewPointScore?: number | null;
activeReviewPointMachineScore?: number | null;
activeReviewPointFinalScore?: number | null;
// 认证
jwtToken?: string;
userInfo?: { user_id: number; sub?: string; nick_name?: string };
// 权限
canCreateProposal: boolean;
canVoteProposal: boolean;
canDeleteProposal: boolean;
// 回调
onOpinionSubmitted?: (proposal: ScoringProposal) => void;
onProposalsChanged?: (proposals: ScoringProposal[]) => void;
onNavigateToReviewPoint?: (pointId: string | number) => void;
}
// 内部状态(需在组件内实现)
// const [performingAction, setPerformingAction] = useState<string | null>(null);
// 用于追踪当前操作状态,防止重复提交
```
### 14.11 投票操作完整逻辑(修正版)
```typescript
const handleOpinionAction = async (
opinionId: string | number,
action: OpinionActionType // 'agree' | 'disagree' | 'withdraw_vote' | 'withdraw_opinion'
) => {
const actionKey = `${opinionId}-${action}`;
setPerformingAction(actionKey);
try {
const result = await performOpinionAction(
{ opinionId, action }, jwtToken, userInfo
);
if (result.error) {
toastService.error(result.error);
setPerformingAction(null);
return;
}
toastService.success(result.data?.message || '操作成功');
await loadOpinions(); // 刷新列表 + 通知父组件
// onProposalsChanged 在 loadOpinions 内部调用
} catch (error) {
toastService.error(error instanceof Error ? error.message : '操作失败');
} finally {
setPerformingAction(null);
}
};
```