43 Commits

Author SHA1 Message Date
Misaka_Company
1ffa0650a6 1.14.1 2026-04-28 12:17:15 +08:00
Misaka_Company
72dba32a52 docs: add release notes for version 1.14.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:17:11 +08:00
Misaka_Company
98865f5d7e fix(cleaner): preserve production IDs in operation history
The 总排号 field was always empty because getCleanerData() resolved
production IDs to order numbers before passing them to the cleaner,
losing the original inputs. Now originalInputs are carried through
the full chain so the resolver can properly set productionId.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:57:47 +08:00
Misaka_Company
5ff99cdd0f refactor: use dot notation for table name config (schema.table instead of schema_table)
Replace underscore-based table name splitting with dot-based splitting
to match the standard schema.tablename format, removing MySQL compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:10:03 +08:00
Misaka
664f26d63f fix(cleaner): guard missing session refresh config 2026-04-24 19:11:21 +08:00
Misaka_Company
e39fc87869 1.14.0 2026-04-24 15:50:33 +08:00
Misaka_Company
4b071e4331 docs: add release notes for version 1.14.0 2026-04-24 15:50:29 +08:00
Misaka_Company
28a632d0a7 Merge branch 'dev-log-enhance' into dev 2026-04-24 15:41:23 +08:00
Misaka_Company
ac43790127 Add cleaner session refresh and ERP diagnostics 2026-04-24 15:40:47 +08:00
Misaka_Company
723d6de0ae 1.13.0 2026-04-17 12:43:45 +08:00
Misaka_Company
9d7fe8f4e7 docs: add release notes for version 1.13.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:43:39 +08:00
Misaka_Company
f49f99fc0c perf(cleaner-history): parallelize batch fetching in searchBatches
Replace sequential for-loop with Promise.all so that matched batches
are fetched concurrently instead of one-by-one, reducing total query
latency from O(n) serial round-trips to a single parallel batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:42:07 +08:00
Misaka_Company
622543fff4 fix(cleaner-history): highlight username and status in batch summary during search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:30:08 +08:00
Misaka_Company
74d9b4042e feat(cleaner-history): integrate search UI into history modal
Add search bar to CleanerOperationHistoryModal with keyword search
across batch IDs, order numbers, and material codes/names. Search
results auto-expand with preloaded data and highlight matched text.
Also add searchHistoryRecords to the CleanerAPI type definition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:21:02 +08:00
Misaka_Company
ed9058c93d feat(cleaner-history): add renderer search types and highlight utility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:11:26 +08:00
Misaka_Company
9167359c6e feat(cleaner-history): expose search API in preload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:09:31 +08:00
Misaka_Company
5faf26df3f feat(cleaner-history): add search IPC handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:07:20 +08:00
Misaka_Company
3a30694684 feat(cleaner-history): add searchBatches DAO method
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:05:17 +08:00
Misaka_Company
c61d62fd98 feat(cleaner-history): add search types and IPC channel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:59:46 +08:00
Misaka_Company
aeb3595b36 docs: add implementation plan for cleaner history search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:52:05 +08:00
Misaka_Company
b8925926cb docs: add design for cleaner history full-level search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:46:34 +08:00
Misaka_Company
2936f1fca3 perf: optimize MaterialTypeManagementDialog with memo, parallel fetch, and stable callbacks
- Use Promise.all for parallel managers + records loading (async-parallel)
- Wrap KeywordCard in memo to skip unnecessary list item re-renders
- Stabilize handlers with useCallback + functional setState pattern
- Hoist generateId to module scope to avoid per-render recreation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:25:00 +08:00
Misaka_Company
f57fdf69f7 refactor: modernize MaterialTypeManagementDialog UI and fix admin hover overlap
Restructure the dialog with a card-grid layout, KeywordCard subcomponent,
and smooth hover animations. Fix admin view where manager badge and delete
button overlapped by using flex layout with translate and max-width transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:55:08 +08:00
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
Misaka_Company
1f06fd275e 1.12.2 2026-04-14 15:20:34 +08:00
Misaka_Company
c86508989b docs: add release notes for version 1.12.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:20:27 +08:00
Misaka_Company
838783e384 fix(auth): clear cached silentLoginPromise on logout to allow re-authentication
After logout, the cached silentLoginPromise caused silentLogin() to return
a stale result instead of re-executing loginByComputerName(), leaving
sessionManager.currentUser as null and making subsequent switchUser() calls fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:17:34 +08:00
Misaka_Company
d5028bfcf4 1.12.1 2026-04-14 14:54:29 +08:00
Misaka_Company
b2b29e9754 docs: add release notes for version 1.12.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:53:23 +08:00
Misaka_Company
cba3c8c4f0 feat(ui): use icons with tooltips for material results
- Replace text badges with icons for cleaner UI
- Map database values: 'success'→deleted, 'skipped', 'uncertain', 'failed_*'→failed
- Add hover tooltip showing status name (Deleted, Skipped, Uncertain, Failed)
- Icons: CheckCircle (green), CircleMinus (gray), AlertTriangle (amber), XCircle (red)
- Add cursor-help to indicate hoverable elements
2026-04-14 14:34:32 +08:00
Misaka_Company
343cb24234 chore: run pretier format across project
- Format TypeScript source files
- Format documentation files
- Update eslint config formatting
2026-04-14 14:03:58 +08:00
Misaka_Company
4ce5b91340 feat(ui): add serial number columns to cleaner operation history
- Add order-level serial number column in order table
- Add material-level serial number column in material details table
- Update colSpan from 10 to 11 to accommodate new column
2026-04-14 14:01:21 +08:00
Misaka_Company
1f033eb315 docs: add plans/ directory naming conventions
- Add date-prefixed naming format: YYYY-MM-DD-description-type.md
- Document -plan.md and -design.md type suffixes
- Add examples from existing plan files
- Update classification examples to include plans/ naming
2026-04-14 12:25:46 +08:00
Misaka_Company
681f3ba517 refactor(docs): reorganize documentation directory structure
- Create user/ - User guides and configuration documentation
- Create features/ - Feature specifications and business flows
- Create debugging/ - Debug guides and quick references
- Create testing/ - Test infrastructure, reports, and plans
- Create internal/ - Internal plans, analyses, and templates
- Move cleaner/*.md to cleaner/ directory
- Move LOGGING_*.md to developer/guides/

Add docs/README.md as documentation index with category navigation
and quick lookup guide.

The reorganized structure makes it easier for users and developers
to quickly locate relevant documentation.
2026-04-14 12:15:09 +08:00
95 changed files with 4805 additions and 1060 deletions

View File

@@ -65,6 +65,7 @@ orderResolution:
cleaner:
queryBatchSize: 100
processConcurrency: 1
sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话
logging:
level: info

288
docs/README.md Normal file
View File

@@ -0,0 +1,288 @@
# ERPAuto 文档指南
本文档是 ERPAuto 项目文档的**分类指南和编写规范**,用于:
- 指导文档的分类和归档
- 规范新文档的命名和格式
- 帮助开发者快速定位应创建的文档类型
---
## 📚 文档分类体系
### 一、按受众分类
| 分类 | 目录 | 受众 | 内容示例 |
| -------------- | ------------ | ---------- | ---------------------------- |
| **用户文档** | `user/` | 最终用户 | 使用指南、配置说明、迁移指南 |
| **开发者文档** | `developer/` | 开发人员 | 架构设计、开发指南、模块说明 |
| **内部文档** | `internal/` | 项目维护者 | 分析报告、优化计划、模板 |
### 二、按内容类型分类
| 分类 | 目录 | 内容特点 |
| ------------ | ----------------------------------- | -------------------------------- |
| **功能特性** | `features/` | 功能说明、业务流程、重构概览 |
| **调试指南** | `debugging/` | 调试指南、快速参考、故障排查 |
| **测试文档** | `testing/` | 测试计划、测试报告、测试基础设施 |
| **模块文档** | `cleaner/`, `browser/`, `database/` | 特定模块的详细文档 |
| **计划文档** | `plans/` | 设计方案、实施计划 |
| **发布说明** | `releases/` | 版本发布记录 |
---
## 📝 文档命名规范
### 文件名格式
```
<主题>-<子主题>-<类型>.md
```
**规则:**
- 使用**小写字母**和**连字符** (`-`)
- 不使用空格、下划线或大写字母
- 保持简短但有描述性
**示例:**
```
✅ user-override-match-feature.md
✅ settings-partial-save.md
✅ cleaner-validation-flow.md
✅ test-improvement-plan.md
❌ UserOverrideMatchFeature.md # 驼峰命名
❌ user_override_match.md # 下划线
❌ user override match.md # 空格
```
### 类型后缀约定
| 后缀 | 用途 | 示例 |
| -------------- | ---------- | ----------------------------------------- |
| `-guide.md` | 指南类文档 | `erp-login-debug-guide.md` |
| `-quickref.md` | 快速参考 | `erp-login-debug-quickref.md` |
| `-flow.md` | 流程说明 | `settings-save-button-flow.md` |
| `-feature.md` | 功能特性 | `user-override-match-feature.md` |
| `-plan.md` | 计划方案 | `test-improvement-plan.md` |
| `-report.md` | 报告总结 | `TEST_REVIEW_REPORT.md` |
| `-template.md` | 模板文件 | `cleaner-execution-report-template.md` |
| `-overview.md` | 概览说明 | `validation-handler-refactor-overview.md` |
### Plans 路径专用命名规范
`plans/` 目录使用**日期前缀**命名法,便于按时间排序和管理:
```
<YYYY-MM-DD>-<描述>-<类型>.md
```
**类型标识:**
| 类型后缀 | 用途 | 内容重点 |
| ------------ | -------- | -------------------------------------- |
| `-plan.md` | 实施计划 | 任务分解、时间线、资源分配、风险评估 |
| `-design.md` | 设计方案 | 技术架构、接口设计、数据模型、决策理由 |
**示例:**
```
✅ 2026-04-13-cleaner-db-persistence-plan.md
✅ 2026-04-13-cleaner-db-persistence-design.md
✅ 2026-04-05-postgresql-integration-plan.md
✅ 2026-04-05-postgresql-integration-design.md
❌ cleaner-db-plan.md # 缺少日期
❌ 2026-4-13-cleaner-db-plan.md # 日期格式不正确(应为 2026-04-13
❌ 2026-04-13-plan-cleaner-db.md # 类型应在最后
```
**相关文件对:**
同一个项目通常会有配对的计划和设计文档:
- `2026-04-13-cleaner-db-persistence-plan.md` - 实施计划
- `2026-04-13-cleaner-db-persistence-design.md` - 设计方案
使用相同的日期和描述,便于关联查找。
---
## 🗂️ 分类决策流程
创建新文档时,按以下流程确定分类:
```
1. 文档的读者是谁?
├─ 最终用户 → user/
├─ 开发者 → developer/
└─ 项目维护者 → internal/ 或其他专业目录
2. 文档的内容类型是什么?
├─ 功能说明 → features/
├─ 调试帮助 → debugging/
├─ 测试相关 → testing/
├─ 模块特定 → cleaner/, browser/, database/
├─ 设计计划 → plans/
└─ 发布记录 → releases/
3. 是否需要快速参考?
└─ 是 → 使用 -quickref.md 后缀,放入 debugging/
```
### 分类示例
| 文档主题 | 正确分类 | 理由 |
| ----------------- | ----------------------------------------------------- | ------------ |
| 如何配置 ERP 连接 | `user/config-erp-guide.md` | 用户操作指南 |
| 日志系统设计 | `developer/architecture/logging-design.md` | 架构设计 |
| 登录失败排查 | `debugging/erp-login-quickref.md` | 调试快速参考 |
| 测试覆盖率分析 | `testing/coverage-analysis-report.md` | 测试报告 |
| 物料清理模块说明 | `cleaner/module-overview.md` | 模块文档 |
| 新功能实施计划 | `plans/2026-04-14-new-feature-implementation-plan.md` | 实施计划 |
| 数据库设计文档 | `plans/2026-04-14-database-schema-design.md` | 设计方案 |
---
## 📋 文档模板
### 指南类文档模板
```markdown
# <功能> 指南
## 概述
简要说明文档目的和适用范围。
## 前置条件
列出使用该功能的前提条件。
## 操作步骤
1. 步骤一
2. 步骤二
3. 步骤三
## 常见问题
- Q: 问题描述
- A: 解决方案
## 相关文档
- [相关文档 1](link)
- [相关文档 2](link)
```
### 功能特性文档模板
```markdown
# <功能名称> 特性说明
## 背景
为什么需要这个功能。
## 功能描述
功能的具体行为和预期结果。
## 用户流程
用户使用该功能的完整流程。
## 技术实现
关键实现细节(可选)。
## 影响范围
对其他模块的影响。
```
### 计划文档模板
```markdown
# <项目名称> 实施计划
## 目标
项目要达成的目标。
## 范围
包含和不包含的内容。
## 任务分解
- [ ] 任务 1
- [ ] 任务 2
- [ ] 任务 3
## 时间线
预计开始和结束时间。
## 风险
可能的风险和应对措施。
```
---
## 🔧 文档维护
### 文档更新
- **功能变更时**:同步更新相关文档
- **发现错误时**:立即修正并提交
- **版本发布时**:更新 `releases/` 中的发布说明
### 文档审查
新文档创建后,应检查:
- [ ] 分类是否正确
- [ ] 命名是否符合规范
- [ ] 是否使用了模板
- [ ] 链接是否有效
- [ ] 是否添加到相关索引
### 废弃文档
过时的文档应:
1. 在文件顶部添加 `> ⚠️ 已废弃` 标记
2. 说明废弃原因和替代文档
3. 在下一个版本发布时移至 `archive/` 目录
---
## 📖 根目录文档
`docs/` 根目录仅保留**跨category的项目级文档**
| 文档 | 用途 |
| -------------------------------------- | ----------------- |
| `README.md` | 本文档 - 分类指南 |
| `build-and-release-guide.md` | 构建和发布流程 |
| `portable-auto-update-architecture.md` | 便携版更新架构 |
**原则**:如果文档不属于特定分类,且对项目整体重要,可放在根目录。
---
## 🔍 找不到合适的分类?
如果现有分类无法容纳你的文档:
1. 检查是否可以归入 `internal/`(内部文档)
2. 考虑是否应该创建新的子目录
3. 在提交 PR 时说明分类理由
---
_最后更新2026-04-14_

View File

@@ -188,21 +188,26 @@ flowchart LR
**表名转换逻辑**:
```typescript
// MySQL: dbo_MaterialsToBeDeleted
// 输入格式: dbo.MaterialsToBeDeleted
// SQL Server: [dbo].[MaterialsToBeDeleted]
function getTableName(mysqlTableName: string): string {
const dbType = process.env.DB_TYPE?.toLowerCase()
if (dbType === 'sqlserver' || dbType === 'mssql') {
// 找到第一个下划线分割schema和表名
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
// PostgreSQL: "dbo"."MaterialsToBeDeleted"
function getValidationTableName(dottedTableName: string): string {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const dotIndex = dottedTableName.indexOf('.')
if (dotIndex > 0) {
const schema = dottedTableName.substring(0, dotIndex)
const tableName = dottedTableName.substring(dotIndex + 1)
if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]`
}
return `[dbo].[${mysqlTableName}]`
return `"${schema}"."${tableName}"`
}
return mysqlTableName
if (dbType === 'sqlserver') {
return `[dbo].[${dottedTableName}]`
}
return `"public"."${dottedTableName}"`
}
```

View File

@@ -541,7 +541,7 @@ graph LR
### 10.2 默认配置示例
```env
DB_TABLE_NAME=productionContractData_26 年压力表合同数据
DB_TABLE_NAME=ERPAuto.vw_productionContractData
DB_FIELD_PRODUCTION_ID=总排号
DB_FIELD_ORDER_NUMBER=生产订单号
```

View File

@@ -13,14 +13,14 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
## 设计决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 |
| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 |
| 批次标识 | `BatchId`UUID与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 |
| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 |
| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代报告相关代码CleanerReportGenerator、generateAndUploadReport删除 |
| 前端历史 | 独立 CleanerOperationHistoryModal复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 |
| 决策项 | 选择 | 理由 |
| -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 |
| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 |
| 批次标识 | `BatchId`UUID与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 |
| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 |
| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代报告相关代码CleanerReportGenerator、generateAndUploadReport删除 |
| 前端历史 | 独立 CleanerOperationHistoryModal复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 |
## 数据库表结构
@@ -32,25 +32,25 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
一次清理操作(含重试)的全局信息。每次尝试一行记录。
| 列名 | 类型 | 说明 |
|------|------|------|
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 批次 ID一次清理操作含重试共享 |
| AttemptNumber | INT | 第几次尝试1=首次2=外层重试) |
| UserId | INT | 操作用户 ID |
| Username | NVARCHAR(255) | 操作用户名 |
| OperationTime | DATETIME | 操作时间 |
| EndTime | DATETIME | 结束时间 |
| Status | NVARCHAR(50) | pending / success / failed / partial / crashed |
| IsDryRun | BIT | 是否模拟运行 |
| TotalOrders | INT | 订单总数 |
| OrdersProcessed | INT | 已处理订单数 |
| TotalMaterialsDeleted | INT | 总删除物料数 |
| TotalMaterialsSkipped | INT | 总跳过物料数 |
| TotalMaterialsFailed | INT | 总失败物料数 |
| TotalUncertainDeletions | INT | 总不确定删除数 |
| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) |
| AppVersion | NVARCHAR(20) | 应用版本号 |
| 列名 | 类型 | 说明 |
| ----------------------- | ---------------- | ---------------------------------------------- |
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 批次 ID一次清理操作含重试共享 |
| AttemptNumber | INT | 第几次尝试1=首次2=外层重试) |
| UserId | INT | 操作用户 ID |
| Username | NVARCHAR(255) | 操作用户名 |
| OperationTime | DATETIME | 操作时间 |
| EndTime | DATETIME | 结束时间 |
| Status | NVARCHAR(50) | pending / success / failed / partial / crashed |
| IsDryRun | BIT | 是否模拟运行 |
| TotalOrders | INT | 订单总数 |
| OrdersProcessed | INT | 已处理订单数 |
| TotalMaterialsDeleted | INT | 总删除物料数 |
| TotalMaterialsSkipped | INT | 总跳过物料数 |
| TotalMaterialsFailed | INT | 总失败物料数 |
| TotalUncertainDeletions | INT | 总不确定删除数 |
| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) |
| AppVersion | NVARCHAR(20) | 应用版本号 |
### 2. `CleanerOrderHistory`(订单级)
@@ -58,20 +58,20 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。
| 列名 | 类型 | 说明 |
|------|------|------|
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
| AttemptNumber | INT | 关联执行表 AttemptNumber |
| OrderNumber | NVARCHAR(255) | 订单号 |
| Status | NVARCHAR(50) | pending / success / failed |
| MaterialsDeleted | INT | 删除物料数 |
| MaterialsSkipped | INT | 跳过物料数 |
| MaterialsFailed | INT | 删除失败物料数 |
| UncertainDeletions | INT | 不确定删除数 |
| RetryCount | INT | 内层重试次数 |
| RetrySuccess | BIT | 内层重试是否成功 |
| ErrorMessage | NVARCHAR(MAX) | 错误信息 |
| 列名 | 类型 | 说明 |
| ------------------ | ---------------- | -------------------------- |
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
| AttemptNumber | INT | 关联执行表 AttemptNumber |
| OrderNumber | NVARCHAR(255) | 订单号 |
| Status | NVARCHAR(50) | pending / success / failed |
| MaterialsDeleted | INT | 删除物料数 |
| MaterialsSkipped | INT | 跳过物料数 |
| MaterialsFailed | INT | 删除失败物料数 |
| UncertainDeletions | INT | 不确定删除数 |
| RetryCount | INT | 内层重试次数 |
| RetrySuccess | BIT | 内层重试是否成功 |
| ErrorMessage | NVARCHAR(MAX) | 错误信息 |
关联方式:`BatchId + AttemptNumber` 关联执行表。
@@ -81,19 +81,19 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
每个物料在每次尝试中的操作明细。
| 列名 | 类型 | 说明 |
|------|------|------|
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
| AttemptNumber | INT | 关联执行表 AttemptNumber |
| OrderNumber | NVARCHAR(255) | 所属订单号 |
| MaterialCode | NVARCHAR(255) | 物料代码 |
| MaterialName | NVARCHAR(255) | 物料名称 |
| RowNumber | INT | 行号 |
| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain |
| Reason | NVARCHAR(MAX) | 跳过/失败原因 |
| AttemptCount | INT | 删除尝试次数 |
| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 |
| 列名 | 类型 | 说明 |
| ------------------ | ---------------- | -------------------------------------- |
| ID | INT IDENTITY | 自增主键 |
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
| AttemptNumber | INT | 关联执行表 AttemptNumber |
| OrderNumber | NVARCHAR(255) | 所属订单号 |
| MaterialCode | NVARCHAR(255) | 物料代码 |
| MaterialName | NVARCHAR(255) | 物料名称 |
| RowNumber | INT | 行号 |
| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain |
| Reason | NVARCHAR(MAX) | 跳过/失败原因 |
| AttemptCount | INT | 删除尝试次数 |
| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 |
关联方式:`BatchId + AttemptNumber + OrderNumber` 关联订单表。
@@ -102,18 +102,21 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
首次执行到第 80 个订单时崩溃,外层重试成功完成全部 211 个订单:
**CleanerExecution**
```
BatchId=uuid-1, Attempt=1, Status=crashed, TotalOrders=211, Processed=80, ...
BatchId=uuid-1, Attempt=2, Status=success, TotalOrders=211, Processed=211, ...
```
**CleanerOrderHistory**Attempt=1 中部分记录)
```
BatchId=uuid-1, Attempt=1, Order=SC001, Status=success, Deleted=5, Skipped=1
BatchId=uuid-1, Attempt=1, Order=SC080, Status=crashed, Error=查询超时
```
**CleanerOrderHistory**Attempt=2 中部分记录)
```
BatchId=uuid-1, Attempt=2, Order=SC001, Status=success, Deleted=5, Skipped=1
BatchId=uuid-1, Attempt=2, Order=SC080, Status=success, Deleted=3, Skipped=0
@@ -121,6 +124,7 @@ BatchId=uuid-1, Attempt=2, Order=SC211, Status=success, Deleted=2, Skipped=0
```
**CleanerMaterialDetail**SC080 在 Attempt=2 中的物料)
```
BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-001, Result=deleted
BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason=不可删除
@@ -218,14 +222,14 @@ BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason
## 移除的概念
| 概念 | 原因 |
|------|------|
| ExecutionIdCLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
| generateExecutionId() | 随 ExecutionId 一起移除 |
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
| 报告文件名去重 | 数据库 UUID 天然唯一 |
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
| 概念 | 原因 |
| ------------------------------ | --------------------------------- |
| ExecutionIdCLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
| generateExecutionId() | 随 ExecutionId 一起移除 |
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
| 报告文件名去重 | 数据库 UUID 天然唯一 |
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
## 不涉及的部分

View File

@@ -13,6 +13,7 @@
## Task 1: 新增类型定义
**Files:**
- Create: `src/main/types/cleaner-history.types.ts`
**Step 1: 创建类型文件**
@@ -146,6 +147,7 @@ feat(cleaner): add type definitions for cleaner operation history
## Task 2: 新增 DAO 层
**Files:**
- Create: `src/main/services/database/cleaner-operation-history-dao.ts`
**Step 1: 创建 DAO 文件**
@@ -165,47 +167,101 @@ export class CleanerOperationHistoryDAO {
}
async insertExecution(input: InsertCleanerExecutionInput): Promise<boolean>
async updateExecutionStatus(batchId: string, attemptNumber: number, status: string, ordersProcessed: number, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, endTime: Date, errorMessage?: string): Promise<boolean>
async updateExecutionStatus(
batchId: string,
attemptNumber: number,
status: string,
ordersProcessed: number,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
endTime: Date,
errorMessage?: string
): Promise<boolean>
// ===== 订单表 =====
private getOrderTableName(): string {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory')
}
async insertOrderRecords(batchId: string, attemptNumber: number, orders: InsertOrderInput[]): Promise<boolean>
async updateOrderStatus(batchId: string, attemptNumber: number, orderNumber: string, status: string, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, retryCount: number, retrySuccess: boolean, errorMessage?: string): Promise<boolean>
async insertOrderRecords(
batchId: string,
attemptNumber: number,
orders: InsertOrderInput[]
): Promise<boolean>
async updateOrderStatus(
batchId: string,
attemptNumber: number,
orderNumber: string,
status: string,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
retryCount: number,
retrySuccess: boolean,
errorMessage?: string
): Promise<boolean>
// ===== 物料表 =====
private getMaterialTableName(): string {
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
}
async insertMaterialDetails(batchId: string, attemptNumber: number, details: InsertMaterialDetailInput[]): Promise<boolean>
async insertMaterialDetails(
batchId: string,
attemptNumber: number,
details: InsertMaterialDetailInput[]
): Promise<boolean>
// ===== 查询 =====
async getBatches(userId?: number, options?: GetCleanerBatchesOptions): Promise<CleanerBatchStats[]>
async getBatchDetails(batchId: string): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise<CleanerMaterialRecord[]>
async getBatches(
userId?: number,
options?: GetCleanerBatchesOptions
): Promise<CleanerBatchStats[]>
async getBatchDetails(
batchId: string
): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
async getMaterialDetails(
batchId: string,
attemptNumber: number,
orderNumber: string
): Promise<CleanerMaterialRecord[]>
// ===== 删除 =====
async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }>
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }>
// ===== 列询执行级记录 =====
async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise<CleanerMaterialRecord[]>
async getMaterialDetails(
batchId: string,
attemptNumber: number,
orderNumber: string
): Promise<CleanerMaterialRecord[]>
// ===== 删除 =====
async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }>
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }>
async disconnect(): Promise<void>
}
```
`getBatches` 查询逻辑:
- `GROUP BY BatchId`,取 `MAX(AttemptNumber)` 对应的执行记录状态作为最终状态
- 汇总订单级的 success/failed 计数
- 支持 userId 过滤(普通用户)和 usernames 过滤(管理员)
- 支持分页
`getBatchDetails` 查询逻辑:
- 返回某 BatchId 下所有 execution 记录 + order 记录
- 前端用 attemptNumber 区分不同尝试
@@ -227,6 +283,7 @@ feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence
## Task 3: 新增 IPC channels
**Files:**
- Modify: `src/shared/ipc-channels.ts`
**Step 1: 添加 cleaner history channels**
@@ -252,6 +309,7 @@ feat(cleaner): add IPC channels for cleaner operation history
## Task 4: 新增 IPC handler
**Files:**
- Create: `src/main/ipc/cleaner-history-handler.ts`
- Modify: `src/main/ipc/index.ts` — 注册新 handler
@@ -282,14 +340,24 @@ export function registerCleanerHistoryHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS,
async (event, batchId: string): Promise<IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>> => {
async (
event,
batchId: string
): Promise<
IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
> => {
// ... 与 operation-history-handler 的 getBatchDetails 模式一致
}
)
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS,
async (event, batchId: string, attemptNumber: number, orderNumber: string): Promise<IpcResult<CleanerMaterialRecord[]>> => {
async (
event,
batchId: string,
attemptNumber: number,
orderNumber: string
): Promise<IpcResult<CleanerMaterialRecord[]>> => {
// ...
}
)
@@ -323,6 +391,7 @@ feat(cleaner): add IPC handlers for cleaner operation history
## Task 5: 新增 Preload API
**Files:**
- Modify: `src/preload/api/cleaner.ts` — 新增 history 方法
- Modify: `src/preload/index.d.ts` — 新增类型声明
@@ -374,6 +443,7 @@ feat(cleaner): add preload API for cleaner operation history
## Task 6: 改造 CleanerApplicationService — 写入数据库记录
**Files:**
- Modify: `src/main/services/cleaner/cleaner-application-service.ts`
这是核心变更。`runCleaner` 方法需要:
@@ -403,6 +473,7 @@ async runCleaner(
**Step 4: 执行后更新订单记录和写入物料明细**
清理完成后遍历 `result.details``OrderCleanDetail[]`),对每个订单:
- 调用 `historyDao.updateOrderStatus(...)` 更新订单结果
- 调用 `historyDao.insertMaterialDetails(...)` 写入物料明细skipped + failed 材料全部写入)
@@ -413,6 +484,7 @@ async runCleaner(
**Step 6: 外层重试改造**
`result.crashed` 时:
1. 调用 `historyDao.updateExecutionStatus(batchId, 1, 'crashed', ...)` 标记首次尝试为 crashed
2. 调用 `historyDao.insertExecution({ batchId, attemptNumber: 2, ... })` 创建第二次尝试
3. 调用 `historyDao.insertOrderRecords(batchId, 2, orders)` 写入第二次尝试的 pending 订单
@@ -435,11 +507,13 @@ refactor(cleaner): replace report generation with database persistence
## Task 7: 改造 cleaner-handler.ts — 执行前后写入
**Files:**
- Modify: `src/main/ipc/cleaner-handler.ts`
**Step 1: 修改 CLEANER_RUN handler**
在调用 `cleanerService.runCleaner()` 之前:
1. 获取当前用户信息
2. `batchId = randomUUID()`
3. 创建 `CleanerOperationHistoryDAO` 实例
@@ -469,6 +543,7 @@ refactor(cleaner): write execution records to database in IPC handler
## Task 8: 删除 Markdown 报告生成器
**Files:**
- Delete: `src/main/services/report/cleaner-report-generator.ts`
**Step 1: 删除文件**
@@ -495,6 +570,7 @@ refactor(cleaner): remove Markdown report generator
## Task 9: 前端 — 新增操作历史弹窗
**Files:**
- Create: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
- Modify: `src/renderer/src/pages/CleanerPage.tsx`
@@ -531,6 +607,7 @@ feat(cleaner): add operation history modal with database-backed records
## Task 10: 更新 renderer 类型定义
**Files:**
- Modify: `src/renderer/src/hooks/cleaner/types.ts`
**Step 1: 添加 history 相关类型**
@@ -553,6 +630,7 @@ feat(cleaner): add renderer types for cleaner operation history
## Task 11: 清理旧代码
**Files:**
- Modify: `src/renderer/src/hooks/cleaner/types.ts` — 移除 `CleanerReportData.crashed`(如果不再需要)
- 检查 `ReportViewerDialog.tsx``ReportAnalysisDialog.tsx` 是否仍被 Cleaner 使用
@@ -564,6 +642,7 @@ feat(cleaner): add renderer types for cleaner operation history
**Step 2: 评估 ReportViewerDialog 和 ReportAnalysisDialog**
这两个组件目前用于查看 Markdown 报告文件。如果 Cleaner 不再使用它们:
- 在 CleanerPage 中移除相关按钮和引用
- 不删除组件本身Extractor 可能仍在使用,后续统一清理)

View File

@@ -8,13 +8,13 @@
## 设计决策
| 决策项 | 选择 | 理由 |
|---|---|---|
| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 |
| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 |
| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 |
| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 |
| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 |
| 决策项 | 选择 | 理由 |
| ------------ | ------------------------- | -------------------------------- |
| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 |
| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 |
| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 |
| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 |
| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 |
## 变更清单
@@ -25,7 +25,7 @@
```typescript
export interface CleanerResult {
// ... 现有字段
crashed?: boolean // true = outer catch triggered, 流程级崩溃
crashed?: boolean // true = outer catch triggered, 流程级崩溃
}
```
@@ -87,6 +87,7 @@ runCleaner(eventSender, input) {
生成时机: `runCleaner()` 入口处,在 ERP 登录之前。重试时同一个 executionId 不变。
用途:
- 报告文件名: `cleaner-report-CLN-20260410112930-A7FK.md`
- RustFS 存储路径中包含该 ID重试时覆盖同一文件
- 报告内容中显示该 ID
@@ -98,13 +99,13 @@ runCleaner(eventSender, input) {
在执行摘要表格中新增字段:
```markdown
| 项目 | 值 |
| ---------------- | --------------------------------- |
| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增
| **应用版本** | `1.11.1` | ← 新增
| **执行时间** | `2026-04-10 11:29:30` |
| **执行模式** | `正式执行` |
| ... | ... |
| 项目 | 值 |
| ------------ | ------------------------- | ------ |
| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增 |
| **应用版本** | `1.11.1` | ← 新增 |
| **执行时间** | `2026-04-10 11:29:30` |
| **执行模式** | `正式执行` |
| ... | ... |
```
- **执行 ID**: 从 ReportOptions 传入
@@ -118,8 +119,8 @@ export interface ReportOptions {
username: string
startTime: number
endTime: number
executionId: string // ← 新增
appVersion: string // ← 新增
executionId: string // ← 新增
appVersion: string // ← 新增
}
```

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

View File

@@ -0,0 +1,94 @@
# Cleaner Operation History - Full-Level Search Design
Date: 2026-04-17
## Summary
Add a full-level search feature to `CleanerOperationHistoryModal` that allows users to search across batches, orders, and materials by entering a single keyword. A new backend search API returns pre-joined three-level nested data, and the frontend renders it with keyword highlighting.
## Interaction Design
- **Search bar**: placed in the toolbar area, above the user filter chips, with a search icon and clear button.
- **Trigger**: press Enter or click the search button (no per-keystroke requests).
- **Search mode behavior**:
- Hides pagination controls (results are cross-page).
- Matching batches auto-expand with orders and materials displayed directly.
- Non-matching levels are hidden.
- Clearing the search box returns to normal browse mode.
- **Highlighting**: matched text wrapped in `<mark>` with yellow background.
- **Empty result**: shows "未找到匹配的记录" message.
- **Result cap**: backend limits to 20 batches; if truncated, shows a hint.
## Search Fields
| Level | Searchable fields |
|-------|-------------------|
| Batch | `batchId`, `username`, `status` |
| Order | `orderNumber`, `productionId` |
| Material | `materialCode`, `materialName` |
## Data Flow
```
renderer: window.electron.cleaner.searchHistoryRecords(query, options)
→ preload: expose searchHistoryRecords
→ main IPC handler: cleaner:searchHistoryRecords
→ service/DAO: searchCleanerHistory(searchQuery, options)
→ DB query (JOIN batches + orders + materials, LIKE filter)
```
### Input Types
```typescript
interface SearchCleanerHistoryOptions {
query: string
usernames?: string[] // admin-only user scope
limit?: number // default 20
}
```
### Response Type
```typescript
interface CleanerHistorySearchResult {
batches: Array<{
batch: CleanerHistoryBatchStats
executions: ExecutionRecord[]
orders: Array<{
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}>
}>
totalMatches: number
}
```
## Frontend Changes
1. **Modal top-level**: add `searchMode` / `searchQuery` state; switch data source between search API and paginated API.
2. **Toolbar**: add search input with Search icon and clear button.
3. **BatchItem**: accept optional pre-loaded `orders` + `materials` props; skip lazy-loading in search mode.
4. **Highlight utility**: `highlightText(text: string, query: string)` wraps matches in `<mark>` tags.
5. **Footer**: hide pagination in search mode; show "找到 X 个批次" + "清除搜索" button.
## Backend Changes
| File | Change |
|------|--------|
| `src/main/types/cleaner-history.types.ts` | Add `SearchCleanerHistoryOptions`, `CleanerHistorySearchResult` types |
| DAO (cleaner history) | Add `searchCleanerHistory` method with SQL LIKE across joined tables |
| Service (cleaner) | Add `searchHistoryRecords` method |
| IPC handler | Register `cleaner:searchHistoryRecords` channel |
| Preload | Expose `searchHistoryRecords` method |
| `src/renderer/src/hooks/cleaner/types.ts` | Sync search result types |
## Affected Files
- `src/main/types/cleaner-history.types.ts` — new types
- `src/main/services/database/cleaner-history-dao.ts` (or similar) — new search method
- `src/main/services/cleaner-service.ts` (or similar) — new search method
- `src/main/ipc/cleaner-handler.ts` (or similar) — new IPC channel
- `src/preload/index.ts` (or cleaner-specific) — expose search API
- `src/renderer/src/hooks/cleaner/types.ts` — sync types
- `src/renderer/src/components/CleanerOperationHistoryModal.tsx` — search UI + state
- `src/renderer/src/components/cleaner-history-highlight.ts` — highlight utility (new file)

View File

@@ -0,0 +1,675 @@
# Cleaner History Full-Level Search Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add full-level search (batch + order + material) to the CleanerOperationHistoryModal, using a new backend search API that returns pre-joined three-level nested data, with keyword highlighting in the frontend.
**Architecture:** New `searchHistoryRecords` IPC channel goes through the existing DAO pattern. The DAO method performs a UNION-based SQL query across all three tables to find matching BatchIds, then fetches the full nested data for those batches. The frontend switches between browse mode (paginated) and search mode (full results) based on whether a search query is active.
**Tech Stack:** TypeScript, React, SQL (MySQL/PostgreSQL/SQL Server via dialect abstraction), Electron IPC
---
### Task 1: Add search types to main process
**Files:**
- Modify: `src/main/types/cleaner-history.types.ts` (append at end)
- Modify: `src/shared/ipc-channels.ts` (add new channel)
**Step 1: Add search types to cleaner-history.types.ts**
Append after the existing `GetCleanerBatchesOptions` interface:
```typescript
/** Search options for full-level history search */
export interface SearchCleanerHistoryOptions {
query: string
usernames?: string[]
limit?: number
}
/** A single batch's full nested data for search results */
export interface CleanerSearchBatchResult {
batch: CleanerBatchStats
executions: CleanerExecutionRecord[]
orders: Array<{
order: CleanerOrderRecord
materials: CleanerMaterialRecord[]
}>
}
/** Search response */
export interface CleanerHistorySearchResult {
batches: CleanerSearchBatchResult[]
totalMatches: number
}
```
**Step 2: Add IPC channel to ipc-channels.ts**
In the `// Cleaner operation history` section, after `CLEANER_HISTORY_DELETE_BATCH`, add:
```typescript
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
Expected: No new errors related to these types
**Step 4: Commit**
```bash
git add src/main/types/cleaner-history.types.ts src/shared/ipc-channels.ts
git commit -m "feat(cleaner-history): add search types and IPC channel"
```
---
### Task 2: Add search DAO method
**Files:**
- Modify: `src/main/services/database/cleaner-operation-history-dao.ts`
**Step 1: Add imports for new types**
At the top of the file, add to the existing import from `../../types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerSearchBatchResult,
CleanerHistorySearchResult
} from '../../types/cleaner-history.types'
```
**Step 2: Add the searchBatches method to the DAO class**
Add this method after the `getBatches` method (around line 707). The strategy:
1. First, find matching BatchIds via a UNION query across all three tables using LIKE.
2. Then fetch full nested data (batch stats, executions, orders, materials) for those batch IDs.
```typescript
// ==================== QUERY: SEARCH ====================
/**
* Full-level search across batches, orders, and materials
* Uses UNION to find matching BatchIds, then fetches full nested data
*/
async searchBatches(
userId: number | undefined,
options: SearchCleanerHistoryOptions
): Promise<CleanerHistorySearchResult> {
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
const likeValue = `%${options.query}%`
const limit = options.limit ?? 20
// Step 1: Find distinct BatchIds matching the query across all tables
const batchIdSql = `
SELECT DISTINCT BatchId FROM (
SELECT e.BatchId FROM ${execTable} e
WHERE e.BatchId LIKE ${dialect.param(0)}
OR e.Username LIKE ${dialect.param(0)}
OR e.Status LIKE ${dialect.param(0)}
UNION ALL
SELECT o.BatchId FROM ${orderTable} o
WHERE o.OrderNumber LIKE ${dialect.param(0)}
OR o.ProductionId LIKE ${dialect.param(0)}
UNION ALL
SELECT m.BatchId FROM ${materialTable} m
WHERE m.MaterialCode LIKE ${dialect.param(0)}
OR m.MaterialName LIKE ${dialect.param(0)}
) AS matched
${userId !== undefined ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE UserId = ${dialect.param(1)})` : ''}
${options.usernames && options.usernames.length > 0 ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE Username IN (${dialect.params(options.usernames.length)}))` : ''}
`
const batchIdParams: (string | number)[] = [likeValue]
if (userId !== undefined) {
batchIdParams.push(userId)
}
if (options.usernames && options.usernames.length > 0) {
batchIdParams.push(...options.usernames)
}
const batchIdResult = await trackDuration(
async () => await dbService.query(batchIdSql, batchIdParams),
{
operationName: 'CleanerOperationHistoryDAO.searchBatches.batchIds',
context: { operationType: 'SELECT', query: options.query }
}
)
const matchedBatchIds = batchIdResult.result.rows.map((r) => r.BatchId as string)
if (matchedBatchIds.length === 0) {
return { batches: [], totalMatches: 0 }
}
// Apply limit
const limitedBatchIds = matchedBatchIds.slice(0, limit)
// Step 2: For each batch, fetch full nested data in parallel
const batches: CleanerSearchBatchResult[] = []
for (const batchId of limitedBatchIds) {
// Fetch batch stats
const batchStatsArr = await this.getBatches(userId, { limit: 1 })
const batchStats = batchStatsArr.find((b) => b.batchId === batchId)
if (!batchStats) continue
// Fetch executions + orders
const details = await this.getBatchDetails(batchId)
// Fetch materials for all orders
const ordersWithMaterials = await Promise.all(
details.orders.map(async (order) => {
const materials = await this.getMaterialDetails(
batchId,
order.attemptNumber,
order.orderNumber
)
return { order, materials }
})
)
batches.push({
batch: batchStats,
executions: details.executions,
orders: ordersWithMaterials
})
}
return {
batches,
totalMatches: matchedBatchIds.length
}
} catch (error) {
log.error('Search batches error', {
operationType: 'SELECT',
requestId: getRequestId(),
query: options.query,
error: error instanceof Error ? error.message : String(error)
})
return { batches: [], totalMatches: 0 }
}
}
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
Expected: No errors related to the DAO
**Step 4: Commit**
```bash
git add src/main/services/database/cleaner-operation-history-dao.ts
git commit -m "feat(cleaner-history): add searchBatches DAO method"
```
---
### Task 3: Add IPC handler for search
**Files:**
- Modify: `src/main/ipc/cleaner-history-handler.ts`
**Step 1: Add new IPC handler**
In `registerCleanerHistoryHandlers()`, after the `CLEANER_HISTORY_DELETE_BATCH` handler (before the closing log statement at the end), add:
```typescript
/**
* Search across all history levels (batches, orders, materials)
* Admin users search all records, regular users search only their own
*/
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
async (
_event,
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
if (!options.query || options.query.trim().length === 0) {
return { batches: [], totalMatches: 0 }
}
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Searching cleaner history', {
userId: currentUser.id,
userType: currentUser.userType,
query: options.query
})
return await dao.searchBatches(userId, {
...options,
query: options.query.trim()
})
}, 'cleanerHistory:search')
}
)
```
**Step 2: Update imports in cleaner-history-handler.ts**
Add to the existing import from `../../types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../types/cleaner-history.types'
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
**Step 4: Commit**
```bash
git add src/main/ipc/cleaner-history-handler.ts
git commit -m "feat(cleaner-history): add search IPC handler"
```
---
### Task 4: Expose search API in preload
**Files:**
- Modify: `src/preload/api/cleaner.ts`
**Step 1: Add search method to cleanerApi**
After the `deleteHistoryBatch` method, add:
```typescript
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options),
```
**Step 2: Add imports**
Add to the existing import from `../../main/types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../../main/types/cleaner-history.types'
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/preload/tsconfig.json 2>&1 | head -20`
**Step 4: Commit**
```bash
git add src/preload/api/cleaner.ts
git commit -m "feat(cleaner-history): expose search API in preload"
```
---
### Task 5: Add renderer-side types and highlight utility
**Files:**
- Modify: `src/renderer/src/hooks/cleaner/types.ts` (add search result types)
- Create: `src/renderer/src/components/cleaner-history-highlight.tsx`
**Step 1: Add search result types to renderer types**
Append to `src/renderer/src/hooks/cleaner/types.ts`:
```typescript
// Search result types (mirrors main process types)
export interface CleanerHistorySearchOrderResult {
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}
export interface CleanerHistorySearchBatchResult {
batch: CleanerHistoryBatchStats
executions: CleanerHistoryExecutionRecord[]
orders: CleanerHistorySearchOrderResult[]
}
export interface CleanerHistorySearchResult {
batches: CleanerHistorySearchBatchResult[]
totalMatches: number
}
```
**Step 2: Create highlight utility**
Create `src/renderer/src/components/cleaner-history-highlight.tsx`:
```tsx
import React from 'react'
/**
* Highlight matching text with a <mark> tag
* Case-insensitive matching of the query within text
*/
export function highlightText(text: string, query: string): React.ReactNode {
if (!query || !text) return text
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const index = lowerText.indexOf(lowerQuery)
if (index === -1) return text
const before = text.substring(0, index)
const match = text.substring(index, index + query.length)
const after = text.substring(index + query.length)
return (
<>
{before}
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
{highlightText(after, query)}
</>
)
}
```
**Step 3: Commit**
```bash
git add src/renderer/src/hooks/cleaner/types.ts src/renderer/src/components/cleaner-history-highlight.tsx
git commit -m "feat(cleaner-history): add renderer search types and highlight utility"
```
---
### Task 6: Integrate search into CleanerOperationHistoryModal
**Files:**
- Modify: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
This is the largest task. The changes are:
1. Add search state variables
2. Add search bar UI to the toolbar
3. Modify BatchItem to accept pre-loaded data in search mode
4. Switch footer between pagination and search result summary
**Step 1: Add new imports**
Add to the lucide-react import:
```typescript
import { Search, X } from 'lucide-react'
```
Add the highlight utility and new types:
```typescript
import { highlightText } from './cleaner-history-highlight'
import type { CleanerHistorySearchResult } from '../hooks/cleaner/types'
```
**Step 2: Add search state in the main modal component**
After the existing state declarations (around line 651), add:
```typescript
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
const [isSearching, setIsSearching] = useState(false)
```
**Step 3: Add the search execution function**
Add after `clearUserFilters`:
```typescript
const executeSearch = useCallback(async () => {
const trimmed = searchInput.trim()
if (!trimmed) {
setSearchQuery('')
setSearchResult(null)
return
}
setIsSearching(true)
setSearchQuery(trimmed)
try {
const options =
isAdmin && selectedUsers.length > 0
? { query: trimmed, usernames: selectedUsers }
: { query: trimmed }
const result = await window.electron.cleaner.searchHistoryRecords(options)
if (result.success && result.data) {
setSearchResult(result.data)
} else {
setSearchResult({ batches: [], totalMatches: 0 })
}
} catch {
setSearchResult({ batches: [], totalMatches: 0 })
} finally {
setIsSearching(false)
}
}, [searchInput, isAdmin, selectedUsers])
const clearSearch = () => {
setSearchInput('')
setSearchQuery('')
setSearchResult(null)
}
```
**Step 4: Add search bar UI**
In the toolbar section, before the user filter `<div className="mb-3">` block, add the search input:
```tsx
{/* Search bar */}
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void executeSearch()
}}
placeholder="搜索批次ID、订单号、物料编码/名称..."
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={loading}
/>
{searchInput && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={clearSearch}
>
<X size={16} />
</button>
)}
</div>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
onClick={() => void executeSearch()}
disabled={isSearching || !searchInput.trim()}
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
```
**Step 5: Modify the batch list section to support search mode**
Replace the batch list section (the `<div className="flex-1 overflow-y-auto">` block) with conditional rendering:
```tsx
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{searchQuery ? (
// Search mode
isSearching ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : searchResult && searchResult.batches.length > 0 ? (
<div className="flex flex-col gap-3">
{searchResult.batches.map((result) => (
<BatchItem
key={result.batch.batchId}
batch={result.batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
onRequestDelete={requestDeleteConfirmation}
searchQuery={searchQuery}
preloadedExecutions={result.executions}
preloadedOrders={result.orders}
/>
))}
</div>
) : (
<div className="flex items-center justify-center h-32 text-gray-500">
{searchQuery}
</div>
)
) : (
// Browse mode (existing logic)
<>
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => (
<BatchItem
key={batch.batchId}
batch={batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
onRequestDelete={requestDeleteConfirmation}
/>
))}
</div>
)}
</>
)}
</div>
```
**Step 6: Modify footer for search mode**
Replace the footer section to conditionally show pagination or search summary:
```tsx
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-center">
{searchQuery && searchResult ? (
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{searchResult.totalMatches}
{searchResult.totalMatches > (searchResult.batches.length) &&
`(显示前 ${searchResult.batches.length} 个)`}
</span>
<button
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 underline"
onClick={clearSearch}
>
</button>
</div>
) : (
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
{/* ... existing pagination buttons ... */}
</div>
)}
</div>
```
**Step 7: Update BatchItem props and search mode rendering**
Update `BatchItemProps` interface to support optional preloaded data:
```typescript
interface BatchItemProps {
batch: CleanerHistoryBatchStats
isAdmin: boolean
onDelete: (batchId: string) => void
onRequestDelete: (batchId: string) => Promise<boolean>
searchQuery?: string
preloadedExecutions?: ExecutionRecord[]
preloadedOrders?: Array<{
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}>
}
```
In the BatchItem component, when `searchQuery` is set and preloaded data is available:
- Start expanded (`isExpanded` initial state: `!!searchQuery`)
- Use preloaded executions/orders directly instead of fetching
- Pass `searchQuery` to text rendering for highlighting
**Step 8: Verify typecheck**
Run: `npm run typecheck`
**Step 9: Commit**
```bash
git add src/renderer/src/components/CleanerOperationHistoryModal.tsx
git commit -m "feat(cleaner-history): integrate search UI into history modal"
```
---
### Task 7: Final verification
**Step 1: Run full typecheck**
Run: `npm run typecheck`
**Step 2: Run linter**
Run: `npm run lint`
**Step 3: Build the project**
Run: `npm run build`
**Step 4: Manual test checklist**
- [ ] Open the Cleaner Operation History Modal
- [ ] Verify search bar appears at top of toolbar
- [ ] Type a keyword and press Enter — results should load
- [ ] Matching batches auto-expand with orders and materials
- [ ] Highlighted text appears with yellow background
- [ ] Clear button (X) resets to browse mode
- [ ] Pagination hidden during search, shown after clearing
- [ ] Admin: user filter combined with search works
- [ ] Regular user: only their own records searched
- [ ] Empty search query does nothing
- [ ] Non-matching query shows "未找到匹配" message

6
docs/releases/1.12.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.12.1
## 界面与交互
- 操作历史面板新增序号列,订单和物料明细表均可直观查看行号。
- 物料操作结果改用图标显示(已删除 / 已跳过 / 不确定 / 失败),悬停可查看状态名称。

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

@@ -0,0 +1,5 @@
# 1.12.2
## 问题修复
- 修复管理员切换用户后登出,再次选择用户无法进入应用的问题。

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 环境下物料数据写入失败的问题,支持无唯一约束的表。
## 改进
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。

18
docs/releases/1.13.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.13.0
## 清理操作历史
- 新增历史记录全局搜索,支持按批次 ID、订单号、物料编码/名称、用户名、状态等关键字检索。
- 搜索结果高亮显示匹配关键字,快速定位目标记录。
- 搜索结果自动展开批次详情、订单和物料明细,无需逐层手动点击。
- 搜索支持与用户筛选联动,管理员可限定搜索范围到指定用户。
## 界面与交互
- 物料类型管理面板样式更新,改善整体视觉一致性。
- 修复管理员模式下物料类型管理面板的悬浮重叠问题。
## 改进
- 优化历史搜索查询性能,多个批次数据并行获取,减少等待时间。
- 物料类型管理面板加载速度优化,减少不必要的重渲染。

16
docs/releases/1.14.0.md Normal file
View File

@@ -0,0 +1,16 @@
# 1.14.0
## 核心功能
- 清理任务新增长批次自动重建会话机制,在大批量订单处理中可按批次边界自动重开浏览器并继续执行,降低长时间运行中断的概率。
- 会话重建阈值支持配置,可结合批次大小灵活调整重建时机,避免在批次处理中途打断流程。
## 问题修复
- 修复无头模式下大批量清理任务更容易在后半程停滞的问题,提升真实数据处理场景下的完成率。
- 优化清理流程在登录态异常场景下的识别能力,减少详情页长时间等待后才暴露问题的情况。
## 改进
- 新增 ERP 页面状态诊断日志,可区分登录页、首页、查询页、详情页等关键页面状态,方便快速定位异常发生位置。
- 补充详情页打开、页面跳转、弹窗创建和并发等待等关键日志,排查间歇性问题时可以更快还原现场。

10
docs/releases/1.14.1.md Normal file
View File

@@ -0,0 +1,10 @@
# 1.14.1
## 问题修复
- 修复清理任务操作历史中「总排号」始终为空的问题,原始生产编号现在能正确保留并显示在历史记录中。
- 修复会话重建配置缺失时清理任务可能异常中断的问题,提升配置不完整场景下的运行稳定性。
## 改进
- 表名配置统一使用 `schema.tablename` 标准点分写法,与数据库标准格式保持一致。

View File

@@ -180,7 +180,7 @@ validation:
# 订单号解析配置
orderResolution:
tableName: 'productionContractData_26 年压力表合同数据'
tableName: 'ERPAuto.vw_productionContractData'
productionIdField: '总排号'
orderNumberField: '生产订单号'
```

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.12.0",
"version": "1.14.1",
"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

@@ -19,7 +19,9 @@ import type {
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../types/cleaner-history.types'
const log = createLogger('CleanerHistoryHandler')
@@ -152,5 +154,42 @@ export function registerCleanerHistoryHandlers(): void {
}
)
/**
* Search across all history levels (batches, orders, materials)
* Admin users search all records, regular users search only their own
*/
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
async (
_event,
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
if (!options.query || options.query.trim().length === 0) {
return { batches: [], totalMatches: 0 }
}
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Searching cleaner history', {
userId: currentUser.id,
userType: currentUser.userType,
query: options.query
})
return await dao.searchBatches(userId, {
...options,
query: options.query.trim()
})
}, 'cleanerHistory:search')
}
)
log.info('Cleaner history IPC handlers registered')
}

View File

@@ -14,7 +14,8 @@ export const CleanerInputSchema = z.object({
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
dryRun: z.boolean(),
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
processConcurrency: z.number().int().min(1).max(20).optional().default(1),
sessionRefreshOrderThreshold: z.number().int().positive().optional().default(160)
// Note: onProgress is a function, not validated via Zod
})

View File

@@ -215,6 +215,7 @@ export class AuthApplicationService {
}
this.sessionManager.logout()
this.silentLoginPromise = null
await this.updateService.setUserContext(null)
},
{ operation: 'logout' }

View File

@@ -27,6 +27,7 @@ import type { InsertMaterialDetailInput, InsertOrderInput } from '../../types/cl
import type { OrderMapping } from '../../types/order-resolver.types'
const log = createLogger('CleanerApplicationService')
const DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD = 160
export class CleanerApplicationService {
async runCleaner(
@@ -65,7 +66,8 @@ export class CleanerApplicationService {
}
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
const inputsToResolve = input.originalInputs?.length ? input.originalInputs : input.orderNumbers
const mappings = await resolver.resolve(inputsToResolve)
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
@@ -148,6 +150,8 @@ export class CleanerApplicationService {
log.info('Login successful')
const totalOrders = validOrderNumbers.length
const effectiveSessionRefreshOrderThreshold =
this.resolveSessionRefreshOrderThreshold(input, configManager)
this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, {
phase: 'login',
currentOrderIndex: 0,
@@ -159,6 +163,7 @@ export class CleanerApplicationService {
const modifiedInput: CleanerInput = {
...input,
orderNumbers: validOrderNumbers,
sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold,
onProgress: (message, progress, extra) => {
this.sendProgress(eventSender, message, progress ?? 0, extra)
}
@@ -168,7 +173,8 @@ export class CleanerApplicationService {
batchId,
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
processConcurrency: input.processConcurrency ?? 1,
sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold
})
let cleaner = new CleanerService(authService)
@@ -450,18 +456,34 @@ export class CleanerApplicationService {
: result.errors.length > 0
? AuditStatus.FAILURE
: AuditStatus.SUCCESS
const effectiveSessionRefreshOrderThreshold = this.resolveSessionRefreshOrderThreshold(
input,
ConfigManager.getInstance()
)
logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
orderCount,
dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
})
}
private resolveSessionRefreshOrderThreshold(
input: CleanerInput,
configManager: Pick<ConfigManager, 'getConfig'>
): number {
return (
input.sessionRefreshOrderThreshold ??
configManager.getConfig().cleaner?.sessionRefreshOrderThreshold ??
DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
)
}
/**
* Save attempt results to database: update order statuses, insert material details,
* and update execution status.
@@ -484,11 +506,7 @@ export class CleanerApplicationService {
batchId,
attemptNumber,
detail.orderNumber,
detail.notFound
? 'erp_not_found'
: detail.errors.length > 0
? 'failed'
: 'success',
detail.notFound ? 'erp_not_found' : detail.errors.length > 0 ? 'failed' : 'success',
detail.materialsDeleted,
detail.materialsSkipped,
detail.materialsFailed,

View File

@@ -99,7 +99,8 @@ const DEFAULT_CONFIG: FullConfig = {
},
cleaner: {
queryBatchSize: 100,
processConcurrency: 1
processConcurrency: 1,
sessionRefreshOrderThreshold: 160
},
orderResolution: {
tableName: '',

View File

@@ -18,7 +18,10 @@ import type {
InsertCleanerExecutionInput,
InsertOrderInput,
InsertMaterialDetailInput,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerSearchBatchResult,
CleanerHistorySearchResult
} from '../../types/cleaner-history.types'
const log = createLogger('CleanerOperationHistoryDAO')
@@ -122,6 +125,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 +631,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 +918,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: '没有权限删除此批次' }
}
@@ -985,6 +983,172 @@ export class CleanerOperationHistoryDAO {
}
}
// ==================== QUERY: SEARCH BATCHES ====================
/**
* Full-level search across batches, orders, and materials.
* Uses a UNION ALL query to find matching BatchIds, then fetches
* full nested data for each matched batch.
*
* @param userId - Optional user ID for filtering
* @param options - Search options (query string, usernames filter, result limit)
* @returns Search result with matched batches and total count
*/
async searchBatches(
userId: number | undefined,
options: SearchCleanerHistoryOptions
): Promise<CleanerHistorySearchResult> {
const emptyResult: CleanerHistorySearchResult = { batches: [], totalMatches: 0 }
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
const { query, limit = 20 } = options
const safeLimit = Math.floor(limit)
const likePattern = `%${query}%`
// Build UNION ALL to find distinct BatchIds matching the query
// Each subquery searches a different table's columns
let paramIndex = 0
const p = () => dialect.param(paramIndex++)
const unionSql = `
SELECT DISTINCT BatchId FROM (
SELECT BatchId FROM ${execTable}
WHERE BatchId LIKE ${p()}
OR Username LIKE ${p()}
OR Status LIKE ${p()}
UNION ALL
SELECT BatchId FROM ${orderTable}
WHERE OrderNumber LIKE ${p()}
OR ProductionId LIKE ${p()}
UNION ALL
SELECT BatchId FROM ${materialTable}
WHERE MaterialCode LIKE ${p()}
OR MaterialName LIKE ${p()}
) AS matched
WHERE BatchId IN (
SELECT BatchId FROM ${execTable}
WHERE 1=1
${userId !== undefined ? `AND UserId = ${p()}` : ''}
${userId === undefined && options.usernames && options.usernames.length > 0 ? `AND Username IN (${dialect.params(options.usernames.length)})` : ''}
)
`
const unionParams: (string | number)[] = [
likePattern, likePattern, likePattern, // exec table: BatchId, Username, Status
likePattern, likePattern, // order table: OrderNumber, ProductionId
likePattern, likePattern // material table: MaterialCode, MaterialName
]
// User filtering params
if (userId !== undefined) {
unionParams.push(userId)
} else if (options.usernames && options.usernames.length > 0) {
unionParams.push(...options.usernames)
}
const { result } = await trackDuration(
async () => await dbService.query(unionSql, unionParams),
{
operationName: 'CleanerOperationHistoryDAO.searchBatches.union',
context: { operationType: 'SELECT', query }
}
)
const matchedBatchIds: string[] = result.rows.map((row) => row.BatchId as string)
const totalMatches = matchedBatchIds.length
const limitedBatchIds = matchedBatchIds.slice(0, safeLimit)
if (limitedBatchIds.length === 0) {
return { batches: [], totalMatches: 0 }
}
// Fetch full nested data for each matched batch (parallel)
const batchResults = await Promise.all(
limitedBatchIds.map(async (batchId) => {
try {
const details = await this.getBatchDetails(batchId)
if (details.executions.length === 0) {
return null
}
// Derive batch stats from execution records
const latestExec = details.executions.reduce((a, b) =>
a.attemptNumber > b.attemptNumber ? a : b
)
const batch: CleanerBatchStats = {
batchId,
userId: latestExec.userId,
username: latestExec.username,
operationTime: latestExec.operationTime.toISOString(),
status: latestExec.status,
totalAttempts: details.executions.length,
totalOrders: latestExec.totalOrders,
ordersProcessed: latestExec.ordersProcessed,
totalMaterialsDeleted: latestExec.totalMaterialsDeleted,
totalMaterialsFailed: latestExec.totalMaterialsFailed,
successCount: details.orders.filter((o) => o.status === 'success').length,
failedCount: details.orders.filter((o) => o.status === 'failed').length,
isDryRun: latestExec.isDryRun
}
// Fetch materials for each order
const ordersWithMaterials = await Promise.all(
details.orders.map(async (order) => {
const materials = await this.getMaterialDetails(
batchId,
order.attemptNumber,
order.orderNumber
)
return { order, materials }
})
)
return {
batch,
executions: details.executions,
orders: ordersWithMaterials
} satisfies CleanerSearchBatchResult
} catch (error) {
log.error('Error fetching batch data for search result', {
operationType: 'SELECT',
batchId,
error: error instanceof Error ? error.message : String(error)
})
return null
}
})
)
const batches = batchResults.filter((r): r is CleanerSearchBatchResult => r !== null)
log.info('Search batches completed', {
operationType: 'SELECT',
requestId: getRequestId(),
query,
totalMatches,
returnedBatches: batches.length
})
return { batches, totalMatches }
} catch (error) {
log.error('Search batches error', {
operationType: 'SELECT',
requestId: getRequestId(),
query: options.query,
error: error instanceof Error ? error.message : String(error)
})
return emptyResult
}
}
// ==================== UTILITIES ====================
/**

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'],

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import type { ErpConfig, ErpSession } from '../../types/erp.types'
import { createLogger } from '../logger'
import { capturePageContext } from './erp-error-context'
import { attachPageDiagnostics, attachContextDiagnostics } from './page-diagnostics'
import { capturePageState } from './page-state'
const log = createLogger('ErpAuthService')
@@ -64,6 +65,10 @@ export class ErpAuthService {
await page.goto(loginUrl)
log.debug('已导航到登录页面')
log.info('[PAGE_STATE] 页面状态快照', {
step: 'auth.login_page_loaded',
...(await capturePageState(page, context))
})
// Wait for page to load
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
@@ -82,7 +87,14 @@ export class ErpAuthService {
if (!contentFrame) {
log.error('Failed to access forwardFrame content frame', {
...(await capturePageContext(page))
...(await capturePageContext(
page,
undefined,
'auth.forwardFrame',
undefined,
undefined,
'auth_forward_frame'
))
})
throw new Error('Failed to access forwardFrame content frame')
}
@@ -96,7 +108,14 @@ export class ErpAuthService {
} catch (e) {
log.error('Failed to find username input', {
error: e instanceof Error ? e.message : String(e),
...(await capturePageContext(page, undefined, 'login.username'))
...(await capturePageContext(
page,
undefined,
'login.username',
undefined,
undefined,
'login_username'
))
})
throw new Error(`Failed to find username input: ${e}`)
}
@@ -107,7 +126,14 @@ export class ErpAuthService {
} catch (e) {
log.error('Failed to find password input', {
error: e instanceof Error ? e.message : String(e),
...(await capturePageContext(page, undefined, 'login.password'))
...(await capturePageContext(
page,
undefined,
'login.password',
undefined,
undefined,
'login_password'
))
})
throw new Error(`Failed to find password input: ${e}`)
}
@@ -118,7 +144,14 @@ export class ErpAuthService {
} catch (e) {
log.error('Failed to click login button', {
error: e instanceof Error ? e.message : String(e),
...(await capturePageContext(page, undefined, 'login.button'))
...(await capturePageContext(
page,
undefined,
'login.button',
undefined,
undefined,
'login_button'
))
})
throw new Error(`Failed to click login button: ${e}`)
}
@@ -128,6 +161,10 @@ export class ErpAuthService {
})
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
log.info('[PAGE_STATE] 页面状态快照', {
step: 'auth.login_result_confirmed',
...(await capturePageState(page, context, { includeFrameHierarchy: true }))
})
// Create session with mainFrame (Python returns main_frame as part of login result)
this.session = {

View File

@@ -16,6 +16,9 @@ export interface ErpErrorContext {
targetSelector?: string
step?: string
screenshotPath?: string
orderId?: string
materialCode?: string
errorStage?: string
}
/**
@@ -69,11 +72,18 @@ async function captureScreenshot(page: Page, step?: string): Promise<string | un
*
* @param page - The Playwright page to inspect
* @param targetSelector - Optional selector that was being targeted
* @param step - Optional step name for context
* @param orderId - Optional order ID for error correlation
* @param materialCode - Optional material code for error correlation
* @param stage - Optional stage name (defaults to 'unknown')
*/
export async function capturePageContext(
page: Page,
targetSelector?: string,
step?: string
step?: string,
orderId?: string,
materialCode?: string,
stage: string = 'unknown'
): Promise<ErpErrorContext> {
const ctx: ErpErrorContext = {}
@@ -98,6 +108,16 @@ export async function capturePageContext(
ctx.step = step
}
if (orderId) {
ctx.orderId = orderId
}
if (materialCode) {
ctx.materialCode = materialCode
}
ctx.errorStage = stage
ctx.screenshotPath = await captureScreenshot(page, step)
return ctx

View File

@@ -116,7 +116,14 @@ export class ExtractorCore {
if (!fFrame) {
log.error('Failed to access popup forward frame', {
...(await capturePageContext(popupPage, undefined, 'navigate.forwardFrame'))
...(await capturePageContext(
popupPage,
undefined,
'navigate.forwardFrame',
undefined,
undefined,
'navigate_forward_frame'
))
})
throw new Error('Failed to access popup forward frame')
}
@@ -128,7 +135,14 @@ export class ExtractorCore {
if (!workFrame) {
log.error('Failed to access inner work frame', {
...(await capturePageContext(popupPage, undefined, 'navigate.innerFrame'))
...(await capturePageContext(
popupPage,
undefined,
'navigate.innerFrame',
undefined,
undefined,
'navigate_inner_frame'
))
})
throw new Error('Failed to access inner work frame')
}

View File

@@ -40,7 +40,7 @@ export function getDbConfig() {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return {
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
TABLE_NAME: config.orderResolution.tableName || 'ERPAuto.vw_productionContractData',
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
}
@@ -58,35 +58,28 @@ export class OrderNumberResolver {
/**
* Get table name based on database type
* Converts schema_tablename format to database-specific quoting:
* Converts schema.tablename format to database-specific quoting:
* - SQL Server: [schema].[tablename]
* - PostgreSQL: "schema"."tablename"
* - MySQL: schema_tablename (as-is)
* e.g., productionContractData_26年压力表合同数据 ->
* SQL Server: [productionContractData].[26年压力表合同数据]
* PostgreSQL: "productionContractData"."26年压力表合同数据"
* MySQL: productionContractData_26年压力表合同数据
* e.g., ERPAuto.vw_productionContractData ->
* SQL Server: [ERPAuto].[vw_productionContractData]
* PostgreSQL: "ERPAuto"."vw_productionContractData"
*/
private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver' || this.dbService.type === 'postgresql') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
if (this.dbService.type === 'sqlserver') {
return `[${schema}].[${actualTableName}]`
}
return `"${schema}"."${actualTableName}"`
}
// If no underscore found, default schema
const dotIndex = tableName.indexOf('.')
if (dotIndex > 0) {
const schema = tableName.substring(0, dotIndex)
const actualTableName = tableName.substring(dotIndex + 1)
if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]`
return `[${schema}].[${actualTableName}]`
}
return `"public"."${tableName}"`
return `"${schema}"."${actualTableName}"`
}
return tableName
// No dot found — use default schema
if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]`
}
return `"public"."${tableName}"`
}
/**

View File

@@ -0,0 +1,214 @@
import type { BrowserContext, Page } from 'playwright'
export type ErpPageKind = 'login' | 'home' | 'query' | 'detail' | 'cas_login' | 'unknown'
export interface CapturePageStateOptions {
includeFrameHierarchy?: boolean
includeBodyTextPreview?: boolean
bodyTextPreviewLength?: number
}
export interface ErpPageState {
pageUrl?: string
pageTitle?: string
pageKind: ErpPageKind
hasForwardFrame: boolean
hasMainIframe: boolean
hasLoginForm: boolean
hasWorkbenchMarker: boolean
hasQueryMarker: boolean
hasDetailHeader: boolean
isCasLoginRedirect: boolean
frameCount?: number
popupCount?: number
visibleMarkers?: string[]
frameHierarchy?: Array<{ name: string; url: string }>
bodyTextPreview?: string
}
async function safePageUrl(page: Page): Promise<string | undefined> {
try {
return page.url()
} catch {
return undefined
}
}
async function safePageTitle(page: Page): Promise<string | undefined> {
try {
const title = await page.title()
return title.slice(0, 200)
} catch {
return undefined
}
}
async function safeFrameCount(page: Page): Promise<number | undefined> {
try {
return page.frames().length
} catch {
return undefined
}
}
async function safePopupCount(context?: BrowserContext): Promise<number | undefined> {
try {
return context?.pages().length
} catch {
return undefined
}
}
async function safeLocatorExists(locator: ReturnType<Page['locator']>): Promise<boolean> {
try {
return (await locator.count()) > 0
} catch {
return false
}
}
async function safeBodyPreview(page: Page, maxLength: number): Promise<string | undefined> {
try {
const text = await page.locator('body').innerText({ timeout: 1000 })
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength)
} catch {
return undefined
}
}
export async function capturePageState(
page: Page,
context?: BrowserContext,
options: CapturePageStateOptions = {}
): Promise<ErpPageState> {
const pageUrl = await safePageUrl(page)
const pageTitle = await safePageTitle(page)
const isCasLoginRedirect = !!pageUrl?.includes('euc.yonyoucloud.com/cas/login')
let hasForwardFrame = false
let hasMainIframe = false
let hasLoginForm = false
let hasWorkbenchMarker = false
let hasQueryMarker = false
let hasDetailHeader = false
try {
hasForwardFrame = await safeLocatorExists(page.locator('#forwardFrame'))
} catch {
hasForwardFrame = false
}
let forwardFrame: Awaited<ReturnType<ReturnType<Page['locator']>['contentFrame']>> | null = null
if (hasForwardFrame) {
try {
forwardFrame = await page.locator('#forwardFrame').contentFrame()
} catch {
forwardFrame = null
}
}
if (forwardFrame) {
try {
hasMainIframe = (await forwardFrame.locator('#mainiframe').count()) > 0
} catch {
hasMainIframe = false
}
try {
hasWorkbenchMarker = (await forwardFrame.locator('.nc-workbench-icon').count()) > 0
} catch {
hasWorkbenchMarker = false
}
try {
hasLoginForm =
(await forwardFrame.getByRole('textbox', { name: '用户名' }).count()) > 0 ||
(await forwardFrame.getByRole('textbox', { name: '密码' }).count()) > 0
} catch {
hasLoginForm = false
}
}
let innerFrame: Awaited<
ReturnType<ReturnType<NonNullable<typeof forwardFrame>['locator']>['contentFrame']>
> | null = null
if (forwardFrame && hasMainIframe) {
try {
innerFrame = await forwardFrame.locator('#mainiframe').contentFrame()
} catch {
innerFrame = null
}
}
if (innerFrame) {
try {
hasQueryMarker =
(await innerFrame.getByText('订单号查询').count()) > 0 ||
(await innerFrame.locator('#rc_select_0').count()) > 0
} catch {
hasQueryMarker = false
}
try {
hasDetailHeader = (await innerFrame.getByText(/^离散备料计划维护:/).count()) > 0
} catch {
hasDetailHeader = false
}
}
if (!hasLoginForm) {
try {
hasLoginForm =
(await page.getByRole('textbox', { name: '用户名' }).count()) > 0 ||
(await page.getByRole('textbox', { name: '密码' }).count()) > 0
} catch {
hasLoginForm = false
}
}
const visibleMarkers: string[] = []
if (isCasLoginRedirect) visibleMarkers.push('cas_login_url')
if (hasLoginForm) visibleMarkers.push('login_form')
if (hasWorkbenchMarker) visibleMarkers.push('workbench_icon')
if (hasQueryMarker) visibleMarkers.push('query_marker')
if (hasDetailHeader) visibleMarkers.push('detail_header')
if (hasForwardFrame) visibleMarkers.push('forwardFrame')
if (hasMainIframe) visibleMarkers.push('mainiframe')
let pageKind: ErpPageKind = 'unknown'
if (isCasLoginRedirect) pageKind = 'cas_login'
else if (hasLoginForm) pageKind = 'login'
else if (hasDetailHeader) pageKind = 'detail'
else if (hasQueryMarker) pageKind = 'query'
else if (hasWorkbenchMarker) pageKind = 'home'
const state: ErpPageState = {
pageUrl,
pageTitle,
pageKind,
hasForwardFrame,
hasMainIframe,
hasLoginForm,
hasWorkbenchMarker,
hasQueryMarker,
hasDetailHeader,
isCasLoginRedirect,
frameCount: await safeFrameCount(page),
popupCount: await safePopupCount(context),
visibleMarkers
}
if (options.includeFrameHierarchy) {
try {
state.frameHierarchy = page.frames().map((frame) => ({ name: frame.name(), url: frame.url() }))
} catch {
state.frameHierarchy = undefined
}
}
if (options.includeBodyTextPreview) {
state.bodyTextPreview = await safeBodyPreview(page, options.bodyTextPreviewLength ?? 500)
}
return state
}

View File

@@ -42,9 +42,7 @@ async function runMySQLMigration(): Promise<void> {
return
}
await service.query(
`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`
)
await service.query(`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`)
console.log('Added ProductionId column to CleanerOrderHistory.')
} finally {
if (service.isConnected()) await service.disconnect()

View File

@@ -41,7 +41,7 @@ export async function getSourceNumbersFromInputs(
}
if (productionIds.length > 0) {
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据')
const contractTableName = getValidationTableName('ERPAuto.vw_productionContractData')
const batchSize = 2000
if (dbType === 'sqlserver') {

View File

@@ -236,6 +236,7 @@ export class ValidationApplicationService {
): Promise<{
success: boolean
orderNumbers?: string[]
originalInputs?: string[]
materialCodes?: string[]
error?: string
}> {
@@ -288,6 +289,7 @@ export class ValidationApplicationService {
return {
success: true,
orderNumbers,
originalInputs: sharedIds,
materialCodes
}
} catch (error) {
@@ -325,7 +327,7 @@ export class ValidationApplicationService {
}
private async loadTypeKeywords(dbService: ValidationDatabaseService): Promise<TypeKeyword[]> {
const typeKeywordTableName = getValidationTableName('dbo_MaterialsTypeToBeDeleted')
const typeKeywordTableName = getValidationTableName('dbo.MaterialsTypeToBeDeleted')
const sql = `
SELECT MaterialName, ManagerName
FROM ${typeKeywordTableName}
@@ -341,7 +343,7 @@ export class ValidationApplicationService {
private async loadMarkedCodes(
dbService: ValidationDatabaseService
): Promise<Map<string, string>> {
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted')
const markedTableName = getValidationTableName('dbo.MaterialsToBeDeleted')
const sql = `
SELECT MaterialCode, ManagerName
FROM ${markedTableName}
@@ -416,7 +418,7 @@ export class ValidationApplicationService {
try {
dbService = await createValidationDatabaseService()
const detailTableName = getValidationTableName('dbo_DiscreteMaterialPlanData')
const detailTableName = getValidationTableName('dbo.DiscreteMaterialPlanData')
const enrichedMaterials: MaterialRecordSummary[] = []
log.info(`Enriching ${materials.length} materials with details`)
@@ -501,7 +503,7 @@ export class ValidationApplicationService {
selectedManagers: string[],
orderNumbers: string[]
): Promise<string[]> {
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted')
const markedTableName = getValidationTableName('dbo.MaterialsToBeDeleted')
// Admin with selected managers: filter MaterialsToBeDeleted by ManagerName IN (selectedManagers)
if (isAdmin && selectedManagers && selectedManagers.length > 0) {

View File

@@ -52,25 +52,22 @@ export async function createValidationDatabaseService(): Promise<ValidationDatab
return mysqlService
}
export function getValidationTableName(mysqlTableName: string): string {
export function getValidationTableName(dottedTableName: string): string {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver' || dbType === 'postgresql') {
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]`
}
return `"${schema}"."${tableName}"`
}
const dotIndex = dottedTableName.indexOf('.')
if (dotIndex > 0) {
const schema = dottedTableName.substring(0, dotIndex)
const tableName = dottedTableName.substring(dotIndex + 1)
if (dbType === 'sqlserver') {
return `[dbo].[${mysqlTableName}]`
return `[${schema}].[${tableName}]`
}
return `"public"."${mysqlTableName}"`
return `"${schema}"."${tableName}"`
}
return mysqlTableName
// No dot found — use default schema
if (dbType === 'sqlserver') {
return `[dbo].[${dottedTableName}]`
}
return `"public"."${dottedTableName}"`
}

View File

@@ -111,3 +111,26 @@ export interface GetCleanerBatchesOptions {
offset?: number
usernames?: string[]
}
/** Search options for full-level history search */
export interface SearchCleanerHistoryOptions {
query: string
usernames?: string[]
limit?: number
}
/** A single batch's full nested data for search results */
export interface CleanerSearchBatchResult {
batch: CleanerBatchStats
executions: CleanerExecutionRecord[]
orders: Array<{
order: CleanerOrderRecord
materials: CleanerMaterialRecord[]
}>
}
/** Search response */
export interface CleanerHistorySearchResult {
batches: CleanerSearchBatchResult[]
totalMatches: number
}

View File

@@ -13,11 +13,13 @@ export interface CleanerProgress {
export interface CleanerInput {
orderNumbers: string[]
originalInputs?: string[]
materialCodes: string[]
dryRun: boolean
headless?: boolean
queryBatchSize?: number
processConcurrency?: number
sessionRefreshOrderThreshold?: number
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}

View File

@@ -117,7 +117,8 @@ export const validationConfigSchema = z.object({
*/
export const cleanerConfigSchema = z.object({
queryBatchSize: z.number().int().min(1).max(100).default(100),
processConcurrency: z.number().int().min(1).max(20).default(1)
processConcurrency: z.number().int().min(1).max(20).default(1),
sessionRefreshOrderThreshold: z.number().int().positive().default(160)
})
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>

View File

@@ -15,7 +15,9 @@ import type {
CleanerBatchStats,
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord
CleanerMaterialRecord,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from './cleaner-history.types'
import type { IpcResult } from './ipc.types'
@@ -158,6 +160,14 @@ export interface CleanerAPI {
* @param batchId - Batch ID
*/
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
/**
* Search cleaner history records by keyword
* @param options - Search options (query, usernames, limit)
*/
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
) => Promise<IpcResult<CleanerHistorySearchResult>>
}
/**

View File

@@ -8,7 +8,9 @@ import type {
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../../main/types/cleaner-history.types'
import type { IpcResult } from '../../main/types/ipc.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
@@ -53,5 +55,10 @@ export const cleanerApi = {
),
deleteHistoryBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId)
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId),
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options)
} as const

File diff suppressed because it is too large Load Diff

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

@@ -1,19 +1,30 @@
/**
* Material Type Management Dialog
* Material Type Management Dialog (Modern UI Refactor)
*
* Provides a dialog for managing material type keywords used to identify
* materials for deletion. Admin users can see all records and filter by manager.
* Regular users can only see and edit their own records.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
import { Modal } from './ui/Modal'
import React, { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
X,
Plus,
RotateCcw,
Search,
Trash2,
User,
CloudUpload,
CheckCircle2,
AlertCircle,
Users
} from 'lucide-react'
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
import { useLogger } from '../hooks/useLogger'
// --- Interfaces ---
interface MaterialTypeRecord {
id?: number
materialName: string
@@ -21,6 +32,7 @@ interface MaterialTypeRecord {
}
interface RowState {
localId: string // Stable ID for React mapping during edits/filters
record: MaterialTypeRecord
state: 'original' | 'new' | 'modified' | 'deleted'
originalRecord?: MaterialTypeRecord
@@ -34,40 +46,179 @@ interface MaterialTypeManagementDialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | null>
}
// Stable ID generator for React keys
const generateId = () => Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
// --- KeywordCard Component (Handles individual items) ---
interface KeywordCardProps {
item: RowState
isAdmin: boolean
managers: string[]
onUpdate: (localId: string, newRecord: Partial<MaterialTypeRecord>) => void
onDelete: (localId: string) => void
onRestore: (localId: string) => void
}
const KeywordCard = memo(function KeywordCard({ item, isAdmin, managers, onUpdate, onDelete, onRestore }: KeywordCardProps) {
const [isEditing, setIsEditing] = useState(item.state === 'new');
const [text, setText] = useState(item.record.materialName);
const [manager, setManager] = useState(item.record.managerName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
if (item.state !== 'new') {
inputRef.current.select();
}
}
}, [isEditing, item.state]);
const handleSave = () => {
const trimmedText = text.trim();
if (!trimmedText) {
onDelete(item.localId); // Delete if empty
} else {
setIsEditing(false);
if (trimmedText !== item.record.materialName || manager !== item.record.managerName) {
onUpdate(item.localId, { materialName: trimmedText, managerName: manager });
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') {
setText(item.record.materialName);
setManager(item.record.managerName);
setIsEditing(false);
if (item.state === 'new') onDelete(item.localId);
}
};
// Deleted State View
if (item.state === 'deleted') {
return (
<div className="group relative flex items-center h-10 px-3 bg-red-50/50 border border-red-200 rounded-lg transition-all">
<span className="flex-1 truncate text-sm text-red-700/60 font-medium line-through mr-6">
{item.record.materialName}
</span>
<button
onClick={(e) => { e.stopPropagation(); onRestore(item.localId); }}
className="absolute right-2 p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded-md transition-all"
title="撤销删除"
>
<RotateCcw size={14} />
</button>
</div>
);
}
// Editing State View
if (isEditing) {
return (
<div
className="relative flex items-center h-10 px-3 bg-indigo-50 border border-indigo-400 rounded-lg shadow-sm ring-2 ring-indigo-100 transition-all"
onBlur={(e) => {
// Delay save to allow dropdown clicks. Checks if new focus is outside this container.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
handleSave();
}
}}
>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 mr-2 flex-shrink-0"></div>
<input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="关键词..."
className="flex-1 w-full min-w-[80px] bg-transparent outline-none text-sm font-medium text-indigo-900 placeholder-indigo-300"
/>
{isAdmin && (
<select
value={manager}
onChange={(e) => setManager(e.target.value)}
className="ml-2 bg-white outline-none text-xs text-indigo-700 border border-indigo-200 rounded px-1 py-0.5 focus:ring-2 focus:ring-indigo-200"
>
<option value=""></option>
{managers.map(m => <option key={m} value={m}>{m}</option>)}
</select>
)}
</div>
);
}
// Normal / Modified State View
const isModified = item.state === 'modified' || item.state === 'new';
return (
<div
onClick={() => setIsEditing(true)}
className={`group relative flex items-center h-10 px-3 bg-white border rounded-lg cursor-pointer transition-all duration-200 hover:shadow-md hover:border-indigo-300
${isModified ? 'border-amber-300 bg-amber-50/30' : 'border-slate-200'}
`}
>
<div className={`w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-colors
${isModified ? 'bg-amber-400' : 'bg-slate-300 group-hover:bg-indigo-400'}
`}></div>
<span className="flex-1 truncate text-sm text-slate-700 font-medium group-hover:text-indigo-700 transition-colors mr-2">
{item.record.materialName}
</span>
<div className="flex-shrink-0 flex items-center">
{isAdmin && (
<span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded truncate max-w-[60px] group-hover:bg-indigo-50 group-hover:text-indigo-500 group-hover:-translate-x-2 transition-all duration-300 ease-in-out">
{item.record.managerName || '未指定'}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.localId); }}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md max-w-0 overflow-hidden opacity-0 group-hover:max-w-8 group-hover:opacity-100 transition-all duration-200"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
});
// --- Main Dialog Component ---
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername,
triggerRef
// triggerRef // Retained for compatibility, but layout uses custom overlay
}) => {
const [rows, setRows] = useState<RowState[]>([])
const [managers, setManagers] = useState<string[]>([])
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
const [editValue, setEditValue] = useState('')
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const logger = useLogger('MaterialType')
const tableRef = useRef<HTMLTableElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const selectRef = useRef<HTMLSelectElement>(null)
// Confirmation dialog hook
const { confirm, dialog: confirmDialog } = useConfirmDialog()
// Calculate pending changes count
const pendingCount = rows.filter(
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
).length
const isSynced = pendingCount === 0;
const loadData = useCallback(async () => {
setLoading(true)
try {
// Load managers list
const managersResult = await window.electron.materialType.getManagers()
const [managersResult, recordsResult] = await Promise.all([
window.electron.materialType.getManagers(),
isAdmin
? window.electron.materialType.getAll()
: window.electron.materialType.getByManager(currentUsername)
])
if (managersResult.success && managersResult.data) {
setManagers(managersResult.data)
if (isAdmin) {
@@ -75,28 +226,17 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
}
}
// Load records
let records: MaterialTypeRecord[] = []
if (isAdmin) {
const result = await window.electron.materialType.getAll()
if (result.success && result.data) {
records = result.data
}
} else {
const result = await window.electron.materialType.getByManager(currentUsername)
if (result.success && result.data) {
records = result.data
}
}
if (recordsResult.success && recordsResult.data) records = recordsResult.data
setRows(
records.map((record) => ({
localId: generateId(),
record,
state: 'original' as const,
originalRecord: { ...record }
}))
)
setSelectedRowIndex(null)
} catch (error) {
logger.error('Failed to load material types', {
error: error instanceof Error ? error.message : String(error),
@@ -108,137 +248,73 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
}
}, [currentUsername, isAdmin, logger])
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
void loadData()
}
if (isOpen) void loadData()
}, [isOpen, loadData])
// Focus input when editing starts
useEffect(() => {
const activeElement = inputRef.current ?? selectRef.current
if (editingCell && activeElement) {
activeElement.focus()
if (activeElement instanceof HTMLInputElement) {
activeElement.select()
}
const filteredRows = useMemo(() => {
let result = rows;
if (isAdmin && selectedManagers.size > 0) {
result = result.filter(row => selectedManagers.has(row.record.managerName) || row.state === 'new');
}
}, [editingCell])
if (searchQuery) {
const lowerQuery = searchQuery.toLowerCase();
result = result.filter(row => row.record.materialName.toLowerCase().includes(lowerQuery));
}
return result;
}, [rows, isAdmin, selectedManagers, searchQuery])
// Filter rows by selected managers (admin only)
const filteredRows = React.useMemo(() => {
if (!isAdmin) return rows
if (selectedManagers.size === 0) return rows
return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new')
}, [rows, isAdmin, selectedManagers])
// Insert new row
const insertNewRow = useCallback(() => {
// --- Row Actions ---
const handleAdd = useCallback(() => {
const newRow: RowState = {
record: {
materialName: '',
managerName: isAdmin ? '' : currentUsername
},
localId: generateId(),
record: { materialName: '', managerName: isAdmin ? '' : currentUsername },
state: 'new'
}
// 在 setRows 回调中计算索引并设置编辑状态
setRows((prev) => {
const newIndex = prev.length
setTimeout(() => {
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
setEditValue('')
}, 0)
return [...prev, newRow]
})
setRows(prev => [newRow, ...prev])
setSearchQuery('')
}, [isAdmin, currentUsername])
// Delete row
const deleteRow = useCallback((index: number) => {
setRows((prev) => {
const newRows = [...prev]
const row = newRows[index]
if (row.state === 'new') {
// Remove new rows directly
newRows.splice(index, 1)
} else {
// Mark existing rows as deleted
newRows[index] = { ...row, state: 'deleted' }
}
return newRows
})
setSelectedRowIndex(null)
}, [])
// Start editing a cell
const startEdit = useCallback(
(rowIndex: number, field: string) => {
const row = rows[rowIndex]
if (row.state === 'deleted') return
setEditingCell({ rowIndex, field })
setEditValue(row.record[field as keyof MaterialTypeRecord] as string)
},
[rows]
)
// Save edit
const saveEdit = useCallback(() => {
if (!editingCell) return
const { rowIndex, field } = editingCell
setRows((prev) => {
const newRows = [...prev]
const row = newRows[rowIndex]
const newValue = editValue.trim()
// Update the record
newRows[rowIndex] = {
const handleUpdate = useCallback((localId: string, updates: Partial<MaterialTypeRecord>) => {
setRows(prev => prev.map(row => {
if (row.localId !== localId) return row;
return {
...row,
record: {
...row.record,
[field]: newValue
},
record: { ...row.record, ...updates },
state: row.state === 'new' ? 'new' : 'modified'
}
return newRows
})
setEditingCell(null)
setEditValue('')
}, [editingCell, editValue])
// Cancel edit
const cancelEdit = useCallback(() => {
setEditingCell(null)
setEditValue('')
}))
}, [])
// Handle keyboard events
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (editingCell) {
if (event.key === 'Enter') {
saveEdit()
} else if (event.key === 'Escape') {
cancelEdit()
}
return
}
const handleDelete = useCallback((localId: string) => {
setRows(prev => {
const row = prev.find(r => r.localId === localId);
if (!row) return prev;
if (row.state === 'new') return prev.filter(r => r.localId !== localId);
return prev.map(r => r.localId === localId ? { ...r, state: 'deleted' } : r);
})
}, [])
if (event.key === 'Insert') {
event.preventDefault()
insertNewRow()
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
event.preventDefault()
deleteRow(selectedRowIndex)
}
},
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
)
const handleRestore = useCallback((localId: string) => {
setRows(prev => prev.map(r => {
if (r.localId !== localId) return r;
const wasModified = r.originalRecord?.materialName !== r.record.materialName ||
r.originalRecord?.managerName !== r.record.managerName;
return { ...r, state: wasModified ? 'modified' : 'original' }
}))
}, [])
const handleReset = async () => {
if (pendingCount === 0) return
const confirmed = await confirm({
title: '确认重置',
message: '确定要放弃所有未保存的更改吗?',
variant: 'warning'
})
if (confirmed) void loadData()
}
// Save all changes
const handleSave = async () => {
const toInsert: MaterialTypeRecord[] = []
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
@@ -273,19 +349,11 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
setSaving(true)
try {
const result = await window.electron.materialType.upsertBatch({
toInsert,
toUpdate,
toDelete
})
const payload = result.success
? (result.data as { stats?: { success?: number; failed?: number } } | undefined)
: undefined
const result = await window.electron.materialType.upsertBatch({ toInsert, toUpdate, toDelete })
const payload = result.success ? (result.data as { stats?: { success?: number; failed?: number } } | undefined) : undefined
if (result.success) {
showSuccess(
`保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}`
)
showSuccess(`保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}`)
await loadData()
} else {
showError(`保存失败:${result.error || '未知错误'}`)
@@ -294,29 +362,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
logger.error('Failed to save material types', {
error: error instanceof Error ? error.message : String(error),
inserts: toInsert.length,
updates: toUpdate.length,
deletes: toDelete.length
inserts: toInsert.length, updates: toUpdate.length, deletes: toDelete.length
})
} finally {
setSaving(false)
}
}
// Reset changes
const handleReset = async () => {
if (pendingCount === 0) return
const confirmed = await confirm({
title: '确认重置',
message: '确定要放弃所有未保存的更改吗?',
variant: 'warning'
})
if (confirmed) {
void loadData()
}
}
// Handle close with unsaved changes warning
const handleClose = async () => {
if (pendingCount > 0) {
const confirmed = await confirm({
@@ -329,253 +381,170 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
onClose()
}
// Get row background color
const getRowStyle = (row: RowState): React.CSSProperties => {
if (row.state === 'deleted') {
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
}
if (row.state === 'new') {
return { backgroundColor: '#dcfce7' }
}
if (row.state === 'modified') {
return { backgroundColor: '#fef9c3' }
}
return {}
}
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="物料类型管理"
size="2xl"
triggerRef={triggerRef}
>
<div onKeyDown={handleKeyDown}>
{/* Manager filter (admin only) */}
{isAdmin && (
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Users size={16} />
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4 md:p-8 font-sans">
<div className="w-full max-w-5xl bg-white rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden flex flex-col h-[85vh] animate-in fade-in zoom-in-95 duration-200">
{/* --- Header --- */}
<header className="px-6 py-5 border-b border-slate-100 flex justify-between items-start bg-slate-50/50">
<div>
<div className="flex items-center space-x-2 text-xs font-semibold text-slate-400 tracking-wider mb-1 uppercase">
<span>Material Type Management</span>
</div>
<h1 className="text-xl font-bold text-slate-800 tracking-tight"></h1>
<p className="text-sm text-slate-500 mt-1">
{isAdmin ? '集中维护和管理所有负责人的物料关键词。' : '管理属于您的物料关键词。'}
</p>
</div>
<button
onClick={handleClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<X size={20} />
</button>
</header>
{/* --- Toolbar --- */}
<div className="px-6 py-4 border-b border-slate-100 bg-white flex flex-col sm:flex-row justify-between items-center gap-6 z-10">
<div className="flex items-center space-x-6 w-full sm:w-auto">
<button
onClick={handleAdd}
className="flex items-center justify-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg shadow-sm shadow-indigo-200 transition-all active:scale-95"
>
<Plus size={16} className="mr-1.5" />
</button>
<button
onClick={handleReset}
disabled={pendingCount === 0 || loading}
className="flex items-center justify-center px-3 py-2 bg-white border border-slate-200 hover:bg-slate-50 text-slate-600 text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={16} className="mr-1.5 text-slate-400" />
</button>
<div className="relative hidden sm:block">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search size={14} className="text-slate-400" />
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
<input
type="text"
placeholder="搜索关键词..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-4 py-2 bg-slate-50 border border-transparent outline-none focus:border-indigo-200 focus:bg-white rounded-lg text-sm text-slate-700 w-56 transition-all"
/>
</div>
</div>
<div className="flex items-center space-x-6 w-full sm:w-auto justify-end">
<div className="flex items-center bg-slate-50 rounded-lg p-1 border border-slate-100">
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
<span className="text-slate-400 mr-1.5"></span>
<span className="font-semibold text-slate-800">{filteredRows.length}</span>
</div>
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
<User size={12} className="mr-1.5 text-slate-400" />
<span className="truncate max-w-[80px]">{isAdmin ? '管理员视图' : currentUsername}</span>
</div>
<div className={`flex items-center px-4 py-1.5 text-xs font-medium ${isSynced ? 'text-emerald-600' : 'text-amber-600'}`}>
{isSynced ? <><CheckCircle2 size={14} className="mr-1" /> </> : <><AlertCircle size={14} className="mr-1" /> {pendingCount}</>}
</div>
</div>
<div className="flex flex-wrap gap-2">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers((prev) => {
const newSet = new Set(prev)
if (e.target.checked) newSet.add(manager)
else newSet.delete(manager)
return newSet
})
<button
onClick={handleSave}
disabled={pendingCount === 0 || saving}
className={`flex items-center justify-center px-5 py-2 text-sm font-medium rounded-lg transition-all duration-300
${pendingCount > 0 ? 'bg-slate-800 hover:bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-400 cursor-not-allowed'}
`}
>
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
{saving ? '保存中...' : '保存更改'}
</button>
</div>
</div>
{/* --- Admin Manager Filter --- */}
{isAdmin && managers.length > 0 && (
<div className="px-6 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center overflow-x-auto hide-scrollbar">
<div className="flex items-center gap-3 text-xs font-medium text-slate-500 mr-8 flex-shrink-0">
<Users size={14} />
</div>
<div className="flex gap-3 flex-nowrap">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
selectedManagers.size === managers.length ? 'bg-slate-800 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
}`}
>
</button>
{managers.map((manager) => {
const isSelected = selectedManagers.has(manager);
return (
<button
key={manager}
onClick={() => {
const newSet = new Set(selectedManagers);
isSelected ? newSet.delete(manager) : newSet.add(manager);
setSelectedManagers(newSet);
}}
/>
{manager}
</label>
))}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors ${
isSelected ? 'bg-indigo-100 text-indigo-700 border border-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
}`}
>
{manager}
</button>
)
})}
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<button
onClick={insertNewRow}
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
>
<Plus size={14} /> (Insert)
</button>
<button
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
disabled={selectedRowIndex === null}
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={14} /> (Delete)
</button>
<button
onClick={handleReset}
disabled={pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={14} />
</button>
</div>
<div className="flex items-center gap-3">
{pendingCount > 0 && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
{pendingCount}
</span>
)}
<button
onClick={handleSave}
disabled={saving || pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={14} /> {saving ? '保存中...' : '保存'}
</button>
</div>
</div>
{/* Table */}
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
{/* --- Main Content Grid --- */}
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
{loading ? (
<div className="flex items-center justify-center py-12 text-slate-500">...</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center text-slate-400">
<RotateCcw size={24} className="animate-spin mb-3 opacity-50" />
<span className="text-sm">...</span>
</div>
</div>
) : filteredRows.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 auto-rows-max">
{filteredRows.map(row => (
<KeywordCard
key={row.localId}
item={row}
isAdmin={isAdmin}
managers={managers}
onUpdate={handleUpdate}
onDelete={handleDelete}
onRestore={handleRestore}
/>
))}
</div>
) : (
<table ref={tableRef} className="w-full text-sm">
<thead className="bg-slate-100 sticky top-0">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
</th>
<th className="px-4 py-2 text-left font-medium text-slate-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
<tr>
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
&quot;&quot;
</td>
</tr>
) : (
filteredRows
.filter((r) => r.state !== 'deleted')
.map((row, index) => {
const originalIndex = rows.indexOf(row)
const isSelected = selectedRowIndex === originalIndex
const isEditingMaterial =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'materialName'
const isEditingManager =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'managerName'
return (
<tr
key={index}
style={getRowStyle(row)}
className={`${isSelected ? 'ring-2 ring-blue-300 ring-inset' : ''} hover:bg-slate-50 cursor-pointer`}
onClick={() => setSelectedRowIndex(originalIndex)}
>
<td className="px-4 py-2 border-r border-slate-100">
{isEditingMaterial ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'materialName')}
>
{row.record.materialName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
<td className="px-4 py-2">
{isEditingManager ? (
isAdmin ? (
<select
ref={selectRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{managers.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'managerName')}
>
{row.record.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-3">
<Search size={32} className="opacity-20" />
<p className="text-sm"></p>
</div>
)}
</div>
{/* Footer info */}
<div className="mt-3 text-xs text-slate-500 flex justify-between">
<span>
| Insert | Delete
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
</span>
<span> {filteredRows.filter((r) => r.state !== 'deleted').length} </span>
</div>
</div>
{/* --- Footer --- */}
<footer className="px-6 py-3 bg-white border-t border-slate-100 flex justify-between items-center text-xs text-slate-400">
<p></p>
<p> <span className="font-semibold text-slate-600">{filteredRows.length}</span> / {rows.length} </p>
</footer>
{/* Confirmation Dialog */}
</div>
{/* Retain the original confirmation dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal>
</div>
)
}
export default MaterialTypeManagementDialog
export default MaterialTypeManagementDialog

View File

@@ -0,0 +1,27 @@
import React from 'react'
/**
* Highlight matching text with a <mark> tag
* Case-insensitive matching of the query within text
*/
export function highlightText(text: string, query: string): React.ReactNode {
if (!query || !text) return text
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const index = lowerText.indexOf(lowerQuery)
if (index === -1) return text
const before = text.substring(0, index)
const match = text.substring(index, index + query.length)
const after = text.substring(index + query.length)
return (
<>
{before}
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
{highlightText(after, query)}
</>
)
}

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

@@ -10,6 +10,7 @@ import type { CleanerExportItem, MaterialBatchChange } from './helpers'
interface CleanerDataPayload {
success?: boolean
orderNumbers?: string[]
originalInputs?: string[]
materialCodes?: string[]
}
@@ -61,7 +62,8 @@ export async function loadCleanerConfig(): Promise<CleanerConfigResult | null> {
return {
queryBatchSize: result.data.queryBatchSize,
processConcurrency: result.data.processConcurrency
processConcurrency: result.data.processConcurrency,
sessionRefreshOrderThreshold: result.data.sessionRefreshOrderThreshold
}
}
@@ -120,6 +122,7 @@ export async function runCleanerExecution(params: {
headless: boolean
queryBatchSize: number
processConcurrency: number
sessionRefreshOrderThreshold: number
selectedManagers: string[]
}): Promise<CleanerReportData> {
const cleanerDataResult = await window.electron.validation.getCleanerData({
@@ -145,11 +148,13 @@ export async function runCleanerExecution(params: {
const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList,
originalInputs: cleanerData?.originalInputs,
materialCodes: materialCodeList,
dryRun: params.dryRun,
headless: params.headless,
queryBatchSize: params.queryBatchSize,
processConcurrency: params.processConcurrency
processConcurrency: params.processConcurrency,
sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold
})
const cleanerRunData = response.success ? (response.data as CleanerRunPayload | null) : null

View File

@@ -60,6 +60,7 @@ export interface CleanerInitializationResult {
export interface CleanerConfigResult {
queryBatchSize: number
processConcurrency: number
sessionRefreshOrderThreshold: number
}
// Cleaner operation history types (mirrors preload/index.d.ts)
@@ -129,3 +130,21 @@ export interface CleanerHistoryMaterialRecord {
attemptCount: number
finalErrorCategory: string | null
}
// Search result types (mirrors main process types)
export interface CleanerHistorySearchOrderResult {
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}
export interface CleanerHistorySearchBatchResult {
batch: CleanerHistoryBatchStats
executions: CleanerHistoryExecutionRecord[]
orders: CleanerHistorySearchOrderResult[]
}
export interface CleanerHistorySearchResult {
batches: CleanerHistorySearchBatchResult[]
totalMatches: number
}

View File

@@ -62,6 +62,7 @@ export function useCleaner() {
const [headless, setHeadless] = useState(() => getStoredBoolean('cleaner_headless', true))
const [queryBatchSize, setQueryBatchSize] = useState(100)
const [processConcurrency, setProcessConcurrency] = useState(1)
const [sessionRefreshOrderThreshold, setSessionRefreshOrderThreshold] = useState(160)
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
// Inline editing state for manager field (Admin only)
@@ -138,6 +139,7 @@ export function useCleaner() {
if (result) {
setQueryBatchSize(result.queryBatchSize)
setProcessConcurrency(result.processConcurrency)
setSessionRefreshOrderThreshold(result.sessionRefreshOrderThreshold)
}
} catch (err) {
logger.error('Failed to load cleaner config', {
@@ -377,6 +379,7 @@ export function useCleaner() {
headless,
queryBatchSize,
processConcurrency,
sessionRefreshOrderThreshold,
selectedManagers: Array.from(selectedManagers)
})
setReportData(result)
@@ -440,6 +443,8 @@ export function useCleaner() {
setQueryBatchSize,
processConcurrency,
setProcessConcurrency,
sessionRefreshOrderThreshold,
setSessionRefreshOrderThreshold,
updateProcessConcurrency,
showSettingsMenu,
setShowSettingsMenu,

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

@@ -26,6 +26,7 @@ export const IPC_CHANNELS = {
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
// Database service - MySQL
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',

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

@@ -279,6 +279,14 @@ describe('CleanerApplicationService', () => {
expect(result.ordersProcessed).toBe(1)
})
it('should fall back to default session refresh threshold when cleaner config is missing', async () => {
const eventSender: any = { send: vi.fn() }
await service.runCleaner(eventSender, makeInput())
expect(lastCleanerInput?.sessionRefreshOrderThreshold).toBe(160)
})
it('should close ERP browser on success', async () => {
await service.runCleaner({ send: vi.fn() } as any, makeInput())

View File

@@ -91,7 +91,7 @@ vi.mock('../../../../src/main/services/erp/erp-auth', () => ({
}))
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({
CleanerOperationHistoryDAO: class {
CleanerOperationHistoryDAO: class {
async getBatchDetails(batchId: string) {
return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] }
}

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"')
})
})

View File

@@ -125,31 +125,25 @@ describe('ValidationDatabaseService', () => {
})
describe('getValidationTableName', () => {
it('returns table name unchanged for mysql', async () => {
currentDbType = 'mysql'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('MaterialsToBeDeleted')).toBe('MaterialsToBeDeleted')
})
it('converts schema_table to [schema].[table] for sqlserver', async () => {
it('converts schema.table to [schema].[table] for sqlserver', async () => {
currentDbType = 'sqlserver'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('dbo_Materials')).toBe('[dbo].[Materials]')
expect(mod.getValidationTableName('dbo.Materials')).toBe('[dbo].[Materials]')
})
it('wraps nameless table in [dbo].[name] for sqlserver', async () => {
it('wraps dotless table in [dbo].[name] for sqlserver', async () => {
currentDbType = 'sqlserver'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('Materials')).toBe('[dbo].[Materials]')
})
it('converts schema_table to "schema"."table" for postgresql', async () => {
it('converts schema.table to "schema"."table" for postgresql', async () => {
currentDbType = 'postgresql'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('public_Materials')).toBe('"public"."Materials"')
expect(mod.getValidationTableName('public.Materials')).toBe('"public"."Materials"')
})
it('wraps nameless table in "public"."name" for postgresql', async () => {
it('wraps dotless table in "public"."name" for postgresql', async () => {
currentDbType = 'postgresql'
const mod = await import('../../../../src/main/services/validation/validation-database')
expect(mod.getValidationTableName('Materials')).toBe('"public"."Materials"')