refactor: unify cleaner history status display
This commit is contained in:
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
@@ -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` 的优先级顺序。
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
success: '成功',
|
||||
partial: '部分成功',
|
||||
failed: '失败',
|
||||
crashed: '崩溃',
|
||||
pending: '进行中',
|
||||
not_found: '未找到',
|
||||
erp_not_found: 'ERP不存在'
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
success: <CheckCircle size={16} className="text-green-600" />,
|
||||
partial: <Clock size={16} className="text-amber-600" />,
|
||||
failed: <XCircle size={16} className="text-red-600" />,
|
||||
crashed: <XCircle size={16} className="text-red-600" />,
|
||||
pending: <Clock size={16} className="text-gray-500" />,
|
||||
not_found: <XCircle size={16} className="text-orange-600" />,
|
||||
erp_not_found: <XCircle size={16} className="text-orange-600" />
|
||||
}
|
||||
|
||||
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) => {
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">状态</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{statusIcons[batch.status] || statusIcons.pending}
|
||||
{batchStatusDisplay.icon}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[batch.status] || statusStyles.pending
|
||||
}`}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
|
||||
>
|
||||
{statusLabels[batch.status] || batch.status}
|
||||
{batchStatusDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<React.Fragment key={orderKey}>
|
||||
@@ -525,12 +496,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[order.status] || statusStyles.pending
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${orderStatusDisplay.badgeClassName}`}
|
||||
>
|
||||
{statusIcons[order.status]}
|
||||
{statusLabels[order.status] || order.status}
|
||||
{orderStatusDisplay.icon}
|
||||
{orderStatusDisplay.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -601,79 +570,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{materials.map((mat, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">
|
||||
{mat.materialCode}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">
|
||||
{mat.materialName}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.rowNumber}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{mat.result === 'success' || mat.result === 'deleted' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Deleted"
|
||||
>
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className="text-green-600 flex-shrink-0"
|
||||
aria-label="Deleted"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result === 'skipped' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Skipped"
|
||||
>
|
||||
<CircleMinus
|
||||
size={16}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
aria-label="Skipped"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result === 'uncertain' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Uncertain"
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="text-amber-600 flex-shrink-0"
|
||||
aria-label="Uncertain"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result?.startsWith('failed') ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Failed"
|
||||
>
|
||||
<XCircle
|
||||
size={16}
|
||||
className="text-red-600 flex-shrink-0"
|
||||
aria-label="Failed"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
|
||||
{mat.reason || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">
|
||||
{mat.attemptCount}
|
||||
</span>
|
||||
) : (
|
||||
'1'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<MaterialDetailRow key={idx} index={idx} material={mat} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">{material.materialCode}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{resultDisplay.icon ? (
|
||||
<span className="inline-block cursor-help" title={resultDisplay.title}>
|
||||
{resultDisplay.icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{resultDisplay.title}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">{material.reason || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{material.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">{material.attemptCount}</span>
|
||||
) : (
|
||||
'1'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== Main Modal Component ======
|
||||
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
|
||||
isOpen,
|
||||
|
||||
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
@@ -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: <Icon size={size} className={meta.iconClassName} />
|
||||
}
|
||||
}
|
||||
|
||||
export function getCleanerMaterialResultDisplay(
|
||||
result: string | null | undefined,
|
||||
size = 16
|
||||
): CleanerMaterialResultDisplay {
|
||||
if (result === 'success' || result === 'deleted') {
|
||||
return {
|
||||
title: 'Deleted',
|
||||
icon: <CheckCircle size={size} className="text-green-600 flex-shrink-0" aria-label="Deleted" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'skipped') {
|
||||
return {
|
||||
title: 'Skipped',
|
||||
icon: <CircleMinus size={size} className="text-gray-400 flex-shrink-0" aria-label="Skipped" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'uncertain') {
|
||||
return {
|
||||
title: 'Uncertain',
|
||||
icon: <AlertTriangle size={size} className="text-amber-600 flex-shrink-0" aria-label="Uncertain" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result?.startsWith('failed')) {
|
||||
return {
|
||||
title: 'Failed',
|
||||
icon: <XCircle size={size} className="text-red-600 flex-shrink-0" aria-label="Failed" />
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: result || 'Unknown',
|
||||
icon: null
|
||||
}
|
||||
}
|
||||
42
tests/unit/cleaner-history-status.test.tsx
Normal file
42
tests/unit/cleaner-history-status.test.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user