10 Commits

Author SHA1 Message Date
Misaka_Company
bb495c7a93 1.12.4 2026-04-15 15:44:08 +08:00
Misaka_Company
33ffc0406d docs: add release notes for version 1.12.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 15:42:53 +08:00
Misaka
8fa4d6c16d fix: support postgres material upserts without unique constraints 2026-04-14 21:55:05 +08:00
Misaka
cb59dda727 refactor: unify operation history delete dialogs 2026-04-14 21:23:54 +08:00
Misaka
fbcaa11b1c refactor: unify cleaner history status display 2026-04-14 21:16:05 +08:00
Misaka
b5b8af078d feat: improve cleaner history pagination and report states 2026-04-14 21:10:05 +08:00
Misaka
5b43d5a60c fix: restore cleaner history in postgresql 2026-04-14 20:49:29 +08:00
Misaka_Company
936c98a023 1.12.3 2026-04-14 15:37:09 +08:00
Misaka_Company
c661a12287 docs: add release notes for version 1.12.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:36:53 +08:00
Misaka_Company
1cd6660774 chore: remove unused playwright config and debug scripts
Remove playwright.config.ts (no longer using Playwright for E2E),
and delete test-s3-playwright.js / test-s3-playwright-simple.js
(one-off S3 debug scripts).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:33:25 +08:00
23 changed files with 1485 additions and 300 deletions

View 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` 的优先级顺序。

5
docs/releases/1.12.3.md Normal file
View File

@@ -0,0 +1,5 @@
# 1.12.3
## 内部优化
- 清理项目根目录无用文件,移除已弃用的 Playwright 配置和调试脚本。

11
docs/releases/1.12.4.md Normal file
View File

@@ -0,0 +1,11 @@
# 1.12.4
## 问题修复
- 修复清理器操作历史在 PostgreSQL 数据库下无法正常加载的问题。
- 修复 PostgreSQL 环境下物料数据写入失败的问题,支持无唯一约束的表。
## 改进
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "erpauto",
"version": "1.12.2",
"version": "1.12.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.12.2",
"version": "1.12.4",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.12.2",
"version": "1.12.4",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",

View File

@@ -1,23 +0,0 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 120000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
// Test configuration for Electron
projects: [
{
name: 'electron',
testMatch: '**/*.test.ts'
}
]
})

View File

@@ -122,6 +122,15 @@ export class CleanerOperationHistoryDAO {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
}
private getIsDryRunAggregateSql(): string {
const dialect = this.getDialect()
if (dialect.dbType === 'postgresql') {
return `MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END)`
}
return `MAX(CAST(e.IsDryRun AS INT))`
}
/**
* Get database service instance using DatabaseFactory
*/
@@ -619,9 +628,9 @@ export class CleanerOperationHistoryDAO {
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted,
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed,
MAX(CAST(e.IsDryRun AS INT)) as IsDryRun,
ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
ISNULL(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
${this.getIsDryRunAggregateSql()} as IsDryRun,
COALESCE(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
COALESCE(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
FROM ${execTable} e
INNER JOIN (
SELECT BatchId, MAX(AttemptNumber) as max_attempt
@@ -906,27 +915,13 @@ export class CleanerOperationHistoryDAO {
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
// Check if batch exists
const checkSql = `
SELECT TOP 1 UserId
FROM ${execTable}
WHERE BatchId = ${dialect.param(0)}
`
const checkResult = await trackDuration(
async () => await dbService.query(checkSql, [batchId]),
{
operationName: 'CleanerOperationHistoryDAO.deleteBatch.check',
context: { operationType: 'SELECT', batchId }
}
)
if (checkResult.result.rows.length === 0) {
const details = await this.getBatchDetails(batchId)
if (details.executions.length === 0) {
return { success: false, error: '批次不存在' }
}
// Permission check: non-admin can only delete own batches
const batchUserId = checkResult.result.rows[0].UserId as number
const batchUserId = details.executions[0].userId
if (!isAdmin && batchUserId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' }
}

View File

@@ -110,6 +110,58 @@ export class MaterialsToBeDeletedDAO {
const manager = managerName?.trim() || null
const dialect = this.getDialect()
if (dbService.type === 'postgresql') {
const updateSql = `
UPDATE ${tableName}
SET ManagerName = ${dialect.param(0)}
WHERE MaterialCode = ${dialect.param(1)}
`
const updateResult = await trackDuration(
async () => await dbService.query(updateSql, [manager, code]),
{
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' }
}
)
if (updateResult.result.rowCount > 0) {
return true
}
const insertSql = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
SELECT ${dialect.param(0)}, ${dialect.param(1)}
WHERE NOT EXISTS (
SELECT 1
FROM ${tableName}
WHERE MaterialCode = ${dialect.param(0)}
)
`
const insertResult = await trackDuration(
async () => await dbService.query(insertSql, [code, manager]),
{
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' }
}
)
if (insertResult.result.rowCount > 0) {
return true
}
const retryUpdateResult = await trackDuration(
async () => await dbService.query(updateSql, [manager, code]),
{
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' }
}
)
return retryUpdateResult.result.rowCount > 0
}
const { sql: sqlString } = dialect.upsert({
table: tableName,
keyColumns: ['MaterialCode'],
@@ -177,22 +229,12 @@ export class MaterialsToBeDeletedDAO {
}
try {
const { sql: sqlString } = dialect.upsert({
table: tableName,
keyColumns: ['MaterialCode'],
allColumns: ['MaterialCode', 'ManagerName'],
startParamIndex: 0
})
await trackDuration(
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
{
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
context: { tableName, operationType: 'UPSERT', batchId }
}
)
stats.success++
const success = await this.upsertMaterial(materialCode, managerName)
if (success) {
stats.success++
} else {
stats.failed++
}
} catch (error) {
log.error('Error upserting material', {
tableName,
@@ -201,7 +243,6 @@ export class MaterialsToBeDeletedDAO {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
stats.failed++
}
}
@@ -238,19 +279,8 @@ export class MaterialsToBeDeletedDAO {
managerName: string
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const dialect = this.getDialect()
const { sql: sqlString } = dialect.upsert({
table: tableName,
keyColumns: ['MaterialCode'],
allColumns: ['MaterialCode', 'ManagerName'],
startParamIndex: 0
})
await dbService.query(sqlString, [materialCode, managerName || null])
return { success: true }
const success = await this.upsertMaterial(materialCode, managerName)
return { success }
} catch (error) {
log.error('Update manager error', {
materialCode,

View File

@@ -210,6 +210,58 @@ export class MaterialsTypeToBeDeletedDAO {
const manager = managerName?.trim() || null
const dialect = this.getDialect()
if (dbService.type === 'postgresql') {
const updateSql = `
UPDATE ${tableName}
SET ManagerName = ${dialect.param(0)}
WHERE MaterialName = ${dialect.param(1)}
`
const updateResult = await trackDuration(
async () => await dbService.query(updateSql, [manager, name]),
{
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' }
}
)
if (updateResult.result.rowCount > 0) {
return true
}
const insertSql = `
INSERT INTO ${tableName} (MaterialName, ManagerName)
SELECT ${dialect.param(0)}, ${dialect.param(1)}
WHERE NOT EXISTS (
SELECT 1
FROM ${tableName}
WHERE MaterialName = ${dialect.param(0)}
)
`
const insertResult = await trackDuration(
async () => await dbService.query(insertSql, [name, manager]),
{
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' }
}
)
if (insertResult.result.rowCount > 0) {
return true
}
const retryUpdateResult = await trackDuration(
async () => await dbService.query(updateSql, [manager, name]),
{
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' }
}
)
return retryUpdateResult.result.rowCount > 0
}
const { sql: sqlString } = dialect.upsert({
table: tableName,
keyColumns: ['MaterialName'],

View File

@@ -6,8 +6,10 @@
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useTransition } from 'react'
import { Modal } from './ui/Modal'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
import { useLogger } from '../hooks/useLogger'
import {
RefreshCw,
@@ -16,9 +18,6 @@ import {
ChevronRight,
CheckCircle,
XCircle,
CircleMinus,
AlertTriangle,
Clock,
Copy,
FlaskConical
} from 'lucide-react'
@@ -28,6 +27,15 @@ import type {
CleanerHistoryOrderRecord,
CleanerHistoryMaterialRecord
} from '../hooks/cleaner/types'
import {
canStartHistoryLoad,
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.
@@ -62,37 +70,10 @@ interface BatchItemProps {
batch: CleanerHistoryBatchStats
isAdmin: boolean
onDelete: (batchId: string) => void
onRequestDelete: (batchId: string) => Promise<boolean>
}
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 BATCH_PAGE_SIZE = 5
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
if (!dateStr) return '-'
@@ -135,7 +116,7 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
// ====== BatchItem Component ======
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
// Each BatchItem manages its own details, orders, and material state locally.
const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: BatchItemProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
@@ -145,46 +126,53 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
() => new Map()
)
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
const [materialLoadStates, setMaterialLoadStates] = useState<Map<string, HistoryLoadState>>(
() => new Map()
)
const [detailsLoadState, setDetailsLoadState] = useState<HistoryLoadState>('idle')
const [isDeleting, setIsDeleting] = useState(false)
const detailsLoadedRef = useRef(false)
const loadedMaterialsRef = useRef<Set<string>>(new Set())
const logger = useLogger('BatchItem')
// Fetch batch details when first expanded
useEffect(() => {
if (!isExpanded || detailsLoadedRef.current) return
detailsLoadedRef.current = true
const fetchDetails = useCallback(async () => {
if (!canStartHistoryLoad(detailsLoadState)) return
const fetchDetails = async () => {
try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
if (result.success && result.data) {
setExecutions(result.data.executions)
setOrders(result.data.orders)
setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start'))
try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
if (result.success && result.data) {
setExecutions(result.data.executions)
setOrders(result.data.orders)
const execs = result.data.executions
if (execs.length > 0) {
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
}
const execs = result.data.executions
if (execs.length > 0) {
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
}
} catch (err) {
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
}
}
void fetchDetails()
}, [isExpanded, batch.batchId, logger])
setDetailsLoadState('success')
} else {
setDetailsLoadState('error')
}
} catch (err) {
setDetailsLoadState('error')
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
}
}, [batch.batchId, detailsLoadState, logger])
const fetchMaterials = useCallback(
async (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}`
if (loadedMaterialsRef.current.has(cacheKey)) return
loadedMaterialsRef.current.add(cacheKey)
const loadState = materialLoadStates.get(cacheKey) ?? 'idle'
if (!canStartHistoryLoad(loadState)) return
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start'))
return next
})
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
try {
const result = await window.electron.cleaner.getHistoryMaterialDetails(
@@ -194,8 +182,24 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
)
if (result.success && result.data) {
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'success')
return next
})
} else {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
}
} catch (err) {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
logger.error('Failed to fetch material details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId,
@@ -210,9 +214,17 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
})
}
},
[batch.batchId, logger]
[batch.batchId, logger, materialLoadStates]
)
const toggleBatchExpansion = () => {
const nextExpanded = !isExpanded
setIsExpanded(nextExpanded)
if (nextExpanded) {
void fetchDetails()
}
}
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}`
const isCurrentlyExpanded = expandedOrders.has(cacheKey)
@@ -232,7 +244,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const handleDelete = async () => {
if (isDeleting) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
const confirmed = await onRequestDelete(batch.batchId)
if (!confirmed) return
setIsDeleting(true)
@@ -250,12 +262,16 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
}
}
const filteredOrders =
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
const visibleExecutions =
currentAttempt !== undefined
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
: executions
const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status)
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
const filtered =
currentAttempt !== undefined
? orders.filter((o) => o.attemptNumber === currentAttempt)
: orders
const values = filtered
const values = filteredOrders
.map((o) => String(o[field] ?? ''))
.filter((v) => v && v !== '-')
.join('\n')
@@ -271,9 +287,6 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
.catch(() => showError('复制失败,请手动复制'))
}
const filteredOrders =
currentAttempt !== undefined ? orders.filter((o) => o.attemptNumber === currentAttempt) : orders
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Batch summary */}
@@ -281,7 +294,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => setIsExpanded((prev) => !prev)}
onClick={toggleBatchExpansion}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
@@ -300,13 +313,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>
@@ -384,38 +395,42 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
</div>
)}
<div className="flex flex-wrap gap-4 text-xs text-gray-600">
{executions
.filter((e) => currentAttempt === undefined || e.attemptNumber === currentAttempt)
.map((exec) => (
<React.Fragment key={exec.attemptNumber}>
<span>{formatDuration(exec.operationTime, exec.endTime)}</span>
<span>
{exec.ordersProcessed}/{exec.totalOrders}
{visibleExecutions.map((exec) => (
<React.Fragment key={exec.attemptNumber}>
<span>{formatDuration(exec.operationTime, exec.endTime)}</span>
<span>
{exec.ordersProcessed}/{exec.totalOrders}
</span>
<span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span>
<span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span>
)}
{exec.errorMessage && (
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
</React.Fragment>
))}
)}
{exec.errorMessage && (
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
</React.Fragment>
))}
</div>
</div>
)}
{/* Order table */}
{filteredOrders.length > 0 ? (
{detailsLoadState === 'loading' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-gray-500">...</div>
) : detailsLoadState === 'error' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-red-600">
</div>
) : filteredOrders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
@@ -452,6 +467,8 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const isOrderExpanded = expandedOrders.has(orderKey)
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}>
@@ -482,12 +499,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">
@@ -531,6 +546,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
<td colSpan={11} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? (
<div className="text-xs text-gray-500">...</div>
) : materialLoadState === 'error' ? (
<div className="text-xs text-red-600">
</div>
) : materials.length > 0 ? (
<table className="w-full text-xs">
<thead>
@@ -554,79 +573,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>
@@ -653,6 +600,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,
@@ -664,9 +646,14 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
const [error, setError] = useState<string | null>(null)
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [currentPage, setCurrentPage] = useState(0)
const [isFilterPending, startFilterTransition] = useTransition()
const { confirm, dialog: confirmDialog } = useConfirmDialog()
const logger = useLogger('CleanerOperationHistory')
const isAdmin = user?.userType === 'Admin'
const hasPreviousPage = currentPage > 0
const hasNextPage = batches.length === BATCH_PAGE_SIZE
const fetchBatches = useCallback(async () => {
setLoading(true)
@@ -674,8 +661,12 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
try {
const options =
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
? {
limit: BATCH_PAGE_SIZE,
offset: currentPage * BATCH_PAGE_SIZE,
usernames: selectedUsers
}
: { limit: BATCH_PAGE_SIZE, offset: currentPage * BATCH_PAGE_SIZE }
const result = await window.electron.cleaner.getHistoryBatches(options)
if (result.success && result.data) {
@@ -688,7 +679,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
} finally {
setLoading(false)
}
}, [isAdmin, selectedUsers])
}, [currentPage, isAdmin, selectedUsers])
const fetchAllUsers = useCallback(async () => {
try {
@@ -717,14 +708,53 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
}, [])
const requestDeleteConfirmation = useCallback(
async (batchId: string) => {
const batch = batches.find((item) => item.batchId === batchId)
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
return confirm({
title: '确认删除历史批次',
message: (
<div className="space-y-3">
<p className="text-gray-700"></p>
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
<div></div>
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
</div>
</div>
),
confirmText: '删除',
cancelText: '取消',
variant: 'danger'
})
},
[batches, confirm]
)
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
startFilterTransition(() => {
setCurrentPage(0)
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
})
}
const clearUserFilters = () => {
setSelectedUsers([])
startFilterTransition(() => {
setCurrentPage(0)
setSelectedUsers([])
})
}
const goToPreviousPage = () => {
setCurrentPage((prev) => Math.max(0, prev - 1))
}
const goToNextPage = () => {
if (!hasNextPage) return
setCurrentPage((prev) => prev + 1)
}
if (!isOpen) return null
@@ -786,8 +816,9 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
{isFilterPending && <span className="text-sm text-blue-600">...</span>}
</div>
</div>
<button
@@ -821,6 +852,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
batch={batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
onRequestDelete={requestDeleteConfirmation}
/>
))}
</div>
@@ -828,16 +860,29 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
</div>
{/* Footer */}
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
<div className="pt-4 border-t border-gray-200 flex justify-center">
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
<button
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={goToPreviousPage}
disabled={loading || !hasPreviousPage}
>
</button>
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
{currentPage + 1}
</div>
<button
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={goToNextPage}
disabled={loading || !hasNextPage}
>
</button>
</div>
</div>
</div>
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal>
)
}

View File

@@ -11,6 +11,7 @@ import React from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react'
import { Modal } from './ui/Modal'
import type { CleanerProgress } from '../hooks/cleaner/types'
import { getExecutionReportState } from './execution-report-state'
interface ExecutionReportDialogProps {
isOpen: boolean
@@ -56,6 +57,12 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
const showProgress = isExecuting && progress
const isProgressing = !!showProgress
const reportState = getExecutionReportState({
dryRun,
errors,
materialsFailed,
uncertainDeletions
})
// Update timer during progress
React.useEffect(() => {
@@ -123,11 +130,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
title={
isProgressing
? '正在执行清理...'
: dryRun
? '预览执行报告'
: hasErrors
? '执行完成 (有错误)'
: '执行完成'
: reportState.title
}
size={isProgressing ? 'lg' : 'md'}
showCloseButton={!isExecuting}
@@ -161,19 +164,17 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
<div className="flex justify-center mb-4">
{dryRun ? (
<Package className="w-12 h-12 text-amber-500" />
) : hasErrors ? (
) : reportState.state === 'failure' ? (
<XCircle className="w-12 h-12 text-red-500" />
) : reportState.state === 'partial_success' ? (
<AlertTriangle className="w-12 h-12 text-amber-500" />
) : reportState.state === 'manual_review' ? (
<AlertTriangle className="w-12 h-12 text-yellow-500" />
) : (
<CheckCircle className="w-12 h-12 text-green-500" />
)}
</div>
<p className="text-sm text-gray-600">
{dryRun
? '预览模式 - 未实际删除数据'
: hasErrors
? '部分操作未能完成,请查看下方错误信息'
: '所有操作已成功完成'}
</p>
<p className="text-sm text-gray-600">{reportState.summary}</p>
</div>
)}
@@ -405,19 +406,33 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div>
)}
{!hasErrors && !dryRun && (
{reportState.showSuccessBanner && (
<div className="flex items-center justify-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 text-green-700 text-sm">
<CheckCircle size={16} className="flex-shrink-0" />
<span> ERP </span>
</div>
)}
{!hasErrors && dryRun && (
{reportState.showPreviewBanner && (
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
<Package size={16} className="flex-shrink-0" />
<span></span>
</div>
)}
{reportState.state === 'partial_success' && (
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
<AlertTriangle size={16} className="flex-shrink-0" />
<span></span>
</div>
)}
{reportState.state === 'manual_review' && (
<div className="flex items-center justify-center gap-2 p-3 bg-yellow-50 rounded-lg border border-yellow-200 text-yellow-700 text-sm">
<AlertTriangle size={16} className="flex-shrink-0" />
<span> ERP </span>
</div>
)}
</>
)}
</Modal>

View File

@@ -7,6 +7,8 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
import { useLogger } from '../hooks/useLogger'
import {
RefreshCw,
@@ -83,6 +85,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const [deleting, setDeleting] = useState<Set<string>>(new Set())
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const { confirm, dialog: confirmDialog } = useConfirmDialog()
const logger = useLogger('OperationHistory')
const isAdmin = user?.userType === 'Admin'
@@ -172,7 +175,24 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const handleDeleteBatch = async (batchId: string) => {
if (deleting.has(batchId)) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
const batch = batches.find((item) => item.batchId === batchId)
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
const confirmed = await confirm({
title: '确认删除提取历史',
message: (
<div className="space-y-3">
<p className="text-gray-700"></p>
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
<div></div>
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
</div>
</div>
),
confirmText: '删除',
cancelText: '取消',
variant: 'danger'
})
if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId))
@@ -503,6 +523,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
</button>
</div>
</div>
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal>
)
}

View File

@@ -0,0 +1,19 @@
export type HistoryLoadState = 'idle' | 'loading' | 'success' | 'error'
export const canStartHistoryLoad = (state: HistoryLoadState): boolean =>
state === 'idle' || state === 'error'
export const getNextHistoryLoadState = (
currentState: HistoryLoadState,
event: 'start' | 'success' | 'error'
): HistoryLoadState => {
if (event === 'start') {
return canStartHistoryLoad(currentState) ? 'loading' : currentState
}
if (event === 'success') {
return 'success'
}
return 'error'
}

View 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
}
}

View File

@@ -0,0 +1,80 @@
export type ExecutionReportResultState =
| 'preview'
| 'success'
| 'partial_success'
| 'manual_review'
| 'failure'
export interface ExecutionReportStateInput {
dryRun?: boolean
errors?: string[]
materialsFailed?: number
uncertainDeletions?: number
}
export interface ExecutionReportStateDescriptor {
state: ExecutionReportResultState
title: string
summary: string
showSuccessBanner: boolean
showPreviewBanner: boolean
}
export function getExecutionReportState(
input: ExecutionReportStateInput
): ExecutionReportStateDescriptor {
const {
dryRun = false,
errors = [],
materialsFailed = 0,
uncertainDeletions = 0
} = input
if (dryRun) {
return {
state: 'preview',
title: '预览执行报告',
summary: '预览模式 - 未实际删除数据',
showSuccessBanner: false,
showPreviewBanner: true
}
}
if (errors.length > 0) {
return {
state: 'failure',
title: '执行完成 (失败)',
summary: '执行过程中出现错误,请先处理错误后再继续。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
if (materialsFailed > 0) {
return {
state: 'partial_success',
title: '执行完成 (部分成功)',
summary: '部分物料删除失败,请结合下方统计和历史记录继续排查。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
if (uncertainDeletions > 0) {
return {
state: 'manual_review',
title: '执行完成 (需人工确认)',
summary: '存在不确定删除结果,请人工复核后再判断是否完成。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
return {
state: 'success',
title: '执行完成',
summary: '所有操作已成功完成',
showSuccessBanner: true,
showPreviewBanner: false
}
}

View File

@@ -167,17 +167,19 @@ const CleanerPage: React.FC = () => {
/>
</Suspense>
<Suspense fallback={null}>
<CleanerOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={
currentUsername
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
: null
}
/>
</Suspense>
{showHistoryModal ? (
<Suspense fallback={null}>
<CleanerOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={
currentUsername
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
: null
}
/>
</Suspense>
) : null}
{/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
canStartHistoryLoad,
getNextHistoryLoadState
} from '../../src/renderer/src/components/cleaner-history-load-state'
describe('cleaner history load state helpers', () => {
it('allows initial and failed loads to retry', () => {
expect(canStartHistoryLoad('idle')).toBe(true)
expect(canStartHistoryLoad('error')).toBe(true)
})
it('prevents duplicate requests after loading starts or succeeds', () => {
expect(canStartHistoryLoad('loading')).toBe(false)
expect(canStartHistoryLoad('success')).toBe(false)
})
it('retries after an error but keeps successful loads cached', () => {
const failedState = getNextHistoryLoadState('loading', 'error')
const retryState = getNextHistoryLoadState(failedState, 'start')
const successState = getNextHistoryLoadState(retryState, 'success')
const blockedState = getNextHistoryLoadState(successState, 'start')
expect(failedState).toBe('error')
expect(retryState).toBe('loading')
expect(successState).toBe('success')
expect(blockedState).toBe('success')
})
})

View 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()
})
})

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { mockHistoryModalModuleLoad } = vi.hoisted(() => ({
mockHistoryModalModuleLoad: vi.fn()
}))
vi.mock('../../src/renderer/src/hooks/useCleaner', () => ({
useCleaner: () => ({
isAdmin: false,
currentUsername: 'tester',
dryRun: false,
setDryRun: vi.fn(),
valMode: 'database_full',
setValMode: vi.fn(),
validationResults: [],
selectedItems: new Set<string>(),
setSelectedItems: vi.fn(),
setHiddenItems: vi.fn(),
managers: [],
selectedManagers: [],
setSelectedManagers: vi.fn(),
isRunning: false,
isExecuting: false,
isValidationRunning: false,
isExporting: false,
isTypeDialogOpen: false,
setIsTypeDialogOpen: vi.fn(),
headless: false,
setHeadless: vi.fn(),
processConcurrency: 1,
updateProcessConcurrency: vi.fn(),
showSettingsMenu: false,
setShowSettingsMenu: vi.fn(),
filteredResults: [],
isReportDialogOpen: false,
setIsReportDialogOpen: vi.fn(),
reportData: null,
editingCell: null,
editValue: '',
setEditValue: vi.fn(),
inputRef: { current: null },
startEdit: vi.fn(),
saveEdit: vi.fn(),
cancelEdit: vi.fn(),
handleAssignManagerOnSelect: vi.fn(),
progress: null,
startTime: null,
resetStartTime: vi.fn(),
handleValidation: vi.fn(),
handleCheckboxToggle: vi.fn(),
handleConfirmDeletion: vi.fn(),
handleExecuteDeletion: vi.fn(),
handleExportResults: vi.fn(),
confirmDialog: null
})
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerExecutionBar', () => ({
CleanerExecutionBar: () => React.createElement('div', null, 'execution-bar')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerResultsTable', () => ({
CleanerResultsTable: () => React.createElement('div', null, 'results-table')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerSidebar', () => ({
CleanerSidebar: () => React.createElement('aside', null, 'sidebar')
}))
vi.mock('../../src/renderer/src/components/cleaner/CleanerToolbar', () => ({
CleanerToolbar: () => React.createElement('div', null, 'toolbar')
}))
vi.mock('../../src/renderer/src/components/ui/ConfirmDialog', () => ({
ConfirmDialog: () => React.createElement('div', null, 'confirm-dialog')
}))
vi.mock('../../src/renderer/src/components/MaterialTypeManagementDialog', () => ({
default: () => React.createElement('div', null, 'type-dialog')
}))
vi.mock('../../src/renderer/src/components/ExecutionReportDialog', () => ({
default: () => React.createElement('div', null, 'report-dialog')
}))
vi.mock('../../src/renderer/src/components/CleanerOperationHistoryModal', () => {
mockHistoryModalModuleLoad()
return {
default: () => React.createElement('div', null, 'history-dialog')
}
})
import CleanerPage from '../../src/renderer/src/pages/CleanerPage'
describe('CleanerPage history modal lazy loading', () => {
beforeEach(() => {
mockHistoryModalModuleLoad.mockClear()
})
it('does not load the history modal module on the initial render', () => {
renderToStaticMarkup(React.createElement(CleanerPage))
expect(mockHistoryModalModuleLoad).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { getExecutionReportState } from '../../src/renderer/src/components/execution-report-state'
describe('execution report state helpers', () => {
it('treats dry-run as preview regardless of counters', () => {
const state = getExecutionReportState({
dryRun: true,
errors: ['should be ignored'],
materialsFailed: 1,
uncertainDeletions: 1
})
expect(state.state).toBe('preview')
expect(state.title).toBe('预览执行报告')
})
it('treats runtime errors as failure', () => {
const state = getExecutionReportState({
errors: ['boom'],
materialsFailed: 0,
uncertainDeletions: 0
})
expect(state.state).toBe('failure')
expect(state.title).toBe('执行完成 (失败)')
})
it('treats failed materials as partial success when there are no runtime errors', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 3,
uncertainDeletions: 0
})
expect(state.state).toBe('partial_success')
expect(state.showSuccessBanner).toBe(false)
})
it('treats uncertain deletions as manual review when everything else succeeded', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 0,
uncertainDeletions: 2
})
expect(state.state).toBe('manual_review')
expect(state.showSuccessBanner).toBe(false)
})
it('treats clean completion as success', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 0,
uncertainDeletions: 0
})
expect(state.state).toBe('success')
expect(state.showSuccessBanner).toBe(true)
})
})

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const queryMock = vi.fn()
const createMock = vi.fn()
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
vi.mock('../../../../src/main/services/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}),
getRequestId: () => 'test-request-id',
trackDuration: trackDurationMock
}))
vi.mock('../../../../src/main/services/database/index', () => ({
create: createMock
}))
describe('CleanerOperationHistoryDAO (PostgreSQL compatibility)', () => {
beforeEach(() => {
vi.clearAllMocks()
createMock.mockResolvedValue({
type: 'postgresql',
isConnected: () => true,
query: queryMock,
disconnect: vi.fn()
})
})
it('uses PostgreSQL-compatible aggregation in getBatches', async () => {
queryMock.mockResolvedValue({
rows: [],
columns: [],
rowCount: 0
})
const { CleanerOperationHistoryDAO } = await import(
'../../../../src/main/services/database/cleaner-operation-history-dao'
)
const dao = new CleanerOperationHistoryDAO()
await dao.getBatches(undefined, { limit: 10 })
expect(queryMock).toHaveBeenCalledTimes(1)
const sql = queryMock.mock.calls[0][0] as string
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'success\' THEN 1 ELSE 0 END), 0)')
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'failed\' THEN 1 ELSE 0 END), 0)')
expect(sql).toContain('MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END) as IsDryRun')
expect(sql).not.toContain('ISNULL(')
})
it('avoids SQL Server TOP syntax when checking delete permissions', async () => {
queryMock
.mockResolvedValueOnce({
rows: [
{
ID: 1,
BatchId: 'batch-1',
AttemptNumber: 1,
UserId: 7,
Username: 'tester',
OperationTime: new Date('2026-04-14T10:00:00.000Z'),
EndTime: null,
Status: 'success',
IsDryRun: false,
TotalOrders: 1,
OrdersProcessed: 1,
TotalMaterialsDeleted: 1,
TotalMaterialsSkipped: 0,
TotalMaterialsFailed: 0,
TotalUncertainDeletions: 0,
ErrorMessage: null,
AppVersion: '1.12.3'
}
],
columns: [],
rowCount: 1
})
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 0
})
.mockResolvedValue({
rows: [],
columns: [],
rowCount: 1
})
const { CleanerOperationHistoryDAO } = await import(
'../../../../src/main/services/database/cleaner-operation-history-dao'
)
const dao = new CleanerOperationHistoryDAO()
const result = await dao.deleteBatch('batch-1', 7, false)
expect(result).toEqual({ success: true })
const executedSql = queryMock.mock.calls.map(([sql]) => sql as string).join('\n')
expect(executedSql).not.toContain('TOP 1')
expect(executedSql).toContain('FROM "ERPAuto"."CleanerExecution"')
expect(executedSql).toContain('DELETE FROM "ERPAuto"."CleanerMaterialDetail"')
})
})

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const queryMock = vi.fn()
const createMock = vi.fn()
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
vi.mock('../../../../src/main/services/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}),
getRequestId: () => 'test-request-id',
trackDuration: trackDurationMock
}))
vi.mock('../../../../src/main/services/database/index', () => ({
create: createMock
}))
describe('MaterialsToBeDeletedDAO (PostgreSQL compatibility)', () => {
beforeEach(() => {
vi.clearAllMocks()
createMock.mockResolvedValue({
type: 'postgresql',
isConnected: () => true,
query: queryMock,
disconnect: vi.fn()
})
})
it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => {
queryMock
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 0
})
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 1
})
const { MaterialsToBeDeletedDAO } = await import(
'../../../../src/main/services/database/materials-to-be-deleted-dao'
)
const dao = new MaterialsToBeDeletedDAO()
const result = await dao.upsertMaterial('M-001', 'tester')
expect(result).toBe(true)
expect(queryMock).toHaveBeenCalledTimes(2)
const [updateSql, updateParams] = queryMock.mock.calls[0]
const [insertSql, insertParams] = queryMock.mock.calls[1]
expect(updateSql).toContain('UPDATE "dbo"."MaterialsToBeDeleted"')
expect(updateSql).toContain('WHERE MaterialCode = $2')
expect(updateParams).toEqual(['tester', 'M-001'])
expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsToBeDeleted" (MaterialCode, ManagerName)')
expect(insertSql).toContain('WHERE NOT EXISTS')
expect(insertSql).not.toContain('ON CONFLICT')
expect(insertParams).toEqual(['M-001', 'tester'])
})
it('reuses the PostgreSQL-safe path in updateManager', async () => {
queryMock.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 1
})
const { MaterialsToBeDeletedDAO } = await import(
'../../../../src/main/services/database/materials-to-be-deleted-dao'
)
const dao = new MaterialsToBeDeletedDAO()
const result = await dao.updateManager('M-001', 'tester')
expect(result).toEqual({ success: true })
expect(queryMock).toHaveBeenCalledTimes(1)
expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsToBeDeleted"')
})
})

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const queryMock = vi.fn()
const createMock = vi.fn()
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
vi.mock('../../../../src/main/services/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}),
getRequestId: () => 'test-request-id',
trackDuration: trackDurationMock
}))
vi.mock('../../../../src/main/services/database/index', () => ({
create: createMock
}))
describe('MaterialsTypeToBeDeletedDAO (PostgreSQL compatibility)', () => {
beforeEach(() => {
vi.clearAllMocks()
createMock.mockResolvedValue({
type: 'postgresql',
isConnected: () => true,
query: queryMock,
disconnect: vi.fn()
})
})
it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => {
queryMock
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 0
})
.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 1
})
const { MaterialsTypeToBeDeletedDAO } = await import(
'../../../../src/main/services/database/materials-type-to-be-deleted-dao'
)
const dao = new MaterialsTypeToBeDeletedDAO()
const result = await dao.upsertMaterial('测试物料', 'tester')
expect(result).toBe(true)
expect(queryMock).toHaveBeenCalledTimes(2)
const [updateSql, updateParams] = queryMock.mock.calls[0]
const [insertSql, insertParams] = queryMock.mock.calls[1]
expect(updateSql).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"')
expect(updateSql).toContain('WHERE MaterialName = $2')
expect(updateParams).toEqual(['tester', '测试物料'])
expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsTypeToBeDeleted" (MaterialName, ManagerName)')
expect(insertSql).toContain('WHERE NOT EXISTS')
expect(insertSql).not.toContain('ON CONFLICT')
expect(insertParams).toEqual(['测试物料', 'tester'])
})
it('returns after the first update when the material already exists', async () => {
queryMock.mockResolvedValueOnce({
rows: [],
columns: [],
rowCount: 1
})
const { MaterialsTypeToBeDeletedDAO } = await import(
'../../../../src/main/services/database/materials-type-to-be-deleted-dao'
)
const dao = new MaterialsTypeToBeDeletedDAO()
const result = await dao.upsertMaterial('测试物料', 'tester')
expect(result).toBe(true)
expect(queryMock).toHaveBeenCalledTimes(1)
expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"')
})
})