33 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
56 changed files with 3966 additions and 843 deletions

View File

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

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

@@ -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

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.2",
"version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.12.2",
"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.2",
"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

@@ -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.

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

View File

@@ -8,9 +8,10 @@ import type {
OrderCleanDetail
} from '../../types/cleaner.types'
import type { ErpSession } from '../../types/erp.types'
import type { FrameLocator, Locator, Page } from 'playwright'
import type { BrowserContext, FrameLocator, Locator, Page } from 'playwright'
import { createLogger, run, trackDuration } from '../logger'
import { capturePageContext } from './erp-error-context'
import { capturePageState } from './page-state'
const log = createLogger('CleanerService')
@@ -18,6 +19,7 @@ const DEFAULT_QUERY_BATCH_SIZE = 100
const MAX_QUERY_BATCH_SIZE = 100
const DEFAULT_PROCESS_CONCURRENCY = 1
const MAX_PROCESS_CONCURRENCY = 20
const DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD = 160
interface RetryResult {
retriedOrders: number
@@ -67,37 +69,86 @@ class ConcurrencyTracker {
private activeWorkers = 0
private waitQueue = 0
private mutexWaitCount = 0
private currentOwnerWorkerId: number | null = null
private currentOwnerOrderNumber: string | null = null
private waitStartedAt = new Map<number, number>()
workerStarted() {
workerStarted(workerId: number, orderNumber: string) {
this.activeWorkers++
log.verbose('[CONCURRENCY] Worker started', {
workerId,
orderNumber,
activeWorkers: this.activeWorkers,
waitQueue: this.waitQueue,
waitingForPopupMutex: this.mutexWaitCount > 0
})
}
workerCompleted() {
workerCompleted(workerId: number, orderNumber: string) {
this.activeWorkers--
log.verbose('[CONCURRENCY] Worker completed', {
workerId,
orderNumber,
activeWorkers: this.activeWorkers,
queueRemaining: this.waitQueue
})
}
waitingForMutex() {
waitingForMutex(workerId: number, orderNumber: string) {
this.mutexWaitCount++
this.waitQueue++
this.waitStartedAt.set(workerId, Date.now())
log.warn('[CONCURRENCY] Worker waiting for popup mutex', {
workerId,
orderNumber,
mutexWaitCount: this.mutexWaitCount,
activeWorkers: this.activeWorkers
activeWorkers: this.activeWorkers,
queueDepth: this.waitQueue,
currentOwnerWorkerId: this.currentOwnerWorkerId,
currentOwnerOrderNumber: this.currentOwnerOrderNumber
})
}
acquiredMutex() {
acquiredMutex(workerId: number, orderNumber: string) {
const waitStartedAt = this.waitStartedAt.get(workerId)
const waitDurationMs = waitStartedAt ? Date.now() - waitStartedAt : 0
this.waitStartedAt.delete(workerId)
this.mutexWaitCount--
this.waitQueue = Math.max(0, this.waitQueue - 1)
this.currentOwnerWorkerId = workerId
this.currentOwnerOrderNumber = orderNumber
log.verbose('[CONCURRENCY] Worker acquired popup mutex', {
workerId,
orderNumber,
mutexWaitCount: this.mutexWaitCount,
activeWorkers: this.activeWorkers
activeWorkers: this.activeWorkers,
waitDurationMs,
queueDepth: this.waitQueue
})
if (waitDurationMs > 5000) {
log.warn('[CONCURRENCY] Popup mutex slow wait', {
workerId,
orderNumber,
waitDurationMs,
activeWorkers: this.activeWorkers,
queueDepth: this.waitQueue,
currentOwnerWorkerId: this.currentOwnerWorkerId,
currentOwnerOrderNumber: this.currentOwnerOrderNumber
})
}
}
releasedMutex(workerId: number, orderNumber: string) {
if (this.currentOwnerWorkerId === workerId) {
this.currentOwnerWorkerId = null
this.currentOwnerOrderNumber = null
}
log.verbose('[CONCURRENCY] Worker released popup mutex', {
workerId,
orderNumber,
activeWorkers: this.activeWorkers,
queueDepth: this.waitQueue
})
}
}
@@ -148,20 +199,20 @@ export function getMissingOrders(inputOrders: string[], processedOrders: Set<str
export async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
worker: (item: T, index: number, workerId: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length)
const limit = Math.max(1, Math.trunc(concurrency))
let cursor = 0
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
const runners = Array.from({ length: Math.min(limit, items.length) }, async (_, workerId) => {
while (true) {
const current = cursor
cursor += 1
if (current >= items.length) {
return
}
results[current] = await worker(items[current], current)
results[current] = await worker(items[current], current, workerId)
}
})
@@ -262,6 +313,10 @@ export class CleanerService {
1,
MAX_PROCESS_CONCURRENCY
)
const sessionRefreshOrderThreshold =
input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0
? Math.trunc(input.sessionRefreshOrderThreshold)
: DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
log.info('Starting cleaner', {
totalOrders,
@@ -269,6 +324,7 @@ export class CleanerService {
dryRun,
queryBatchSize,
processConcurrency,
sessionRefreshOrderThreshold,
orderNumbers: input.orderNumbers,
materialCodes: input.materialCodes
})
@@ -281,12 +337,13 @@ export class CleanerService {
const session = this.authService.getSession()
const navigation = await this.navigateToCleanerPage(session)
popupPage = navigation.popupPage
const { workFrame } = navigation
let { workFrame } = navigation
await this.setupQueryInterface(workFrame)
await this.setupQueryInterface(workFrame, popupPage)
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
const popupMutex = new AsyncMutex()
let ordersProcessedSinceLogin = 0
const progressState: ProgressState = {
ordersStarted: 0,
ordersCompleted: 0,
@@ -329,7 +386,7 @@ export class CleanerService {
await trackDuration(
async () => {
// Phase 1: Query orders
await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), {
await trackDuration(async () => await this.queryOrders(workFrame, popupPage!, batchOrders), {
operationName: 'query',
message: '执行订单查询',
slowThresholdMs: 3000,
@@ -345,7 +402,7 @@ export class CleanerService {
// Phase 3: Collect query results
const collectResult = await trackDuration(
async () => await this.collectQueryResultRows(workFrame),
async () => await this.collectQueryResultRows(workFrame, popupPage!),
{
operationName: 'collect_results',
message: '收集查询结果',
@@ -358,20 +415,29 @@ export class CleanerService {
// Phase 4: Process all orders in batch
await trackDuration(
async () => {
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
await runWithConcurrency(queriedRows, processConcurrency, async (row, _index, workerId) => {
const { rowIndex, orderNumber } = row
// [新增] Worker 开始追踪
tracker.workerStarted()
tracker.workerStarted(workerId, orderNumber)
try {
const openedDetailPage = await popupMutex.runExclusive(async () => {
// [新增] Mutex 等待追踪
tracker.waitingForMutex()
const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
// [新增] Mutex 获取追踪
tracker.acquiredMutex()
return page
tracker.waitingForMutex(workerId, orderNumber)
try {
const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex, {
orderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
workerId
})
// [新增] Mutex 获取追踪
tracker.acquiredMutex(workerId, orderNumber)
return page
} finally {
tracker.releasedMutex(workerId, orderNumber)
}
})
let detail: OrderCleanDetail
@@ -408,7 +474,7 @@ export class CleanerService {
result.uncertainDeletions += detail.uncertainDeletions
} finally {
// [新增] Worker 完成追踪
tracker.workerCompleted()
tracker.workerCompleted(workerId, orderNumber)
}
})
@@ -446,6 +512,51 @@ export class CleanerService {
// [新增] 清理健康检查定时器
clearInterval(healthCheckInterval)
ordersProcessedSinceLogin += batchOrders.length
const remainingBatches = orderBatches.length - (batchIndex + 1)
log.info('[SESSION_REFRESH_CHECK] 批次完成,检查是否需要重建会话', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
if (remainingBatches > 0 && ordersProcessedSinceLogin >= sessionRefreshOrderThreshold) {
log.info('[SESSION_REFRESH_TRIGGERED] 达到阈值,准备重建会话', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
const refreshedNavigation = await this.refreshSessionAtBatchBoundary({
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
threshold: sessionRefreshOrderThreshold,
ordersProcessedSinceLogin,
totalOrders,
completedOrders: progressState.ordersCompleted
})
popupPage = refreshedNavigation.popupPage
workFrame = refreshedNavigation.workFrame
ordersProcessedSinceLogin = 0
} else if (remainingBatches === 0 && ordersProcessedSinceLogin >= sessionRefreshOrderThreshold) {
log.info('[SESSION_REFRESH_SKIPPED] 已达到阈值但无剩余批次,跳过重建', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
}
}
const retryResult = await this.retryFailedOrders({
@@ -556,6 +667,10 @@ export class CleanerService {
elapsedMs: Date.now() - navStartTime,
popupOpened: !!popupPage
})
await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', {
level: 'info',
elapsedMs: Date.now() - navStartTime
})
// Step 3: Get forward frame
log.debug('[导航 Step 3] 获取 forwardFrame 框架')
@@ -612,6 +727,10 @@ export class CleanerService {
log.debug('[导航 Step 5] 等待页面就绪标志', { selector: '#hot-key-head_list', timeout: 30000 })
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
const totalNavTime = Date.now() - navStartTime
await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', {
level: 'info',
elapsedMs: totalNavTime
})
log.info('[导航完成] 已导航到清理页面', {
totalNavTimeMs: totalNavTime,
isSlow: totalNavTime > 5000
@@ -620,9 +739,13 @@ export class CleanerService {
return { popupPage, workFrame }
}
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise<void> {
const setupStartTime = Date.now()
log.debug('[查询界面设置开始] 准备配置查询界面')
await this.logPageStateSnapshot(popupPage, 'query.setup.start', {
level: 'debug',
elapsedMs: 0
})
// Step 1: Click search icon
log.debug('[查询设置 Step 1] 点击搜索图标')
@@ -647,6 +770,10 @@ export class CleanerService {
await inputEl.fill('5000')
await inputEl.press('Enter')
const totalSetupTime = Date.now() - setupStartTime
await this.logPageStateSnapshot(popupPage, 'query.setup.ready', {
level: 'info',
elapsedMs: totalSetupTime
})
log.debug('[查询设置完成] 查询界面配置完毕', {
totalSetupTimeMs: totalSetupTime,
queryLimit: 5000,
@@ -654,7 +781,11 @@ export class CleanerService {
})
}
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
private async queryOrders(
workFrame: FrameLocator,
popupPage: Page,
orderNumbers: string[]
): Promise<void> {
const queryStartTime = Date.now()
log.debug('[订单查询开始]', {
orderCount: orderNumbers.length,
@@ -663,6 +794,10 @@ export class CleanerService {
.concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []),
isPreview: orderNumbers.length > 5
})
await this.logPageStateSnapshot(popupPage, 'query.before_submit', {
level: 'debug',
elapsedMs: 0
})
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
log.debug('[订单查询] 准备填入订单号')
@@ -678,9 +813,16 @@ export class CleanerService {
elapsedMs: Date.now() - queryStartTime,
orderCount: orderNumbers.length
})
await this.logPageStateSnapshot(popupPage, 'query.after_submit', {
level: 'info',
elapsedMs: Date.now() - queryStartTime
})
}
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
private async collectQueryResultRows(
workFrame: FrameLocator,
popupPage: Page
): Promise<QueryResultRow[]> {
const collectStartTime = Date.now()
log.debug('[查询结果收集开始] 准备读取查询结果表格')
@@ -707,6 +849,10 @@ export class CleanerService {
}
const totalCollectTime = Date.now() - collectStartTime
await this.logPageStateSnapshot(popupPage, 'query.results.collected', {
level: 'info',
elapsedMs: totalCollectTime
})
log.info('[查询结果收集完成]', {
totalRowsScanned: rowCount,
validOrderCount,
@@ -754,37 +900,111 @@ export class CleanerService {
private async openDetailPageFromRow(
workFrame: FrameLocator,
popupPage: Page,
rowIndex: number
rowIndex: number,
options?: {
orderNumber?: string
orderIndex?: number
orderPosition?: string
workerId?: number
}
): Promise<Page> {
const openStartTime = Date.now()
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
log.info('[NAV_EVENT] 准备从查询结果打开详情页', {
step: 'detail.open.from_query_row',
rowIndex,
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId
})
await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', {
level: 'debug',
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId,
elapsedMs: Date.now() - openStartTime
})
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
await this.clickMaterialPlanMenu(workFrame, popupPage, options)
return await detailPagePromise
const detailPage = await detailPagePromise
log.info('[POPUP_EVENT] 详情页弹窗已创建', {
step: 'detail.popup.opened',
rowIndex,
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId,
popupUrl: detailPage.url(),
elapsedMs: Date.now() - openStartTime
})
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', {
level: 'info',
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId,
elapsedMs: Date.now() - openStartTime
})
return detailPage
}
private async openDetailPageFromCurrentQuery(
workFrame: FrameLocator,
popupPage: Page
popupPage: Page,
orderNumber?: string
): Promise<Page> {
const openStartTime = Date.now()
const firstRow = workFrame.locator('tbody tr').first()
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', {
step: 'detail.open.from_current_query',
orderNumber
})
await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', {
level: 'debug',
orderNumber,
elapsedMs: Date.now() - openStartTime
})
const moreButton = firstRow.locator('a.row-more').first()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber })
return await detailPagePromise
const detailPage = await detailPagePromise
log.info('[POPUP_EVENT] 详情页弹窗已创建', {
step: 'detail.popup.opened.retry',
orderNumber,
popupUrl: detailPage.url(),
elapsedMs: Date.now() - openStartTime
})
await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', {
level: 'info',
orderNumber,
elapsedMs: Date.now() - openStartTime
})
return detailPage
}
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
private async clickMaterialPlanMenu(
workFrame: FrameLocator,
popupPage: Page,
options?: {
orderNumber?: string
orderIndex?: number
orderPosition?: string
workerId?: number
}
): Promise<void> {
const candidates = [
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
hasText: /^备料计划$/
@@ -798,7 +1018,21 @@ export class CleanerService {
const target = candidate.last()
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
log.info('[NAV_EVENT] 点击备料计划菜单', {
step: 'detail.menu.material_plan',
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId
})
await target.click()
await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', {
level: 'debug',
orderNumber: options?.orderNumber,
orderIndex: options?.orderIndex,
orderPosition: options?.orderPosition,
workerId: options?.workerId
})
return
} catch {
// Try next locator candidate
@@ -811,6 +1045,106 @@ export class CleanerService {
throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)')
}
private getBrowserContext(): BrowserContext | undefined {
try {
return this.authService.getSession().context
} catch {
return undefined
}
}
private async logPageStateSnapshot(
page: Page,
step: string,
options: {
level?: 'info' | 'warn' | 'error' | 'debug'
orderNumber?: string
orderIndex?: number
orderPosition?: string
workerId?: number
elapsedMs?: number
includeFrameHierarchy?: boolean
includeBodyTextPreview?: boolean
} = {}
) {
const pageState = await capturePageState(page, this.getBrowserContext(), {
includeFrameHierarchy: options.includeFrameHierarchy,
includeBodyTextPreview: options.includeBodyTextPreview
})
const payload = {
step,
orderNumber: options.orderNumber,
orderIndex: options.orderIndex,
orderPosition: options.orderPosition,
workerId: options.workerId,
elapsedMs: options.elapsedMs,
...pageState
}
const level = options.level ?? 'info'
log[level]('[PAGE_STATE] 页面状态快照', payload)
return payload
}
private async ensureNotRedirectedToLoginPage(
page: Page,
step: string,
expectedOrderNumber: string | undefined,
progressState: ProgressState,
elapsedMs: number
): Promise<void> {
const pageState = await capturePageState(page, this.getBrowserContext(), {
includeFrameHierarchy: true,
includeBodyTextPreview: true
})
if (!pageState.isCasLoginRedirect && pageState.pageKind !== 'login') {
return
}
log.error('[SESSION_LOST] 会话跳转到 CAS 登录页', {
step,
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs,
detectedBy: pageState.isCasLoginRedirect ? 'url_match' : 'login_form_detected',
...pageState
})
throw new Error('ERP 会话已跳转到登录页')
}
private async refreshSessionAtBatchBoundary(params: {
batchIndex: number
totalBatches: number
batchSize: number
threshold: number
ordersProcessedSinceLogin: number
totalOrders: number
completedOrders: number
}): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
const refreshStartTime = Date.now()
log.info('[SESSION_REFRESH_START] 开始关闭浏览器并重新登录', {
...params
})
await this.authService.close()
const session = await this.authService.login()
const navigation = await this.navigateToCleanerPage(session)
await this.setupQueryInterface(navigation.workFrame, navigation.popupPage)
log.info('[SESSION_REFRESH_SUCCESS] 会话重建成功', {
...params,
elapsedMs: Date.now() - refreshStartTime
})
return navigation
}
private async processDetailPage(params: {
detailPage: Page
deleteSet: Set<string>
@@ -861,16 +1195,40 @@ export class CleanerService {
}
try {
await this.logPageStateSnapshot(detailPage, 'detail.page.opened', {
level: 'debug',
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs: Date.now() - processStartTime
})
// Step 1: Access forward frame
log.debug('[详情页面 Step 1] 准备访问 forwardFrame')
await this.ensureNotRedirectedToLoginPage(
detailPage,
'detail.step1.forwardFrame',
expectedOrderNumber,
progressState,
Date.now() - processStartTime
)
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
const errorMsg = '无法访问详情页面的 forwardFrame'
log.error('[详情页面失败] forwardFrame 访问失败', {
const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step1.forwardFrame', {
level: 'error',
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs: Date.now() - processStartTime,
pageUrl: detailPage.url(),
includeFrameHierarchy: true,
includeBodyTextPreview: true
})
const errorMsg = '无法访问详情页面的 forwardFrame'
log.error('[DETAIL_PAGE_INVALID] 详情页未建立', {
failureKind: 'forward_frame_missing',
...pageState,
contextData: await capturePageContext(
detailPage,
undefined,
@@ -888,15 +1246,49 @@ export class CleanerService {
// Step 2: Access inner frame
log.debug('[详情页面 Step 2] 等待并获取 mainiframe 内部框架', { timeout: 30000 })
await this.ensureNotRedirectedToLoginPage(
detailPage,
'detail.step2.mainiframe',
expectedOrderNumber,
progressState,
Date.now() - processStartTime
)
const detailInnerLocator = dFrame.locator('#mainiframe')
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
try {
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
} catch (error) {
const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.mainiframe', {
level: 'error',
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs: Date.now() - processStartTime,
includeFrameHierarchy: true,
includeBodyTextPreview: true
})
log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', {
failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'mainiframe_missing',
...pageState,
error: error instanceof Error ? error.message : String(error)
})
throw error
}
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
const errorMsg = '无法访问详情页面的内部框架'
log.error('[详情页面失败] 内部框架访问失败', {
const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.detailInnerFrame', {
level: 'error',
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs: Date.now() - processStartTime,
pageUrl: detailPage.url(),
includeFrameHierarchy: true,
includeBodyTextPreview: true
})
const errorMsg = '无法访问详情页面的内部框架'
log.error('[DETAIL_PAGE_INVALID] 详情页未建立', {
failureKind: 'mainiframe_missing',
...pageState,
contextData: await capturePageContext(
detailPage,
undefined,
@@ -917,9 +1309,36 @@ export class CleanerService {
selector: '离散备料计划维护',
timeout: 30000
})
await detailInnerFrame
.getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 })
await this.ensureNotRedirectedToLoginPage(
detailPage,
'detail.step3.header',
expectedOrderNumber,
progressState,
Date.now() - processStartTime
)
try {
await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({
state: 'visible',
timeout: 30000
})
} catch (error) {
const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step3.header', {
level: 'error',
orderNumber: expectedOrderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
elapsedMs: Date.now() - processStartTime,
includeFrameHierarchy: true,
includeBodyTextPreview: true
})
log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', {
failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'detail_header_missing',
expectedMarker: '离散备料计划维护:',
...pageState,
error: error instanceof Error ? error.message : String(error)
})
throw error
}
log.debug('[详情页面 Step 3 完成] 页面标题已显示', {
elapsedMs: Date.now() - processStartTime
})
@@ -1743,7 +2162,7 @@ export class CleanerService {
try {
// Step 1: Re-query order
log.debug('[重试查询] 重新查询订单', { orderNumber, attempt })
await this.queryOrders(workFrame, [orderNumber])
await this.queryOrders(workFrame, popupPage, [orderNumber])
await this.waitForLoading(workFrame)
log.debug('[重试查询完成] 查询加载完成', {
orderNumber,
@@ -1768,7 +2187,11 @@ export class CleanerService {
// Step 3: Open detail page
log.debug('[重试详情] 打开订单详情页', { orderNumber, attempt })
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
const detailPage = await this.openDetailPageFromCurrentQuery(
workFrame,
popupPage,
orderNumber
)
log.debug('[重试详情] 详情页已打开', {
orderNumber,
attempt,

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 })
@@ -156,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

@@ -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

@@ -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

View File

@@ -6,8 +6,10 @@
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useTransition } from 'react'
import { Modal } from './ui/Modal'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
import { useLogger } from '../hooks/useLogger'
import {
RefreshCw,
@@ -16,18 +18,28 @@ import {
ChevronRight,
CheckCircle,
XCircle,
CircleMinus,
AlertTriangle,
Clock,
Copy,
FlaskConical
FlaskConical,
Search,
X
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
CleanerHistoryBatchStats,
CleanerHistoryOrderRecord,
CleanerHistoryMaterialRecord
CleanerHistoryMaterialRecord,
CleanerHistorySearchResult
} from '../hooks/cleaner/types'
import {
canStartHistoryLoad,
getNextHistoryLoadState,
type HistoryLoadState
} from './cleaner-history-load-state'
import { highlightText } from './cleaner-history-highlight'
import {
getCleanerHistoryStatusDisplay,
getCleanerMaterialResultDisplay
} from './cleaner-history-status'
// The preload API returns Date for time fields, but IPC serialization converts them to strings.
// Use a local type that accommodates both to satisfy TypeScript.
@@ -62,37 +74,16 @@ 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[]
}>
}
const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
crashed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700',
not_found: 'bg-orange-100 text-orange-700',
erp_not_found: 'bg-orange-100 text-orange-700'
}
const statusLabels: Record<string, string> = {
success: '成功',
partial: '部分成功',
failed: '失败',
crashed: '崩溃',
pending: '进行中',
not_found: '未找到',
erp_not_found: 'ERP不存在'
}
const statusIcons: Record<string, React.ReactNode> = {
success: <CheckCircle size={16} className="text-green-600" />,
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
crashed: <XCircle size={16} className="text-red-600" />,
pending: <Clock size={16} className="text-gray-500" />,
not_found: <XCircle size={16} className="text-orange-600" />,
erp_not_found: <XCircle size={16} className="text-orange-600" />
}
const BATCH_PAGE_SIZE = 5
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
if (!dateStr) return '-'
@@ -135,8 +126,8 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
// ====== BatchItem Component ======
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
// Each BatchItem manages its own details, orders, and material state locally.
const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete, searchQuery, preloadedExecutions, preloadedOrders }: BatchItemProps) => {
const [isExpanded, setIsExpanded] = useState(() => !!searchQuery)
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
@@ -145,46 +136,89 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
() => new Map()
)
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
const [materialLoadStates, setMaterialLoadStates] = useState<Map<string, HistoryLoadState>>(
() => new Map()
)
const [detailsLoadState, setDetailsLoadState] = useState<HistoryLoadState>('idle')
const [isDeleting, setIsDeleting] = useState(false)
const detailsLoadedRef = useRef(false)
const loadedMaterialsRef = useRef<Set<string>>(new Set())
const logger = useLogger('BatchItem')
// Fetch batch details when first expanded
// Initialize from preloaded data in search mode
useEffect(() => {
if (!isExpanded || detailsLoadedRef.current) return
detailsLoadedRef.current = true
if (searchQuery && preloadedExecutions && preloadedOrders) {
setExecutions(preloadedExecutions)
setDetailsLoadState('success')
const fetchDetails = async () => {
try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
if (result.success && result.data) {
setExecutions(result.data.executions)
setOrders(result.data.orders)
const orders = preloadedOrders.map(p => p.order)
setOrders(orders)
const execs = result.data.executions
if (execs.length > 0) {
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
}
}
} catch (err) {
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
if (preloadedExecutions.length > 0) {
setCurrentAttempt(Math.max(...preloadedExecutions.map(e => e.attemptNumber)))
}
}
void fetchDetails()
}, [isExpanded, batch.batchId, logger])
// Pre-populate materials map
const materialsMap = new Map<string, CleanerHistoryMaterialRecord[]>()
const expandedSet = new Set<string>()
for (const { order, materials } of preloadedOrders) {
if (materials.length > 0) {
const cacheKey = `${order.attemptNumber}:${order.orderNumber}`
materialsMap.set(cacheKey, materials)
expandedSet.add(cacheKey)
}
}
setOrderMaterials(materialsMap)
setExpandedOrders(expandedSet)
// Mark all materials as loaded
const loadStatesMap = new Map<string, HistoryLoadState>()
for (const { order } of preloadedOrders) {
const cacheKey = `${order.attemptNumber}:${order.orderNumber}`
loadStatesMap.set(cacheKey, 'success')
}
setMaterialLoadStates(loadStatesMap)
}
}, [searchQuery, preloadedExecutions, preloadedOrders])
const fetchDetails = useCallback(async () => {
if (!canStartHistoryLoad(detailsLoadState)) return
setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start'))
try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
if (result.success && result.data) {
setExecutions(result.data.executions)
setOrders(result.data.orders)
const execs = result.data.executions
if (execs.length > 0) {
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
}
setDetailsLoadState('success')
} else {
setDetailsLoadState('error')
}
} catch (err) {
setDetailsLoadState('error')
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId
})
}
}, [batch.batchId, detailsLoadState, logger])
const fetchMaterials = useCallback(
async (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}`
if (loadedMaterialsRef.current.has(cacheKey)) return
loadedMaterialsRef.current.add(cacheKey)
const loadState = materialLoadStates.get(cacheKey) ?? 'idle'
if (!canStartHistoryLoad(loadState)) return
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start'))
return next
})
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
try {
const result = await window.electron.cleaner.getHistoryMaterialDetails(
@@ -194,8 +228,24 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
)
if (result.success && result.data) {
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'success')
return next
})
} else {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
}
} catch (err) {
setMaterialLoadStates((prev) => {
const next = new Map(prev)
next.set(cacheKey, 'error')
return next
})
logger.error('Failed to fetch material details', {
error: err instanceof Error ? err.message : String(err),
batchId: batch.batchId,
@@ -210,9 +260,17 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
})
}
},
[batch.batchId, logger]
[batch.batchId, logger, materialLoadStates]
)
const toggleBatchExpansion = () => {
const nextExpanded = !isExpanded
setIsExpanded(nextExpanded)
if (nextExpanded) {
void fetchDetails()
}
}
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}`
const isCurrentlyExpanded = expandedOrders.has(cacheKey)
@@ -232,7 +290,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const handleDelete = async () => {
if (isDeleting) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
const confirmed = await onRequestDelete(batch.batchId)
if (!confirmed) return
setIsDeleting(true)
@@ -250,12 +308,16 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
}
}
const filteredOrders =
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
const visibleExecutions =
currentAttempt !== undefined
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
: executions
const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status)
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
const filtered =
currentAttempt !== undefined
? orders.filter((o) => o.attemptNumber === currentAttempt)
: orders
const values = filtered
const values = filteredOrders
.map((o) => String(o[field] ?? ''))
.filter((v) => v && v !== '-')
.join('\n')
@@ -271,9 +333,6 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
.catch(() => showError('复制失败,请手动复制'))
}
const filteredOrders =
currentAttempt !== undefined ? orders.filter((o) => o.attemptNumber === currentAttempt) : orders
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Batch summary */}
@@ -281,7 +340,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => setIsExpanded((prev) => !prev)}
onClick={toggleBatchExpansion}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
@@ -295,18 +354,20 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
<div className="font-medium text-gray-900">
{searchQuery ? highlightText(batch.username, searchQuery) : batch.username}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="flex items-center gap-1">
{statusIcons[batch.status] || statusIcons.pending}
{batchStatusDisplay.icon}
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[batch.status] || statusStyles.pending
}`}
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
>
{statusLabels[batch.status] || batch.status}
{searchQuery
? highlightText(batchStatusDisplay.label, searchQuery)
: batchStatusDisplay.label}
</span>
</div>
</div>
@@ -384,38 +445,42 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
</div>
)}
<div className="flex flex-wrap gap-4 text-xs text-gray-600">
{executions
.filter((e) => currentAttempt === undefined || e.attemptNumber === currentAttempt)
.map((exec) => (
<React.Fragment key={exec.attemptNumber}>
<span>{formatDuration(exec.operationTime, exec.endTime)}</span>
<span>
{exec.ordersProcessed}/{exec.totalOrders}
{visibleExecutions.map((exec) => (
<React.Fragment key={exec.attemptNumber}>
<span>{formatDuration(exec.operationTime, exec.endTime)}</span>
<span>
{exec.ordersProcessed}/{exec.totalOrders}
</span>
<span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span>
<span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span>
)}
{exec.errorMessage && (
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
</React.Fragment>
))}
)}
{exec.errorMessage && (
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
</React.Fragment>
))}
</div>
</div>
)}
{/* Order table */}
{filteredOrders.length > 0 ? (
{detailsLoadState === 'loading' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-gray-500">...</div>
) : detailsLoadState === 'error' && executions.length === 0 && orders.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-red-600">
</div>
) : filteredOrders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
@@ -452,6 +517,8 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
const isOrderExpanded = expandedOrders.has(orderKey)
const materials = orderMaterials.get(orderKey) || []
const isLoadingMaterials = loadingMaterials.has(orderKey)
const materialLoadState = materialLoadStates.get(orderKey) ?? 'idle'
const orderStatusDisplay = getCleanerHistoryStatusDisplay(order.status)
return (
<React.Fragment key={orderKey}>
@@ -475,19 +542,21 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
{index + 1}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.productionId || '-'}
{order.productionId
? (searchQuery ? highlightText(order.productionId, searchQuery) : order.productionId)
: '-'}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.status === 'not_found' ? '-' : order.orderNumber}
{order.status === 'not_found'
? '-'
: (searchQuery ? highlightText(order.orderNumber, searchQuery) : order.orderNumber)}
</td>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[order.status] || statusStyles.pending
}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${orderStatusDisplay.badgeClassName}`}
>
{statusIcons[order.status]}
{statusLabels[order.status] || order.status}
{orderStatusDisplay.icon}
{orderStatusDisplay.label}
</span>
</td>
<td className="px-4 py-2">
@@ -531,6 +600,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
<td colSpan={11} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? (
<div className="text-xs text-gray-500">...</div>
) : materialLoadState === 'error' ? (
<div className="text-xs text-red-600">
</div>
) : materials.length > 0 ? (
<table className="w-full text-xs">
<thead>
@@ -554,79 +627,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
</thead>
<tbody className="divide-y divide-gray-100">
{materials.map((mat, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">
{idx + 1}
</td>
<td className="px-3 py-1.5 font-mono text-gray-700">
{mat.materialCode}
</td>
<td className="px-3 py-1.5 text-gray-700">
{mat.materialName}
</td>
<td className="px-3 py-1.5 text-gray-600">
{mat.rowNumber}
</td>
<td className="px-3 py-1.5">
{mat.result === 'success' || mat.result === 'deleted' ? (
<span
className="inline-block cursor-help"
title="Deleted"
>
<CheckCircle
size={16}
className="text-green-600 flex-shrink-0"
aria-label="Deleted"
/>
</span>
) : mat.result === 'skipped' ? (
<span
className="inline-block cursor-help"
title="Skipped"
>
<CircleMinus
size={16}
className="text-gray-400 flex-shrink-0"
aria-label="Skipped"
/>
</span>
) : mat.result === 'uncertain' ? (
<span
className="inline-block cursor-help"
title="Uncertain"
>
<AlertTriangle
size={16}
className="text-amber-600 flex-shrink-0"
aria-label="Uncertain"
/>
</span>
) : mat.result?.startsWith('failed') ? (
<span
className="inline-block cursor-help"
title="Failed"
>
<XCircle
size={16}
className="text-red-600 flex-shrink-0"
aria-label="Failed"
/>
</span>
) : null}
</td>
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
{mat.reason || '-'}
</td>
<td className="px-3 py-1.5 text-gray-600">
{mat.attemptCount > 1 ? (
<span className="text-amber-600">
{mat.attemptCount}
</span>
) : (
'1'
)}
</td>
</tr>
<MaterialDetailRow key={idx} index={idx} material={mat} searchQuery={searchQuery} />
))}
</tbody>
</table>
@@ -653,6 +654,48 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
BatchItem.displayName = 'BatchItem'
interface MaterialDetailRowProps {
index: number
material: CleanerHistoryMaterialRecord
searchQuery?: string
}
const MaterialDetailRow = ({ index, material, searchQuery }: MaterialDetailRowProps): React.JSX.Element => {
const resultDisplay = getCleanerMaterialResultDisplay(material.result)
return (
<tr className="hover:bg-gray-50">
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
<td className="px-3 py-1.5 font-mono text-gray-700">
{searchQuery ? highlightText(material.materialCode, searchQuery) : material.materialCode}
</td>
<td className="px-3 py-1.5 text-gray-700">
{searchQuery ? highlightText(material.materialName, searchQuery) : material.materialName}
</td>
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
<td className="px-3 py-1.5">
{resultDisplay.icon ? (
<span className="inline-block cursor-help" title={resultDisplay.title}>
{resultDisplay.icon}
</span>
) : (
<span className="text-gray-500">{resultDisplay.title}</span>
)}
</td>
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
{material.reason ? (searchQuery ? highlightText(material.reason, searchQuery) : material.reason) : '-'}
</td>
<td className="px-3 py-1.5 text-gray-600">
{material.attemptCount > 1 ? (
<span className="text-amber-600">{material.attemptCount}</span>
) : (
'1'
)}
</td>
</tr>
)
}
// ====== Main Modal Component ======
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
isOpen,
@@ -664,9 +707,18 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
const [error, setError] = useState<string | null>(null)
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [currentPage, setCurrentPage] = useState(0)
const [isFilterPending, startFilterTransition] = useTransition()
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
const [isSearching, setIsSearching] = useState(false)
const { confirm, dialog: confirmDialog } = useConfirmDialog()
const logger = useLogger('CleanerOperationHistory')
const isAdmin = user?.userType === 'Admin'
const hasPreviousPage = currentPage > 0
const hasNextPage = batches.length === BATCH_PAGE_SIZE
const fetchBatches = useCallback(async () => {
setLoading(true)
@@ -674,8 +726,12 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
try {
const options =
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
? {
limit: BATCH_PAGE_SIZE,
offset: currentPage * BATCH_PAGE_SIZE,
usernames: selectedUsers
}
: { limit: BATCH_PAGE_SIZE, offset: currentPage * BATCH_PAGE_SIZE }
const result = await window.electron.cleaner.getHistoryBatches(options)
if (result.success && result.data) {
@@ -688,7 +744,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
} finally {
setLoading(false)
}
}, [isAdmin, selectedUsers])
}, [currentPage, isAdmin, selectedUsers])
const fetchAllUsers = useCallback(async () => {
try {
@@ -717,14 +773,89 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
}, [])
const requestDeleteConfirmation = useCallback(
async (batchId: string) => {
const batch = batches.find((item) => item.batchId === batchId)
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
return confirm({
title: '确认删除历史批次',
message: (
<div className="space-y-3">
<p className="text-gray-700"></p>
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
<div></div>
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
</div>
</div>
),
confirmText: '删除',
cancelText: '取消',
variant: 'danger'
})
},
[batches, confirm]
)
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
startFilterTransition(() => {
setCurrentPage(0)
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
})
}
const clearUserFilters = () => {
setSelectedUsers([])
startFilterTransition(() => {
setCurrentPage(0)
setSelectedUsers([])
})
}
const 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) {
// IPC serialization converts Date to string, so cast to renderer type
setSearchResult(result.data as unknown as CleanerHistorySearchResult)
} else {
setSearchResult({ batches: [], totalMatches: 0 })
}
} catch {
setSearchResult({ batches: [], totalMatches: 0 })
} finally {
setIsSearching(false)
}
}, [searchInput, isAdmin, selectedUsers])
const clearSearch = () => {
setSearchInput('')
setSearchQuery('')
setSearchResult(null)
}
const goToPreviousPage = () => {
setCurrentPage((prev) => Math.max(0, prev - 1))
}
const goToNextPage = () => {
if (!hasNextPage) return
setCurrentPage((prev) => prev + 1)
}
if (!isOpen) return null
@@ -741,6 +872,38 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
{/* Toolbar */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1">
{/* 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>
{isAdmin && allUsers.length > 0 && (
<div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div>
@@ -786,8 +949,9 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
{isFilterPending && <span className="text-sm text-blue-600">...</span>}
</div>
</div>
<button
@@ -809,35 +973,94 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{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>
{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>
)
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => (
<BatchItem
key={batch.batchId}
batch={batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
/>
))}
</div>
// Browse mode (existing logic unchanged)
<>
{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>
{/* Footer */}
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
<div className="pt-4 border-t border-gray-200 flex justify-center">
{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">
<button
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={goToPreviousPage}
disabled={loading || !hasPreviousPage}
>
</button>
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
{currentPage + 1}
</div>
<button
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={goToNextPage}
disabled={loading || !hasNextPage}
>
</button>
</div>
)}
</div>
</div>
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal>
)
}

View File

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

View File

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

View File

@@ -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

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