- 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 - 文档更新:交叉评查核心模块业务逻辑文档补充评查地区、评查统计、版本号本地化等章节
74 KiB
交叉评查核心模块 — 业务逻辑与实现计划
目录
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 | 关联的文档 ID(FK → 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_regionsCTE 从leaudit_cross_review_task_membersJOINsso_users收集DISTINCT u.area(排除 NULL 和空串),非从文档表获取。
评查统计数据来源:
GetTaskDocuments中esLATERAL 子查询聚合leaudit_rule_results,按risk分级:
passed IS TRUE→ pass_countpassed IS FALSE AND risk = 'high'→ error_countpassed 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:
taskName: str # 必填
taskType: str = "CITY"
docTypeId: int | None
docTypeCode: str | None
memberUserIds: list[int] # 参与者
principalUserIds: list[int] # 负责人
documentIds: list[int] # 初始挂载文档
CrossReviewProposalCreateDTO:
reviewPointResultId: int # 必填 — 评查点结果 ID
documentId: int # 必填 — 文档 ID
evaluationPointId: int | None # 可选 — 评查点定义 ID
auditOpinion: str # 必填 — 意见文本
deductionScore: float # 必填 — 分数调整(负=扣分,正=加分)
CrossReviewProposalVoteDTO:
voteType: str # "agree" | "disagree" | "cancel"
2.4 响应 VO(crossReviewVo.py)
CrossReviewProposalItemVO — 意见列表项:
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:
total: int; page: int; pageSize: int
items: list[CrossReviewProposalItemVO]
CrossReviewPendingVotesVO:
hasPendingVotes: bool
pendingProposals: list[PendingProposalVO]
# PendingProposalVO: { evaluationPointName, pendingVotersNum }
CrossReviewTaskItemVO — 任务列表项:
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 — 任务文档列表项:
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 = 3;4 个成员 → 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()
- 解析文档所属 task_id
- 校验当前用户是任务成员
- 构建完整提案列表(最多 200 条)
- 过滤
status = 'pending'且pendingVoters.length > 0的提案 - 返回
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 行)
改动:
- 扩展 TabKey 类型和 props:
type TabKey = 'result' | 'fields' | 'info' | 'crossOpinions';
// 新增 props:
crossOpinionsContent?: React.ReactNode; // 交叉意见 tab 内容
crossOpinionsBadge?: number; // 意见数量 badge
- 动态 TABS 数组(
useMemo):
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]);
- 渲染内容:
{activeTab === 'crossOpinions' && crossOpinionsContent}
- Badge:
{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:
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)
│
└── 空态:暂无意见
状态管理:
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 页)
- 投票后 → 乐观更新本地状态 + 失败时回滚
提出意见提交逻辑:
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);
};
投票逻辑(乐观更新):
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 行)
改动:
- 导入新组件:
import { CrossCheckingOpinionsPanel } from "@/components/cross-checking/CrossCheckingOpinionsPanel";
-
移除底部栏的「提出意见」「查看意见」按钮(从
bottomActions中删除) -
新增
crossOpinionsContent传入 DetailPanel:
<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}
/>
- 移除 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
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 中的集成点
# 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 导出)
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 接口(前端通用)
// 定义在两个文件中,结构完全一致:
// 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— 提出意见 Modal(line 786-828)- 评查点名称(只读显示)
- 意见文本域
- 扣分分数输入
showOpinionList— 查看意见 Modal(line 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 移除的状态变量
// 这些将移入 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 移除的函数
// handleOpenProposalModal (line 588-592) → 移入 Panel
// handleSubmitProposal (line 594-616) → 移入 Panel
// handleOpenOpinionList (line 618-631) → 移入 Panel
11.3 移除的 JSX
// bottomActions 中的提出意见/查看意见按钮 (line 754-771)
// 提出意见 Modal (line 786-828)
// 查看意见 Modal (line 830-871)
11.4 保留的部分
// handleOpinionSubmitted (line 584-586) → 保留,传给 Panel 作为回调
// handleConfirmResults (line 536-582) → 保留,完成评查按钮仍需要
// checkProposalVotes 调用 → 保留在 handleConfirmResults 中
// localScoringProposals 状态 → 保留,用于 badge 计数
12. 补充:Tab 懒加载实现细节
// 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)使用:
(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:同一个功能实现了两遍,一遍正确、一遍错误。
修复方案:
// 正确访问路径
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:
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
// 在 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() 刷新完整列表
// 在 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:
{/* 已投票且非提案人 → 撤销投票 */}
{!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):
// 状态机
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 实现要点:
// 在 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):
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 层的双重保护:
<Button
disabled={isPerforming('123-agree')} // 1. 按钮 disabled
>
{isPerforming('123-agree') ? '处理中...' : '赞同'} // 2. 文案变化
</Button>
为什么需要这个机制?
- 防止用户在 API 调用中重复点击导致重复请求
- 提供视觉反馈(按钮变灰 + "处理中...")
actionKey按opinionId-action组合,可以同时在不同意见上执行不同操作而不互相阻塞
新 Panel 需要复刻的实现:
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(保留表单,更新关联):
理由:
- 意见文本通常是通用的(如"该字段缺失,建议补充"),用户可能想在多个评查点上提相同意见
- 分数调整可能也相同
- 表单顶部实时更新评查点名称和分数,用户能清楚看到当前关联的是哪个评查点
实现:
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 中的状态,来源有两个:
- 页面初始化:从
getReviewPoints_fromApi的scoring_proposals字段加载 - 新意见提交:通过
handleOpinionSubmitted追加
但是,当用户在新 Tab「交叉意见」中执行投票操作后:
CrossCheckingOpinionsPanel调用performOpinionAction→ 后端_refresh_proposal_status→ 提案状态可能变化CrossCheckingOpinionsPanel刷新自己的opinions列表- 但
CrossCheckingResultClient的localScoringProposals不知道这个变化
数据流断裂点:
CrossCheckingOpinionsPanel (子组件)
├─ loadOpinions() → 拉取最新 opinions ← 数据新鲜
└─ 但未通知父组件!
CrossCheckingResultClient (父组件)
├─ localScoringProposals ← 数据陈旧!
│ ├─ 用于 DetailPanel badge (crossOpinionsBadge)
│ └─ 用于左侧栏 ReviewPointsList 意见计数
└─ 无法感知子组件的投票操作
修复方案:
新增 onProposalsChanged 回调,投票/撤销后由 Panel 向上通知:
// 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 中的对接:
<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 最终定义:
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 投票操作完整逻辑(修正版)
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);
}
};