Compare commits
20 Commits
103effcfca
...
1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee1ea566c | ||
|
|
29f29f6a9e | ||
|
|
baa7622954 | ||
|
|
851c2ce634 | ||
|
|
64349125ba | ||
|
|
a020ee537d | ||
|
|
351e9a92bc | ||
|
|
f5514dd721 | ||
|
|
b94640ca81 | ||
|
|
b8163d7d4f | ||
|
|
569c8e8ecc | ||
|
|
21bb8ef79c | ||
|
|
fbcc656b99 | ||
|
|
3854c0f048 | ||
|
|
7bc6daf1b7 | ||
|
|
db44618ee5 | ||
|
|
2d17a6b792 | ||
|
|
e8aa7d21a8 | ||
|
|
defeb01808 | ||
|
|
b289fb9624 |
@@ -52,3 +52,21 @@ 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 兼容,默认即可)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
|
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
|
||||||
| -------- | -------- | -------- | ---- | --------------------------------- |
|
| -------- | -------- | -------- | ---- | --------------------------------- |
|
||||||
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 7000-7999 范围内(受保护) |
|
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 2000-7999 范围内(受保护) |
|
||||||
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
|
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
|
||||||
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
|
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
|
||||||
| ... | ... | ... | ... | ... |
|
| ... | ... | ... | ... | ... |
|
||||||
|
|||||||
@@ -163,10 +163,10 @@ mindmap
|
|||||||
重试打开详情页失败
|
重试打开详情页失败
|
||||||
重试处理异常
|
重试处理异常
|
||||||
达到最大重试次数 (2 次)
|
达到最大重试次数 (2 次)
|
||||||
业务规则错误
|
业务规则错误
|
||||||
物料不在删除清单
|
物料不在删除清单
|
||||||
行号在保护范围 (7000-7999)
|
行号在保护范围 (2000-7999)
|
||||||
累计待发数量不为空
|
累计待发数量不为空
|
||||||
收尾错误
|
收尾错误
|
||||||
浏览器关闭失败
|
浏览器关闭失败
|
||||||
数据库断开失败
|
数据库断开失败
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { execSync } from 'child_process'
|
||||||
|
import { createRequire } from 'module'
|
||||||
|
|
||||||
// Get git hash (first 7 characters)
|
// Get git hash (first 7 characters)
|
||||||
const getGitHash = (): string => {
|
const getGitHash = (): string => {
|
||||||
@@ -14,6 +15,7 @@ const getGitHash = (): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get version from package.json
|
// Get version from package.json
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
const version = require('./package.json').version
|
const version = require('./package.json').version
|
||||||
const gitHash = getGitHash()
|
const gitHash = getGitHash()
|
||||||
|
|
||||||
@@ -30,6 +32,18 @@ export default defineConfig({
|
|||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()]
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
{
|
||||||
|
name: 'update-title',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<title>ERP Auto Tool</title>',
|
||||||
|
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
3114
package-lock.json
generated
3114
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.0.1",
|
"version": "1.3.1",
|
||||||
"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,9 +16,10 @@
|
|||||||
"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 build && electron-builder --win",
|
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win",
|
||||||
"build:mac": "chcp 65001 && electron-vite build && electron-builder --mac",
|
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
||||||
"build:linux": "chcp 65001 && electron-vite build && electron-builder --linux",
|
"build:linux": "chcp 65001 && npm run prebuild && 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",
|
||||||
@@ -26,9 +27,11 @@
|
|||||||
"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",
|
||||||
@@ -43,7 +46,9 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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'
|
||||||
@@ -262,7 +263,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 (silent, user unaware)
|
// Generate report and upload to RustFS (silent, user unaware)
|
||||||
try {
|
try {
|
||||||
const endTime = Date.now()
|
const endTime = Date.now()
|
||||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
@@ -276,6 +277,47 @@ 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)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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'
|
||||||
@@ -103,5 +104,6 @@ export function registerIpcHandlers(): void {
|
|||||||
registerMaterialTypeHandlers()
|
registerMaterialTypeHandlers()
|
||||||
registerUserErpConfigHandlers()
|
registerUserErpConfigHandlers()
|
||||||
registerLoggerHandlers()
|
registerLoggerHandlers()
|
||||||
|
registerReportHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
183
src/main/ipc/report-handler.ts
Normal file
183
src/main/ipc/report-handler.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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')
|
||||||
|
|
||||||
@@ -174,4 +175,26 @@ 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')
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ const DEFAULT_CONFIG: FullConfig = {
|
|||||||
enableCrud: false,
|
enableCrud: false,
|
||||||
defaultManager: ''
|
defaultManager: ''
|
||||||
},
|
},
|
||||||
|
cleaner: {
|
||||||
|
queryBatchSize: 100,
|
||||||
|
processConcurrency: 1
|
||||||
|
},
|
||||||
orderResolution: {
|
orderResolution: {
|
||||||
tableName: '',
|
tableName: '',
|
||||||
productionIdField: '',
|
productionIdField: '',
|
||||||
@@ -90,6 +94,14 @@ 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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ interface ProgressState {
|
|||||||
totalOrders: number
|
totalOrders: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueryResultRow {
|
||||||
|
rowIndex: number
|
||||||
|
orderNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
class AsyncMutex {
|
class AsyncMutex {
|
||||||
private queue: Promise<void> = Promise.resolve()
|
private queue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
@@ -141,7 +146,7 @@ export class CleanerService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rowNumber >= 7000 && rowNumber < 8000) {
|
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +163,8 @@ export class CleanerService {
|
|||||||
if (!deleteSet.has(materialCode)) {
|
if (!deleteSet.has(materialCode)) {
|
||||||
return '物料不在删除清单中'
|
return '物料不在删除清单中'
|
||||||
}
|
}
|
||||||
if (rowNumber >= 7000 && rowNumber < 8000) {
|
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||||
return '行号在 7000-7999 范围内(受保护)'
|
return '行号在 2000-7999 范围内(受保护)'
|
||||||
}
|
}
|
||||||
if (pendingQty && pendingQty.trim() !== '') {
|
if (pendingQty && pendingQty.trim() !== '') {
|
||||||
return '累计待发数量不为空'
|
return '累计待发数量不为空'
|
||||||
@@ -232,13 +237,11 @@ export class CleanerService {
|
|||||||
await this.queryOrders(workFrame, batchOrders)
|
await this.queryOrders(workFrame, batchOrders)
|
||||||
await this.waitForLoading(workFrame)
|
await this.waitForLoading(workFrame)
|
||||||
|
|
||||||
const rows = workFrame.locator('tbody tr')
|
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||||
const rowCount = await rows.count()
|
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||||
const rowIndexes = Array.from({ length: rowCount }, (_, i) => i)
|
|
||||||
|
|
||||||
const processedOrderNumbersInBatch = new Set<string>()
|
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||||
|
const { rowIndex, orderNumber } = row
|
||||||
await runWithConcurrency(rowIndexes, processConcurrency, async (rowIndex) => {
|
|
||||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||||
})
|
})
|
||||||
@@ -249,12 +252,13 @@ export class CleanerService {
|
|||||||
detailPage: openedDetailPage,
|
detailPage: openedDetailPage,
|
||||||
deleteSet,
|
deleteSet,
|
||||||
dryRun,
|
dryRun,
|
||||||
|
expectedOrderNumber: orderNumber,
|
||||||
progressState,
|
progressState,
|
||||||
onProgress: input.onProgress
|
onProgress: input.onProgress
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
detail = this.createErrorDetail(`BATCH_ROW_${rowIndex + 1}`, message)
|
detail = this.createErrorDetail(orderNumber, message)
|
||||||
} finally {
|
} finally {
|
||||||
progressState.completedOrders += 1
|
progressState.completedOrders += 1
|
||||||
}
|
}
|
||||||
@@ -269,13 +273,9 @@ export class CleanerService {
|
|||||||
result.ordersProcessed += 1
|
result.ordersProcessed += 1
|
||||||
result.materialsDeleted += detail.materialsDeleted
|
result.materialsDeleted += detail.materialsDeleted
|
||||||
result.materialsSkipped += detail.materialsSkipped
|
result.materialsSkipped += detail.materialsSkipped
|
||||||
|
|
||||||
if (this.isOrderNumber(detail.orderNumber)) {
|
|
||||||
processedOrderNumbersInBatch.add(detail.orderNumber)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const missingOrders = getMissingOrders(batchOrders, processedOrderNumbersInBatch)
|
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||||
for (const missingOrder of missingOrders) {
|
for (const missingOrder of missingOrders) {
|
||||||
const missingMessage = '订单未出现在查询结果中'
|
const missingMessage = '订单未出现在查询结果中'
|
||||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||||
@@ -300,6 +300,12 @@ export class CleanerService {
|
|||||||
retryResult.updatedDetails.forEach((updatedDetail) => {
|
retryResult.updatedDetails.forEach((updatedDetail) => {
|
||||||
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
|
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
|
const previousDetail = result.details[index]
|
||||||
|
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
|
||||||
|
result.ordersProcessed += 1
|
||||||
|
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||||
|
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||||
|
}
|
||||||
result.details[index] = updatedDetail
|
result.details[index] = updatedDetail
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -373,6 +379,37 @@ export class CleanerService {
|
|||||||
await workFrame.locator('.search-component-searchBtn').click()
|
await workFrame.locator('.search-component-searchBtn').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
|
||||||
|
const rows = workFrame.locator('tbody tr')
|
||||||
|
const rowCount = await rows.count()
|
||||||
|
const result: QueryResultRow[] = []
|
||||||
|
|
||||||
|
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||||
|
const row = rows.nth(rowIndex)
|
||||||
|
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
|
||||||
|
if (!this.isOrderNumber(orderNumber)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.push({ rowIndex, orderNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
|
||||||
|
try {
|
||||||
|
const cell = row.locator('td[colkey="vbillcode"]')
|
||||||
|
const codeLink = cell.locator('.code-detail-link').first()
|
||||||
|
const rawValue =
|
||||||
|
(await codeLink.count()) > 0 ? await codeLink.innerText() : await cell.innerText()
|
||||||
|
const value = rawValue.trim()
|
||||||
|
const match = value.match(/SC\d{14}/)
|
||||||
|
return match ? match[0] : value
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async openDetailPageFromRow(
|
private async openDetailPageFromRow(
|
||||||
workFrame: FrameLocator,
|
workFrame: FrameLocator,
|
||||||
popupPage: Page,
|
popupPage: Page,
|
||||||
|
|||||||
@@ -132,10 +132,12 @@ export class OrderNumberResolver {
|
|||||||
let params: any[]
|
let params: any[]
|
||||||
|
|
||||||
if (this.dbService.type === 'sqlserver') {
|
if (this.dbService.type === 'sqlserver') {
|
||||||
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] = @p0`
|
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||||
|
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 {
|
||||||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE \`${dbConfig.FIELD_PRODUCTION_ID}\` = ? LIMIT 1`
|
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||||||
|
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||||||
params = [productionId]
|
params = [productionId]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +181,13 @@ export class OrderNumberResolver {
|
|||||||
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
|
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||||
sql = `SELECT DISTINCT [${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(() => '?').join(', ')
|
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||||
sql = `SELECT DISTINCT \`${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)
|
||||||
@@ -232,11 +236,12 @@ export class OrderNumberResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch query productionId to order number mappings
|
// Batch query productionId to order number mappings
|
||||||
|
// 使用小写 key 存储映射,以支持忽略大小写查找
|
||||||
const mappings = new Map<string, string>()
|
const mappings = new Map<string, string>()
|
||||||
if (productionIds.length > 0) {
|
if (productionIds.length > 0) {
|
||||||
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
||||||
batchMappings.forEach((orderNum, prodId) => {
|
batchMappings.forEach((orderNum, prodId) => {
|
||||||
mappings.set(prodId, orderNum)
|
mappings.set(prodId.toLowerCase(), orderNum)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,9 +263,9 @@ 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, lookup from batch mappings (使用小写查找以忽略大小写)
|
||||||
mapping.productionId = input
|
mapping.productionId = input
|
||||||
const orderNumber = mappings.get(input)
|
const orderNumber = mappings.get(input.toLowerCase())
|
||||||
if (orderNumber) {
|
if (orderNumber) {
|
||||||
mapping.orderNumber = orderNumber
|
mapping.orderNumber = orderNumber
|
||||||
mapping.resolved = true
|
mapping.resolved = true
|
||||||
|
|||||||
6
src/main/services/rustfs/index.ts
Normal file
6
src/main/services/rustfs/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* RustFS Service Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { RustfsService } from './rustfs-service'
|
||||||
|
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'
|
||||||
376
src/main/services/rustfs/rustfs-service.ts
Normal file
376
src/main/services/rustfs/rustfs-service.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/main/tools/rustfs-test.ts
Normal file
215
src/main/tools/rustfs-test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
@@ -97,6 +97,15 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -122,6 +131,18 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -131,8 +152,10 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +167,7 @@ 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>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证并解析配置
|
* 验证并解析配置
|
||||||
|
|||||||
@@ -168,3 +168,25 @@ 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>>
|
||||||
|
}
|
||||||
|
|||||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
|
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI, ReportAPI } 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,6 +21,7 @@ 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>>
|
||||||
@@ -110,6 +111,11 @@ 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
|
||||||
}
|
}
|
||||||
@@ -137,7 +143,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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?: {
|
||||||
@@ -195,6 +196,12 @@ 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, {
|
||||||
@@ -204,6 +211,13 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
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
|
||||||
|
|
||||||
@@ -71,6 +72,27 @@ 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
|
||||||
|
|
||||||
@@ -116,6 +138,7 @@ 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}
|
||||||
>
|
>
|
||||||
@@ -204,6 +227,15 @@ 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">
|
||||||
@@ -307,6 +339,29 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|||||||
188
src/renderer/src/components/ReportViewerDialog.tsx
Normal file
188
src/renderer/src/components/ReportViewerDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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
|
||||||
@@ -28,6 +28,8 @@ 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> = {
|
||||||
@@ -51,7 +53,8 @@ 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(
|
||||||
@@ -87,7 +90,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={onClose}
|
onClick={disableBackdropClick ? undefined : onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -78,18 +78,8 @@ 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(() => {
|
const [queryBatchSize, setQueryBatchSize] = useState(100)
|
||||||
const saved = sessionStorage.getItem('cleaner_queryBatchSize')
|
const [processConcurrency, setProcessConcurrency] = useState(1)
|
||||||
const value = saved ? Number(saved) : 100
|
|
||||||
if (!Number.isFinite(value)) return 100
|
|
||||||
return Math.min(100, Math.max(1, Math.trunc(value)))
|
|
||||||
})
|
|
||||||
const [processConcurrency, setProcessConcurrency] = useState(() => {
|
|
||||||
const saved = sessionStorage.getItem('cleaner_processConcurrency')
|
|
||||||
const value = saved ? Number(saved) : 1
|
|
||||||
if (!Number.isFinite(value)) return 1
|
|
||||||
return Math.min(20, Math.max(1, Math.trunc(value)))
|
|
||||||
})
|
|
||||||
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)
|
||||||
@@ -174,6 +164,22 @@ 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])
|
||||||
@@ -182,13 +188,15 @@ export function useCleaner() {
|
|||||||
sessionStorage.setItem('cleaner_headless', headless.toString())
|
sessionStorage.setItem('cleaner_headless', headless.toString())
|
||||||
}, [headless])
|
}, [headless])
|
||||||
|
|
||||||
useEffect(() => {
|
const updateProcessConcurrency = async (value: number) => {
|
||||||
sessionStorage.setItem('cleaner_queryBatchSize', queryBatchSize.toString())
|
const clamped = Math.max(1, Math.min(20, value))
|
||||||
}, [queryBatchSize])
|
setProcessConcurrency(clamped)
|
||||||
|
try {
|
||||||
useEffect(() => {
|
await window.electron.config.updateCleaner({ processConcurrency: clamped })
|
||||||
sessionStorage.setItem('cleaner_processConcurrency', processConcurrency.toString())
|
} catch (err) {
|
||||||
}, [processConcurrency])
|
console.error('Failed to update cleaner config:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('cleaner_validationMode', valMode)
|
sessionStorage.setItem('cleaner_validationMode', valMode)
|
||||||
@@ -463,10 +471,15 @@ export function useCleaner() {
|
|||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
setIsExecuting(false)
|
setIsExecuting(false)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setStartTime(null)
|
// Note: Don't clear startTime here - it's needed for the execution report dialog
|
||||||
|
// 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('没有数据可导出')
|
||||||
@@ -529,6 +542,7 @@ export function useCleaner() {
|
|||||||
setQueryBatchSize,
|
setQueryBatchSize,
|
||||||
processConcurrency,
|
processConcurrency,
|
||||||
setProcessConcurrency,
|
setProcessConcurrency,
|
||||||
|
updateProcessConcurrency,
|
||||||
showSettingsMenu,
|
showSettingsMenu,
|
||||||
setShowSettingsMenu,
|
setShowSettingsMenu,
|
||||||
filteredResults,
|
filteredResults,
|
||||||
@@ -545,6 +559,7 @@ export function useCleaner() {
|
|||||||
handleAssignManagerOnSelect,
|
handleAssignManagerOnSelect,
|
||||||
progress,
|
progress,
|
||||||
startTime,
|
startTime,
|
||||||
|
resetStartTime,
|
||||||
handleValidation,
|
handleValidation,
|
||||||
handleCheckboxToggle,
|
handleCheckboxToggle,
|
||||||
handleConfirmDeletion,
|
handleConfirmDeletion,
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ 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'
|
||||||
|
|
||||||
@@ -46,10 +48,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
setIsTypeDialogOpen,
|
setIsTypeDialogOpen,
|
||||||
headless,
|
headless,
|
||||||
setHeadless,
|
setHeadless,
|
||||||
queryBatchSize,
|
|
||||||
setQueryBatchSize,
|
|
||||||
processConcurrency,
|
processConcurrency,
|
||||||
setProcessConcurrency,
|
updateProcessConcurrency,
|
||||||
showSettingsMenu,
|
showSettingsMenu,
|
||||||
setShowSettingsMenu,
|
setShowSettingsMenu,
|
||||||
filteredResults,
|
filteredResults,
|
||||||
@@ -66,6 +66,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
handleAssignManagerOnSelect,
|
handleAssignManagerOnSelect,
|
||||||
progress,
|
progress,
|
||||||
startTime,
|
startTime,
|
||||||
|
resetStartTime,
|
||||||
handleValidation,
|
handleValidation,
|
||||||
handleCheckboxToggle,
|
handleCheckboxToggle,
|
||||||
handleConfirmDeletion,
|
handleConfirmDeletion,
|
||||||
@@ -74,6 +75,8 @@ 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 可见) */}
|
||||||
@@ -270,6 +273,12 @@ 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>
|
||||||
|
|
||||||
@@ -463,41 +472,24 @@ const CleanerPage: React.FC = () => {
|
|||||||
</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 space-y-3">
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-slate-800">批量查询数量</div>
|
|
||||||
<div className="text-xs text-slate-500 mt-0.5">
|
|
||||||
每批查询订单数,范围 1-100
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={queryBatchSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = Number(e.target.value)
|
|
||||||
if (!Number.isFinite(raw)) return
|
|
||||||
setQueryBatchSize(Math.max(1, Math.min(100, Math.trunc(raw))))
|
|
||||||
}}
|
|
||||||
className="mt-2 w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-slate-800">并行处理数量</div>
|
<div className="text-sm font-medium text-slate-800">并行处理数量</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
同时处理详情页数量,范围 1-20
|
同时处理详情页数量,范围 1-20
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div className="mt-2 flex items-center gap-3">
|
||||||
type="number"
|
<input
|
||||||
min={1}
|
type="range"
|
||||||
max={20}
|
min={1}
|
||||||
value={processConcurrency}
|
max={20}
|
||||||
onChange={(e) => {
|
value={processConcurrency}
|
||||||
const raw = Number(e.target.value)
|
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
|
||||||
if (!Number.isFinite(raw)) return
|
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
setProcessConcurrency(Math.max(1, Math.min(20, Math.trunc(raw))))
|
/>
|
||||||
}}
|
<span className="text-sm font-medium text-slate-700 w-8 text-center">
|
||||||
className="mt-2 w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
{processConcurrency}
|
||||||
/>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +520,10 @@ const CleanerPage: React.FC = () => {
|
|||||||
{/* Execution Report Dialog */}
|
{/* Execution Report Dialog */}
|
||||||
<ExecutionReportDialog
|
<ExecutionReportDialog
|
||||||
isOpen={isReportDialogOpen}
|
isOpen={isReportDialogOpen}
|
||||||
onClose={() => setIsReportDialogOpen(false)}
|
onClose={() => {
|
||||||
|
setIsReportDialogOpen(false)
|
||||||
|
resetStartTime()
|
||||||
|
}}
|
||||||
ordersProcessed={reportData?.ordersProcessed}
|
ordersProcessed={reportData?.ordersProcessed}
|
||||||
materialsDeleted={reportData?.materialsDeleted}
|
materialsDeleted={reportData?.materialsDeleted}
|
||||||
materialsSkipped={reportData?.materialsSkipped}
|
materialsSkipped={reportData?.materialsSkipped}
|
||||||
@@ -542,6 +537,14 @@ const CleanerPage: React.FC = () => {
|
|||||||
successfulRetries={reportData?.successfulRetries}
|
successfulRetries={reportData?.successfulRetries}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Report Viewer Dialog */}
|
||||||
|
<ReportViewerDialog
|
||||||
|
isOpen={isReportViewerOpen}
|
||||||
|
onClose={() => setIsReportViewerOpen(false)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
currentUsername={currentUsername}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,8 +84,19 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check row number range (7000-7999 are protected)
|
// Check row number range (2000-7999 are protected)
|
||||||
if (rowNumber >= 7000 && rowNumber < 8000) {
|
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should skip materials with row number 7000-7999', () => {
|
it('should skip materials with row number 2000-7999', () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
{ rowNumber: 7000, pendingQty: '', materialCode: 'TEST001', expected: false },
|
{ rowNumber: 2000, pendingQty: '', materialCode: 'TEST001', expected: false },
|
||||||
{ rowNumber: 7500, pendingQty: '', materialCode: 'TEST001', expected: false },
|
{ rowNumber: 5000, pendingQty: '', materialCode: 'TEST001', expected: false },
|
||||||
{ rowNumber: 7999, pendingQty: '', materialCode: 'TEST001', expected: false },
|
{ rowNumber: 7999, pendingQty: '', materialCode: 'TEST001', expected: false },
|
||||||
{ rowNumber: 6999, pendingQty: '', materialCode: 'TEST001', expected: true },
|
{ rowNumber: 1999, pendingQty: '', materialCode: 'TEST001', expected: true },
|
||||||
{ rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true }
|
{ rowNumber: 8000, pendingQty: '', materialCode: 'TEST001', expected: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -78,7 +78,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: 6999, pendingQty: '', materialCode: 'TEST001', expected: true },
|
{ rowNumber: 1999, 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 }
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user