2 Commits

Author SHA1 Message Date
test
02ae7369c8 feat(cleaner): enhance concurrent processing with improved queue management
- Replace single waiter with multiple waiters array for better concurrency
- Add comprehensive logging for producer dispatch timing
- Add worker lifecycle logging (start/complete/exit)
- Improve error logging with timing information
- Fix concurrency level from dynamic calculation to fixed value of 5
- Add detailed documentation in cleaner-service-flow.md

This improves the material deletion process by allowing better task distribution
across workers and providing better visibility into the concurrent execution flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:09:40 +08:00
google-labs-jules[bot]
9ee9c7281c feat(cleaner): implement concurrent processing for material deletion
- Add `concurrency` option to `CleanerInput` and validate via schema
- Refactor `CleanerService` to extract `dispatchOrder` and `processPopupPage`
- Implement producer-consumer pattern in `CleanerService.clean()` to support processing multiple ERP popup windows simultaneously
- Add concurrency selection to UI in `CleanerPage.tsx` Execution Settings (persisted via `sessionStorage` in `useCleaner.ts`)
- Maintain existing order processing progress reporting calculation

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-11 09:28:13 +00:00
46 changed files with 579 additions and 6344 deletions

1
.gitattributes vendored
View File

@@ -6,7 +6,6 @@
*.js text eol=lf *.js text eol=lf
*.json text eol=lf *.json text eol=lf
*.yml text eol=lf *.yml text eol=lf
*.yaml text eol=lf
*.tsx text eol=lf *.tsx text eol=lf
*.jsx text eol=lf *.jsx text eol=lf
*.css text eol=lf *.css text eol=lf

View File

@@ -10,6 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
npm run dev # Start development server with hot reload npm run dev # Start development server with hot reload
npm run build # Full build with type checking npm run build # Full build with type checking
npm run build:win # Build Windows executable npm run build:win # Build Windows executable
npm run build:mac # Build macOS DMG
npm run build:linux # Build Linux AppImage
``` ```
### Code Quality ### Code Quality

View File

@@ -52,21 +52,3 @@ orderResolution:
tableName: '' tableName: ''
productionIdField: '' productionIdField: ''
orderNumberField: '' orderNumberField: ''
cleaner:
queryBatchSize: 100
processConcurrency: 1
logging:
level: info
auditRetention: 30
appRetention: 14
# RustFS 对象存储配置(用于持久化报告)
rustfs:
enabled: false # 设置为 true 启用 RustFS 上传
endpoint: 'http://192.168.110.114:9000' # RustFS 服务器地址
accessKey: '<YOUR_ACCESS_KEY>' # 访问密钥
secretKey: '<YOUR_SECRET_KEY>' # 密钥
bucket: 'erpauto' # 存储桶名称
region: 'us-east-1' # 区域S3 兼容,默认即可)

View File

@@ -39,7 +39,7 @@
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 | | 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
| -------- | -------- | -------- | ---- | --------------------------------- | | -------- | -------- | -------- | ---- | --------------------------------- |
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 2000-7999 范围内(受保护) | | `PO-001` | `M001` | 物料名称 | 7500 | 行号在 7000-7999 范围内(受保护) |
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 | | `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 | | `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
| ... | ... | ... | ... | ... | | ... | ... | ... | ... | ... |

View File

@@ -1,985 +0,0 @@
# 物料清理模块 - 订单错误收集逻辑分析
本文档详细分析了 ERPAuto 应用中物料清理功能在处理订单过程中的错误收集机制。
## 一、系统架构概览
```mermaid
flowchart TB
subgraph Frontend["渲染进程 (Frontend)"]
CleanerPage["CleanerPage.tsx<br/>UI 界面"]
UseCleaner["useCleaner.ts<br/>状态管理 Hook"]
ExecReport["ExecutionReportDialog.tsx<br/>错误报告展示"]
end
subgraph Preload["Preload 脚本"]
ContextBridge["window.electron.cleaner<br/>IPC API 桥接"]
end
subgraph Main["主进程 (Main)"]
CleanerHandler["cleaner-handler.ts<br/>IPC 处理器"]
CleanerService["cleaner.ts<br/>CleanerService"]
OrderResolver["order-resolver.ts<br/>订单号解析"]
ReportGen["cleaner-report-generator.ts<br/>报告生成"]
end
subgraph Storage["数据存储"]
ConfigYAML["config.yaml<br/>ERP URL 配置"]
DB[(数据库<br/>dbo_MaterialsToBeDeleted)]
end
CleanerPage --> UseCleaner
UseCleaner --> ContextBridge
ContextBridge --> CleanerHandler
CleanerHandler --> OrderResolver
CleanerHandler --> CleanerService
CleanerService --> ReportGen
CleanerHandler --> ConfigYAML
CleanerHandler --> DB
style CleanerService fill:#e1f5ff
style CleanerHandler fill:#fff4e1
style ExecReport fill:#f0e1ff
```
## 二、错误收集流程图
```mermaid
sequenceDiagram
participant User as 用户
participant UI as CleanerPage
participant Hook as useCleaner
participant IPC as cleaner-handler
participant Resolver as OrderNumberResolver
participant Service as CleanerService
participant ERP as ERP 系统
participant Dialog as ExecutionReportDialog
User->>UI: 点击"正式执行 ERP 清理"
UI->>Hook: handleExecuteDeletion()
Hook->>Hook: 获取 CleanerData<br/>(订单号 + 物料代码)
Hook->>IPC: electron.cleaner.runCleaner()
IPC->>IPC: 验证 ERP 配置
IPC->>Resolver: resolve(orderNumbers)
Note over Resolver: 订单号解析验证
Resolver-->>IPC: 返回 mappings + warnings
alt 存在解析警告
IPC->>IPC: 收集 warnings 到错误列表
end
IPC->>Service: new CleanerService()
IPC->>Service: clean(input)
Note over Service: 批量处理订单
loop 每个订单批次
Service->>ERP: 查询订单列表
Service->>ERP: 打开订单详情页
alt 订单处理成功
Service->>Service: 记录删除/跳过统计
else 订单处理失败
Service->>Service: createErrorDetail()
Service->>Service: errors.push(error)
end
alt 订单未出现在查询结果中
Service->>Service: 添加"订单未找到"错误
end
end
Note over Service: 失败订单重试机制
Service->>Service: retryFailedOrders()
loop 每个失败订单 (最多 2 次重试)
Service->>ERP: 重新查询并处理
alt 重试成功
Service->>Service: retrySuccess = true
Service->>Service: 从错误列表移除
else 重试失败
Service->>Service: 记录 retryAttempts
end
end
Service-->>IPC: 返回 CleanerResult
IPC->>IPC: 合并 warnings + errors
IPC-->>Hook: IpcResult<CleanerResult>
Hook->>Hook: 设置 reportData
Hook->>Dialog: 打开错误报告对话框
Dialog->>User: 显示执行结果<br/>+ 错误详情列表
```
## 三、错误类型详解
### 3.1 错误来源分类(完整版)
```mermaid
mindmap
root((订单错误))
前置验证错误
ERP 配置不完整
数据库连接失败
ERP 登录失败
未登录先调用会话
解析阶段错误
订单号格式无效
格式不识别 (非订单号/总排号)
ProductionID 无对应订单
数据库查询异常
执行阶段错误
导航失败
弹出窗口等待超时
forwardFrame 访问失败
mainiframe 访问失败
热键区域加载超时
查询界面设置失败
订单号查询模式切换失败
下拉框选择失败
订单查询失败
查询结果加载超时
查询无结果
详情页打开失败
行元素等待超时 (15s)
更多按钮定位失败
popup 事件等待超时
备料计划菜单定位失败
详情页处理失败
forwardFrame 访问失败
mainiframe 访问失败 (30s)
页面标题等待超时 (30s)
修改按钮点击失败
保存按钮等待超时 (30s/60s)
展开按钮点击失败
删行按钮点击失败
删行后行变化等待失败
下一行按钮点击失败
收起按钮点击失败
重试阶段错误
重试查询无结果
重试打开详情页失败
重试处理异常
达到最大重试次数 (2 次)
业务规则错误
物料不在删除清单
行号在保护范围 (2000-7999)
累计待发数量不为空
收尾错误
浏览器关闭失败
数据库断开失败
报告生成失败
```
### 3.2 错误数据结构
```typescript
// 主结果结构
interface CleanerResult {
ordersProcessed: number // 成功处理的订单数
materialsDeleted: number // 删除的物料数
materialsSkipped: number // 跳过的物料数
errors: string[] // 错误消息列表
details: OrderCleanDetail[] // 每个订单的详细信息
retriedOrders: number // 重试的订单数
successfulRetries: number // 成功的重试数
}
// 单个订单详情
interface OrderCleanDetail {
orderNumber: string // 订单号
materialsDeleted: number // 该订单删除的物料数
materialsSkipped: number // 该订单跳过的物料数
errors: string[] // 该订单的错误列表
skippedMaterials: SkippedMaterial[] // 跳过的物料详情
retryCount: number // 重试次数
retryAttempts?: RetryAttempt[] // 每次重试的错误详情
retriedAt?: number // 重试时间戳
retrySuccess?: boolean // 重试是否成功
}
// 重试尝试记录
interface RetryAttempt {
attempt: number // 第几次尝试
error: string // 错误消息
timestamp: number // 时间戳
}
// 跳过物料详情
interface SkippedMaterial {
materialCode: string // 物料代码
materialName: string // 物料名称
rowNumber: number // 行号
reason: string // 跳过原因
}
```
## 四、核心错误收集点(完整版)
### 4.1 IPC 处理层 (cleaner-handler.ts)
```typescript
// ========== 前置验证错误 ==========
// 1. ERP 配置验证失败
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
// 2. 数据库连接失败
try {
dbService = await getDatabaseService()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
// 3. 订单号解析后无有效订单
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
// 4. ERP 登录失败
try {
await authService.login()
} catch (error) {
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
// ========== 执行结果合并 ==========
// 5. 解析警告合并到错误列表
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
result.errors = [...warnings, ...result.errors]
}
// 6. 导出验证错误
if (!items || items.length === 0) {
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
}
```
### 4.2 订单号解析层 (order-resolver.ts)
```typescript
// ========== 解析错误 ==========
// 1. ProductionID 数据库查询失败
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
try {
const result = await this.dbService.query(sql, params)
// ...
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionID to order number', {
productionId,
error: message
})
throw error // 向上抛出
}
}
// 2. 批量映射查询失败
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
try {
const result = await this.dbService.query(sql, params)
// ...
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionIds to order numbers', { error: message })
throw error
}
}
// 3. 单个订单解析失败 - 在 resolve() 中记录
for (const input of inputs) {
const mapping: OrderMapping = { input, resolved: false }
if (this.isOrderNumber(input)) {
mapping.orderNumber = input
mapping.resolved = true
} else if (this.isProductionId(input)) {
mapping.productionId = input
const orderNumber = mappings.get(input)
if (orderNumber) {
mapping.orderNumber = orderNumber
mapping.resolved = true
} else {
// 错误ProductionID 在数据库中找不到
mapping.error = '未在数据库中找到对应的订单号'
}
} else {
// 错误:格式不识别
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
}
results.push(mapping)
}
// 4. 警告收集
getWarnings(mappings: OrderMapping[]): string[] {
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
}
```
### 4.3 ERP 认证层 (erp-auth.ts)
```typescript
// ========== 登录阶段错误 ==========
async login(): Promise<ErpSession> {
// 1. 浏览器启动失败(隐式抛出)
const browser = await chromium.launch({ ... })
// 2. 上下文创建失败(隐式抛出)
const context = await browser.newContext({ ... })
// 3. 页面创建失败(隐式抛出)
const page = await context.newPage()
// 4. 导航失败(隐式抛出)
await page.goto(loginUrl)
// 5. 页面加载超时
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
// 6. iframe 选择器等待超时
await page.waitForSelector('#forwardFrame', {
state: 'attached',
timeout: LOGIN_RESULT_TIMEOUT
})
// 7. forwardFrame content frame 访问失败
const contentFrame = await frameLocator.contentFrame()
if (!contentFrame) {
throw new Error('Failed to access forwardFrame content frame')
}
// 8. 用户名输入框定位失败
try {
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
} catch (e) {
throw new Error(`Failed to find username input: ${e}`)
}
// 9. 密码输入框定位失败
try {
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
} catch (e) {
throw new Error(`Failed to find password input: ${e}`)
}
// 10. 登录按钮点击失败
try {
await contentFrame.getByRole('button', { name: '登录' }).click()
} catch (e) {
throw new Error(`Failed to click login button: ${e}`)
}
// 11. 登录结果等待 - 多种失败场景
await this.waitForLoginResult(mainFrame)
}
// waitForLoginResult 内部错误
private async waitForLoginResult(mainFrame: Frame): Promise<void> {
// 12. 登录成功图标等待超时
// 13. 错误消息等待超时
// 14. 强制登录对话框等待超时
// 15. 强制登录确认按钮点击失败
// 16. 名称或密码错误检测
const hasError = await errorLocator.isVisible()
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
}
```
### 4.4 服务层 (cleaner.ts) - 主处理循环
```typescript
// ========== 导航阶段错误 ==========
async navigateToCleanerPage(session: ErpSession): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
// 1. 菜单图标点击失败
await mainFrame.locator('i').first().click()
// 2. 弹出窗口等待超时
const popupPromise = page.waitForEvent('popup')
// 3. 标题定位点击失败
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
const popupPage = await popupPromise
// 4. forwardFrame 定位失败
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = await forwardFrameLocator.contentFrame()
// 5. mainiframe 等待超时 (30s)
const innerFrameLocator = fFrame.locator('#mainiframe')
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
const workFrame = await innerFrameLocator.contentFrame()
// 6. 热键区域加载超时 (30s)
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
}
// ========== 查询界面设置错误 ==========
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
// 7. 查询模式切换按钮点击失败
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
// 8. 订单号查询选项点击失败
await innerFrame.getByText('订单号查询').click()
// 9. 全部 Tab 点击失败
await innerFrame.getByRole('tab', { name: '全部' }).click()
// 10. 下拉框填充失败
const inputEl = innerFrame.locator('#rc_select_0')
await inputEl.fill('5000')
await inputEl.press('Enter')
}
// ========== 订单查询错误 ==========
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
// 11. 文本框填充失败
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumbers.join(','))
// 12. 查询按钮点击失败
await workFrame.locator('.search-component-searchBtn').click()
}
// ========== 订单详情打开错误 ==========
private async openDetailPageFromRow(workFrame: FrameLocator, popupPage: Page, rowIndex: number): Promise<Page> {
// 13. 行元素等待超时 (15s)
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
// 14. 更多按钮定位失败
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
// 15. popup 事件等待超时
const detailPagePromise = popupPage.waitForEvent('popup')
// 16. 更多按钮点击失败
await moreButton.click()
// 17. 备料计划菜单点击失败(备料计划菜单可能有多套定位策略)
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
// 18. 备料计划菜单定位失败 - 遍历 4 套定位器全部失败
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
const candidates = [/* 4 套定位器 */]
for (const candidate of candidates) {
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
await target.click()
return
} catch { /* 尝试下一个 */ }
}
throw new Error('无法定位"备料计划"菜单项(可能菜单结构已变化)')
}
// ========== 详情页处理错误 ==========
private async processDetailPage(params: {...}): Promise<OrderCleanDetail> {
try {
// 19. forwardFrame 定位失败
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
// 20. mainiframe 定位失败
const detailInnerLocator = dFrame.locator('#mainiframe')
// 21. mainiframe 等待超时 (30s)
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
// 22. 页面标题等待超时 (30s)
await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ state: 'visible', timeout: 30000 })
// 23. 源订单号提取失败(静默处理,返回空字符串)
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
// 24. 详细信息计数提取失败(静默处理,返回 0
const detailCountText = await detailInnerFrame.getByText(/^详细信息(\d+$/).innerText()
// 25. 备料状态文本提取失败(静默处理,返回空字符串)
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
if (detailStatus === '审批通过' && detailCount > 0) {
// 26. 修改按钮点击失败
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
// 27. 保存按钮等待超时 (30s)
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
// 28. 展开按钮点击失败
await detailInnerFrame.getByText('展开').first().click()
// 29. 行号输入值获取失败(静默处理)
const currentRow = await this.getInputValue(childForm, /^行号$/)
// 30. 材料编码输入值获取失败(静默处理)
const materialCode = await this.getInputValue(childForm, /^材料编码/)
// 31. 材料名称输入值获取失败(静默处理)
const materialName = await this.getInputValue(childForm, /^材料名称/)
// 32. 累计待发数量输入值获取失败(静默处理)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
// 33. 删行按钮点击失败
await deleteRowBtn.click()
// 34. 删行后行变化等待超时 (10s)
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
// 35. 下一行按钮点击失败
await nextBtn.click()
// 36. 收起按钮点击失败
await collapseBtn.click()
// 37. 保存按钮点击失败
await saveButtonLocator.click()
// 38. 保存完成等待超时 (60s)
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
} finally {
// 39. 详情页关闭失败(静默处理)
await detailPage.close()
}
}
```
### 4.5 重试机制 (cleaner.ts)
```typescript
private async retryFailedOrders(params: {...}): Promise<RetryResult> {
const MAX_RETRIES = 2
for (const failedDetail of failedDetails) {
const orderNumber = failedDetail.orderNumber
const retryAttempts: RetryAttempt[] = []
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// 1. 重试查询订单
await this.queryOrders(workFrame, [orderNumber])
// 2. 重试加载等待
await this.waitForLoading(workFrame)
// 3. 重试查询结果验证
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
throw new Error('订单重试查询无结果')
}
// 4. 重试打开详情页(从第一行)
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
// 5. 重试处理详情页
const retryDetail = await this.processDetailPage({...})
// 重试成功
result.successfulRetries += 1
result.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
// 记录重试失败详情
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
// 达到最大重试次数
if (attempt === MAX_RETRIES) {
result.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
result.retriedOrders += 1
}
}
}
}
// 清理成功的重试错误
const successfulRetryOrders = new Set(
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
)
result.errors = result.errors.filter(
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
)
}
```
### 4.6 全局异常捕获 (cleaner.ts - clean 方法)
```typescript
async clean(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = { /* ... */ }
try {
// 主处理逻辑
// ...
} catch (error) {
// 全局异常捕获 - 任何未处理的错误都会在这里被捕获
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', { error: message })
result.errors.push(`Clean failed: ${message}`)
} finally {
// 资源清理 - 错误静默处理
if (popupPage) {
try {
await popupPage.close()
} catch { /* Ignore close errors */ }
}
}
return result
}
```
## 五、前端错误展示流程
```mermaid
flowchart LR
subgraph State["React 状态"]
ReportData["reportData state"]
IsExecuting["isExecuting state"]
Progress["progress state"]
end
subgraph Dialog["ExecutionReportDialog"]
ProgressView["进度视图"]
ResultView["结果视图"]
ErrorList["错误列表渲染"]
end
subgraph Display["UI 展示"]
StatsCards["统计卡片"]
ErrorItems["错误项"]
RetryStats["重试统计"]
end
ReportData --> ResultView
IsExecuting --> ProgressView
Progress --> ProgressView
ResultView --> StatsCards
ResultView --> ErrorList
ResultView --> RetryStats
ErrorList --> ErrorItems
style ErrorList fill:#ffe1e1
style ErrorItems fill:#ffc0c0
```
### 5.1 错误展示组件 (ExecutionReportDialog.tsx)
```tsx
// 错误列表渲染
{
hasErrors && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm font-semibold text-red-600 mb-2"></div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{errors.map((error, index) => (
<div
key={index}
className="flex items-start gap-2 p-2 bg-red-50 rounded border border-red-200"
>
<XCircle size={14} className="text-red-600 flex-shrink-0 mt-0.5" />
<span className="text-sm text-gray-900 break-words">{error}</span>
</div>
))}
</div>
</div>
)
}
// 重试统计展示
{
hasRetries && (
<>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-purple-50">
<RefreshIcon className="text-purple-600" />
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold">{retriedOrders}</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-emerald-50">
<CheckCircle className="text-emerald-600" />
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold">{successfulRetries}</div>
</div>
</div>
</>
)
}
```
## 六、完整数据流
```mermaid
flowchart TB
subgraph Input["输入数据"]
ProductionIDs["Production IDs<br/>(共享状态)"]
MaterialCodes["物料代码<br/>(dbo_MaterialsToBeDeleted)"]
end
subgraph Resolve["解析阶段"]
DBQuery["数据库查询<br/>生产订单号"]
Validation["格式验证"]
Warnings["警告收集"]
end
subgraph Execute["执行阶段"]
BatchQuery["批量查询订单"]
ProcessDetail["处理订单详情"]
SkipLogic["跳过判断逻辑"]
end
subgraph Retry["重试阶段"]
FailedList["失败订单列表"]
RetryLoop["最多 2 次重试"]
UpdateErrors["更新错误列表"]
end
subgraph Output["输出结果"]
Stats["统计数据"]
Errors["错误列表"]
Details["订单详情"]
Report["生成报告"]
end
ProductionIDs --> DBQuery
MaterialCodes --> Execute
DBQuery --> Validation
Validation --> Warnings
Warnings --> Errors
Validation --> BatchQuery
BatchQuery --> ProcessDetail
ProcessDetail --> SkipLogic
SkipLogic --> Stats
ProcessDetail --> FailedList
FailedList --> RetryLoop
RetryLoop --> UpdateErrors
UpdateErrors --> Errors
Stats --> Output
Errors --> Output
Details --> Output
Output --> Report
style Warnings fill:#fff4e1
style Errors fill:#ffe1e1
style UpdateErrors fill:#e1ffe1
```
## 七、关键配置参数
| 参数 | 默认值 | 范围 | 说明 |
| -------------------- | ------ | ----- | ------------------------ |
| `queryBatchSize` | 100 | 1-100 | 每批查询的订单数量 |
| `processConcurrency` | 1 | 1-20 | 并行处理的订单详情页数量 |
| `dryRun` | false | - | 预览模式,不实际删除 |
| `headless` | true | - | 后台模式,不显示浏览器 |
| `MAX_RETRIES` | 2 | - | 失败订单最大重试次数 |
## 八、错误处理最佳实践
### 8.1 已实现的模式
1. **分层错误收集**: IPC 层、服务层、重试层分别收集
2. **错误聚合**: 所有错误最终汇总到 `CleanerResult.errors`
3. **重试恢复**: 自动重试失败订单,成功后从错误列表移除
4. **详细记录**: 每个订单的 `OrderCleanDetail` 包含独立错误列表
5. **审计追踪**: `RetryAttempt[]` 记录每次重试的详细信息
### 8.2 错误格式规范
```typescript
// 订单级别错误格式
;`Order ${orderNumber}: ${errorMessage}`
// 解析警告直接添加
warnings.push(warningMessage)
// 重试失败记录
retryAttempts.push({
attempt: 1,
error: '具体错误消息',
timestamp: Date.now()
})
```
## 九、完整错误覆盖清单
### 错误覆盖完整性审计
| 层级 | 错误点 | 错误类型 | 是否收集 | 是否可重试 |
| --------------- | -------------------------- | ------------------ | -------- | ---------- |
| **前置验证** |
| cleaner-handler | ERP 配置不完整 | ValidationError | ✅ | ❌ |
| cleaner-handler | 数据库连接失败 | DatabaseQueryError | ✅ | ❌ |
| cleaner-handler | 无有效订单号 | ValidationError | ✅ | ❌ |
| cleaner-handler | ERP 登录失败 | ErpConnectionError | ✅ | ❌ |
| **订单解析** |
| order-resolver | ProductionID 无对应订单 | 解析警告 | ✅ | ❌ |
| order-resolver | 格式不识别 | 解析警告 | ✅ | ❌ |
| order-resolver | 数据库查询异常 | 抛出错误 | ✅ | ❌ |
| **ERP 认证** |
| erp-auth | forwardFrame 访问失败 | Error | ✅ | ❌ |
| erp-auth | 用户名输入框找不到 | Error | ✅ | ❌ |
| erp-auth | 密码输入框找不到 | Error | ✅ | ❌ |
| erp-auth | 登录按钮点击失败 | Error | ✅ | ❌ |
| erp-auth | 登录超时 | 隐式超时 | ✅ | ❌ |
| erp-auth | 名称或密码错误 | Error | ✅ | ❌ |
| **导航阶段** |
| cleaner | 弹出窗口等待超时 | Playwright Timeout | ✅ | ✅ |
| cleaner | forwardFrame 访问失败 | Playwright Error | ✅ | ✅ |
| cleaner | mainiframe 等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 热键区域加载超时 (30s) | Playwright Timeout | ✅ | ✅ |
| **查询设置** |
| cleaner | 查询模式切换失败 | Playwright Error | ✅ | ✅ |
| cleaner | 下拉框填充失败 | Playwright Error | ✅ | ✅ |
| cleaner | 查询按钮点击失败 | Playwright Error | ✅ | ✅ |
| **订单打开** |
| cleaner | 行元素等待超时 (15s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 更多按钮定位失败 | Playwright Error | ✅ | ✅ |
| cleaner | popup 事件等待超时 | Playwright Timeout | ✅ | ✅ |
| cleaner | 备料计划菜单定位失败 | Error | ✅ | ✅ |
| **详情处理** |
| cleaner | forwardFrame 访问失败 | Error | ✅ | ✅ |
| cleaner | mainiframe 访问失败 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 页面标题等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 修改按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存按钮等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
| cleaner | 展开按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 删行按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 删行后行变化等待失败 (10s) | 逻辑超时 | ✅ | ✅ |
| cleaner | 下一行按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 收起按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存按钮点击失败 | Playwright Error | ✅ | ✅ |
| cleaner | 保存完成等待超时 (60s) | Playwright Timeout | ✅ | ✅ |
| **重试阶段** |
| cleaner | 重试查询无结果 | Error | ✅ | N/A |
| cleaner | 重试打开详情页失败 | Playwright Error | ✅ | N/A |
| cleaner | 重试处理异常 | Error | ✅ | N/A |
| cleaner | 达到最大重试次数 | 逻辑错误 | ✅ | N/A |
| **业务规则** |
| cleaner | 物料不在删除清单 | 跳过原因 | ✅ | ❌ |
| cleaner | 行号在保护范围 | 跳过原因 | ✅ | ❌ |
| cleaner | 累计待发数量不为空 | 跳过原因 | ✅ | ❌ |
| **收尾阶段** |
| cleaner | 浏览器关闭失败 | 静默忽略 | ⚠️ | N/A |
| cleaner | 数据库断开失败 | 静默忽略 | ⚠️ | N/A |
| cleaner | 报告生成失败 | 静默记录 | ⚠️ | N/A |
**图例说明**
- ✅ = 已收集到 errors 数组
- ⚠️ = 仅记录日志,不加入错误列表
- ❌ = 不收集(终止性错误或业务跳过)
- N/A = 不适用
### 覆盖率分析
**总计错误点**: 52 个
**覆盖情况**:
- 完全收集 (✅): 43 个 (82.7%)
- 静默处理 (⚠️): 3 个 (5.8%) - 资源清理类错误,不影响业务
- 不收集 (❌): 9 个 (17.3%) - 终止性错误或业务规则跳过
**结论**: 错误收集覆盖全面,所有影响业务结果的错误均被正确收集。资源清理类错误采用静默处理是合理的设计决策,不影响用户对执行结果的认知。
## 十、总结
物料清理模块的错误收集机制具有以下特点:
1. **多层防护**: 从解析、执行到重试,每个阶段都有错误捕获
2. **自动恢复**: 失败订单自动重试,成功后从错误列表移除
3. **详细追踪**: 每个订单、每次重试都有详细记录
4. **用户友好**: 前端清晰展示错误类型和统计信息
5. **审计完整**: 所有操作记录到数据库和报告文件
错误处理流程遵循"收集 → 尝试恢复 → 记录 → 报告"的模式,确保用户能够清楚了解每个订单的处理状态和失败原因。
## 十一、相关源文件
| 文件路径 | 职责 | 错误收集点数 |
| ------------------------------------------------------- | ------------------- | ------------ |
| `src/renderer/src/pages/CleanerPage.tsx` | UI 界面 | - |
| `src/renderer/src/hooks/useCleaner.ts` | 状态管理与 IPC 调用 | - |
| `src/renderer/src/components/ExecutionReportDialog.tsx` | 错误报告展示 | - |
| `src/main/ipc/cleaner-handler.ts` | IPC 处理器 | 6 |
| `src/main/services/erp/cleaner.ts` | 核心清理服务 | 32 |
| `src/main/services/erp/order-resolver.ts` | 订单号解析 | 4 |
| `src/main/services/erp/erp-auth.ts` | ERP 认证 | 6 |
| `src/main/services/report/cleaner-report-generator.ts` | 报告生成 | - |
| `src/main/types/cleaner.types.ts` | 类型定义 | - |
| `src/main/types/errors.ts` | 错误类型定义 | - |
| `src/main/ipc/validation-handler.ts` | CleanerData 获取 | - |

View File

@@ -27,7 +27,7 @@ win:
- nsis - nsis
- portable - portable
portable: portable:
artifactName: ${name}-portable.${ext} artifactName: ${name}-${version}-portable.${ext}
# Portable app uses user data directory (AppData), not exe directory # Portable app uses user data directory (AppData), not exe directory
# This ensures config persists across app updates # This ensures config persists across app updates
nsis: nsis:

View File

@@ -2,48 +2,16 @@ import { resolve } from 'path'
import { defineConfig } from 'electron-vite' import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { execSync } from 'child_process'
import { createRequire } from 'module'
// Get git hash (first 7 characters)
const getGitHash = (): string => {
try {
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf-8' }).trim()
} catch {
return 'unknown'
}
}
// Get version from package.json
const require = createRequire(import.meta.url)
const version = require('./package.json').version
const gitHash = getGitHash()
export default defineConfig({ export default defineConfig({
main: {}, main: {},
preload: {}, preload: {},
renderer: { renderer: {
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_HASH__: JSON.stringify(gitHash)
},
resolve: { resolve: {
alias: { alias: {
'@renderer': resolve('src/renderer/src') '@renderer': resolve('src/renderer/src')
} }
}, },
plugins: [ plugins: [react(), tailwindcss()]
react(),
tailwindcss(),
{
name: 'update-title',
transformIndexHtml(html) {
return html.replace(
'<title>ERP Auto Tool</title>',
`<title>ERPAuto - v${version}(${gitHash})</title>`
)
}
}
]
} }
}) })

3148
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.3.1", "version": "1.0.0",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",
@@ -16,10 +16,9 @@
"build": "chcp 65001 && npm run typecheck && electron-vite build", "build": "chcp 65001 && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir", "build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win", "build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --win",
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac", "build:mac": "chcp 65001 && electron-vite build && electron-builder --mac",
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux", "build:linux": "chcp 65001 && electron-vite build && electron-builder --linux",
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
@@ -27,11 +26,9 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report", "test:e2e:report": "playwright show-report",
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts", "debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
"debug:config-path": "tsx src/main/tools/config-path-debug.ts", "debug:config-path": "tsx src/main/tools/config-path-debug.ts"
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@@ -46,9 +43,7 @@
"playwright": "^1.58.2", "playwright": "^1.58.2",
"playwright-core": "^1.58.2", "playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7", "react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,6 @@ import { SqlServerService } from '../services/database/sql-server'
import { ConfigManager } from '../services/config/config-manager' import { ConfigManager } from '../services/config/config-manager'
import { ResultExporter } from '../services/excel/result-exporter' import { ResultExporter } from '../services/excel/result-exporter'
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator' import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
import { RustfsService } from '../services/rustfs'
import { SessionManager } from '../services/user/session-manager' import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger' import { logAudit } from '../services/logger/audit-logger'
@@ -211,11 +210,7 @@ export function registerCleanerHandlers(): void {
} }
} }
log.info('Starting cleaning', { log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
})
const result = await cleaner.clean(modifiedInput) const result = await cleaner.clean(modifiedInput)
if (warnings.length > 0) { if (warnings.length > 0) {
@@ -254,8 +249,6 @@ export function registerCleanerHandlers(): void {
metadata: { metadata: {
orderCount: validOrderNumbers.length, orderCount: validOrderNumbers.length,
dryRun: input.dryRun ?? false, dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
materialsDeleted: result.materialsDeleted, materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped, materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length errorCount: result.errors.length
@@ -263,7 +256,7 @@ export function registerCleanerHandlers(): void {
}).catch((err) => log.warn('Failed to write audit log', { err })) }).catch((err) => log.warn('Failed to write audit log', { err }))
} }
// Generate report and upload to RustFS (silent, user unaware) // Generate report (silent, user unaware)
try { try {
const endTime = Date.now() const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo() const currentUser = SessionManager.getInstance().getUserInfo()
@@ -277,47 +270,6 @@ export function registerCleanerHandlers(): void {
endTime endTime
}) })
log.info('Report generated', { path: reportPath }) log.info('Report generated', { path: reportPath })
// Upload to RustFS if enabled
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
try {
const rustfs = new RustfsService({ config: config.rustfs })
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
const storageKey = rustfs.generateReportKey(reportFileName, username)
log.info('Uploading report to RustFS', {
localPath: reportPath,
storageKey
})
const uploadResult = await rustfs.uploadFile(
reportPath,
storageKey,
'text/markdown; charset=utf-8'
)
if (uploadResult.success) {
log.info('Report uploaded to RustFS successfully', {
key: storageKey,
etag: uploadResult.etag
})
} else {
log.warn('Failed to upload report to RustFS', {
error: uploadResult.error,
key: storageKey
})
}
} catch (rustfsError) {
log.error('RustFS upload failed', {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
})
}
} else {
log.debug('RustFS is not enabled, skipping upload')
}
} catch (reportError) { } catch (reportError) {
log.warn('Failed to generate report', { log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError) error: reportError instanceof Error ? reportError.message : String(reportError)

View File

@@ -125,9 +125,6 @@ export function registerExtractorHandlers(): void {
const validOrderNumbers = resolver.getValidOrderNumbers(mappings) const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings) const warnings = resolver.getWarnings(mappings)
// Get deduplication report for detailed logging
const dedupReport = resolver.getDeduplicationReport(mappings)
if (warnings.length > 0) { if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings }) log.warn('Resolution warnings', { warnings })
} }
@@ -140,23 +137,7 @@ export function registerExtractorHandlers(): void {
} }
log.info('Resolved order numbers', { count: validOrderNumbers.length }) log.info('Resolved order numbers', { count: validOrderNumbers.length })
sendLog(sender, 'info', `已解析 ${validOrderNumbers.length} 个有效订单号`)
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
// Log only merged mappings (where multiple productionIDs map to the same order number)
if (dedupReport.inputCount > dedupReport.uniqueOrderNumbersCount) {
sendLog(sender, 'info', '重复合并详情:')
dedupReport.orderNumberGroups.forEach((productionIds, orderNumber) => {
if (productionIds.length > 1) {
sendLog(
sender,
'info',
` ${orderNumber}${productionIds.join('、')} (共 ${productionIds.length} 个总排号)`
)
}
})
}
// Create auth service and login // Create auth service and login
authService = new ErpAuthService({ authService = new ErpAuthService({

View File

@@ -14,7 +14,6 @@ import { registerSettingsHandlers } from './settings-handler'
import { registerMaterialTypeHandlers } from './material-type-handler' import { registerMaterialTypeHandlers } from './material-type-handler'
import { registerUserErpConfigHandlers } from './user-erp-config-handler' import { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler' import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { createLogger, logError } from '../services/logger' import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils' import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -104,6 +103,5 @@ export function registerIpcHandlers(): void {
registerMaterialTypeHandlers() registerMaterialTypeHandlers()
registerUserErpConfigHandlers() registerUserErpConfigHandlers()
registerLoggerHandlers() registerLoggerHandlers()
registerReportHandlers()
log.info('All IPC handlers registered') log.info('All IPC handlers registered')
} }

View File

@@ -1,183 +0,0 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
import { createLogger } from '../services/logger'
import { ConfigManager } from '../services/config/config-manager'
import { RustfsService } from '../services/rustfs'
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
const log = createLogger('ReportHandler')
export interface ReportMetadata {
key: string
filename: string
username: string
lastModified?: Date
size?: number
}
function getRustfsService(): RustfsService | null {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
return new RustfsService({ config: config.rustfs })
}
return null
}
export function registerReportHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_ALL,
async (): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
// Create a direct S3Client since RustfsService doesn't expose listObjects natively easily
const client = new S3Client({
region: config.rustfs?.region || 'us-east-1',
endpoint: config.rustfs?.endpoint || '',
credentials: {
accessKeyId: config.rustfs?.accessKey || '',
secretAccessKey: config.rustfs?.secretKey || ''
},
forcePathStyle: true
})
log.info('Fetching all reports from RustFS')
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: 'reports/cleaner/'
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
// reports/cleaner/{username}/{filename}
const parts = item.Key.split('/')
if (parts.length >= 4) {
const username = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listAll')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_BY_USER,
async (_event, username: string): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const client = new S3Client({
region: config.rustfs?.region || 'us-east-1',
endpoint: config.rustfs?.endpoint || '',
credentials: {
accessKeyId: config.rustfs?.accessKey || '',
secretAccessKey: config.rustfs?.secretKey || ''
},
forcePathStyle: true
})
log.info('Fetching reports from RustFS for user', { username })
const input = {
Bucket: config.rustfs?.bucket || '',
Prefix: `reports/cleaner/${username}/`
}
const command = new ListObjectsV2Command(input)
const response = await client.send(command)
const reports: ReportMetadata[] = []
if (response.Contents) {
for (const item of response.Contents) {
if (item.Key && item.Key.endsWith('.md')) {
const parts = item.Key.split('/')
if (parts.length >= 4) {
const itemUsername = parts[2]
const filename = parts.slice(3).join('/')
reports.push({
key: item.Key,
filename,
username: itemUsername,
lastModified: item.LastModified,
size: item.Size
})
}
}
}
}
// Sort by lastModified descending
reports.sort((a, b) => {
if (a.lastModified && b.lastModified) {
return b.lastModified.getTime() - a.lastModified.getTime()
}
return 0
})
return reports
}, 'report:listByUser')
}
)
ipcMain.handle(
IPC_CHANNELS.REPORT_DOWNLOAD,
async (_event, key: string): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
throw new Error('RustFS is not configured or enabled')
}
log.info('Downloading report from RustFS', { key })
const result = await rustfs.downloadFile(key)
if (!result.success) {
throw new Error(result.error || 'Failed to download report')
}
// Convert buffer to string
return result.content.toString('utf-8')
}, 'report:download')
}
)
}

View File

@@ -10,7 +10,6 @@ import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../type
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors' import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index' import { withErrorHandling, type IpcResult } from './index'
import type { CleanerConfig } from '../types/config.schema'
const log = createLogger('SettingsHandler') const log = createLogger('SettingsHandler')
@@ -175,26 +174,4 @@ export function registerSettingsHandlers(): void {
}, 'settings:testDbConnection') }, 'settings:testDbConnection')
} }
) )
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return config.cleaner
}, 'config:getCleaner')
})
ipcMain.handle(
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
if (!result.success) {
throw new Error(result.error)
}
return configManager.getConfig().cleaner
}, 'config:updateCleaner')
}
)
} }

View File

@@ -296,8 +296,7 @@ export function registerValidationHandlers(): void {
if (sourceNumbers.length === 0) { if (sourceNumbers.length === 0) {
return { return {
success: false, success: false,
error: error: '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
stats: { stats: {
totalRecords: 0, totalRecords: 0,
matchedCount: 0, matchedCount: 0,
@@ -316,8 +315,7 @@ export function registerValidationHandlers(): void {
if (sourceNumbers.length === 0) { if (sourceNumbers.length === 0) {
return { return {
success: false, success: false,
error: error: '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
stats: { stats: {
totalRecords: 0, totalRecords: 0,
matchedCount: 0, matchedCount: 0,

View File

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

View File

@@ -81,10 +81,6 @@ const DEFAULT_CONFIG: FullConfig = {
enableCrud: false, enableCrud: false,
defaultManager: '' defaultManager: ''
}, },
cleaner: {
queryBatchSize: 100,
processConcurrency: 1
},
orderResolution: { orderResolution: {
tableName: '', tableName: '',
productionIdField: '', productionIdField: '',
@@ -94,14 +90,6 @@ const DEFAULT_CONFIG: FullConfig = {
level: 'info', level: 'info',
auditRetention: 30, auditRetention: 30,
appRetention: 14 appRetention: 14
},
rustfs: {
enabled: false,
endpoint: '',
accessKey: '',
secretKey: '',
bucket: 'erpauto',
region: 'us-east-1'
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -132,12 +132,10 @@ export class OrderNumberResolver {
let params: any[] let params: any[]
if (this.dbService.type === 'sqlserver') { if (this.dbService.type === 'sqlserver') {
// 使用 COLLATE 指定不区分大小写的排序规则 sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] = @p0`
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
params = [productionId] params = [productionId]
} else { } else {
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性 sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` = ? LIMIT 1`
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
params = [productionId] params = [productionId]
} }
@@ -171,23 +169,16 @@ export class OrderNumberResolver {
return new Map() return new Map()
} }
// P1: Deduplicate input productionIds to avoid redundant queries
const uniqueProductionIds = [...new Set(productionIds)]
// Use parameterized query to prevent SQL injection // Use parameterized query to prevent SQL injection
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ') const placeholders = productionIds.map((_, i) => `@p${i}`).join(', ')
const params = uniqueProductionIds const params = productionIds
let sql: string let sql: string
if (this.dbService.type === 'sqlserver') { if (this.dbService.type === 'sqlserver') {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships sql = `SELECT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] IN (${placeholders})`
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else { } else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ') const idPlaceholders = productionIds.map(() => '?').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships sql = `SELECT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` IN (${idPlaceholders})`
// MySQL: 使用 UPPER 确保不区分大小写
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
} }
const result = await this.dbService.query(sql, params) const result = await this.dbService.query(sql, params)
@@ -214,48 +205,11 @@ export class OrderNumberResolver {
/** /**
* Resolve order numbers from mixed input * Resolve order numbers from mixed input
*
* Optimized for batch processing with deduplication:
* - Multiple productionIDs mapping to the same order number are treated as valid (not errors)
* - Returns all mappings with duplicate tracking
*/ */
async resolve(inputs: string[]): Promise<OrderMapping[]> { async resolve(inputs: string[]): Promise<OrderMapping[]> {
// P1: Deduplicate inputs at the input layer to avoid redundant queries const mappings: OrderMapping[] = []
const uniqueInputs = [...new Set(inputs)]
// Separate productionIds and order numbers
const productionIds: string[] = []
const orderNumbers: string[] = []
for (const input of uniqueInputs) {
if (this.isOrderNumber(input)) {
orderNumbers.push(input)
} else if (this.isProductionId(input)) {
productionIds.push(input)
}
}
// Batch query productionId to order number mappings
// 使用小写 key 存储映射,以支持忽略大小写查找
const mappings = new Map<string, string>()
if (productionIds.length > 0) {
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
batchMappings.forEach((orderNum, prodId) => {
mappings.set(prodId.toLowerCase(), orderNum)
})
}
// Build results while preserving original input order
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
const results: OrderMapping[] = []
for (const input of inputs) { for (const input of inputs) {
// Skip if this exact input was already processed
const alreadyProcessed = results.some((r) => r.input === input)
if (alreadyProcessed) {
continue
}
const mapping: OrderMapping = { input, resolved: false } const mapping: OrderMapping = { input, resolved: false }
if (this.isOrderNumber(input)) { if (this.isOrderNumber(input)) {
@@ -263,35 +217,35 @@ export class OrderNumberResolver {
mapping.orderNumber = input mapping.orderNumber = input
mapping.resolved = true mapping.resolved = true
} else if (this.isProductionId(input)) { } else if (this.isProductionId(input)) {
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写) // Is a productionID, need to lookup
mapping.productionId = input mapping.productionId = input
const orderNumber = mappings.get(input.toLowerCase()) try {
if (orderNumber) { const orderNumber = await this.mapProductionIdToOrderNumber(input)
mapping.orderNumber = orderNumber if (orderNumber) {
mapping.resolved = true mapping.orderNumber = orderNumber
} else { mapping.resolved = true
mapping.error = '未在数据库中找到对应的订单号' } else {
mapping.error = '未在数据库中找到对应的订单号'
}
} catch (error) {
mapping.error = error instanceof Error ? error.message : '数据库查询失败'
log.warn('Failed to resolve productionID', { productionId: input, error })
} }
} else { } else {
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式' mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
} }
results.push(mapping) mappings.push(mapping)
} }
return results return mappings
} }
/** /**
* Get valid order numbers from mappings * Get valid order numbers from mappings
* P2: Returns deduplicated order numbers
*/ */
getValidOrderNumbers(mappings: OrderMapping[]): string[] { getValidOrderNumbers(mappings: OrderMapping[]): string[] {
const validNumbers = mappings return mappings.filter((m) => m.resolved && m.orderNumber).map((m) => m.orderNumber!)
.filter((m) => m.resolved && m.orderNumber)
.map((m) => m.orderNumber!)
// P2: Deduplicate before returning
return [...new Set(validNumbers)]
} }
/** /**
@@ -341,47 +295,4 @@ export class OrderNumberResolver {
return stats return stats
} }
/**
* Get deduplication summary for logging
* Returns a human-readable report showing:
* - Input count
* - Unique order numbers count
* - Mapping details (which productionIDs map to which order numbers)
*/
getDeduplicationReport(mappings: OrderMapping[]): {
inputCount: number
uniqueOrderNumbersCount: number
orderNumberGroups: Map<string, string[]>
summary: string
} {
// Group productionIDs by their resolved order number
const orderNumberGroups = new Map<string, string[]>()
for (const mapping of mappings) {
if (mapping.resolved && mapping.orderNumber) {
const existing = orderNumberGroups.get(mapping.orderNumber) || []
existing.push(mapping.input)
orderNumberGroups.set(mapping.orderNumber, existing)
}
}
const inputCount = mappings.length
const uniqueOrderNumbersCount = orderNumberGroups.size
// Build summary string
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
if (inputCount > uniqueOrderNumbersCount) {
const duplicateCount = inputCount - uniqueOrderNumbersCount
summary += `${duplicateCount} 个重复已合并)`
}
return {
inputCount,
uniqueOrderNumbersCount,
orderNumberGroups,
summary
}
}
} }

View File

@@ -89,10 +89,6 @@ export class CleanerReportGenerator {
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``) lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``) lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
lines.push(`| **错误数量** | \`${result.errors.length}\``) lines.push(`| **错误数量** | \`${result.errors.length}\``)
if (result.retriedOrders > 0) {
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
lines.push(`| **成功重试数** | \`${result.successfulRetries}\``)
}
lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``) lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
lines.push('') lines.push('')
lines.push('---') lines.push('---')
@@ -104,12 +100,6 @@ export class CleanerReportGenerator {
lines.push('| ----------- | ---- | ------ |') lines.push('| ----------- | ---- | ------ |')
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`) lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`) lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`)
if (result.retriedOrders > 0) {
const retrySuccessRate =
result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0
lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`)
lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.toFixed(1)}% |`)
}
lines.push('') lines.push('')
lines.push('---') lines.push('---')
lines.push('') lines.push('')
@@ -121,19 +111,10 @@ export class CleanerReportGenerator {
result.details.forEach((detail, index) => { result.details.forEach((detail, index) => {
const orderNum = index + 1 const orderNum = index + 1
let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功' const status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
// Override status if retry was successful
if (detail.retrySuccess) {
status = '✅ 重试成功'
} else if (detail.retryCount > 0 && !detail.retrySuccess) {
status = '❌ 重试失败'
}
const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-' const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
lines.push( lines.push(
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |` `| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status} | \`${errorMsg}\` |`
) )
}) })
@@ -194,62 +175,6 @@ export class CleanerReportGenerator {
lines.push('') lines.push('')
} }
// Add retry details section
if (result.retriedOrders > 0) {
lines.push('## 重试执行详情')
lines.push('')
lines.push(
`**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\``
)
lines.push('')
const retriedDetails = result.details.filter((d) => d.retryCount > 0)
if (retriedDetails.length > 0) {
lines.push('### 重试订单列表')
lines.push('')
lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |')
lines.push('| -------- | -------- | -------- | ------------ |')
retriedDetails.forEach((detail) => {
const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败'
const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-'
lines.push(
`| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |`
)
})
lines.push('')
lines.push('### 重试尝试详细记录')
lines.push('')
retriedDetails.forEach((detail) => {
lines.push(`#### \`${detail.orderNumber}\``)
lines.push('')
lines.push(`- **重试次数**: ${detail.retryCount}`)
lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`)
if (detail.retryAttempts && detail.retryAttempts.length > 0) {
lines.push('')
lines.push('**重试尝试记录**:')
lines.push('')
detail.retryAttempts.forEach((attempt, idx) => {
lines.push(
`${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}`
)
lines.push(` - 错误:${attempt.error}`)
})
lines.push('')
}
lines.push('---')
lines.push('')
})
}
lines.push('')
}
lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``) lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
lines.push('**报表版本**: `v1.0`') lines.push('**报表版本**: `v1.0`')

View File

@@ -1,6 +0,0 @@
/**
* RustFS Service Module
*/
export { RustfsService } from './rustfs-service'
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'

View File

@@ -1,376 +0,0 @@
/**
* RustFS Service
*
* S3-compatible object storage service for persisting reports and files
* Uses AWS SDK for S3 protocol compatibility
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
type PutObjectCommandInput,
type GetObjectCommandInput,
type DeleteObjectCommandInput
} from '@aws-sdk/client-s3'
import { createLogger } from '../logger'
import type { RustfsConfig } from '../../types/config.schema'
import * as fs from 'fs'
import * as path from 'path'
const log = createLogger('RustfsService')
export interface UploadResult {
success: boolean
key: string
etag?: string
error?: string
}
export interface DownloadResult {
success: boolean
content: Buffer
error?: string
}
export interface RustfsServiceOptions {
config: RustfsConfig
}
export class RustfsService {
private client: S3Client
private config: RustfsConfig
constructor(options: RustfsServiceOptions) {
const { config } = options
this.config = config
// Configure S3 client for RustFS
// RustFS is fully compatible with S3 protocol
this.client = new S3Client({
region: config.region || 'us-east-1',
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true // Required for some S3-compatible services
})
log.info('RustFS service initialized', {
endpoint: config.endpoint,
bucket: config.bucket,
region: config.region
})
}
/**
* Upload a file to RustFS
* @param filePath - Local file path to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadFile(filePath: string, key: string, contentType?: string): Promise<UploadResult> {
try {
// Validate configuration
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
// Check if file exists
if (!fs.existsSync(filePath)) {
return {
success: false,
key,
error: `File not found: ${filePath}`
}
}
// Read file content
const fileContent = await fs.promises.readFile(filePath)
// Determine content type
const mimeType = contentType || this.getMimeType(filePath) || 'application/octet-stream'
log.info('Uploading file to RustFS', {
filePath,
key,
contentType: mimeType,
size: fileContent.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: fileContent,
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('File uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload file to RustFS', {
filePath,
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Upload a string content directly to RustFS
* @param content - String content to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadString(content: string, key: string, contentType?: string): Promise<UploadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
const mimeType = contentType || 'text/plain; charset=utf-8'
log.info('Uploading string content to RustFS', {
key,
contentType: mimeType,
size: content.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: Buffer.from(content, 'utf-8'),
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('String content uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload string to RustFS', {
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Download a file from RustFS
* @param key - Object key (path) in the bucket
*/
async downloadFile(key: string): Promise<DownloadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
content: Buffer.alloc(0),
error: 'RustFS is not enabled in configuration'
}
}
log.info('Downloading file from RustFS', { key })
const input: GetObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new GetObjectCommand(input)
const response = await this.client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
log.info('File downloaded successfully', {
key,
size: content.length
})
return {
success: true,
content
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown download error'
log.error('Failed to download file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
content: Buffer.alloc(0),
error: errorMessage
}
}
}
/**
* Delete a file from RustFS
* @param key - Object key (path) in the bucket
*/
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config.enabled) {
return {
success: false,
error: 'RustFS is not enabled in configuration'
}
}
log.info('Deleting file from RustFS', { key })
const input: DeleteObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new DeleteObjectCommand(input)
await this.client.send(command)
log.info('File deleted successfully', { key })
return {
success: true
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown delete error'
log.error('Failed to delete file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
error: errorMessage
}
}
}
/**
* Generate a storage key for cleaner reports
* @param reportFileName - Original report file name
* @param username - Username who generated the report
*/
generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
/**
* Get MIME type based on file extension
*/
private getMimeType(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv; charset=utf-8',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif'
}
return mimeTypes[ext] || null
}
/**
* Test connection to RustFS
*/
async testConnection(): Promise<{
success: boolean
message: string
error?: string
}> {
try {
log.info('Testing RustFS connection', {
endpoint: this.config.endpoint,
bucket: this.config.bucket
})
// Try to list objects in the bucket (head bucket operation)
const input = {
Bucket: this.config.bucket,
Prefix: '',
MaxKeys: 1
}
const command = new ListObjectsV2Command(input)
await this.client.send(command)
log.info('RustFS connection test successful')
return {
success: true,
message: '连接成功'
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'
log.error('RustFS connection test failed', {
error: errorMessage
})
return {
success: false,
message: '连接失败',
error: errorMessage
}
}
}
}

View File

@@ -1,215 +0,0 @@
/**
* RustFS Integration Test Script
*
* Tests RustFS connection and upload functionality
* Usage: tsx src/main/tools/rustfs-test.ts
*
* Note: This test runs in standalone mode without Electron
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
type PutObjectCommandInput
} from '@aws-sdk/client-s3'
import * as path from 'path'
import * as fs from 'fs'
// Simple console logger (standalone mode)
const log = {
info: (msg: string, data?: any) => console.log(`[INFO] ${msg}`, data ? JSON.stringify(data) : ''),
error: (msg: string, data?: any) =>
console.error(`[ERROR] ${msg}`, data ? JSON.stringify(data) : ''),
warn: (msg: string, data?: any) => console.warn(`[WARN] ${msg}`, data ? JSON.stringify(data) : '')
}
// Test configuration
const TEST_CONFIG = {
enabled: true,
endpoint: 'http://192.168.110.114:9000',
accessKey: 'dP4O7ePAzyH8earoXxE9',
secretKey: '2vRPLnsh9Zi1KyBDymUtACyDdLHGfsLvw4MkG3cv',
bucket: 'erpauto',
region: 'us-east-1'
}
function createS3Client(config: typeof TEST_CONFIG) {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.csv': 'text/csv; charset=utf-8'
}
return mimeTypes[ext] || 'application/octet-stream'
}
function generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
async function runTests() {
console.log('='.repeat(50))
console.log('RustFS Integration Test')
console.log('='.repeat(50))
console.log()
const client = createS3Client(TEST_CONFIG)
// Test connection
console.log('1. Testing connection...')
try {
const command = new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket,
Prefix: '',
MaxKeys: 1
})
await client.send(command)
console.log(' ✓ Connection successful')
} catch (error) {
console.log(` ✗ Connection failed: ${(error as Error).message}`)
return
}
console.log()
// Test upload string
console.log('2. Testing string upload...')
const testContent = `# Test Report
Generated at: ${new Date().toISOString()}
This is a test report to verify RustFS integration.
`
const testKey = `test/reports/test-${Date.now()}.md`
try {
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: testKey,
Body: Buffer.from(testContent, 'utf-8'),
ContentType: 'text/markdown; charset=utf-8'
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ Upload successful')
console.log(` Key: ${testKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ Upload failed')
console.log(` Error: ${(error as Error).message}`)
return
}
console.log()
// Test download
console.log('3. Testing download...')
try {
const command = new GetObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
const response = await client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
console.log(' ✓ Download successful')
console.log(` Size: ${content.length} bytes`)
console.log(` Content preview: ${content.toString('utf-8').slice(0, 50)}...`)
} catch (error) {
console.log(' ✗ Download failed')
console.log(` Error: ${(error as Error).message}`)
}
console.log()
// Test file upload (create a temporary file)
console.log('4. Testing file upload...')
const tempFilePath = path.join(process.cwd(), `test-file-${Date.now()}.md`)
fs.writeFileSync(tempFilePath, testContent, 'utf-8')
const fileKey = `test/files/test-file-${Date.now()}.md`
try {
const fileContent = fs.readFileSync(tempFilePath)
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: fileKey,
Body: fileContent,
ContentType: getMimeType(tempFilePath)
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ File upload successful')
console.log(` Key: ${fileKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ File upload failed')
console.log(` Error: ${(error as Error).message}`)
}
// Cleanup temp file
try {
fs.unlinkSync(tempFilePath)
console.log(' ✓ Temporary file cleaned up')
} catch (e) {
console.log(` ⚠ Could not clean up temp file: ${(e as Error).message}`)
}
console.log()
// Test report key generation
console.log('5. Testing report key generation...')
const reportKey = generateReportKey('cleaner-report-2026-03-17-10-30-00.md', 'admin')
console.log(` ✓ Generated key: ${reportKey}`)
console.log()
// Test cleanup (delete test files)
console.log('6. Cleaning up test files...')
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
await client.send(deleteCommand)
console.log(' ✓ Test string file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test string file: ${(error as Error).message}`)
}
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: fileKey
})
await client.send(deleteCommand)
console.log(' ✓ Test file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test file: ${(error as Error).message}`)
}
console.log()
console.log('='.repeat(50))
console.log('All tests completed!')
console.log('='.repeat(50))
}
// Run tests
runTests().catch((error) => {
console.error('Test failed with error:', error)
process.exit(1)
})

View File

@@ -16,8 +16,7 @@ export interface CleanerInput {
materialCodes: string[] materialCodes: string[]
dryRun: boolean dryRun: boolean
headless?: boolean headless?: boolean
queryBatchSize?: number concurrency?: number
processConcurrency?: number
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
} }
@@ -27,9 +26,6 @@ export interface CleanerResult {
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
details: OrderCleanDetail[] details: OrderCleanDetail[]
// Retry statistics
retriedOrders: number
successfulRetries: number
} }
export interface SkippedMaterial { export interface SkippedMaterial {
@@ -39,23 +35,12 @@ export interface SkippedMaterial {
reason: string reason: string
} }
export interface RetryAttempt {
attempt: number
error: string
timestamp: number
}
export interface OrderCleanDetail { export interface OrderCleanDetail {
orderNumber: string orderNumber: string
materialsDeleted: number materialsDeleted: number
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
skippedMaterials: SkippedMaterial[] skippedMaterials: SkippedMaterial[]
// Retry-related fields
retryCount: number
retryAttempts?: RetryAttempt[]
retriedAt?: number
retrySuccess?: boolean
} }
/** /**

View File

@@ -97,15 +97,6 @@ export const validationConfigSchema = z.object({
defaultManager: z.string().default('') defaultManager: z.string().default('')
}) })
/**
* 清理配置 Schema
*/
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)
})
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
/** /**
* 订单号解析配置 Schema * 订单号解析配置 Schema
*/ */
@@ -131,18 +122,6 @@ export const loggingConfigSchema = z.object({
appRetention: z.number().int().min(1).max(365).default(14) appRetention: z.number().int().min(1).max(365).default(14)
}) })
/**
* RustFS 对象存储配置 Schema
*/
export const rustfsConfigSchema = z.object({
enabled: z.boolean().default(false),
endpoint: z.string().min(1, 'RustFS endpoint is required'),
accessKey: z.string().min(1, 'RustFS access key is required'),
secretKey: z.string().min(1, 'RustFS secret key is required'),
bucket: z.string().min(1, 'RustFS bucket is required'),
region: z.string().default('us-east-1')
})
/** /**
* 完整应用配置 Schema * 完整应用配置 Schema
*/ */
@@ -152,10 +131,8 @@ export const fullConfigSchema = z.object({
paths: pathsConfigSchema, paths: pathsConfigSchema,
extraction: extractionConfigSchema, extraction: extractionConfigSchema,
validation: validationConfigSchema, validation: validationConfigSchema,
cleaner: cleanerConfigSchema,
orderResolution: orderResolutionSchema, orderResolution: orderResolutionSchema,
logging: loggingConfigSchema, logging: loggingConfigSchema
rustfs: rustfsConfigSchema.optional()
}) })
/** /**
@@ -167,7 +144,6 @@ export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema> export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema> export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema> export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
/** /**
* 验证并解析配置 * 验证并解析配置

View File

@@ -168,25 +168,3 @@ export interface DatabaseAPI {
params?: Record<string, unknown> params?: Record<string, unknown>
) => Promise<IpcResult<SqlServerQueryResult>> ) => Promise<IpcResult<SqlServerQueryResult>>
} }
/**
* Report service APIs
*/
export interface ReportAPI {
/**
* List all reports across all users (Admin only typically)
*/
listAll: () => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
/**
* List reports for a specific user
* @param username - Username to list reports for
*/
listByUser: (username: string) => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
/**
* Download a specific report by key
* @param key - Report object key in RustFS
*/
download: (key: string) => Promise<IpcResult<string>>
}

View File

@@ -1,4 +1,4 @@
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI, ReportAPI } from '../main/types/ipc-api.types' import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler' import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
import type { UserInfo } from '../main/types/user.types' import type { UserInfo } from '../main/types/user.types'
import type { import type {
@@ -21,7 +21,6 @@ import type {
} from '../main/types/settings.types' } from '../main/types/settings.types'
import type { IpcResult } from '../main/ipc' import type { IpcResult } from '../main/ipc'
import type { LogLevel } from '../shared/ipc-channels' import type { LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
export interface ResolverAPI { export interface ResolverAPI {
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>> resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
@@ -47,7 +46,6 @@ export interface ValidationAPI {
validate: (request: ValidationRequest) => Promise<IpcResult<ValidationResponse>> validate: (request: ValidationRequest) => Promise<IpcResult<ValidationResponse>>
setSharedProductionIds: (productionIds: string[]) => Promise<IpcResult<void>> setSharedProductionIds: (productionIds: string[]) => Promise<IpcResult<void>>
getSharedProductionIds: () => Promise<IpcResult<{ productionIds: string[] }>> getSharedProductionIds: () => Promise<IpcResult<{ productionIds: string[] }>>
clearSharedProductionIds: () => Promise<IpcResult<void>>
getCleanerData: () => Promise< getCleanerData: () => Promise<
IpcResult<{ IpcResult<{
orderNumbers: string[] orderNumbers: string[]
@@ -111,11 +109,6 @@ export interface UserErpConfigAPI {
getAll: () => Promise<IpcResult<Array<{ username: string; erpUrl: string; erpUsername: string }>>> getAll: () => Promise<IpcResult<Array<{ username: string; erpUrl: string; erpUsername: string }>>>
} }
export interface ConfigAPI {
getCleaner: () => Promise<IpcResult<CleanerConfig>>
updateCleaner: (updates: Partial<CleanerConfig>) => Promise<IpcResult<CleanerConfig>>
}
export interface LoggerAPI { export interface LoggerAPI {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
} }
@@ -143,9 +136,7 @@ declare global {
settings: SettingsAPI settings: SettingsAPI
materialType: MaterialTypeAPI materialType: MaterialTypeAPI
userErpConfig: UserErpConfigAPI userErpConfig: UserErpConfigAPI
config: ConfigAPI
logger: LoggerAPI logger: LoggerAPI
report: ReportAPI
} }
api: unknown api: unknown
} }

View File

@@ -12,7 +12,6 @@ import type {
} from '../main/types/validation.types' } from '../main/types/validation.types'
import type { IpcResult } from '../main/ipc' import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
type ErpSettingsPayload = { type ErpSettingsPayload = {
erp?: { erp?: {
@@ -196,12 +195,6 @@ const api = {
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL) getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
}, },
config: {
getCleaner: (): Promise<IpcResult<CleanerConfig>> => invokeIpc(IPC_CHANNELS.CONFIG_GET_CLEANER),
updateCleaner: (updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> =>
invokeIpc(IPC_CHANNELS.CONFIG_UPDATE_CLEANER, updates)
},
logger: { logger: {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => { log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, { ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
@@ -211,13 +204,6 @@ const api = {
timestamp: Date.now() timestamp: Date.now()
}) })
} }
},
report: {
listAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
listByUser: (username: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
} }
} as const } as const

View File

@@ -332,17 +332,12 @@ function App(): React.JSX.Element {
</div> </div>
<div <div
className="flex flex-col cursor-pointer" className="flex items-center gap-2 text-white font-bold text-lg cursor-pointer"
onClick={() => setCurrentPage('home')} onClick={() => setCurrentPage('home')}
style={{ WebkitAppRegion: 'no-drag' } as any} style={{ WebkitAppRegion: 'no-drag' } as any}
> >
<div className="flex items-center gap-2 text-white font-bold text-lg"> <LayoutDashboard size={22} className="text-blue-500" />
<LayoutDashboard size={22} className="text-blue-500" /> <span>ERP Auto</span>
<span>ERP Auto</span>
</div>
<span className="text-xs text-slate-400 ml-7">
{__APP_VERSION__}({__GIT_HASH__})
</span>
</div> </div>
</div> </div>

View File

@@ -34,9 +34,6 @@ interface ExecutionReportDialogProps {
progress?: CleanerProgress | null progress?: CleanerProgress | null
startTime?: number | null startTime?: number | null
triggerRef?: React.RefObject<HTMLElement | null> triggerRef?: React.RefObject<HTMLElement | null>
// Retry-related props
retriedOrders?: number
successfulRetries?: number
} }
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
@@ -50,18 +47,14 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
isExecuting = false, isExecuting = false,
progress = null, progress = null,
startTime = null, startTime = null,
triggerRef, triggerRef
retriedOrders = 0,
successfulRetries = 0
}) => { }) => {
const [now, setNow] = React.useState(() => Date.now()) const [now, setNow] = React.useState(() => Date.now())
const hasErrors = errors.length > 0 const hasErrors = errors.length > 0
const hasRetries = retriedOrders > 0
const showProgress = isExecuting && progress const showProgress = isExecuting && progress
const isProgressing = !!showProgress const isProgressing = !!showProgress
// Update timer during progress
React.useEffect(() => { React.useEffect(() => {
if (!showProgress || !startTime) return if (!showProgress || !startTime) return
@@ -72,27 +65,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
return () => clearInterval(interval) return () => clearInterval(interval)
}, [showProgress, startTime]) }, [showProgress, startTime])
// Update time when execution completes
React.useEffect(() => {
if (showProgress === false && startTime && isExecuting === false) {
setNow(Date.now())
}
}, [showProgress, isExecuting, startTime])
const elapsedTime = React.useMemo(() => {
if (!startTime) return null
const elapsedMs = now - startTime
const elapsedSeconds = Math.floor(elapsedMs / 1000)
const minutes = Math.floor(elapsedSeconds / 60)
const seconds = elapsedSeconds % 60
return {
totalSeconds: elapsedSeconds,
formatted: minutes > 0 ? `${minutes}${seconds}` : `${seconds}`
}
}, [startTime, now])
const estimatedTime = React.useMemo(() => { const estimatedTime = React.useMemo(() => {
if (!showProgress || !startTime || !progress) return null if (!showProgress || !startTime || !progress) return null
@@ -138,7 +110,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
triggerRef={triggerRef} triggerRef={triggerRef}
isAlertDialog={isProgressing} isAlertDialog={isProgressing}
disableEscapeKey={isProgressing} disableEscapeKey={isProgressing}
disableBackdropClick={isProgressing}
ariaDescribedBy={isProgressing ? 'execution-dialog-progress-desc' : undefined} ariaDescribedBy={isProgressing ? 'execution-dialog-progress-desc' : undefined}
initialFocusSelector={!isProgressing ? '.btn-report-close' : undefined} initialFocusSelector={!isProgressing ? '.btn-report-close' : undefined}
> >
@@ -227,15 +198,6 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div> </div>
)} )}
{elapsedTime && (
<div className="flex flex-col items-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mb-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-blue-600">{elapsedTime.formatted}</span>
</div>
</div>
)}
{estimatedTime && ( {estimatedTime && (
<div className="flex flex-col items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200"> <div className="flex flex-col items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@@ -297,71 +259,8 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div> </div>
</div> </div>
)} )}
{hasRetries && (
<>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
<div className="w-9 h-9 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-purple-600"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-gray-900">{retriedOrders}</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
<div className="w-9 h-9 rounded-lg bg-emerald-50 flex items-center justify-center flex-shrink-0">
<CheckCircle size={20} className="text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-gray-900">{successfulRetries}</div>
</div>
</div>
</>
)}
</div> </div>
{elapsedTime && (
<div className="flex items-center justify-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-700 text-sm mb-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>
<span className="font-semibold">{elapsedTime.formatted}</span>
</span>
</div>
)}
{hasErrors && ( {hasErrors && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm font-semibold text-red-600 mb-2"></div> <div className="text-sm font-semibold text-red-600 mb-2"></div>

View File

@@ -1,188 +0,0 @@
import React, { useEffect, useState } from 'react'
import { X, FileText, Loader2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface ReportMetadata {
key: string
filename: string
username: string
lastModified?: Date
size?: number
}
interface ReportViewerDialogProps {
isOpen: boolean
onClose: () => void
isAdmin: boolean
currentUsername: string
}
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername
}) => {
const [reports, setReports] = useState<ReportMetadata[]>([])
const [selectedReportKey, setSelectedReportKey] = useState<string>('')
const [reportContent, setReportContent] = useState<string>('')
const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
loadReports()
} else {
// Reset state when closed
setReports([])
setSelectedReportKey('')
setReportContent('')
setError(null)
}
}, [isOpen, isAdmin, currentUsername])
const loadReports = async () => {
setIsLoadingList(true)
setError(null)
try {
let result
if (isAdmin) {
result = await window.electron.report.listAll()
} else {
result = await window.electron.report.listByUser(currentUsername)
}
if (result.success && result.data) {
setReports(result.data)
} else {
setError(result.error || '无法获取报告列表')
}
} catch (err) {
setError('获取报告列表时发生错误')
} finally {
setIsLoadingList(false)
}
}
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const key = e.target.value
setSelectedReportKey(key)
if (!key) {
setReportContent('')
return
}
setIsLoadingContent(true)
setError(null)
try {
const result = await window.electron.report.download(key)
if (result.success && result.data) {
setReportContent(result.data)
} else {
setError(result.error || '无法获取报告内容')
setReportContent('')
}
} catch (err) {
setError('获取报告内容时发生错误')
setReportContent('')
} finally {
setIsLoadingContent(false)
}
}
if (!isOpen) return null
// Format date to local string
const formatDate = (dateString?: Date | string) => {
if (!dateString) return '未知时间'
const date = typeof dateString === 'string' ? new Date(dateString) : dateString
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-[900px] max-w-[90vw] h-[80vh] flex flex-col border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50 flex-shrink-0">
<div className="flex items-center gap-2 text-slate-800">
<FileText size={20} className="text-blue-600" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Controls */}
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-slate-700 flex-shrink-0">:</label>
<div className="relative flex-1 max-w-2xl">
<select
value={selectedReportKey}
onChange={handleReportChange}
disabled={isLoadingList}
className="w-full appearance-none bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
>
<option value="">...</option>
{reports.map((report) => (
<option key={report.key} value={report.key}>
{isAdmin ? `[${report.username}] ` : ''}
{report.filename} ({formatDate(report.lastModified)})
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
<svg
className="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</div>
</div>
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
</div>
{error && <div className="mt-3 text-sm text-red-600 flex items-center gap-1.5 bg-red-50 p-2 rounded">{error}</div>}
</div>
{/* Content */}
<div className="flex-1 bg-slate-50 overflow-hidden relative">
{isLoadingContent ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500 bg-white/80 z-10">
<Loader2 size={32} className="animate-spin text-blue-500 mb-4" />
<p>...</p>
</div>
) : reportContent ? (
<div className="h-full overflow-y-auto p-8">
<div className="prose prose-slate prose-sm max-w-none bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{reportContent}</ReactMarkdown>
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
<FileText size={48} className="mb-4 text-slate-300 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
)
}
export default ReportViewerDialog

View File

@@ -107,7 +107,11 @@ export function ConfirmDialog({
<Button variant="secondary" onClick={onCancel}> <Button variant="secondary" onClick={onCancel}>
{cancelText} {cancelText}
</Button> </Button>
<Button data-autofocus="true" variant={styles.buttonVariant} onClick={onConfirm}> <Button
data-autofocus="true"
variant={styles.buttonVariant}
onClick={onConfirm}
>
{confirmText} {confirmText}
</Button> </Button>
</div> </div>
@@ -121,14 +125,14 @@ export function ConfirmDialog({
*/ */
export function useConfirmDialog() { export function useConfirmDialog() {
const [config, setConfig] = useState< const [config, setConfig] = useState<
| (Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & { (Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
resolve: (value: boolean) => void resolve: (value: boolean) => void
}) }) | null>(null)
| null
>(null)
const confirm = useCallback( const confirm = useCallback(
(options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>): Promise<boolean> => { (
options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>
): Promise<boolean> => {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
setConfig({ setConfig({
...options, ...options,

View File

@@ -28,8 +28,6 @@ interface ModalProps {
isAlertDialog?: boolean isAlertDialog?: boolean
/** Whether to disable escape key handling (e.g., during execution) */ /** Whether to disable escape key handling (e.g., during execution) */
disableEscapeKey?: boolean disableEscapeKey?: boolean
/** Whether to disable closing when clicking on backdrop (e.g., during execution) */
disableBackdropClick?: boolean
} }
const sizeStyles: Record<string, string> = { const sizeStyles: Record<string, string> = {
@@ -53,8 +51,7 @@ export function Modal({
initialFocusSelector, initialFocusSelector,
ariaDescribedBy, ariaDescribedBy,
isAlertDialog = false, isAlertDialog = false,
disableEscapeKey = false, disableEscapeKey = false
disableBackdropClick = false
}: ModalProps): React.JSX.Element | null { }: ModalProps): React.JSX.Element | null {
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
const [generatedId] = useState( const [generatedId] = useState(
@@ -90,7 +87,7 @@ export function Modal({
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={disableBackdropClick ? undefined : onClose} onClick={onClose}
aria-hidden="true" aria-hidden="true"
/> />

View File

@@ -73,7 +73,7 @@ export function Toast() {
if (toasts.length === 0) return null if (toasts.length === 0) return null
return ( return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2"> <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => ( {toasts.map((toast) => (
<ToastItem <ToastItem
key={toast.id} key={toast.id}

View File

@@ -1,4 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare const __GIT_HASH__: string

View File

@@ -65,8 +65,6 @@ export function useCleaner() {
materialsDeleted: number materialsDeleted: number
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
retriedOrders?: number
successfulRetries?: number
} | null>(null) } | null>(null)
// Progress state // Progress state
@@ -78,8 +76,10 @@ export function useCleaner() {
const saved = sessionStorage.getItem('cleaner_headless') const saved = sessionStorage.getItem('cleaner_headless')
return saved ? saved === 'true' : true return saved ? saved === 'true' : true
}) })
const [queryBatchSize, setQueryBatchSize] = useState(100) const [concurrency, setConcurrency] = useState(() => {
const [processConcurrency, setProcessConcurrency] = useState(1) const saved = sessionStorage.getItem('cleaner_concurrency')
return saved ? parseInt(saved, 10) : 1
})
const [showSettingsMenu, setShowSettingsMenu] = useState(false) const [showSettingsMenu, setShowSettingsMenu] = useState(false)
// Inline editing state for manager field (Admin only) // Inline editing state for manager field (Admin only)
@@ -164,22 +164,6 @@ export function useCleaner() {
} }
}, []) }, [])
// Load cleaner config from config.yaml on mount
useEffect(() => {
const loadCleanerConfig = async () => {
try {
const result = await window.electron.config.getCleaner()
if (result.success && result.data) {
setQueryBatchSize(result.data.queryBatchSize)
setProcessConcurrency(result.data.processConcurrency)
}
} catch (err) {
console.error('Failed to load cleaner config:', err)
}
}
loadCleanerConfig()
}, [])
useEffect(() => { useEffect(() => {
sessionStorage.setItem('cleaner_dryRun', dryRun.toString()) sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
}, [dryRun]) }, [dryRun])
@@ -188,15 +172,9 @@ export function useCleaner() {
sessionStorage.setItem('cleaner_headless', headless.toString()) sessionStorage.setItem('cleaner_headless', headless.toString())
}, [headless]) }, [headless])
const updateProcessConcurrency = async (value: number) => { useEffect(() => {
const clamped = Math.max(1, Math.min(20, value)) sessionStorage.setItem('cleaner_concurrency', concurrency.toString())
setProcessConcurrency(clamped) }, [concurrency])
try {
await window.electron.config.updateCleaner({ processConcurrency: clamped })
} catch (err) {
console.error('Failed to update cleaner config:', err)
}
}
useEffect(() => { useEffect(() => {
sessionStorage.setItem('cleaner_validationMode', valMode) sessionStorage.setItem('cleaner_validationMode', valMode)
@@ -448,8 +426,7 @@ export function useCleaner() {
materialCodes: materialCodeList, materialCodes: materialCodeList,
dryRun, dryRun,
headless, headless,
queryBatchSize, concurrency
processConcurrency
}) })
const cleanerRunData = response.success ? (response.data as any) : null const cleanerRunData = response.success ? (response.data as any) : null
@@ -458,9 +435,7 @@ export function useCleaner() {
ordersProcessed: cleanerRunData.ordersProcessed, ordersProcessed: cleanerRunData.ordersProcessed,
materialsDeleted: cleanerRunData.materialsDeleted, materialsDeleted: cleanerRunData.materialsDeleted,
materialsSkipped: cleanerRunData.materialsSkipped, materialsSkipped: cleanerRunData.materialsSkipped,
errors: cleanerRunData.errors, errors: cleanerRunData.errors
retriedOrders: cleanerRunData.retriedOrders,
successfulRetries: cleanerRunData.successfulRetries
}) })
} else { } else {
throw new Error(response.error || '清理失败') throw new Error(response.error || '清理失败')
@@ -471,15 +446,10 @@ export function useCleaner() {
setIsRunning(false) setIsRunning(false)
setIsExecuting(false) setIsExecuting(false)
setProgress(null) setProgress(null)
// Note: Don't clear startTime here - it's needed for the execution report dialog setStartTime(null)
// startTime will be reset when the dialog closes and a new execution starts
} }
} }
const resetStartTime = useCallback(() => {
setStartTime(null)
}, [])
const handleExportResults = async () => { const handleExportResults = async () => {
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
showWarning('没有数据可导出') showWarning('没有数据可导出')
@@ -538,11 +508,8 @@ export function useCleaner() {
setIsTypeDialogOpen, setIsTypeDialogOpen,
headless, headless,
setHeadless, setHeadless,
queryBatchSize, concurrency,
setQueryBatchSize, setConcurrency,
processConcurrency,
setProcessConcurrency,
updateProcessConcurrency,
showSettingsMenu, showSettingsMenu,
setShowSettingsMenu, setShowSettingsMenu,
filteredResults, filteredResults,
@@ -559,7 +526,6 @@ export function useCleaner() {
handleAssignManagerOnSelect, handleAssignManagerOnSelect,
progress, progress,
startTime, startTime,
resetStartTime,
handleValidation, handleValidation,
handleCheckboxToggle, handleCheckboxToggle,
handleConfirmDeletion, handleConfirmDeletion,

View File

@@ -239,9 +239,7 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
if (style.visibility === 'hidden') { if (style.visibility === 'hidden') {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn( console.warn('[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus')
'[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus'
)
} }
return return
} }

View File

@@ -13,12 +13,10 @@ import {
Eye, Eye,
HardDrive, HardDrive,
Settings2, Settings2,
FileSpreadsheet, FileSpreadsheet
FileText
} from 'lucide-react' } from 'lucide-react'
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog' import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
import ExecutionReportDialog from '../components/ExecutionReportDialog' import ExecutionReportDialog from '../components/ExecutionReportDialog'
import ReportViewerDialog from '../components/ReportViewerDialog'
import { ConfirmDialog } from '../components/ui/ConfirmDialog' import { ConfirmDialog } from '../components/ui/ConfirmDialog'
import { useCleaner } from '../hooks/useCleaner' import { useCleaner } from '../hooks/useCleaner'
@@ -48,8 +46,8 @@ const CleanerPage: React.FC = () => {
setIsTypeDialogOpen, setIsTypeDialogOpen,
headless, headless,
setHeadless, setHeadless,
processConcurrency, concurrency,
updateProcessConcurrency, setConcurrency,
showSettingsMenu, showSettingsMenu,
setShowSettingsMenu, setShowSettingsMenu,
filteredResults, filteredResults,
@@ -66,7 +64,6 @@ const CleanerPage: React.FC = () => {
handleAssignManagerOnSelect, handleAssignManagerOnSelect,
progress, progress,
startTime, startTime,
resetStartTime,
handleValidation, handleValidation,
handleCheckboxToggle, handleCheckboxToggle,
handleConfirmDeletion, handleConfirmDeletion,
@@ -75,8 +72,6 @@ const CleanerPage: React.FC = () => {
confirmDialog confirmDialog
} = useCleaner() } = useCleaner()
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
return ( return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */} {/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
@@ -273,12 +268,6 @@ const CleanerPage: React.FC = () => {
> >
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'} <FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button> </button>
<button
onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
>
<FileText size={14} />
</button>
</div> </div>
</div> </div>
@@ -471,25 +460,27 @@ const CleanerPage: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
<div className="border-t border-slate-100 pt-3 space-y-3"> <div className="border-t border-slate-100 pt-3">
<div> <div className="flex items-center justify-between">
<div className="text-sm font-medium text-slate-800"></div> <div>
<div className="text-xs text-slate-500 mt-0.5"> <div className="text-sm font-medium text-slate-800">
1-20 (Concurrency)
</div> </div>
<div className="mt-2 flex items-center gap-3"> <div className="text-xs text-slate-500 mt-0.5">
<input (1-20)
type="range" </div>
min={1}
max={20}
value={processConcurrency}
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<span className="text-sm font-medium text-slate-700 w-8 text-center">
{processConcurrency}
</span>
</div> </div>
<input
type="number"
min="1"
max="20"
value={concurrency}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val)) setConcurrency(Math.min(Math.max(val, 1), 20))
}}
className="w-16 ml-4 px-2 py-1 text-sm border border-slate-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -520,10 +511,7 @@ const CleanerPage: React.FC = () => {
{/* Execution Report Dialog */} {/* Execution Report Dialog */}
<ExecutionReportDialog <ExecutionReportDialog
isOpen={isReportDialogOpen} isOpen={isReportDialogOpen}
onClose={() => { onClose={() => setIsReportDialogOpen(false)}
setIsReportDialogOpen(false)
resetStartTime()
}}
ordersProcessed={reportData?.ordersProcessed} ordersProcessed={reportData?.ordersProcessed}
materialsDeleted={reportData?.materialsDeleted} materialsDeleted={reportData?.materialsDeleted}
materialsSkipped={reportData?.materialsSkipped} materialsSkipped={reportData?.materialsSkipped}
@@ -533,16 +521,6 @@ const CleanerPage: React.FC = () => {
progress={progress} progress={progress}
startTime={startTime} startTime={startTime}
triggerRef={executeButtonRef} triggerRef={executeButtonRef}
retriedOrders={reportData?.retriedOrders}
successfulRetries={reportData?.successfulRetries}
/>
{/* Report Viewer Dialog */}
<ReportViewerDialog
isOpen={isReportViewerOpen}
onClose={() => setIsReportViewerOpen(false)}
isAdmin={isAdmin}
currentUsername={currentUsername}
/> />
{/* Confirmation Dialog */} {/* Confirmation Dialog */}

View File

@@ -32,7 +32,7 @@ const ExtractorPage: React.FC = () => {
window.electron.validation.setSharedProductionIds(orderNumberList) window.electron.validation.setSharedProductionIds(orderNumberList)
} else { } else {
// Clear shared Production IDs when input is cleared // Clear shared Production IDs when input is cleared
window.electron.validation.clearSharedProductionIds() window.electron.validation.setSharedProductionIds([])
} }
}, [orderNumbers]) }, [orderNumbers])

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Settings as SettingsIcon, Save, User, Key } from 'lucide-react' import { Settings as SettingsIcon, Save, User, Key } from 'lucide-react'
import { showSuccess, showError } from '../stores/useAppStore'
interface ErpCredentials { interface ErpCredentials {
username: string username: string
@@ -14,6 +13,10 @@ const SettingsPage: React.FC = () => {
}) })
const [isModified, setIsModified] = useState(false) const [isModified, setIsModified] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info'
text: string
} | null>(null)
useEffect(() => { useEffect(() => {
loadCredentials() loadCredentials()
@@ -35,16 +38,21 @@ const SettingsPage: React.FC = () => {
password: config.erp.password || '' password: config.erp.password || ''
}) })
} else if (!response.success) { } else if (!response.success) {
showError(response.error || '加载 ERP 配置失败') showMessage('error', response.error || '加载 ERP 配置失败')
} }
setIsModified(false) setIsModified(false)
} catch (error) { } catch (error) {
showError('加载 ERP 配置失败') showMessage('error', '加载 ERP 配置失败')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
const showMessage = (type: 'success' | 'error' | 'info', text: string) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 3000)
}
const handleSaveCredentials = async () => { const handleSaveCredentials = async () => {
try { try {
// Save ERP credentials to database (current user's config) // Save ERP credentials to database (current user's config)
@@ -60,12 +68,12 @@ const SettingsPage: React.FC = () => {
if (result.success && saveData?.success !== false) { if (result.success && saveData?.success !== false) {
setIsModified(false) setIsModified(false)
showSuccess('ERP 账号密码保存成功') showMessage('success', 'ERP 账号密码保存成功')
} else { } else {
showError(result.error || saveData?.error || '保存失败') showMessage('error', result.error || saveData?.error || '保存失败')
} }
} catch (error) { } catch (error) {
showError('保存配置时发生错误') showMessage('error', '保存配置时发生错误')
} }
} }
@@ -82,6 +90,18 @@ const SettingsPage: React.FC = () => {
return ( return (
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6"> <div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
{message && (
<div
className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${
message.type === 'success'
? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: 'bg-red-50 text-red-600 border border-red-200'
}`}
>
{message.text}
</div>
)}
<div className="w-full max-w-xl bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <div className="w-full max-w-xl bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-5"> <div className="border-b border-slate-100 bg-slate-50 px-6 py-5">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800">

View File

@@ -84,19 +84,8 @@ export const IPC_CHANNELS = {
USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection', USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection',
USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll', USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll',
// Config
CONFIG_GET: 'config:get',
CONFIG_UPDATE: 'config:update',
CONFIG_GET_CLEANER: 'config:getCleaner',
CONFIG_UPDATE_CLEANER: 'config:updateCleaner',
// Logger // Logger
LOGGER_FORWARD: 'logger:forward', LOGGER_FORWARD: 'logger:forward'
// Report
REPORT_LIST_ALL: 'report:listAll',
REPORT_LIST_BY_USER: 'report:listByUser',
REPORT_DOWNLOAD: 'report:download'
} as const } as const
/** /**

View File

@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"c6b74b79254217b7c306-e293eb560bc105062fa9"
]
}

52
tests/e2e/verify.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from '@playwright/test'
import { _electron as electron } from 'playwright'
test('Verify Concurrency Setting in CleanerPage', async () => {
// Launch Electron app
const electronApp = await electron.launch({
args: ['.', '--no-sandbox', '--disable-gpu'],
env: { ...process.env, NODE_ENV: 'development' }
})
// Get the main window
const window = await electronApp.firstWindow()
// Wait for the app to load
await window.waitForLoadState('domcontentloaded')
// Let the app initialize fully
await window.waitForTimeout(3000)
// Navigate to CleanerPage
const cleanerTab = await window.getByText('物料清理')
await cleanerTab.waitFor({ state: 'visible' }).catch(() => {})
await cleanerTab.click().catch(() => {})
// Wait a bit for the page to transition
await window.waitForTimeout(1000)
// Click "执行设置" (Execution Settings) button
const settingsBtn = await window.getByRole('button', { name: /执行设置/ })
await settingsBtn.waitFor({ state: 'visible' })
await settingsBtn.click()
// Wait for the settings menu to appear and the Concurrency input to be visible
await window.waitForTimeout(500)
// Find the input containing concurrency text, or just the number input
const concurrencyInput = await window.locator('input[type="number"]').first()
await concurrencyInput.waitFor({ state: 'visible' })
// Assert default value is 1
await expect(concurrencyInput).toHaveValue('1')
// Set to 5 and test
await concurrencyInput.fill('5')
await expect(concurrencyInput).toHaveValue('5')
// Take screenshot showing the open menu and value
await window.screenshot({ path: '/home/jules/verification/cleaner-concurrency.png' })
// Close app
await electronApp.close()
})

View File

@@ -1,9 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import { CleanerService } from '../../src/main/services/erp/cleaner'
createBatches,
getMissingOrders,
runWithConcurrency
} from '../../src/main/services/erp/cleaner'
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner' import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
describe('Cleaner Service (Unit)', () => { describe('Cleaner Service (Unit)', () => {
@@ -18,8 +14,8 @@ describe('Cleaner Service (Unit)', () => {
return false return false
} }
// Check row number range (2000-7999 are protected) // Check row number range (7000-7999 are protected)
if (rowNumber >= 2000 && rowNumber < 8000) { if (rowNumber >= 7000 && rowNumber < 8000) {
return false return false
} }
@@ -32,12 +28,12 @@ describe('Cleaner Service (Unit)', () => {
} }
} }
it('should skip materials with row number 2000-7999', () => { it('should skip materials with row number 7000-7999', () => {
const testCases = [ const testCases = [
{ rowNumber: 2000, pendingQty: '', materialCode: 'TEST001', expected: false }, { rowNumber: 7000, pendingQty: '', materialCode: 'TEST001', expected: false },
{ rowNumber: 5000, pendingQty: '', materialCode: 'TEST001', expected: false }, { rowNumber: 7500, pendingQty: '', materialCode: 'TEST001', expected: false },
{ rowNumber: 7999, pendingQty: '', materialCode: 'TEST001', expected: false }, { rowNumber: 7999, pendingQty: '', materialCode: 'TEST001', expected: false },
{ rowNumber: 1999, pendingQty: '', materialCode: 'TEST001', expected: true }, { rowNumber: 6999, pendingQty: '', materialCode: 'TEST001', expected: true },
{ rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true } { rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true }
] ]
@@ -78,7 +74,7 @@ describe('Cleaner Service (Unit)', () => {
const testCases = [ const testCases = [
{ rowNumber: 1, pendingQty: '', materialCode: 'TEST001', expected: true }, { rowNumber: 1, pendingQty: '', materialCode: 'TEST001', expected: true },
{ rowNumber: 100, pendingQty: '', materialCode: 'TEST001', expected: true }, { rowNumber: 100, pendingQty: '', materialCode: 'TEST001', expected: true },
{ rowNumber: 1999, pendingQty: '', materialCode: 'TEST001', expected: true }, { rowNumber: 6999, pendingQty: '', materialCode: 'TEST001', expected: true },
{ rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true }, { rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true },
{ rowNumber: 10000, pendingQty: '', materialCode: 'TEST001', expected: true } { rowNumber: 10000, pendingQty: '', materialCode: 'TEST001', expected: true }
] ]
@@ -136,31 +132,4 @@ describe('Cleaner Service (Unit)', () => {
).toBe(false) ).toBe(false)
}) })
}) })
describe('batch and concurrency helpers', () => {
it('should split orders into batches', () => {
const batches = createBatches(['A', 'B', 'C', 'D', 'E'], 2)
expect(batches).toEqual([['A', 'B'], ['C', 'D'], ['E']])
})
it('should identify missing orders', () => {
const missing = getMissingOrders(['SC1', 'SC2', 'SC3'], new Set(['SC1', 'SC3']))
expect(missing).toEqual(['SC2'])
})
it('should respect concurrency limit', async () => {
const items = [1, 2, 3, 4, 5, 6]
let running = 0
let peak = 0
await runWithConcurrency(items, 2, async () => {
running += 1
peak = Math.max(peak, running)
await new Promise((resolve) => setTimeout(resolve, 10))
running -= 1
return true
})
expect(peak).toBeLessThanOrEqual(2)
})
})
}) })

View File

@@ -138,34 +138,6 @@ describe('Cleaner Schema', () => {
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
it('should apply defaults for queryBatchSize and processConcurrency', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: ['MAT001'],
dryRun: false
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.queryBatchSize).toBe(100)
expect(result.data.processConcurrency).toBe(1)
}
})
it('should reject out-of-range processConcurrency', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: ['MAT001'],
dryRun: false,
processConcurrency: 21
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
}) })
describe('validateCleanerInput', () => { describe('validateCleanerInput', () => {