diff --git a/docs/交叉评查/交叉评查核心模块业务逻辑与Tab实现计划.md b/docs/交叉评查/交叉评查核心模块业务逻辑与Tab实现计划.md new file mode 100644 index 0000000..5ddb4fb --- /dev/null +++ b/docs/交叉评查/交叉评查核心模块业务逻辑与Tab实现计划.md @@ -0,0 +1,1752 @@ +# 交叉评查核心模块 — 业务逻辑与实现计划 + +## 目录 + +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 | 关联的文档 ID | +| `audit_status` | INT | 评查状态:`0`=未完成,`1`=已完成 | +| `create_time` | TIMESTAMPTZ | | +| `delete_time` | TIMESTAMPTZ | | + +### 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 | | + +--- + +## 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 } +``` + +**`CrossReviewTaskDocumentVO`** — 任务文档列表项: +```python +documentId, name, documentNumber, typeId, typeName +processingStatus, versionNo, isLatestVersion +versionGroupKey, totalVersions +auditStatus # 0=未完成, 1=已完成 +createdAt, fileSize +``` + +--- + +## 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()` + +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 = (评查点列表+意见) + │ ├─ 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 && ( + + {crossOpinionsBadge} + +)} +``` + +### 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([]); +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 + { + 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 封装参考 + +全部位于 `lib/api/legacy/cross-checking/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` | 检查是否负责人 | + +--- + +## 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: + """分页查询当前用户参与的任务""" + + async def GetTaskProgress(self, CurrentUserId: int, TaskId: int) + -> CrossReviewTaskProgressVO: + """统计 completedDocuments / totalDocuments""" + + async def GetTaskDocuments(self, CurrentUserId: int, TaskId: int, + dto: CrossReviewTaskDocumentQueryDTO) + -> CrossReviewTaskDocumentPageVO: + """分页查询任务下的文档列表""" + + 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; // 抽取字段内容 + contentPage?: Record; // 字段所在页码 + fieldPositions?: Record; + 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` — 提出意见 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 移除的状态变量 +```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([]); // 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 请求) + + ) : 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 && ( + +)} +``` + +**与新 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); +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(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 + +``` + +**为什么需要这个机制?** +- 防止用户在 API 调用中重复点击导致重复请求 +- 提供视觉反馈(按钮变灰 + "处理中...") +- `actionKey` 按 `opinionId-action` 组合,可以同时在不同意见上执行不同操作而不互相阻塞 + +**新 Panel 需要复刻的实现**: +```typescript +const [performingAction, setPerformingAction] = useState(null); + +const isPerforming = (actionKey: string) => performingAction === actionKey; + +// 在每条意见卡片的按钮中使用: + +``` + +--- + +### 14.7 ⚠️ 表单在切换评查点时的行为 + +**场景分析**: + +用户在「交叉意见」Tab 中已经打开了提出意见表单,正在填写。此时在左侧 RulesDirectory 中点击了另一个评查点,`activeReviewPointId` 发生变化。 + +**需要决策的行为**: + +| 方案 | 表单状态 | 意见文本 | 分数调整 | 优点 | 缺点 | +|------|---------|---------|---------|------|------| +| A: 保留表单,更新关联 | 保持打开 | 保留 | 保留 | 操作流畅 | 用户可能混淆评查点 | +| B: 关闭表单 | 关闭 | 清空 | 清空 | 清晰无歧义 | 丢失用户输入 | +| C: 提示确认 | 弹确认框 | 保留/清空可选 | 保留/清空可选 | 最安全 | 多一步操作 | + +**推荐方案 A(保留表单,更新关联)**: + +理由: +1. 意见文本通常是通用的(如"该字段缺失,建议补充"),用户可能想在多个评查点上提相同意见 +2. 分数调整可能也相同 +3. 表单顶部实时更新评查点名称和分数,用户能清楚看到当前关联的是哪个评查点 + +**实现**: +```typescript +useEffect(() => { + if (showForm && activeReviewPointId) { + // 不关闭表单,不重置用户输入 + // 仅更新表单头部的评查点名称和分数展示 + // 用户能清楚看到关联评查点已变更 + } +}, [activeReviewPointId, activeReviewPointScore, activeReviewPointFinalScore]); + +// 表单头部实时反映当前评查点: +
+ 评查点:{activeReviewPointName || '—'} + | + 满分 {activeReviewPointScore ?? '—'} 分 + | + 已获得 {activeReviewPointFinalScore ?? activeReviewPointMachineScore ?? '—'} 分 +
+``` + +**边缘场景**: +- 用户尚未选择评查点就打开表单 → `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 + { + 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(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); + } +}; +```