# 交叉评查核心模块 — 业务逻辑与实现计划

## 目录

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（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_regions` CTE 从 `leaudit_cross_review_task_members` JOIN `sso_users` 收集 `DISTINCT u.area`（排除 NULL 和空串），非从文档表获取。

> **评查统计数据来源**：`GetTaskDocuments` 中 `es` LATERAL 子查询聚合 `leaudit_rule_results`，按 `risk` 分级：
> - `passed IS TRUE` → pass_count
> - `passed IS FALSE AND risk = 'high'` → error_count
> - `passed IS FALSE AND risk IN ('low','medium')` → warning_count

---

## 2. 后端 API 全景

**路由前缀**: `/api/v3/cross-review`  
**控制器**: `fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py`  
**服务实现**: `fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py`

### 2.1 任务管理

| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/tasks` | 创建交叉评查任务 | `cross_review:task:create` |
| POST | `/tasks/query` | 查询当前用户的任务（分页） | `cross_review:task:read` |
| GET | `/tasks/{taskId}/progress` | 查询任务进度（完成数/总数） | `cross_review:progress:view` |
| GET | `/tasks/{taskId}/documents` | 查询任务文档列表（分页） | `cross_review:document:read` |
| POST | `/tasks/{taskId}/can-confirm` | 检查当前用户可否确认完成 | — |
| POST | `/tasks/{taskId}/documents/{docId}/complete` | 确认文档评查完成 | `cross_review:document:complete` |
| POST | `/tasks/{taskId}/documents/upload` | 向任务补传文档 | `cross_review:document:read` |

### 2.2 提案（意见）管理

| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/proposals` | 创建提案（提出意见） | `cross_review:proposal:create` |
| POST | `/proposals/{proposalId}/votes` | 投票（agree/disagree/cancel） | `cross_review:proposal:vote` |
| DELETE | `/proposals/{proposalId}` | 撤销提案（仅提案人） | `cross_review:proposal:delete` |
| GET | `/documents/{docId}/proposals` | 获取文档的意见列表（分页） | `cross_review:proposal:read` |
| GET | `/documents/{docId}/pending-votes` | 获取待投票摘要 | — |

### 2.3 请求 DTO（`crossReviewDto.py`）

**`CrossReviewTaskCreateDTO`**:
```python
taskName: str              # 必填
taskType: str = "CITY"
docTypeId: int | None
docTypeCode: str | None
memberUserIds: list[int]   # 参与者
principalUserIds: list[int] # 负责人
documentIds: list[int]     # 初始挂载文档
```

**`CrossReviewProposalCreateDTO`**:
```python
reviewPointResultId: int   # 必填 — 评查点结果 ID
documentId: int            # 必填 — 文档 ID
evaluationPointId: int | None  # 可选 — 评查点定义 ID
auditOpinion: str          # 必填 — 意见文本
deductionScore: float      # 必填 — 分数调整（负=扣分，正=加分）
```

**`CrossReviewProposalVoteDTO`**:
```python
voteType: str  # "agree" | "disagree" | "cancel"
```

### 2.4 响应 VO（`crossReviewVo.py`）

**`CrossReviewProposalItemVO`** — 意见列表项：
```python
proposalId: int
evaluationPointName: str        # 评查点名称
proposedScore: float            # 提议分数调整
reason: str                     # 意见内容
proposer: str                   # 提案人姓名
votes: list[VoteItemVO]         # 所有投票明细
agreeVoters: list[str]          # 赞同者姓名
disagreeVoters: list[str]       # 反对者姓名
pendingVoters: list[str]        # 待投票者姓名
canVote: bool                   # 当前用户可否投票
problemMessage: str
proposerId: int
createdAt: datetime | None
status: str                     # pending | approved | rejected | cancelled
```

**`CrossReviewProposalPageVO`**:
```python
total: int; page: int; pageSize: int
items: list[CrossReviewProposalItemVO]
```

**`CrossReviewPendingVotesVO`**:
```python
hasPendingVotes: bool
pendingProposals: list[PendingProposalVO]
# PendingProposalVO: { evaluationPointName, pendingVotersNum }
```

**`CrossReviewTaskItemVO`** — 任务列表项：
```python
taskId: int
taskName: str
taskType: str
docTypeId: int | None
docTypeCode: str | None
status: str
progress: float
totalDocuments: int
completedDocuments: int
createdAt: datetime | None
evaluationRegion: list[str]  # 评查地区 — 从任务成员 sso_users.area 去重收集
```

**`CrossReviewTaskDocumentVO`** — 任务文档列表项：
```python
documentId, name, documentNumber, typeId, typeName
processingStatus, versionNo, isLatestVersion
versionGroupKey, totalVersions
auditStatus  # 0=未评查, 1=已评查（文档×任务级别）
createdAt, fileSize
path         # 文件路径 — 来自 leaudit_document_files.local_path
uploadTime   # 上传时间 — 来自 leaudit_document_files.created_at
# 以下为评查统计字段 — 聚合自 leaudit_rule_results
totalEvaluationPoints: int   # 总评查点数
passCount: int               # 通过数（passed IS TRUE）
warningCount: int            # 警告数（passed IS FALSE AND risk IN ('low','medium')）
errorCount: int              # 错误数（passed IS FALSE AND risk = 'high'）
manualCount: int             # 人工审核数（暂为 0）
issueCount: int              # 问题总数
warningMessages: list[str]   # 警告消息
errorMessages: list[str]     # 错误消息
issueMessages: list[str]     # 问题消息（综合）
manualMessages: list[str]    # 人工审核消息（暂为空）
finalScore: float            # 最终得分（通过规则分数之和）
fullScore: float             # 满分（所有规则分数之和）
scoreSummary: str            # 得分摘要（如 "85.0/100.0"）
scorePercent: float          # 得分百分比（0-100）
```

---

## 3. 计分与投票核心逻辑

### 3.1 计分公式

位置：`crossReviewServiceImpl.py:_calculate_current_score()` (line 1133)

```
current_score = base_score + SUM(approved_deltas)
```

**base_score 规则**:

| 条件 | base_score |
|------|-----------|
| `edit_audit_status = 1` 且 `override_result = TRUE` | 满分 (`rule_result.score`) |
| `edit_audit_status = 1` 且 `override_result = FALSE` | 0 |
| `rule_result.passed = TRUE`（机器评查通过） | 满分 |
| `rule_result.passed = FALSE`（机器评查未通过） | 0 |

**approved_delta**: 该评查点下所有 `status = 'approved'` 的提案的 `proposed_score_delta` 之和。

> `deductionScore` 可以是**负数**（扣分）或**正数**（加分）。

### 3.2 提案创建时的分数校验

位置：`crossReviewServiceImpl.py:CreateProposal()` (line 672)

| 条件 | 结果 |
|------|------|
| `deductionScore < 0` 且 `currentScore <= 0` | 拒绝："当前分数已为 0，无法继续扣分" |
| `deductionScore > 0` 且 `currentScore >= fullScore` | 拒绝："当前分数已为满分，无法继续加分" |
| 通过校验 | 创建提案 + 提案人自动投"赞同"票 |

### 3.3 投票机制：过半多数制

位置：`crossReviewServiceImpl.py:_refresh_proposal_status()` (line 1235)

```
threshold = floor(memberCount / 2) + 1
```

例如：5 个成员 → threshold = 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  = <ReviewPointsList /> (评查点列表+意见)
  │     ├─ bottomActions   = [提出意见] [查看意见] (→ Modal)
  │     ├─ isProposer      = Boolean(isProposer && canCompleteDocument)
  │     └─ completeButtonLabel = "完成评查"
  │
  └─ 弹窗: 提出意见 Modal / 查看意见 Modal
```

---

## 5. 数据流全景

### 5.1 任务创建 → 评查 → 完成流程

```
1. 创建任务
   POST /api/v3/cross-review/tasks
   → 插入 leaudit_cross_review_tasks
   → 插入 leaudit_cross_review_task_members (参与者+负责人)
   → 插入 leaudit_cross_review_task_documents (初始文档)
   → 触发机器评查

2. 评查文档
   GET /api/v3/review-points/{fileId}
   → documentServiceImpl.GetReviewPoints()
   → 加载 review_points (评查结果)
   → 加载 scoring_proposals (_loadScoringProposals)
   → 加载 comparison_document (比对文档)

3. 提出意见
   POST /api/v3/cross-review/proposals
   → 校验 dedectionScore 边界 (_calculate_current_score)
   → 插入 leaudit_cross_review_proposals
   → 提案人自动投 agree (_vote_proposal)
   → _refresh_proposal_status → pending/approved/rejected

4. 投票
   POST /api/v3/cross-review/proposals/{id}/votes
   → 插入/更新 leaudit_cross_review_votes
   → _refresh_proposal_status → 检查是否达到阈值

5. 完成评查
   GET  /api/v3/cross-review/documents/{id}/pending-votes (检查待投票)
   POST /api/v3/cross-review/tasks/{taskId}/documents/{docId}/complete
   → 更新 leaudit_cross_review_task_documents.audit_status = 1
   → 检查任务下所有文档是否完成 → 更新任务状态
```

### 5.2 计分数据流（详细）

```
机器评查
  │
  ├─ rule_result.score         → 满分
  ├─ rule_result.passed        → TRUE/FALSE
  └─ rule_result.machineScore  → AI 评分
        │
        ▼
人工审核（可选）
  │
  ├─ PATCH /api/v3/review-points/{id}/audit
  ├─ edit_audit_status = 1
  └─ override_result = TRUE/FALSE
        │
        ▼
提案投票
  │
  ├─ approved 提案的 proposed_score_delta 求和
  └─ 加到 base_score 上
        │
        ▼
current_score = base_score + SUM(approved_deltas)
```

### 5.3 提案生命周期

```
CreateProposal
  │
  ├─ status = "pending"
  ├─ auto-vote: proposer → agree
  └─ _refresh_proposal_status()
        │
        ├─ agree >= threshold → "approved"  (delta 计入 current_score)
        ├─ disagree >= threshold → "rejected"
        ├─ agree + remaining < threshold → "rejected"
        └─ otherwise → keep "pending"
              │
              ├─ VoteProposal (agree/disagree/cancel)
              │   └─ _refresh_proposal_status()
              │
              └─ CancelProposal (仅提案人, only when "pending")
                  └─ status → "cancelled"
```

---

## 6. 右侧栏「交叉意见」Tab 实现计划

### 6.1 目标

- 在 `DetailPanel` 新增第 4 个 Tab：**交叉意见**
- 意见列表 + 提出意见表单 + 投票操作直接嵌入面板
- 替代当前底部栏按钮 + Modal 方案
- 非交叉评查页面不受影响（slot 模式按需注入）

### 6.2 Step 1: 扩展 `DetailPanel`

**文件**: `components/reviews/rightColumn/DetailPanel.tsx`（+25 行）

**改动**：

1. 扩展 TabKey 类型和 props：
```typescript
type TabKey = 'result' | 'fields' | 'info' | 'crossOpinions';

// 新增 props:
crossOpinionsContent?: React.ReactNode;  // 交叉意见 tab 内容
crossOpinionsBadge?: number;             // 意见数量 badge
```

2. 动态 TABS 数组（`useMemo`）：
```typescript
const BASE_TABS = [
  { key: 'result' as const, label: '评查结果' },
  { key: 'fields' as const, label: '抽取字段' },
  { key: 'info'   as const, label: '文件信息' },
];

const tabs = useMemo(() => {
  if (crossOpinionsContent != null) {
    return [...BASE_TABS, { key: 'crossOpinions' as TabKey, label: '交叉意见' }];
  }
  return BASE_TABS;
}, [crossOpinionsContent]);
```

3. 渲染内容：
```tsx
{activeTab === 'crossOpinions' && crossOpinionsContent}
```

4. Badge：
```tsx
{tab.key === 'crossOpinions' && crossOpinionsBadge != null && crossOpinionsBadge > 0 && (
  <span className="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px]
    rounded-full bg-red-500 text-white text-[10px] px-1">
    {crossOpinionsBadge}
  </span>
)}
```

### 6.3 Step 2: 新建 `CrossCheckingOpinionsPanel`

**文件**: `components/cross-checking/CrossCheckingOpinionsPanel.tsx`（新建，~350 行）

**Props**：
```typescript
interface CrossCheckingOpinionsPanelProps {
  documentId: string | number;
  // 当前选中评查点信息（用于表单自动关联 + 分数展示）
  activeReviewPointId?: string | number | null;
  activeReviewPointName?: string;
  activeReviewPointScore?: number | null;        // 满分
  activeReviewPointMachineScore?: number | null;  // AI 得分
  activeReviewPointFinalScore?: number | null;    // 人工调整后得分（null → fallback machineScore）
  // 认证
  jwtToken?: string;
  userInfo?: any;
  // 权限
  canCreateProposal: boolean;
  canVoteProposal: boolean;
  canDeleteProposal: boolean;
  // 回调
  onOpinionSubmitted?: (proposal: ScoringProposal) => void;
  onNavigateToReviewPoint?: (pointId: string | number) => void;
}
```

**组件结构**：
```
CrossCheckingOpinionsPanel
├── 头部
│   ├── "交叉意见" 标题 + 总数
│   └── [提出意见] 按钮（canCreateProposal 时显示）
│
├── 提出意见表单（可展开/折叠）
│   ├── 分数状态条：评查点名称 | 满分 X 分 | 已获得 Y 分
│   ├── 意见文本域
│   ├── 分数调整输入（负数扣分，正数加分）
│   ├── 前端边界校验提示
│   └── [取消] [提交意见]
│
├── 意见列表（分页）
│   └── 每条意见卡片：
│       ├── 头部：评查点名称（可点击→跳转 Tab） + 状态标签 + 时间
│       ├── 正文：意见内容
│       ├── 元信息：提案人 | 提议调整：+X/-X 分
│       ├── 投票区：
│       │   ├── 赞同者列表（绿色标签）
│       │   ├── 反对者列表（红色标签）
│       │   ├── 待投票者列表（灰色标签）
│       │   └── [赞同] [反对] 按钮（canVoteProposal && can_vote 时显示）
│       └── 操作：[撤销意见]（canDeleteProposal && 当前用户是提案人 && status=pending）
│
└── 空态：暂无意见
```

**状态管理**：
```typescript
const [opinions, setOpinions] = useState<CrossCheckingOpinion[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ auditOpinion: '', deductionScore: 0 });
const [submitting, setSubmitting] = useState(false);
```

**数据加载策略**：
- 懒加载：仅在 Tab 切换到 `crossOpinions` 时首次触发 `getCrossCheckingOpinions()`
- 提交意见后 → 刷新列表（重置到第 1 页）
- 投票后 → 乐观更新本地状态 + 失败时回滚

**提出意见提交逻辑**：
```typescript
const handleSubmit = async () => {
  // 1. 校验
  if (!form.auditOpinion.trim()) { toastService.warning('请输入评查意见'); return; }
  if (!activeReviewPointId) { toastService.warning('请先在左侧选择评查点'); return; }

  // 2. 前端初步边界校验（后端做精确校验）
  const currentScore = activeReviewPointFinalScore ?? activeReviewPointMachineScore ?? 0;
  const fullScore = activeReviewPointScore ?? 0;
  if (form.deductionScore < 0 && currentScore + form.deductionScore < 0) {
    toastService.warning('扣分后分数不能低于 0'); return;
  }
  if (form.deductionScore > 0 && fullScore > 0 && currentScore + form.deductionScore > fullScore) {
    toastService.warning('加分后分数不能超过满分'); return;
  }

  // 3. 调用 API
  setSubmitting(true);
  const result = await submitCrossCheckingOpinion({
    reviewPointResultId: String(activeReviewPointId),
    documentId: String(documentId),
    evaluationPointId: null,
    auditOpinion: form.auditOpinion,
    deductionScore: form.deductionScore,
  }, jwtToken, userInfo);
  setSubmitting(false);

  // 4. 处理结果
  if (result.error) { toastService.error(result.error); return; }
  toastService.success('意见提交成功 — 提案人自动投赞同票');
  setShowForm(false);
  setForm({ auditOpinion: '', deductionScore: 0 });
  loadOpinions();  // 刷新列表
  onOpinionSubmitted?.(result.data as any);
};
```

**投票逻辑**（乐观更新）：
```typescript
const handleVote = async (proposalId: string | number, voteType: 'agree' | 'disagree') => {
  // 乐观更新
  const previous = opinions;
  setOpinions(prev => prev.map(o =>
    o.proposal_id === proposalId ? { ...o, can_vote: false } : o
  ));

  const result = await performOpinionAction({
    opinionId: proposalId, action: voteType
  }, jwtToken, userInfo);

  if (result.error) {
    toastService.error(result.error);
    setOpinions(previous);  // 回滚
    return;
  }
  loadOpinions();  // 刷新（获取后端 _refresh_proposal_status 后的状态）
};
```

### 6.4 Step 3: 修改 `CrossCheckingResultClient`

**文件**: `app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx`（+35 / -80 行）

**改动**：

1. 导入新组件：
```typescript
import { CrossCheckingOpinionsPanel } from "@/components/cross-checking/CrossCheckingOpinionsPanel";
```

2. 移除底部栏的「提出意见」「查看意见」按钮（从 `bottomActions` 中删除）

3. 新增 `crossOpinionsContent` 传入 DetailPanel：
```typescript
<DetailPanel
  // ... 现有 props ...
  crossOpinionsContent={
    <CrossCheckingOpinionsPanel
      documentId={document.id}
      activeReviewPointId={activeReviewPointResultId}
      activeReviewPointName={activeReviewPoint?.name || activeReviewPoint?.title}
      activeReviewPointScore={activeReviewPoint?.score ?? null}
      activeReviewPointMachineScore={activeReviewPoint?.machineScore ?? null}
      activeReviewPointFinalScore={activeReviewPoint?.finalScore ?? null}
      jwtToken={jwtToken}
      userInfo={userInfo}
      canCreateProposal={canCreateProposal}
      canVoteProposal={canVoteProposal}
      canDeleteProposal={canDeleteProposal}
      onOpinionSubmitted={handleOpinionSubmitted}
      onNavigateToReviewPoint={(pointId) => {
        setRightActiveTab('result');
        handleReviewPointSelect(pointId);
      }}
    />
  }
  crossOpinionsBadge={localScoringProposals.length}
/>
```

4. 移除 Modal 相关状态和 JSX（`showProposalModal`, `showOpinionList`, `opinionList`, `opinionListLoading`, `proposalForm`, `isSubmittingProposal`）

### 6.5 文件改动汇总

| 文件 | 操作 | 改动量 |
|------|------|--------|
| `components/reviews/rightColumn/DetailPanel.tsx` | 修改 | +25 行 |
| `components/cross-checking/CrossCheckingOpinionsPanel.tsx` | **新建** | ~350 行 |
| `app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx` | 修改 | +35 / -80 行 |

**总计**: 1 新文件，2 修改，净增 ~330 行。后端无需改动。

### 6.6 兼容性

- `DetailPanel` 不传 `crossOpinionsContent` 时保持 3 Tab，行为完全不变
- `ReviewsTestClient` 零改动
- 权限控制：无 `cross_review:proposal:read` 权限时 Tab 仍显示但内容为空态提示

### 6.7 交互流程

```
用户打开交叉评查详情页
  │
  ├─ 点击「交叉意见」Tab
  │   ├─ 首次加载：GET /proposals?page=1&pageSize=10
  │   ├─ 每条意见卡片：
  │   │   ├─ 点击评查点名称 → 切换到「评查结果」Tab 并定位该评查点
  │   │   ├─ 赞同/反对按钮（有投票权限 & can_vote=true 时显示）
  │   │   └─ 撤销按钮（提案人 + pending 状态时显示）
  │   └─ 点击「提出意见」展开内联表单
  │       ├─ 显示：评查点名称 | 满分 X 分 | 已获得 Y 分
  │       ├─ 填写意见文本 + 分数调整值
  │       ├─ 前端边界校验
  │       └─ 提交 → 后端精确校验 → 提案人自动投赞同票 → 刷新列表
  │
  └─ 底部栏：保持不变（下载 + 完成评查按钮）
```

---

## 7. 前端 API 封装参考

### 7.1 交叉意见相关（`cross-file-result.ts`）

| 函数 | HTTP | 路径 | 用途 |
|------|------|------|------|
| `submitCrossCheckingOpinion` | POST | `/api/v3/cross-review/proposals` | 提交意见 |
| `getCrossCheckingOpinions` | GET | `/api/v3/cross-review/documents/{id}/proposals` | 获取意见列表（分页） |
| `performOpinionAction` | POST/DELETE | `/proposals/{id}/votes` 或 `/proposals/{id}` | 投票 / 撤销意见 |
| `checkProposalVotes` | GET | `/api/v3/cross-review/documents/{id}/pending-votes` | 检查待投票 |
| `confirmReviewResults` | POST | `/api/v3/cross-review/tasks/{taskId}/documents/{docId}/complete` | 完成评查 |
| `updateCrossCheckingReviewResult` | PATCH | `/api/v3/review-points/{id}/audit` | 更新评查结果状态 |
| `findIsProposer` | GET | `/api/v3/cross-review/tasks/{taskId}/can-confirm` | 检查是否负责人 |

### 7.2 任务列表与文档列表（`cross-files.ts`）

| 函数 | HTTP | 路径 | 用途 |
|------|------|------|------|
| `getUserTaskDocuments` | POST | `/api/v3/cross-review/tasks/query` | 获取用户任务列表（V3） |
| `getTaskDocumentsWithVersions` | GET | `/api/v3/cross-review/tasks/{id}/documents` | 获取任务文档列表（含版本归纳） |
| `getCrossCheckingDocumentTypes` | — | — | 获取可选文档类型 |
| `getCrossCheckingTasks` | — | — | 任务列表包装（含前端筛选） |

### 7.3 版本号任务本地化

`d.version_no` 是文档的**全局**版本号（跨所有任务），前端在 `getTaskDocumentsWithVersions` 中做任务本地重映射：

```
同一 version_group_key 内：
1. 按原始 version_number 升序排列（保留全局先后顺序）
2. 重新分配 version_number = 1, 2, 3...
3. 同时更新 history_versions 中的 version_number
```

**示例**：文档全局版本 v11 → 任务内第 1 次上传 → 显示 V1；v12 → 第 2 次 → 显示 V2。

### 7.4 评查统计数据映射

前端 `getTaskDocumentsWithVersions` 将后端 V3 返回的 camelCase 字段映射为内部使用的 snake_case：

```
API 返回 (JSON)           → 前端类型 (CrossReviewDocumentWithVersion)
────────────────────────────────────────────────────────────
passCount                 → pass_count
warningCount              → warning_count
errorCount                → error_count
manualCount               → manual_count
scorePercent              → score_percent
warningMessages           → warning_messages
errorMessages             → error_messages
...
```

---

## 8. 补充：后端服务实现详解

### 8.1 `crossReviewServiceImpl.py` 完整方法签名

文件位置：`fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py`

```python
class CrossReviewServiceImpl(ICrossReviewService):

    # ===== 任务管理 =====
    async def CreateTask(self, CurrentUserId: int, dto: CrossReviewTaskCreateDTO)
        -> CrossReviewTaskCreateVO:
        """创建任务: 插入 task → 插入 members → 挂载 documents → 触发评查"""

    async def GetUserTasks(self, CurrentUserId: int, dto: CrossReviewTaskQueryDTO)
        -> CrossReviewTaskPageVO:
        """
        分页查询当前用户参与的任务。
        SQL 含两个 CTE：
          - doc_stats: 统计每任务 total_documents / completed_documents
          - task_regions: 从任务成员 JOIN sso_users 收集去重 area AS evaluationRegion
        """

    async def GetTaskProgress(self, CurrentUserId: int, TaskId: int)
        -> CrossReviewTaskProgressVO:
        """统计 completedDocuments / totalDocuments"""

    async def GetTaskDocuments(self, CurrentUserId: int, TaskId: int,
                               dto: CrossReviewTaskDocumentQueryDTO)
        -> CrossReviewTaskDocumentPageVO:
        """
        分页查询任务下的文档列表。
        含版本归纳：同一 version_group_key 的文档归为一组，totalVersions 为任务内计数。
        含评查统计 LATERAL 子查询 (es)：聚合 leaudit_rule_results 的
          pass_count / warning_count / error_count / score_percent 等。
        """

    async def CanConfirmTaskDocument(self, CurrentUserId: int, TaskId: int)
        -> CrossReviewPermissionVO:
        """检查: assigner_id 或 member_role='principal' 才可完成评查"""

    async def CompleteTaskDocument(self, CurrentUserId: int, TaskId: int,
                                    DocumentId: int)
        -> CrossReviewTaskCompleteVO:
        """标记文档 audit_status=1; 若全部文档完成则更新任务状态"""

    async def UploadTaskDocument(self, CurrentUserId: int, TaskId: int,
                                  file: UploadFile, ...)
        -> CrossReviewTaskDocumentUploadVO:
        """上传新文档并挂载到任务"""

    # ===== 提案 / 意见 =====
    async def CreateProposal(self, CurrentUserId: int,
                              dto: CrossReviewProposalCreateDTO)
        -> CrossReviewProposalCreateVO:
        """
        创建提案流程:
        1. 校验 deductionScore 边界 (_calculate_current_score)
        2. 检查重复提案 (同人+同评查点+同文档)
        3. INSERT leaudit_cross_review_proposals
        4. 提案人自动投 agree (_vote_proposal)
        5. _refresh_proposal_status → 返回状态
        """

    async def VoteProposal(self, CurrentUserId: int, ProposalId: int,
                            dto: CrossReviewProposalVoteDTO)
        -> CrossReviewProposalVoteVO:
        """
        投票流程:
        1. 校验提案存在且非 cancelled
        2. 校验投票人是任务成员
        3. UPSERT leaudit_cross_review_votes
        4. _refresh_proposal_status → 可能变 approved/rejected
        """

    async def CancelProposal(self, CurrentUserId: int, ProposalId: int)
        -> CrossReviewProposalCancelVO:
        """仅提案人 + status=pending 时可撤销"""

    async def GetDocumentProposals(self, CurrentUserId: int,
                                    DocumentId: int, page: int, pageSize: int)
        -> CrossReviewProposalPageVO:
        """
        获取意见列表:
        1. 解析 task_id
        2. 加载所有成员列表 → 计算 pendingVoters
        3. 加载投票记录 → 计算 agreeVoters/disagreeVoters
        4. 判断 canVote (当前用户是否可投票)
        5. 分页返回
        """

    async def GetDocumentPendingVotes(self, CurrentUserId: int,
                                       DocumentId: int)
        -> CrossReviewPendingVotesVO:
        """过滤 status=pending 且 pendingVoters>0 的提案"""

    # ===== 内部方法 =====
    async def _calculate_current_score(self, session, ruleResultId, documentId)
        -> float:
        """current_score = base_score + SUM(approved_deltas)"""

    async def _refresh_proposal_status(self, session, proposalId) -> str:
        """过半多数制状态机"""

    async def _vote_proposal(self, session, proposalId, voterId, voteType):
        """内部投票 (创建提案时自动调用)"""
```

### 8.2 `documentServiceImpl.py` 中的集成点

```python
# line 629
async def GetReviewPoints(self, CurrentUserId: int, DocumentId: int)
    -> ReviewPointsAggregateVO:
    """
    核心方法 — 返回评查详情聚合数据:
    1. 加载 review_points (评查结果列表)
    2. 加载 stats (通过/不通过/警告 统计)
    3. 加载 reviewInfo (评查时间/模型/规则组)
    4. 加载 document (文档元信息)
    5. 加载 comparison_document (比对文档)
    6. 调用 _loadScoringProposals() ← 交叉评查意见
    """

# line 2267
async def _loadScoringProposals(self, session, documentId) -> list[dict]:
    """
    PRIMARY:   查询 leaudit_cross_review_proposals
              映射: rule_result_id→evaluation_result_id,
                    proposed_score_delta→proposed_score,
                    create_time→created_at, update_time→updated_at
    FALLBACK:  如果表不存在, 回退到旧 cross_scoring_proposals 表
    """
```

---

## 9. 补充：前端 ReviewPoint 类型定义

### 9.1 完整 ReviewPoint 接口

位置：`components/reviews/types.ts`（或 `components/reviews/index.ts` 导出）

```typescript
interface ReviewPoint {
  id: string | number;
  name?: string;           // 评查点名称
  title?: string;          // 评查点标题（备选字段）
  content?: Record<string, unknown>;  // 抽取字段内容
  contentPage?: Record<string, number>; // 字段所在页码
  fieldPositions?: Record<string, {
    bbox: [number, number, number, number];       // PDF 坐标
    page_box: [number, number, number, number];  // 页面坐标
    page_num: number;       // 页码 (0-indexed)
    confidence?: number;    // OCR 置信度
    match_method?: string;  // 匹配方法
  }>;
  score?: number;          // 满分 ← 用于意见表单分数展示
  machineScore?: number;   // AI 机器评查得分 ← 用于意见表单分数展示
  finalScore?: number | null; // 人工调整后得分 ← 优先级最高
  status?: string;         // 评查状态
  // ... 其他字段
}
```

### 9.2 ScoringProposal 接口（前端通用）

```typescript
// 定义在两个文件中，结构完全一致:
// a) lib/api/legacy/evaluation_points/reviews.ts line 165
// b) components/cross-checking/ReviewPointsList.tsx line 171

interface ScoringProposal {
  id: string | number;
  evaluation_result_id: string | number;  // = rule_result_id (后端)
  proposer_id: string | number;
  proposed_score: number;   // = proposed_score_delta (DB)
  reason: string;           // = reason (DB)
  status: string;           // pending | approved | rejected | cancelled
  created_at: string;
  updated_at: string;
  document_id: string | number;
}
```

### 9.3 两个接口的对应关系

| 来源 | 字段名 | 含义 |
|------|--------|------|
| `getReviewPoints_fromApi` → `scoring_proposals` | `ScoringProposal[]` | 从评查详情 API 返回，数据来自 `_loadScoringProposals()` |
| `getCrossCheckingOpinions` → `opinions` | `CrossCheckingOpinion[]` | 从交叉评查专用 API 返回，含投票明细 + canVote |
| 后端 DB `proposed_score_delta` | NUMERIC(10,2) | 数据库存储 |
| 前端展示 | `proposed_score` 或 `deductionScore` | 负数=扣分，正数=加分 |

---

## 10. 补充：前端现有意见 UI 完整分析

### 10.1 ReviewPointsList 中的意见列表 Modal

位置：`components/cross-checking/ReviewPointsList.tsx` line 2932-3096

这是当前**左侧栏 RulesDirectory** 的 `resultContent` slot 中的意见列表。它和内联在 DetailPanel 中的用法不同：

- **ReviewPointsList** 渲染在左侧栏（22% 宽度），空间紧凑
- 意见列表以 Table 组件显示，列包括：
  - `evaluation_point_name` — 评查点名称
  - `problem_message` — 问题描述
  - `reason` — 调整理由（截断 20 字符，tooltip 显示全文）
  - `proposed_score` — 带颜色：绿色正数/红色负数，显示 ± 符号
  - `votes` — 三组人名标签（绿=赞同，红=反对，灰=待投）
  - `proposer` — 提案人
  - `created_at` — 时间
  - `opinion_status` — 已通过/未通过/投票中
  - `operation` — 投票按钮 + 撤销按钮
- 浮动按钮显示提案总数 `scoringProposals.length`
- **提出意见表单**以 Modal 形式弹出

### 10.2 CrossCheckingResultClient 中的独立 Modal

位置：`app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx` line 786-871

与 ReviewPointsList 中的 Modal **完全独立**，是另一套实现：

- `showProposalModal` — 提出意见 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<any[]>([]);             // line 370
const [opinionListLoading, setOpinionListLoading] = useState(false);   // line 371
```

### 11.2 移除的函数
```typescript
// handleOpenProposalModal (line 588-592) → 移入 Panel
// handleSubmitProposal (line 594-616) → 移入 Panel
// handleOpenOpinionList (line 618-631) → 移入 Panel
```

### 11.3 移除的 JSX
```typescript
// bottomActions 中的提出意见/查看意见按钮 (line 754-771)
// 提出意见 Modal (line 786-828)
// 查看意见 Modal (line 830-871)
```

### 11.4 保留的部分
```typescript
// handleOpinionSubmitted (line 584-586) → 保留，传给 Panel 作为回调
// handleConfirmResults (line 536-582) → 保留，完成评查按钮仍需要
// checkProposalVotes 调用 → 保留在 handleConfirmResults 中
// localScoringProposals 状态 → 保留，用于 badge 计数
```

---

## 12. 补充：Tab 懒加载实现细节

```typescript
// CrossCheckingResultClient.tsx 新增

const [crossOpinionsLoaded, setCrossOpinionsLoaded] = useState(false);

// 当用户切换到 crossOpinions tab 时标记已加载
const handleTabChange = (tab: TabKey) => {
  setRightActiveTab(tab);
  if (tab === 'crossOpinions') {
    setCrossOpinionsLoaded(true);
  }
};

// 只有在 tab 被访问过后才渲染 Panel（避免未访问就发起 API 请求）
<DetailPanel
  activeTab={rightActiveTab}
  onTabChange={handleTabChange}  // 替代原来的 setRightActiveTab
  // ...
  crossOpinionsContent={
    crossOpinionsLoaded ? (
      <CrossCheckingOpinionsPanel ... />
    ) : null  // 首次访问前不渲染，避免不必要的 API 调用
  }
/>
```

---

## 13. 补充：完整交互时序图

```
用户点击左侧评查点
  │
  ├─ RulesDirectory.onRuleSelect(pointId)
  │   └─ setActiveReviewPointResultId(pointId)
  │       └─ PdfPreview 定位到对应页 → 高亮框
  │       └─ DetailPanel「评查结果」Tab 显示该点详情
  │       └─ CrossCheckingOpinionsPanel 收到新的 activeReviewPointId
  │           └─ 打开「提出意见」表单时自动关联新评查点
  │           └─ 分数状态条更新为当前评查点的满分/已得分
  │
用户点击「交叉意见」Tab
  │
  ├─ setRightActiveTab('crossOpinions')
  │   └─ 首次访问 → 懒加载 Panel → GET /proposals?page=1
  │   └─ Panel 渲染意见列表
  │
  ├─ 点击「提出意见」
  │   └─ showForm = true
  │   └─ 表单显示：评查点 {name} | 满分 {score} | 已获得 {currentScore}
  │   └─ 填写意见 + 分数调整
  │   └─ 点击「提交意见」
  │       ├─ 前端边界校验
  │       ├─ POST /api/v3/cross-review/proposals
  │       │   ├─ 后端 _calculate_current_score 精确校验
  │       │   ├─ INSERT leaudit_cross_review_proposals
  │       │   ├─ 提案人自动投 agree
  │       │   └─ _refresh_proposal_status → 返回状态
  │       ├─ 成功 → 刷新列表 + 回调 onOpinionSubmitted
  │       └─ 失败 → 显示后端错误消息
  │
  ├─ 对某条意见投票
  │   └─ 点击「赞同」或「反对」
  │       ├─ 乐观更新：本地修改 can_vote = false
  │       ├─ POST /api/v3/cross-review/proposals/{id}/votes
  │       │   └─ 后端 _refresh_proposal_status → 可能达到阈值
  │       ├─ 成功 → 刷新列表（获取最新投票状态）
  │       └─ 失败 → 回滚本地状态 + 显示错误
  │
  └─ 点击评查点名称（意见卡片中）
      └─ onNavigateToReviewPoint(pointId)
          ├─ setRightActiveTab('result')
          └─ handleReviewPointSelect(pointId) → 定位 + 高亮

---

## 14. 补充：代码审查发现的遗漏逻辑与技术债

以下是在编写计划时对现有代码进行逐行审查后发现的**潜在问题**，需在实现时一并修复，避免技术债累积。

### 14.1 🐛 `handleSubmitProposal` 未更新 badge 计数

**位置**: `CrossCheckingResultClient.tsx` line 594-616

**根因分析**:

`handleSubmitProposal` 和 `handleOpinionSubmitted` 是两个独立编写的函数，开发时未建立调用关系。`handleOpinionSubmitted`（line 584-586）仅通过 `onOpinionSubmitted` prop 从子组件 `ReviewPointsList` 接收回调，而 `handleSubmitProposal` 是本地 Modal 的提交逻辑，两者之间没有任何数据流通。

```
用户提交意见 → handleSubmitProposal → API → 成功 → toast + 关闭Modal
                                                       ↓
                                              缺少: handleOpinionSubmitted
                                                       ↓
                                         localScoringProposals 未更新
                                                       ↓
                                              左侧 badge 数不变
                                              右侧底部栏 badge 不变
```

**影响评估**:
- 用户提交意见成功后，左侧栏 `ReviewPointsList` 的浮动意见数不增加
- 底部栏 `DetailPanel` 的 badge 不更新
- **但下一轮轮询或页面刷新后数据会恢复正确**（非永久数据丢失）
- 用户体验：提交成功后看不到反馈，可能怀疑提交失败而重复提交

**修复方案**:

方案 A（最小改动）: 在成功回调中构造 `ScoringProposal` 并调用 `handleOpinionSubmitted`
```typescript
toastService.success("意见提交成功");
setShowProposalModal(false);
// Fix 14.1: 通知父组件更新 badge
if (result.data?.data) {
  handleOpinionSubmitted({
    id: result.data.data.id,
    evaluation_result_id: activeReviewPoint.id,
    proposer_id: (userInfo as any)?.user_id ?? 0,
    proposed_score: proposalForm.deductionScore,
    reason: proposalForm.auditOpinion,
    status: 'pending',
    created_at: result.data.data.created_at,
    updated_at: result.data.data.created_at,
    document_id: document.id,
  });
}
```
缺点: 依赖客户端构造数据，可能有字段不准确。

方案 B（新 Tab 方案采用）: 提交后直接调用 `getCrossCheckingOpinions()` 刷新完整列表，从列表接口获取后端确认的完整数据。这是更稳健的做法，将在 `CrossCheckingOpinionsPanel` 中实现。

**边缘场景**:
- 网络延迟导致提交成功但列表刷新失败 → toast 提示"提交成功，但列表刷新失败，请手动刷新"
- 用户在提交后立即切换 Tab → 列表在后台刷新，切回时展示最新数据

---

### 14.2 🐛 `getCrossCheckingOpinions` 响应数据访问路径错误

**位置**: `CrossCheckingResultClient.tsx` line 625

**根因分析**:

这是典型的**API 封装层与消费者之间的契约不匹配**问题。

`getCrossCheckingOpinions` 函数（`cross-file-result.ts:240-298`）返回结构：
```
ApiResponse<{ opinions: CrossCheckingOpinion[], total: number }>
  → result = {
      data: { opinions: [...], total: number }  // 成功
      error: string                              // 失败
    }
```

但消费者代码（`CrossCheckingResultClient.tsx:625`）使用：
```typescript
(result.data as any)?.items || (result.data as any)?.data || []
```

**路径对比**:
| 消费者期望 | 实际返回 | 结果 |
|-----------|---------|------|
| `.data.items` | 不存在 | `undefined` |
| `.data.data` | 不存在（实际是 `.data.opinions`） | `undefined` |
| fallback `[]` | — | **永远空数组** |

这说明**当前 Modal 的意见列表功能从未正常工作过**。因为 API 返回 `{ opinions, total }` 但代码访问 `.items || .data`，两者都访问不到，永远 fallback 到空数组。

**影响评估**:
- **CRITICAL**: 现有「查看意见」Modal 永远显示"暂无评查意见"，即使文档下有多条意见
- 用户无法通过现有 UI 查看任何已提交的意见
- 但 ReviewPointsList 中独立的意见 Modal（使用 `loadOpinionListData`）不受影响，因为它直接从 `getCrossCheckingOpinions` 获取数据并正确访问 `.opinions`

**为什么有两个意见列表？**:
- `CrossCheckingResultClient` 的「查看意见」Modal → 使用 `handleOpenOpinionList` → 数据路径错误 → 永远为空
- `ReviewPointsList` 的意见列表 Modal → 使用 `loadOpinionListData` → 数据路径正确 → 正常展示

这是一个**重复造轮子导致的 bug**：同一个功能实现了两遍，一遍正确、一遍错误。

**修复方案**:
```typescript
// 正确访问路径
const opinionData = result.data as { opinions?: any[]; total?: number } | undefined;
setOpinionList(opinionData?.opinions || []);
```

由于新 Tab 方案会用 `CrossCheckingOpinionsPanel` 统一替代这两个独立 Modal，此 bug 将被彻底消除。

**边缘场景**:
- API 直接返回错误（result.error 有值）→ 已在 line 624 处理，不会走到 line 625
- result.data 为 null → `opinionData?.opinions` 短路回退到 `[]`
- 后端返回格式变更 → 使用 TypeScript 类型约束（`{ opinions?: ...; total?: ... }`），编译时即可发现

---

### 14.3 🐛 `submitCrossCheckingOpinion` 返回值不完整

**位置**: `cross-file-result.ts` line 200-208

**根因分析**:

后端 `CreateProposal` 接口（`crossReviewServiceImpl.py`）返回的 VO：
```python
class CrossReviewProposalCreateVO:
    proposalId: int          # 仅返回 ID
    createdAt: datetime | None  # 仅返回创建时间
```

这是后端的设计决策：**创建接口只返回最小信息**（ID + 时间），完整提案数据需通过列表接口 `GET /documents/{id}/proposals` 获取。

前端 API 封装忠实反映了这个限制，只提取 `proposalId` 和 `createdAt`。但前端消费者（`handleOpinionSubmitted`）期望完整的 `ScoringProposal` 对象来更新本地状态。

```
后端 CreateProposal
  → 入 INSERT leaudit_cross_review_proposals
  → 自动投票
  → _refresh_proposal_status
  → 返回 { proposalId, createdAt }  ← 只有两个字段

前端期望 ScoringProposal:
  id, evaluation_result_id, proposer_id, proposed_score,
  reason, status, created_at, updated_at, document_id
  → 8 个字段缺失!
```

**影响评估**:
- 14.1 的修复如果直接用返回值填充 `ScoringProposal`，会缺少 6 个字段（`evaluation_result_id`, `proposer_id`, `proposed_score`, `reason`, `status`, `document_id`）
- badge 计数可以正常更新（只需要 `.length`），但意见对象的字段不完整
- 如果后续 UI 使用了这些缺失字段（如显示意见内容、状态标签），会出现 `undefined`

**修复方案**:

方案 A（当前过渡方案）: 客户端根据表单输入数据 + 当前上下文构造完整的 `ScoringProposal`
```typescript
// 在 handleSubmitProposal 中构造 (已应用于 14.1 修复)
handleOpinionSubmitted({
  id: result.data.data.id,
  evaluation_result_id: activeReviewPoint.id,        // 来自当前选中评查点
  proposer_id: userInfo?.user_id ?? 0,                // 来自登录信息
  proposed_score: proposalForm.deductionScore,        // 来自表单输入
  reason: proposalForm.auditOpinion,                  // 来自表单输入
  status: 'pending',                                  // 新提案默认 pending
  created_at: result.data.data.created_at,            // 来自 API 返回
  updated_at: result.data.data.created_at,            // 创建时与 created_at 相同
  document_id: document.id,                           // 来自当前文档
});
```
优点: 无需额外 API 调用，即时更新  
缺点: 客户端数据可能与后端状态不一致（如后端 _refresh_proposal_status 已将状态变为 approved）

方案 B（新 Tab 方案采用）: 提交后直接调用 `loadOpinions()` 刷新完整列表
```typescript
// 在 CrossCheckingOpinionsPanel 中
const handleSubmit = async () => {
  // ... 校验 ...
  const result = await submitCrossCheckingOpinion({...});
  if (result.error) { toastService.error(result.error); return; }
  toastService.success('意见提交成功');
  setShowForm(false);
  await loadOpinions();  // 重新拉取完整列表，数据来自后端权威源
  onProposalsChanged?.(currentOpinions);  // 通知父组件同步 badge
};
```
优点: 数据 100% 准确，从后端权威源获取  
缺点: 一次额外的 HTTP 请求

**边缘场景**:
- 提交成功但列表刷新失败 → 提交本身成功 + toast 成功提示 + 降级显示旧列表 + 手动刷新按钮
- 后端 _refresh_proposal_status 立即将提案变为 approved → 方案 B 能正确反映，方案 A 会显示为 pending（不一致）
- 并发提交（两个用户同时提交同一评查点）→ 后端有重复检查，第一个成功第二个返回错误

### 14.4 ⚠️ 撤销投票 UI 缺失

**ReviewPointsList 已有但新 Panel 计划遗漏**

**功能说明**:

当用户已经对某条意见投过票，后端 `can_vote` 变为 `false`，此时用户应看到「撤销投票」按钮而非「赞同/反对」按钮。撤销投票调用 `performOpinionAction({ action: 'withdraw_vote' })`，后端将投票记录标记为 `cancel`。

**ReviewPointsList 中的完整投票按钮状态机** (line 3194-3241):

```
用户查看一条意见时:

1. 先判断 isProposer (当前用户 === proposal.proposer_id)
   是 → 如果是 pending 状态，显示 [撤销意见] 按钮
   
2. 判断 can_vote:
   true && canVoteProposal
     → 显示 [赞同] [反对] 两个按钮
   
   false && !isProposer && canVoteProposal
     → 显示 [撤销投票] 按钮 ← 新 Panel 计划遗漏!
   
   false && !isProposer && !canVoteProposal
     → 无操作按钮（只读）
```

**为什么需要「撤销投票」**:
- 用户可能改变了主意，想从赞同改为反对（或反之）
- 后端不支持直接修改投票类型，必须先撤销再重新投票
- 撤销后 `can_vote` 变回 `true`，赞同/反对按钮重新出现

**新 Panel 需要补充的 UI**:
```tsx
{/* 已投票且非提案人 → 撤销投票 */}
{!record.can_vote && !isProposer && canVoteProposal && (
  <Button
    className="bg-yellow-600 hover:bg-yellow-700 ..."
    onClick={() => handleWithdrawAction(record.proposal_id, 'withdraw_vote')}
    disabled={isPerforming(`${record.proposal_id}-withdraw_vote`)}
  >
    {isPerforming(`${record.proposal_id}-withdraw_vote`) ? '处理中...' : '撤销投票'}
  </Button>
)}
```

**与新 Tab 方案的集成**:
- 新 Tab「交叉意见」中的每条意见卡片都需要实现此状态机
- 与 14.5（倒计时）和 14.6（操作状态管理）配合使用

---

### 14.5 ⚠️ 撤销操作安全倒计时

**ReviewPointsList 已有但新 Panel 计划遗漏**

**功能说明**:

对 `withdraw_vote`（撤销投票）和 `withdraw_opinion`（撤销意见）操作，ReviewPointsList 实现了 **3 秒倒计时确认**机制。这是因为撤销操作不可逆（尤其是撤销意见会删除整个提案），需要防止用户误点击。

**完整实现逻辑** (ReviewPointsList line 3150-3192):

```typescript
// 状态机
const [showModal, setShowModal] = useState<null | OpinionActionType>(null);
const [countdown, setCountdown] = useState(3);
const [counting, setCounting] = useState(false);

// 倒计时 Effect
useEffect(() => {
  if (showModal && (showModal === 'withdraw_opinion' || showModal === 'withdraw_vote')
      && counting && countdown > 0) {
    timer = setTimeout(() => setCountdown(c => c - 1), 1000);
  } else if (countdown === 0) {
    setCounting(false);
  }
  return () => clearTimeout(timer);
}, [showModal, counting, countdown]);

// 确认处理
const handleConfirm = () => {
  if (showModal === 'withdraw_opinion' || showModal === 'withdraw_vote') {
    if (countdown === 0) {  // 倒计时结束后才可确认
      handleOpinionAction(record.proposal_id, showModal);
      setShowModal(null);
      setCountdown(3);   // 重置倒计时
      setCounting(false);
    }
  } else {
    // 赞同/反对直接执行，不需要倒计时
    handleOpinionAction(record.proposal_id, showModal!);
    setShowModal(null);
  }
};
```

**用户交互流程**:
```
1. 用户点击 [撤销投票] / [撤销意见]
2. 弹出确认 Modal
3. 按钮文本: "确认(3)" → (1秒后) → "确认(2)" → (1秒后) → "确认(1)" → (1秒后) → "确认"
4. 按钮在倒计时期间 disabled（灰色不可点击）
5. 倒计时到 0 后按钮启用，用户点击确认
6. 用户可随时点击 [取消] 关闭 Modal
```

**为什么赞同/反对不需要倒计时？**
- 赞同/反对可以通过再次操作来"修正"（先撤销再重新投票）
- 撤销意见是删除操作，不可逆
- 撤销投票虽可重新投票，但也是破坏性操作

**新 Panel 实现要点**:
```typescript
// 在 CrossCheckingOpinionsPanel 中
const [confirmModal, setConfirmModal] = useState<{
  proposalId: string | number;
  action: OpinionActionType;
} | null>(null);
const [confirmCountdown, setConfirmCountdown] = useState(3);

const handleWithdrawClick = (proposalId: string | number, action: OpinionActionType) => {
  setConfirmModal({ proposalId, action });
  setConfirmCountdown(3);
};
```

---

### 14.6 ⚠️ 操作进行中状态管理

**ReviewPointsList 已有但新 Panel 计划遗漏**

**功能说明**:

ReviewPointsList 使用 `performingAction` 状态追踪当前正在执行的异步操作，防止用户在 API 调用进行中重复点击。

**实现** (ReviewPointsList line 723-747):
```typescript
const [performingAction, setPerformingAction] = useState<string | null>(null);

const isPerforming = (actionKey: string) => performingAction === actionKey;

const handleOpinionAction = async (opinionId, action) => {
  const actionKey = `${opinionId}-${action}`;  // 例: "123-agree"
  setPerformingAction(actionKey);               // 标记进行中

  try {
    const response = await performOpinionAction(...);
    if (response.error) { toastService.error(...); return; }
    toastService.success(...);
    await loadOpinionListData(...);
  } catch (error) {
    toastService.error(...);
  } finally {
    setPerformingAction(null);  // 清除标记
  }
};
```

**UI 层的双重保护**:
```tsx
<Button
  disabled={isPerforming('123-agree')}           // 1. 按钮 disabled
>
  {isPerforming('123-agree') ? '处理中...' : '赞同'}  // 2. 文案变化
</Button>
```

**为什么需要这个机制？**
- 防止用户在 API 调用中重复点击导致重复请求
- 提供视觉反馈（按钮变灰 + "处理中..."）
- `actionKey` 按 `opinionId-action` 组合，可以同时在不同意见上执行不同操作而不互相阻塞

**新 Panel 需要复刻的实现**:
```typescript
const [performingAction, setPerformingAction] = useState<string | null>(null);

const isPerforming = (actionKey: string) => performingAction === actionKey;

// 在每条意见卡片的按钮中使用:
<button
  disabled={isPerforming(`${opinion.proposal_id}-agree`)}
  onClick={() => handleOpinionAction(opinion.proposal_id, 'agree')}
>
  {isPerforming(`${opinion.proposal_id}-agree`) ? '处理中...' : '赞同'}
</button>
```

---

### 14.7 ⚠️ 表单在切换评查点时的行为

**场景分析**:

用户在「交叉意见」Tab 中已经打开了提出意见表单，正在填写。此时在左侧 RulesDirectory 中点击了另一个评查点，`activeReviewPointId` 发生变化。

**需要决策的行为**:

| 方案 | 表单状态 | 意见文本 | 分数调整 | 优点 | 缺点 |
|------|---------|---------|---------|------|------|
| A: 保留表单，更新关联 | 保持打开 | 保留 | 保留 | 操作流畅 | 用户可能混淆评查点 |
| B: 关闭表单 | 关闭 | 清空 | 清空 | 清晰无歧义 | 丢失用户输入 |
| C: 提示确认 | 弹确认框 | 保留/清空可选 | 保留/清空可选 | 最安全 | 多一步操作 |

**推荐方案 A（保留表单，更新关联）**:

理由:
1. 意见文本通常是通用的（如"该字段缺失，建议补充"），用户可能想在多个评查点上提相同意见
2. 分数调整可能也相同
3. 表单顶部实时更新评查点名称和分数，用户能清楚看到当前关联的是哪个评查点

**实现**:
```typescript
useEffect(() => {
  if (showForm && activeReviewPointId) {
    // 不关闭表单，不重置用户输入
    // 仅更新表单头部的评查点名称和分数展示
    // 用户能清楚看到关联评查点已变更
  }
}, [activeReviewPointId, activeReviewPointScore, activeReviewPointFinalScore]);

// 表单头部实时反映当前评查点:
<div className="mb-3 text-[12px] text-slate-500 bg-white rounded border px-3 py-2">
  评查点：{activeReviewPointName || '—'}
  <span className="mx-2">|</span>
  满分 {activeReviewPointScore ?? '—'} 分
  <span className="mx-2">|</span>
  已获得 {activeReviewPointFinalScore ?? activeReviewPointMachineScore ?? '—'} 分
</div>
```

**边缘场景**:
- 用户尚未选择评查点就打开表单 → `activeReviewPointId` 为 null → 表单提交时校验拦截
- 切换评查点后 newScore 超出当前 deductionScore 范围 → 前端初步校验会拦截，后端精确校验兜底

---

### 14.8 ⚠️ 投票后 `localScoringProposals` 未同步

**根因分析**:

`localScoringProposals` 是 `CrossCheckingResultClient` 中的状态，来源有两个：
1. 页面初始化：从 `getReviewPoints_fromApi` 的 `scoring_proposals` 字段加载
2. 新意见提交：通过 `handleOpinionSubmitted` 追加

但是，当用户在**新 Tab「交叉意见」中执行投票操作**后：
- `CrossCheckingOpinionsPanel` 调用 `performOpinionAction` → 后端 `_refresh_proposal_status` → 提案状态可能变化
- `CrossCheckingOpinionsPanel` 刷新自己的 `opinions` 列表
- 但 `CrossCheckingResultClient` 的 `localScoringProposals` **不知道这个变化**

**数据流断裂点**:
```
CrossCheckingOpinionsPanel (子组件)
  ├─ loadOpinions() → 拉取最新 opinions ← 数据新鲜
  └─ 但未通知父组件!

CrossCheckingResultClient (父组件)
  ├─ localScoringProposals ← 数据陈旧!
  │   ├─ 用于 DetailPanel badge (crossOpinionsBadge)
  │   └─ 用于左侧栏 ReviewPointsList 意见计数
  └─ 无法感知子组件的投票操作
```

**修复方案**:

新增 `onProposalsChanged` 回调，投票/撤销后由 Panel 向上通知：
```typescript
// CrossCheckingOpinionsPanelProps 新增:
onProposalsChanged?: (proposals: ScoringProposal[]) => void;

// 在 loadOpinions 完成后调用:
const loadOpinions = async () => {
  // ... 获取数据 ...
  const newOpinions = opinionData.opinions || [];
  setOpinions(newOpinions);

  // 向上同步：将 CrossCheckingOpinion[] 转为 ScoringProposal[]
  const proposals: ScoringProposal[] = newOpinions.map(o => ({
    id: o.proposal_id,
    evaluation_result_id: 0,  // CrossCheckingOpinion 无此字段
    proposer_id: o.proposer_id,
    proposed_score: o.proposed_score,
    reason: o.reason,
    status: o.status,
    created_at: o.created_at,
    updated_at: o.created_at,
    document_id: documentId as string | number,
  }));
  onProposalsChanged?.(proposals);
};
```

**CrossCheckingResultClient 中的对接**:
```typescript
<CrossCheckingOpinionsPanel
  // ... 其他 props ...
  onProposalsChanged={(proposals) => {
    setLocalScoringProposals(proposals);
  }}
/>
```

**时序**:
```
用户投票赞同
  → handleOpinionAction(opinionId, 'agree')
  → POST /api/v3/cross-review/proposals/{id}/votes
  → 后端 _refresh_proposal_status
  → 前端 loadOpinions() 拉取最新列表
  → onProposalsChanged(最新列表)
  → setLocalScoringProposals(最新列表)
  → DetailPanel badge 自动更新
  → 左侧栏 意见计数自动更新
```

### 14.9 📋 现有代码需一并修复的汇总

| 问题 | 文件 | 严重度 | 修复方式 |
|------|------|--------|---------|
| 14.1 — 提交后 badge 不更新 | `CrossCheckingResultClient.tsx:609` | HIGH | 添加 `handleOpinionSubmitted` 调用 |
| 14.2 — 意见列表数据访问路径错误 | `CrossCheckingResultClient.tsx:625` | **CRITICAL** | 修正为 `.opinions` |
| 14.3 — submit 返回值不完整 | `cross-file-result.ts:200` | MEDIUM | 改为提交后 `loadOpinions()` 刷新 |
| 14.4 — 撤销投票 UI 缺失 | 新 Panel | HIGH | 补充撤销投票按钮逻辑 |
| 14.5 — 撤销安全倒计时 | 新 Panel | MEDIUM | 补充 3 秒倒计时确认 |
| 14.6 — 操作状态管理 | 新 Panel | MEDIUM | 补充 performingAction 状态 |
| 14.7 — 切换评查点 | 新 Panel | LOW | useEffect 处理 |
| 14.8 — 投票后 badge 同步 | 新 Panel + Client | MEDIUM | 新增 onProposalsChanged 回调 |

### 14.10 修正后的组件 Props（最终版）

综合以上发现，`CrossCheckingOpinionsPanelProps` 最终定义：

```typescript
interface CrossCheckingOpinionsPanelProps {
  documentId: string | number;

  // 评查点信息
  activeReviewPointId?: string | number | null;
  activeReviewPointName?: string;
  activeReviewPointScore?: number | null;
  activeReviewPointMachineScore?: number | null;
  activeReviewPointFinalScore?: number | null;

  // 认证
  jwtToken?: string;
  userInfo?: { user_id: number; sub?: string; nick_name?: string };

  // 权限
  canCreateProposal: boolean;
  canVoteProposal: boolean;
  canDeleteProposal: boolean;

  // 回调
  onOpinionSubmitted?: (proposal: ScoringProposal) => void;
  onProposalsChanged?: (proposals: ScoringProposal[]) => void;
  onNavigateToReviewPoint?: (pointId: string | number) => void;
}

// 内部状态（需在组件内实现）
// const [performingAction, setPerformingAction] = useState<string | null>(null);
// 用于追踪当前操作状态，防止重复提交
```

### 14.11 投票操作完整逻辑（修正版）

```typescript
const handleOpinionAction = async (
  opinionId: string | number,
  action: OpinionActionType  // 'agree' | 'disagree' | 'withdraw_vote' | 'withdraw_opinion'
) => {
  const actionKey = `${opinionId}-${action}`;
  setPerformingAction(actionKey);

  try {
    const result = await performOpinionAction(
      { opinionId, action }, jwtToken, userInfo
    );

    if (result.error) {
      toastService.error(result.error);
      setPerformingAction(null);
      return;
    }

    toastService.success(result.data?.message || '操作成功');
    await loadOpinions();  // 刷新列表 + 通知父组件
    // onProposalsChanged 在 loadOpinions 内部调用
  } catch (error) {
    toastService.error(error instanceof Error ? error.message : '操作失败');
  } finally {
    setPerformingAction(null);
  }
};
```
