Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39fc87869 | ||
|
|
4b071e4331 | ||
|
|
28a632d0a7 | ||
|
|
ac43790127 | ||
|
|
723d6de0ae | ||
|
|
9d7fe8f4e7 | ||
|
|
f49f99fc0c | ||
|
|
622543fff4 | ||
|
|
74d9b4042e | ||
|
|
ed9058c93d | ||
|
|
9167359c6e | ||
|
|
5faf26df3f | ||
|
|
3a30694684 | ||
|
|
c61d62fd98 | ||
|
|
aeb3595b36 | ||
|
|
b8925926cb | ||
|
|
2936f1fca3 | ||
|
|
f57fdf69f7 | ||
|
|
bb495c7a93 | ||
|
|
33ffc0406d | ||
|
|
8fa4d6c16d | ||
|
|
cb59dda727 | ||
|
|
fbcaa11b1c | ||
|
|
b5b8af078d | ||
|
|
5b43d5a60c | ||
|
|
936c98a023 | ||
|
|
c661a12287 | ||
|
|
1cd6660774 |
@@ -65,6 +65,7 @@ orderResolution:
|
||||
cleaner:
|
||||
queryBatchSize: 100
|
||||
processConcurrency: 1
|
||||
sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话
|
||||
|
||||
logging:
|
||||
level: info
|
||||
|
||||
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Cleaner v1.11.1 之后更新内容改进计划
|
||||
|
||||
本文档基于 `v1.11.1..v1.12.3` 区间内已完成的前端审查结果整理而成,目标不是重复提交记录,而是为后续实现人员提供一份可以直接排期和落地的改进路线图。计划范围仅覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC 或数据库结构重构。
|
||||
|
||||
## 1. 背景与范围
|
||||
|
||||
本计划覆盖 `v1.11.1` 之后到当前最新版本 `v1.12.3` 的 Cleaner 前端相关更新,重点关注以下变化:
|
||||
|
||||
- 新增 Cleaner 操作历史弹窗
|
||||
- 用数据库持久化替代原有 Markdown 报告查看路径
|
||||
- 为执行结果补充失败与不确定删除统计
|
||||
- 引入 `React.lazy` 和 `BatchItem` 拆分来降低页面负担
|
||||
|
||||
本次计划的核心目标是:
|
||||
|
||||
- 先修复当前历史弹窗与执行结果展示中的稳定性问题
|
||||
- 再优化首屏加载和复杂列表交互性能
|
||||
- 最后补齐长期可维护性和可扩展性基础
|
||||
|
||||
默认审查区间固定为 `v1.11.1..v1.12.3`,默认文档语言为中文,默认落点为 `docs/plans/`。
|
||||
|
||||
## 2. 当前状态总结
|
||||
|
||||
这轮更新已经做对了几件重要的事情:
|
||||
|
||||
- Cleaner 历史记录已经完成数据库化,前端不再依赖旧的 Markdown 报告浏览流
|
||||
- `CleanerOperationHistoryModal` 被独立成单独组件,并通过 `BatchItem` 局部拆分降低兄弟节点联动重渲染
|
||||
- `CleanerPage` 已经开始使用 `React.lazy` 引入历史弹窗与执行报告相关组件
|
||||
- `ExecutionReportDialog` 已经补充 `materialsFailed` 和 `uncertainDeletions` 的展示能力
|
||||
|
||||
这些改动说明整体方向是正确的,但从 React 最佳实践和后续维护成本看,当前实现仍然存在几个明确的改进空间:异步缓存策略不够稳、按需加载没有完全生效、复杂列表的扩展能力有限、前端回归保护不足。
|
||||
|
||||
## 3. 主要改进项
|
||||
|
||||
### P0 立刻修
|
||||
|
||||
#### 3.1 修正历史详情与物料详情的缓存时机
|
||||
|
||||
问题:
|
||||
|
||||
- 当前历史批次详情和物料详情会在请求发起前就标记为“已加载”
|
||||
- 如果首次请求失败,后续再次展开不会重试,用户会长期看到空详情或误导性空状态
|
||||
|
||||
目标:
|
||||
|
||||
- 只在请求成功后写入缓存
|
||||
- 失败后允许再次展开重新请求
|
||||
- 在 UI 上保留现有交互风格,不做视觉重设计
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将详情加载状态拆成 `idle / loading / success / error`
|
||||
- `detailsLoadedRef` 和 `loadedMaterialsRef` 只在成功后更新
|
||||
- 对失败场景提供自然重试路径,优先采用“再次展开即重试”的方式
|
||||
|
||||
预期收益:
|
||||
|
||||
- 避免瞬时请求失败被错误地永久缓存
|
||||
- 提高历史查看功能的稳定性和用户信任感
|
||||
|
||||
#### 3.2 将 Cleaner 历史弹窗改成真正条件挂载
|
||||
|
||||
问题:
|
||||
|
||||
- 当前 `CleanerPage` 虽然使用了 `React.lazy`,但历史弹窗组件仍然会在页面渲染时被挂入树中
|
||||
- 这会导致对应 chunk 仍在首屏阶段就被加载,未达到真正按需加载的效果
|
||||
|
||||
目标:
|
||||
|
||||
- 历史弹窗只在用户打开时才参与渲染和加载
|
||||
- 避免进入 Cleaner 页面就提前下载历史功能代码
|
||||
|
||||
建议方向:
|
||||
|
||||
- 采用条件渲染而不是仅保留 `isOpen` 控制
|
||||
- 延续当前交互样式和打开方式,不调整页面布局
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低 Cleaner 页面的首屏负担
|
||||
- 更符合 `bundle-conditional` 类最佳实践
|
||||
|
||||
#### 3.3 补最小前端回归测试
|
||||
|
||||
问题:
|
||||
|
||||
- 本轮新增了历史弹窗、异步详情展开和执行结果增强,但前端侧缺少对应测试保护
|
||||
|
||||
目标:
|
||||
|
||||
- 为关键行为建立最小可行回归测试
|
||||
- 优先补组件/行为测试,不新增端到端测试要求
|
||||
|
||||
建议方向:
|
||||
|
||||
- 覆盖历史弹窗未打开时不触发懒加载模块请求
|
||||
- 覆盖批次详情和物料详情首次失败后再次展开可重试
|
||||
- 覆盖管理员筛选切换后请求参数与结果一致
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低后续修复和优化时的回归风险
|
||||
- 为后续分页、交互优化提供安全网
|
||||
|
||||
### P1 本周优化
|
||||
|
||||
#### 3.4 为历史列表增加分页能力
|
||||
|
||||
问题:
|
||||
|
||||
- 当前历史列表和明细表格按全量数据渲染,随着批次数量、订单数量和物料数量增加,性能风险会上升
|
||||
|
||||
目标:
|
||||
|
||||
- 让历史列表在数据增长后仍保持可接受的打开和滚动体验
|
||||
|
||||
建议方向:
|
||||
|
||||
- 默认优先采用分页,不先引入虚拟列表库
|
||||
- 先做批次列表分页,再评估是否需要对订单或物料明细做进一步优化
|
||||
|
||||
预期收益:
|
||||
|
||||
- 控制渲染体量
|
||||
- 降低复杂列表在中等数据规模下的卡顿风险
|
||||
|
||||
#### 3.5 管理员筛选切换使用 `startTransition`
|
||||
|
||||
问题:
|
||||
|
||||
- 管理员切换用户筛选时会立即触发批次列表刷新,后续数据量增长后可能影响点击反馈
|
||||
|
||||
目标:
|
||||
|
||||
- 保持筛选按钮点击响应流畅
|
||||
- 将非紧急更新降级处理
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将筛选触发的列表刷新包装到 `startTransition`
|
||||
- 保持现有筛选交互模型不变
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低筛选切换时的阻塞感
|
||||
- 更符合 React 对非紧急更新的建议用法
|
||||
|
||||
#### 3.6 收敛重复派生计算
|
||||
|
||||
问题:
|
||||
|
||||
- 当前实现中存在多处基于 `orders` 和 `currentAttempt` 的重复 `filter/map`
|
||||
- 数据规模扩大后,这些重复遍历会逐步放大渲染成本
|
||||
|
||||
目标:
|
||||
|
||||
- 让渲染中的数据派生更集中、更可读
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将当前 attempt 对应订单集合收敛成单一派生结果
|
||||
- 复制列内容等行为复用同一份派生数据
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低不必要的重复计算
|
||||
- 让 `BatchItem` 的渲染路径更容易维护
|
||||
|
||||
#### 3.7 优化执行报告的结果语义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前执行报告的标题和成功态仍主要依赖 `errors`
|
||||
- 当存在 `materialsFailed` 或 `uncertainDeletions` 时,结果表达仍可能显得过于乐观
|
||||
|
||||
目标:
|
||||
|
||||
- 让执行结果清楚区分成功、部分成功、失败、需人工确认
|
||||
|
||||
建议方向:
|
||||
|
||||
- 重新定义结果态判定优先级
|
||||
- 在不重做 UI 视觉设计的前提下,优化标题、说明文案和结果提示条
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低误判执行结果的风险
|
||||
- 让失败和不确定删除场景更容易被用户注意到
|
||||
|
||||
### P2 后续演进
|
||||
|
||||
#### 3.8 统一状态映射定义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前状态的 label、icon、style 已有集中趋势,但仍是组件内局部定义
|
||||
- 后续新增状态时容易出现展示不一致
|
||||
|
||||
目标:
|
||||
|
||||
- 用统一的受类型约束的映射管理状态展示
|
||||
|
||||
建议方向:
|
||||
|
||||
- 抽离共享状态映射
|
||||
- 覆盖 batch、execution、order、material 这几类状态展示
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低重复定义
|
||||
- 提高新增状态时的一致性和可维护性
|
||||
|
||||
#### 3.9 补无障碍语义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前批次展开和订单展开更多依赖点击容器,语义和键盘可达性还有提升空间
|
||||
|
||||
目标:
|
||||
|
||||
- 让复杂历史弹窗具备更清晰的交互语义
|
||||
|
||||
建议方向:
|
||||
|
||||
- 使用真实按钮作为展开触发器
|
||||
- 增加 `aria-expanded`、`aria-controls` 等属性
|
||||
|
||||
预期收益:
|
||||
|
||||
- 提升键盘交互和屏幕阅读器兼容性
|
||||
- 为后续复杂交互维护提供更稳定语义基础
|
||||
|
||||
#### 3.10 规划历史查询的扩展能力
|
||||
|
||||
问题:
|
||||
|
||||
- 当前查询能力主要围绕固定数量批次列表和基础筛选
|
||||
- 如果历史功能继续增强,前端会越来越依赖更丰富的查询条件
|
||||
|
||||
目标:
|
||||
|
||||
- 为后续历史功能演进预留明确方向
|
||||
|
||||
建议方向:
|
||||
|
||||
- 预留时间范围筛选
|
||||
- 预留状态筛选
|
||||
- 延续服务端分页方向,而不是继续扩大前端一次性加载量
|
||||
|
||||
预期收益:
|
||||
|
||||
- 让后续功能迭代有稳定扩展路径
|
||||
- 避免复杂度持续堆积在当前单一弹窗实现中
|
||||
|
||||
## 4. 推荐执行顺序
|
||||
|
||||
建议按以下顺序推进:
|
||||
|
||||
1. 先修 `P0`,优先处理缓存时机错误和按需加载未完全生效的问题
|
||||
2. 在 `P0` 修复完成后补最小前端回归测试,锁住关键行为
|
||||
3. 再做 `P1`,先分页,再处理 `startTransition` 和重复派生计算
|
||||
4. 最后进入 `P2`,统一状态映射、补无障碍语义,并规划历史查询扩展能力
|
||||
|
||||
这个顺序的原则是:先修稳定性,再做性能,再做长期演进。
|
||||
|
||||
## 5. 完成标准
|
||||
|
||||
本计划相关改进完成后,至少应满足以下验收标准:
|
||||
|
||||
- 历史弹窗未打开时,不触发对应懒加载模块请求
|
||||
- 批次详情或物料详情首次请求失败后,用户再次展开可重新请求
|
||||
- 用户筛选切换后,列表数据与筛选条件一致
|
||||
- 执行报告在存在 `materialsFailed` 或 `uncertainDeletions` 时,不再展示为完全成功
|
||||
- `npm run typecheck` 通过
|
||||
- 相关前端测试通过
|
||||
- Cleaner 页面关键路径手工验证通过,包括:
|
||||
- 打开历史弹窗
|
||||
- 展开批次详情
|
||||
- 展开订单物料详情
|
||||
- 切换管理员筛选
|
||||
- 查看执行结果提示
|
||||
|
||||
## 6. 默认方案与实施约束
|
||||
|
||||
为避免后续实现阶段再次做不必要决策,本计划固定以下默认方案:
|
||||
|
||||
- 历史列表优先采用分页,不先引入虚拟列表库
|
||||
- 历史弹窗继续保留现有交互样式,不做视觉重设计
|
||||
- 测试优先补组件/行为测试,不新增端到端测试要求
|
||||
- 本计划只覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC、数据库结构重构
|
||||
|
||||
如果后续版本继续围绕 Cleaner 历史功能扩展,可以在本计划基础上继续追加更细的实施文档,但不应改变本计划中 `P0 / P1 / P2` 的优先级顺序。
|
||||
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal file
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal 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)
|
||||
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal file
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal 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
5
docs/releases/1.12.3.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 1.12.3
|
||||
|
||||
## 内部优化
|
||||
|
||||
- 清理项目根目录无用文件,移除已弃用的 Playwright 配置和调试脚本。
|
||||
11
docs/releases/1.12.4.md
Normal file
11
docs/releases/1.12.4.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.12.4
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复清理器操作历史在 PostgreSQL 数据库下无法正常加载的问题。
|
||||
- 修复 PostgreSQL 环境下物料数据写入失败的问题,支持无唯一约束的表。
|
||||
|
||||
## 改进
|
||||
|
||||
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
|
||||
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。
|
||||
18
docs/releases/1.13.0.md
Normal file
18
docs/releases/1.13.0.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 1.13.0
|
||||
|
||||
## 清理操作历史
|
||||
|
||||
- 新增历史记录全局搜索,支持按批次 ID、订单号、物料编码/名称、用户名、状态等关键字检索。
|
||||
- 搜索结果高亮显示匹配关键字,快速定位目标记录。
|
||||
- 搜索结果自动展开批次详情、订单和物料明细,无需逐层手动点击。
|
||||
- 搜索支持与用户筛选联动,管理员可限定搜索范围到指定用户。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 物料类型管理面板样式更新,改善整体视觉一致性。
|
||||
- 修复管理员模式下物料类型管理面板的悬浮重叠问题。
|
||||
|
||||
## 改进
|
||||
|
||||
- 优化历史搜索查询性能,多个批次数据并行获取,减少等待时间。
|
||||
- 物料类型管理面板加载速度优化,减少不必要的重渲染。
|
||||
16
docs/releases/1.14.0.md
Normal file
16
docs/releases/1.14.0.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 1.14.0
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 清理任务新增长批次自动重建会话机制,在大批量订单处理中可按批次边界自动重开浏览器并继续执行,降低长时间运行中断的概率。
|
||||
- 会话重建阈值支持配置,可结合批次大小灵活调整重建时机,避免在批次处理中途打断流程。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复无头模式下大批量清理任务更容易在后半程停滞的问题,提升真实数据处理场景下的完成率。
|
||||
- 优化清理流程在登录态异常场景下的识别能力,减少详情页长时间等待后才暴露问题的情况。
|
||||
|
||||
## 改进
|
||||
|
||||
- 新增 ERP 页面状态诊断日志,可区分登录页、首页、查询页、详情页等关键页面状态,方便快速定位异常发生位置。
|
||||
- 补充详情页打开、页面跳转、弹窗创建和并发等待等关键日志,排查间歇性问题时可以更快还原现场。
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.12.2",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erpauto",
|
||||
"version": "1.12.2",
|
||||
"version": "1.14.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.12.2",
|
||||
"version": "1.14.0",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ export class CleanerApplicationService {
|
||||
log.info('Login successful')
|
||||
|
||||
const totalOrders = validOrderNumbers.length
|
||||
const cleanerConfig = configManager.getConfig().cleaner
|
||||
const effectiveSessionRefreshOrderThreshold =
|
||||
input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold
|
||||
this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, {
|
||||
phase: 'login',
|
||||
currentOrderIndex: 0,
|
||||
@@ -159,6 +162,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 +172,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,12 +455,16 @@ export class CleanerApplicationService {
|
||||
: result.errors.length > 0
|
||||
? AuditStatus.FAILURE
|
||||
: AuditStatus.SUCCESS
|
||||
const cleanerConfig = ConfigManager.getInstance().getConfig().cleaner
|
||||
const effectiveSessionRefreshOrderThreshold =
|
||||
input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold
|
||||
|
||||
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
|
||||
|
||||
@@ -99,7 +99,8 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
},
|
||||
cleaner: {
|
||||
queryBatchSize: 100,
|
||||
processConcurrency: 1
|
||||
processConcurrency: 1,
|
||||
sessionRefreshOrderThreshold: 160
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: '',
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
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()
|
||||
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')
|
||||
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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
214
src/main/services/erp/page-state.ts
Normal file
214
src/main/services/erp/page-state.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CleanerInput {
|
||||
headless?: boolean
|
||||
queryBatchSize?: number
|
||||
processConcurrency?: number
|
||||
sessionRefreshOrderThreshold?: number
|
||||
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 +136,54 @@ 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 () => {
|
||||
const orders = preloadedOrders.map(p => p.order)
|
||||
setOrders(orders)
|
||||
|
||||
if (preloadedExecutions.length > 0) {
|
||||
setCurrentAttempt(Math.max(...preloadedExecutions.map(e => e.attemptNumber)))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -167,24 +194,31 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void fetchDetails()
|
||||
}, [isExpanded, batch.batchId, logger])
|
||||
}, [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 handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||
const filtered =
|
||||
const filteredOrders =
|
||||
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
|
||||
const visibleExecutions =
|
||||
currentAttempt !== undefined
|
||||
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||
: orders
|
||||
const values = filtered
|
||||
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
|
||||
: executions
|
||||
const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status)
|
||||
|
||||
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||
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,9 +445,7 @@ 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) => (
|
||||
{visibleExecutions.map((exec) => (
|
||||
<React.Fragment key={exec.attemptNumber}>
|
||||
<span>耗时:{formatDuration(exec.operationTime, exec.endTime)}</span>
|
||||
<span>
|
||||
@@ -415,7 +474,13 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
)}
|
||||
|
||||
{/* 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) => {
|
||||
startFilterTransition(() => {
|
||||
setCurrentPage(0)
|
||||
setSelectedUsers((prev) =>
|
||||
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const clearUserFilters = () => {
|
||||
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,6 +973,33 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
|
||||
{/* 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 unchanged)
|
||||
<>
|
||||
{loading && batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||
) : batches.length === 0 ? (
|
||||
@@ -821,23 +1012,55 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
batch={batch}
|
||||
isAdmin={isAdmin}
|
||||
onDelete={handleDeleteBatch}
|
||||
onRequestDelete={requestDeleteConfirmation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-gray-200 flex justify-end">
|
||||
<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-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||
onClick={onClose}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
if (searchQuery) {
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
result = result.filter(row => row.record.materialName.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
}, [editingCell])
|
||||
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);
|
||||
})
|
||||
}, [])
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if (event.key === 'Insert') {
|
||||
event.preventDefault()
|
||||
insertNewRow()
|
||||
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
|
||||
event.preventDefault()
|
||||
deleteRow(selectedRowIndex)
|
||||
}
|
||||
},
|
||||
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
|
||||
)
|
||||
|
||||
// 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,252 +381,169 @@ 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>
|
||||
<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>
|
||||
</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
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{manager}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* --- 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={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"
|
||||
onClick={handleClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<Plus size={14} /> 新增 (Insert)
|
||||
<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={() => 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"
|
||||
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"
|
||||
>
|
||||
<Trash2 size={14} /> 删除 (Delete)
|
||||
<Plus size={16} className="mr-1.5" /> 新增
|
||||
</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"
|
||||
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={14} /> 重置
|
||||
<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 items-center gap-3">
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
||||
{pendingCount} 项待保存
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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"
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
<Save size={14} /> {saving ? '保存中...' : '保存'}
|
||||
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
|
||||
{saving ? '保存中...' : '保存更改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-500">加载中...</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">
|
||||
暂无数据,点击"新增"按钮添加物料类型关键词
|
||||
</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'
|
||||
|
||||
{/* --- 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 (
|
||||
<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()
|
||||
<button
|
||||
key={manager}
|
||||
onClick={() => {
|
||||
const newSet = new Set(selectedManagers);
|
||||
isSelected ? newSet.delete(manager) : newSet.add(manager);
|
||||
setSelectedManagers(newSet);
|
||||
}}
|
||||
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')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{row.record.materialName || (
|
||||
<span className="text-slate-400 italic">双击编辑</span>
|
||||
)}
|
||||
{manager}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* --- Main Content Grid --- */}
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
|
||||
{loading ? (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
{/* --- 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>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{/* Retain the original confirmation dialog */}
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal file
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal 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)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal file
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal 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'
|
||||
}
|
||||
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, CheckCircle, CircleMinus, Clock, XCircle } from 'lucide-react'
|
||||
|
||||
export type CleanerHistoryStatus =
|
||||
| 'success'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'crashed'
|
||||
| 'pending'
|
||||
| 'not_found'
|
||||
| 'erp_not_found'
|
||||
|
||||
export interface CleanerHistoryStatusDisplay {
|
||||
label: string
|
||||
badgeClassName: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
export interface CleanerMaterialResultDisplay {
|
||||
title: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
const HISTORY_STATUS_META: Record<
|
||||
CleanerHistoryStatus,
|
||||
{ label: string; badgeClassName: string; iconClassName: string; icon: typeof CheckCircle }
|
||||
> = {
|
||||
success: {
|
||||
label: '成功',
|
||||
badgeClassName: 'bg-green-100 text-green-700',
|
||||
iconClassName: 'text-green-600',
|
||||
icon: CheckCircle
|
||||
},
|
||||
partial: {
|
||||
label: '部分成功',
|
||||
badgeClassName: 'bg-amber-100 text-amber-700',
|
||||
iconClassName: 'text-amber-600',
|
||||
icon: Clock
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
iconClassName: 'text-red-600',
|
||||
icon: XCircle
|
||||
},
|
||||
crashed: {
|
||||
label: '崩溃',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
iconClassName: 'text-red-600',
|
||||
icon: XCircle
|
||||
},
|
||||
pending: {
|
||||
label: '进行中',
|
||||
badgeClassName: 'bg-gray-100 text-gray-700',
|
||||
iconClassName: 'text-gray-500',
|
||||
icon: Clock
|
||||
},
|
||||
not_found: {
|
||||
label: '未找到',
|
||||
badgeClassName: 'bg-orange-100 text-orange-700',
|
||||
iconClassName: 'text-orange-600',
|
||||
icon: XCircle
|
||||
},
|
||||
erp_not_found: {
|
||||
label: 'ERP不存在',
|
||||
badgeClassName: 'bg-orange-100 text-orange-700',
|
||||
iconClassName: 'text-orange-600',
|
||||
icon: XCircle
|
||||
}
|
||||
}
|
||||
|
||||
export function getCleanerHistoryStatusDisplay(status: string, size = 16): CleanerHistoryStatusDisplay {
|
||||
const meta = HISTORY_STATUS_META[(status in HISTORY_STATUS_META ? status : 'pending') as CleanerHistoryStatus]
|
||||
const Icon = meta.icon
|
||||
|
||||
return {
|
||||
label: status in HISTORY_STATUS_META ? meta.label : status,
|
||||
badgeClassName: meta.badgeClassName,
|
||||
icon: <Icon size={size} className={meta.iconClassName} />
|
||||
}
|
||||
}
|
||||
|
||||
export function getCleanerMaterialResultDisplay(
|
||||
result: string | null | undefined,
|
||||
size = 16
|
||||
): CleanerMaterialResultDisplay {
|
||||
if (result === 'success' || result === 'deleted') {
|
||||
return {
|
||||
title: 'Deleted',
|
||||
icon: <CheckCircle size={size} className="text-green-600 flex-shrink-0" aria-label="Deleted" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'skipped') {
|
||||
return {
|
||||
title: 'Skipped',
|
||||
icon: <CircleMinus size={size} className="text-gray-400 flex-shrink-0" aria-label="Skipped" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'uncertain') {
|
||||
return {
|
||||
title: 'Uncertain',
|
||||
icon: <AlertTriangle size={size} className="text-amber-600 flex-shrink-0" aria-label="Uncertain" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result?.startsWith('failed')) {
|
||||
return {
|
||||
title: 'Failed',
|
||||
icon: <XCircle size={size} className="text-red-600 flex-shrink-0" aria-label="Failed" />
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: result || 'Unknown',
|
||||
icon: null
|
||||
}
|
||||
}
|
||||
80
src/renderer/src/components/execution-report-state.ts
Normal file
80
src/renderer/src/components/execution-report-state.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,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 +121,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({
|
||||
@@ -149,7 +151,8 @@ export async function runCleanerExecution(params: {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -167,6 +167,7 @@ const CleanerPage: React.FC = () => {
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{showHistoryModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<CleanerOperationHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
@@ -178,6 +179,7 @@ const CleanerPage: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
|
||||
@@ -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',
|
||||
|
||||
29
tests/unit/cleaner-history-load-state.test.ts
Normal file
29
tests/unit/cleaner-history-load-state.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
tests/unit/cleaner-history-status.test.tsx
Normal file
42
tests/unit/cleaner-history-status.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCleanerHistoryStatusDisplay,
|
||||
getCleanerMaterialResultDisplay
|
||||
} from '../../src/renderer/src/components/cleaner-history-status'
|
||||
|
||||
describe('cleaner history status helpers', () => {
|
||||
it('returns shared label, badge class and icon for known statuses', () => {
|
||||
const display = getCleanerHistoryStatusDisplay('erp_not_found')
|
||||
|
||||
expect(display.label).toBe('ERP不存在')
|
||||
expect(display.badgeClassName).toBe('bg-orange-100 text-orange-700')
|
||||
expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain(
|
||||
'text-orange-600'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to pending style for unknown statuses while preserving text', () => {
|
||||
const display = getCleanerHistoryStatusDisplay('custom_status')
|
||||
|
||||
expect(display.label).toBe('custom_status')
|
||||
expect(display.badgeClassName).toBe('bg-gray-100 text-gray-700')
|
||||
})
|
||||
|
||||
it('maps failed material outcomes through the shared helper', () => {
|
||||
const display = getCleanerMaterialResultDisplay('failed_timeout')
|
||||
|
||||
expect(display.title).toBe('Failed')
|
||||
expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain(
|
||||
'text-red-600'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns plain text for unknown material outcomes', () => {
|
||||
const display = getCleanerMaterialResultDisplay('needs_manual_check')
|
||||
|
||||
expect(display.title).toBe('needs_manual_check')
|
||||
expect(display.icon).toBeNull()
|
||||
})
|
||||
})
|
||||
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal file
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
60
tests/unit/execution-report-state.test.ts
Normal file
60
tests/unit/execution-report-state.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user