From fbcaa11b1c79393f0456f0af9728943245e2fbf9 Mon Sep 17 00:00:00 2001 From: Misaka Date: Tue, 14 Apr 2026 21:16:05 +0800 Subject: [PATCH] refactor: unify cleaner history status display --- ...14-cleaner-post-1.11.1-improvement-plan.md | 292 ++++++++++++++++++ .../CleanerOperationHistoryModal.tsx | 164 +++------- .../src/components/cleaner-history-status.tsx | 119 +++++++ tests/unit/cleaner-history-status.test.tsx | 42 +++ 4 files changed, 501 insertions(+), 116 deletions(-) create mode 100644 docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md create mode 100644 src/renderer/src/components/cleaner-history-status.tsx create mode 100644 tests/unit/cleaner-history-status.test.tsx diff --git a/docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md b/docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md new file mode 100644 index 0000000..59d4ac1 --- /dev/null +++ b/docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md @@ -0,0 +1,292 @@ +# Cleaner v1.11.1 之后更新内容改进计划 + +本文档基于 `v1.11.1..v1.12.3` 区间内已完成的前端审查结果整理而成,目标不是重复提交记录,而是为后续实现人员提供一份可以直接排期和落地的改进路线图。计划范围仅覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC 或数据库结构重构。 + +## 1. 背景与范围 + +本计划覆盖 `v1.11.1` 之后到当前最新版本 `v1.12.3` 的 Cleaner 前端相关更新,重点关注以下变化: + +- 新增 Cleaner 操作历史弹窗 +- 用数据库持久化替代原有 Markdown 报告查看路径 +- 为执行结果补充失败与不确定删除统计 +- 引入 `React.lazy` 和 `BatchItem` 拆分来降低页面负担 + +本次计划的核心目标是: + +- 先修复当前历史弹窗与执行结果展示中的稳定性问题 +- 再优化首屏加载和复杂列表交互性能 +- 最后补齐长期可维护性和可扩展性基础 + +默认审查区间固定为 `v1.11.1..v1.12.3`,默认文档语言为中文,默认落点为 `docs/plans/`。 + +## 2. 当前状态总结 + +这轮更新已经做对了几件重要的事情: + +- Cleaner 历史记录已经完成数据库化,前端不再依赖旧的 Markdown 报告浏览流 +- `CleanerOperationHistoryModal` 被独立成单独组件,并通过 `BatchItem` 局部拆分降低兄弟节点联动重渲染 +- `CleanerPage` 已经开始使用 `React.lazy` 引入历史弹窗与执行报告相关组件 +- `ExecutionReportDialog` 已经补充 `materialsFailed` 和 `uncertainDeletions` 的展示能力 + +这些改动说明整体方向是正确的,但从 React 最佳实践和后续维护成本看,当前实现仍然存在几个明确的改进空间:异步缓存策略不够稳、按需加载没有完全生效、复杂列表的扩展能力有限、前端回归保护不足。 + +## 3. 主要改进项 + +### P0 立刻修 + +#### 3.1 修正历史详情与物料详情的缓存时机 + +问题: + +- 当前历史批次详情和物料详情会在请求发起前就标记为“已加载” +- 如果首次请求失败,后续再次展开不会重试,用户会长期看到空详情或误导性空状态 + +目标: + +- 只在请求成功后写入缓存 +- 失败后允许再次展开重新请求 +- 在 UI 上保留现有交互风格,不做视觉重设计 + +建议方向: + +- 将详情加载状态拆成 `idle / loading / success / error` +- `detailsLoadedRef` 和 `loadedMaterialsRef` 只在成功后更新 +- 对失败场景提供自然重试路径,优先采用“再次展开即重试”的方式 + +预期收益: + +- 避免瞬时请求失败被错误地永久缓存 +- 提高历史查看功能的稳定性和用户信任感 + +#### 3.2 将 Cleaner 历史弹窗改成真正条件挂载 + +问题: + +- 当前 `CleanerPage` 虽然使用了 `React.lazy`,但历史弹窗组件仍然会在页面渲染时被挂入树中 +- 这会导致对应 chunk 仍在首屏阶段就被加载,未达到真正按需加载的效果 + +目标: + +- 历史弹窗只在用户打开时才参与渲染和加载 +- 避免进入 Cleaner 页面就提前下载历史功能代码 + +建议方向: + +- 采用条件渲染而不是仅保留 `isOpen` 控制 +- 延续当前交互样式和打开方式,不调整页面布局 + +预期收益: + +- 降低 Cleaner 页面的首屏负担 +- 更符合 `bundle-conditional` 类最佳实践 + +#### 3.3 补最小前端回归测试 + +问题: + +- 本轮新增了历史弹窗、异步详情展开和执行结果增强,但前端侧缺少对应测试保护 + +目标: + +- 为关键行为建立最小可行回归测试 +- 优先补组件/行为测试,不新增端到端测试要求 + +建议方向: + +- 覆盖历史弹窗未打开时不触发懒加载模块请求 +- 覆盖批次详情和物料详情首次失败后再次展开可重试 +- 覆盖管理员筛选切换后请求参数与结果一致 + +预期收益: + +- 降低后续修复和优化时的回归风险 +- 为后续分页、交互优化提供安全网 + +### P1 本周优化 + +#### 3.4 为历史列表增加分页能力 + +问题: + +- 当前历史列表和明细表格按全量数据渲染,随着批次数量、订单数量和物料数量增加,性能风险会上升 + +目标: + +- 让历史列表在数据增长后仍保持可接受的打开和滚动体验 + +建议方向: + +- 默认优先采用分页,不先引入虚拟列表库 +- 先做批次列表分页,再评估是否需要对订单或物料明细做进一步优化 + +预期收益: + +- 控制渲染体量 +- 降低复杂列表在中等数据规模下的卡顿风险 + +#### 3.5 管理员筛选切换使用 `startTransition` + +问题: + +- 管理员切换用户筛选时会立即触发批次列表刷新,后续数据量增长后可能影响点击反馈 + +目标: + +- 保持筛选按钮点击响应流畅 +- 将非紧急更新降级处理 + +建议方向: + +- 将筛选触发的列表刷新包装到 `startTransition` +- 保持现有筛选交互模型不变 + +预期收益: + +- 降低筛选切换时的阻塞感 +- 更符合 React 对非紧急更新的建议用法 + +#### 3.6 收敛重复派生计算 + +问题: + +- 当前实现中存在多处基于 `orders` 和 `currentAttempt` 的重复 `filter/map` +- 数据规模扩大后,这些重复遍历会逐步放大渲染成本 + +目标: + +- 让渲染中的数据派生更集中、更可读 + +建议方向: + +- 将当前 attempt 对应订单集合收敛成单一派生结果 +- 复制列内容等行为复用同一份派生数据 + +预期收益: + +- 降低不必要的重复计算 +- 让 `BatchItem` 的渲染路径更容易维护 + +#### 3.7 优化执行报告的结果语义 + +问题: + +- 当前执行报告的标题和成功态仍主要依赖 `errors` +- 当存在 `materialsFailed` 或 `uncertainDeletions` 时,结果表达仍可能显得过于乐观 + +目标: + +- 让执行结果清楚区分成功、部分成功、失败、需人工确认 + +建议方向: + +- 重新定义结果态判定优先级 +- 在不重做 UI 视觉设计的前提下,优化标题、说明文案和结果提示条 + +预期收益: + +- 降低误判执行结果的风险 +- 让失败和不确定删除场景更容易被用户注意到 + +### P2 后续演进 + +#### 3.8 统一状态映射定义 + +问题: + +- 当前状态的 label、icon、style 已有集中趋势,但仍是组件内局部定义 +- 后续新增状态时容易出现展示不一致 + +目标: + +- 用统一的受类型约束的映射管理状态展示 + +建议方向: + +- 抽离共享状态映射 +- 覆盖 batch、execution、order、material 这几类状态展示 + +预期收益: + +- 降低重复定义 +- 提高新增状态时的一致性和可维护性 + +#### 3.9 补无障碍语义 + +问题: + +- 当前批次展开和订单展开更多依赖点击容器,语义和键盘可达性还有提升空间 + +目标: + +- 让复杂历史弹窗具备更清晰的交互语义 + +建议方向: + +- 使用真实按钮作为展开触发器 +- 增加 `aria-expanded`、`aria-controls` 等属性 + +预期收益: + +- 提升键盘交互和屏幕阅读器兼容性 +- 为后续复杂交互维护提供更稳定语义基础 + +#### 3.10 规划历史查询的扩展能力 + +问题: + +- 当前查询能力主要围绕固定数量批次列表和基础筛选 +- 如果历史功能继续增强,前端会越来越依赖更丰富的查询条件 + +目标: + +- 为后续历史功能演进预留明确方向 + +建议方向: + +- 预留时间范围筛选 +- 预留状态筛选 +- 延续服务端分页方向,而不是继续扩大前端一次性加载量 + +预期收益: + +- 让后续功能迭代有稳定扩展路径 +- 避免复杂度持续堆积在当前单一弹窗实现中 + +## 4. 推荐执行顺序 + +建议按以下顺序推进: + +1. 先修 `P0`,优先处理缓存时机错误和按需加载未完全生效的问题 +2. 在 `P0` 修复完成后补最小前端回归测试,锁住关键行为 +3. 再做 `P1`,先分页,再处理 `startTransition` 和重复派生计算 +4. 最后进入 `P2`,统一状态映射、补无障碍语义,并规划历史查询扩展能力 + +这个顺序的原则是:先修稳定性,再做性能,再做长期演进。 + +## 5. 完成标准 + +本计划相关改进完成后,至少应满足以下验收标准: + +- 历史弹窗未打开时,不触发对应懒加载模块请求 +- 批次详情或物料详情首次请求失败后,用户再次展开可重新请求 +- 用户筛选切换后,列表数据与筛选条件一致 +- 执行报告在存在 `materialsFailed` 或 `uncertainDeletions` 时,不再展示为完全成功 +- `npm run typecheck` 通过 +- 相关前端测试通过 +- Cleaner 页面关键路径手工验证通过,包括: + - 打开历史弹窗 + - 展开批次详情 + - 展开订单物料详情 + - 切换管理员筛选 + - 查看执行结果提示 + +## 6. 默认方案与实施约束 + +为避免后续实现阶段再次做不必要决策,本计划固定以下默认方案: + +- 历史列表优先采用分页,不先引入虚拟列表库 +- 历史弹窗继续保留现有交互样式,不做视觉重设计 +- 测试优先补组件/行为测试,不新增端到端测试要求 +- 本计划只覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC、数据库结构重构 + +如果后续版本继续围绕 Cleaner 历史功能扩展,可以在本计划基础上继续追加更细的实施文档,但不应改变本计划中 `P0 / P1 / P2` 的优先级顺序。 diff --git a/src/renderer/src/components/CleanerOperationHistoryModal.tsx b/src/renderer/src/components/CleanerOperationHistoryModal.tsx index c6f9859..55bcf2c 100644 --- a/src/renderer/src/components/CleanerOperationHistoryModal.tsx +++ b/src/renderer/src/components/CleanerOperationHistoryModal.tsx @@ -16,9 +16,6 @@ import { ChevronRight, CheckCircle, XCircle, - CircleMinus, - AlertTriangle, - Clock, Copy, FlaskConical } from 'lucide-react' @@ -33,6 +30,10 @@ import { getNextHistoryLoadState, type HistoryLoadState } from './cleaner-history-load-state' +import { + getCleanerHistoryStatusDisplay, + getCleanerMaterialResultDisplay +} from './cleaner-history-status' // The preload API returns Date for time fields, but IPC serialization converts them to strings. // Use a local type that accommodates both to satisfy TypeScript. @@ -71,36 +72,6 @@ interface BatchItemProps { const BATCH_PAGE_SIZE = 5 -const statusStyles: Record = { - success: 'bg-green-100 text-green-700', - partial: 'bg-amber-100 text-amber-700', - failed: 'bg-red-100 text-red-700', - crashed: 'bg-red-100 text-red-700', - pending: 'bg-gray-100 text-gray-700', - not_found: 'bg-orange-100 text-orange-700', - erp_not_found: 'bg-orange-100 text-orange-700' -} - -const statusLabels: Record = { - success: '成功', - partial: '部分成功', - failed: '失败', - crashed: '崩溃', - pending: '进行中', - not_found: '未找到', - erp_not_found: 'ERP不存在' -} - -const statusIcons: Record = { - success: , - partial: , - failed: , - crashed: , - pending: , - not_found: , - erp_not_found: -} - const formatDateTime = (dateStr: string | Date | null | undefined): string => { if (!dateStr) return '-' const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr @@ -294,6 +265,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { currentAttempt !== undefined ? executions.filter((execution) => execution.attemptNumber === currentAttempt) : executions + const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status) const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => { const values = filteredOrders @@ -338,13 +310,11 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
状态
- {statusIcons[batch.status] || statusIcons.pending} + {batchStatusDisplay.icon} - {statusLabels[batch.status] || batch.status} + {batchStatusDisplay.label}
@@ -495,6 +465,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { const materials = orderMaterials.get(orderKey) || [] const isLoadingMaterials = loadingMaterials.has(orderKey) const materialLoadState = materialLoadStates.get(orderKey) ?? 'idle' + const orderStatusDisplay = getCleanerHistoryStatusDisplay(order.status) return ( @@ -525,12 +496,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { - {statusIcons[order.status]} - {statusLabels[order.status] || order.status} + {orderStatusDisplay.icon} + {orderStatusDisplay.label} @@ -601,79 +570,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { {materials.map((mat, idx) => ( - - - {idx + 1} - - - {mat.materialCode} - - - {mat.materialName} - - - {mat.rowNumber} - - - {mat.result === 'success' || mat.result === 'deleted' ? ( - - - - ) : mat.result === 'skipped' ? ( - - - - ) : mat.result === 'uncertain' ? ( - - - - ) : mat.result?.startsWith('failed') ? ( - - - - ) : null} - - - {mat.reason || '-'} - - - {mat.attemptCount > 1 ? ( - - {mat.attemptCount} - - ) : ( - '1' - )} - - + ))} @@ -700,6 +597,41 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { BatchItem.displayName = 'BatchItem' +interface MaterialDetailRowProps { + index: number + material: CleanerHistoryMaterialRecord +} + +const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.JSX.Element => { + const resultDisplay = getCleanerMaterialResultDisplay(material.result) + + return ( + + {index + 1} + {material.materialCode} + {material.materialName} + {material.rowNumber} + + {resultDisplay.icon ? ( + + {resultDisplay.icon} + + ) : ( + {resultDisplay.title} + )} + + {material.reason || '-'} + + {material.attemptCount > 1 ? ( + {material.attemptCount} + ) : ( + '1' + )} + + + ) +} + // ====== Main Modal Component ====== export const CleanerOperationHistoryModal: React.FC = ({ isOpen, diff --git a/src/renderer/src/components/cleaner-history-status.tsx b/src/renderer/src/components/cleaner-history-status.tsx new file mode 100644 index 0000000..ce99162 --- /dev/null +++ b/src/renderer/src/components/cleaner-history-status.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { AlertTriangle, CheckCircle, CircleMinus, Clock, XCircle } from 'lucide-react' + +export type CleanerHistoryStatus = + | 'success' + | 'partial' + | 'failed' + | 'crashed' + | 'pending' + | 'not_found' + | 'erp_not_found' + +export interface CleanerHistoryStatusDisplay { + label: string + badgeClassName: string + icon: React.ReactNode +} + +export interface CleanerMaterialResultDisplay { + title: string + icon: React.ReactNode | null +} + +const HISTORY_STATUS_META: Record< + CleanerHistoryStatus, + { label: string; badgeClassName: string; iconClassName: string; icon: typeof CheckCircle } +> = { + success: { + label: '成功', + badgeClassName: 'bg-green-100 text-green-700', + iconClassName: 'text-green-600', + icon: CheckCircle + }, + partial: { + label: '部分成功', + badgeClassName: 'bg-amber-100 text-amber-700', + iconClassName: 'text-amber-600', + icon: Clock + }, + failed: { + label: '失败', + badgeClassName: 'bg-red-100 text-red-700', + iconClassName: 'text-red-600', + icon: XCircle + }, + crashed: { + label: '崩溃', + badgeClassName: 'bg-red-100 text-red-700', + iconClassName: 'text-red-600', + icon: XCircle + }, + pending: { + label: '进行中', + badgeClassName: 'bg-gray-100 text-gray-700', + iconClassName: 'text-gray-500', + icon: Clock + }, + not_found: { + label: '未找到', + badgeClassName: 'bg-orange-100 text-orange-700', + iconClassName: 'text-orange-600', + icon: XCircle + }, + erp_not_found: { + label: 'ERP不存在', + badgeClassName: 'bg-orange-100 text-orange-700', + iconClassName: 'text-orange-600', + icon: XCircle + } +} + +export function getCleanerHistoryStatusDisplay(status: string, size = 16): CleanerHistoryStatusDisplay { + const meta = HISTORY_STATUS_META[(status in HISTORY_STATUS_META ? status : 'pending') as CleanerHistoryStatus] + const Icon = meta.icon + + return { + label: status in HISTORY_STATUS_META ? meta.label : status, + badgeClassName: meta.badgeClassName, + icon: + } +} + +export function getCleanerMaterialResultDisplay( + result: string | null | undefined, + size = 16 +): CleanerMaterialResultDisplay { + if (result === 'success' || result === 'deleted') { + return { + title: 'Deleted', + icon: + } + } + + if (result === 'skipped') { + return { + title: 'Skipped', + icon: + } + } + + if (result === 'uncertain') { + return { + title: 'Uncertain', + icon: + } + } + + if (result?.startsWith('failed')) { + return { + title: 'Failed', + icon: + } + } + + return { + title: result || 'Unknown', + icon: null + } +} diff --git a/tests/unit/cleaner-history-status.test.tsx b/tests/unit/cleaner-history-status.test.tsx new file mode 100644 index 0000000..ec593d1 --- /dev/null +++ b/tests/unit/cleaner-history-status.test.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { + getCleanerHistoryStatusDisplay, + getCleanerMaterialResultDisplay +} from '../../src/renderer/src/components/cleaner-history-status' + +describe('cleaner history status helpers', () => { + it('returns shared label, badge class and icon for known statuses', () => { + const display = getCleanerHistoryStatusDisplay('erp_not_found') + + expect(display.label).toBe('ERP不存在') + expect(display.badgeClassName).toBe('bg-orange-100 text-orange-700') + expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain( + 'text-orange-600' + ) + }) + + it('falls back to pending style for unknown statuses while preserving text', () => { + const display = getCleanerHistoryStatusDisplay('custom_status') + + expect(display.label).toBe('custom_status') + expect(display.badgeClassName).toBe('bg-gray-100 text-gray-700') + }) + + it('maps failed material outcomes through the shared helper', () => { + const display = getCleanerMaterialResultDisplay('failed_timeout') + + expect(display.title).toBe('Failed') + expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain( + 'text-red-600' + ) + }) + + it('returns plain text for unknown material outcomes', () => { + const display = getCleanerMaterialResultDisplay('needs_manual_check') + + expect(display.title).toBe('needs_manual_check') + expect(display.icon).toBeNull() + }) +})