(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);
+ }
+};
+```