7 Commits

Author SHA1 Message Date
Misaka_Company
3854c0f048 Merge branch 'dev-rustfs' into dev 2026-03-17 15:49:31 +08:00
Misaka_Company
7bc6daf1b7 feat(rustfs): add test script, dependencies, and configuration template
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:49:20 +08:00
Misaka_Company
db44618ee5 feat(rustfs): integrate report upload into cleaner execution flow
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:48:50 +08:00
Misaka_Company
2d17a6b792 feat(rustfs): add RustFS configuration schema and manager support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:48:16 +08:00
Misaka_Company
e8aa7d21a8 feat(rustfs): add RustFS object storage service for report persistence
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 15:30:00 +08:00
Misaka_Company
defeb01808 fix: show elapsed time in execution result dialog
- Remove setStartTime(null) from finally block to preserve start time for result display
- Add resetStartTime function to useCleaner hook
- Call resetStartTime when execution report dialog closes
- Add useEffect to update timer when execution completes

Now the total elapsed time (总耗时) will be shown in the result dialog after execution completes.
2026-03-17 14:50:57 +08:00
Misaka_Company
b289fb9624 fix: use updateProcessConcurrency to persist slider changes to config.yaml
- CleanerPage now uses updateProcessConcurrency instead of setProcessConcurrency
- This ensures slider changes are persisted to config.yaml via IPC
- Remove unused queryBatchSize and setProcessConcurrency from destructuring
2026-03-17 10:57:24 +08:00
16 changed files with 2504 additions and 60 deletions

View File

@@ -52,3 +52,21 @@ orderResolution:
tableName: ''
productionIdField: ''
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 兼容,默认即可)

1656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,8 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report",
"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": {
"@electron-toolkit/preload": "^3.0.2",
@@ -49,7 +50,8 @@
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"zustand": "^5.0.11",
"@aws-sdk/client-s3": "^3.929.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",

View File

@@ -7,6 +7,7 @@ import { SqlServerService } from '../services/database/sql-server'
import { ConfigManager } from '../services/config/config-manager'
import { ResultExporter } from '../services/excel/result-exporter'
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
import { RustfsService } from '../services/rustfs'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/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 }))
}
// Generate report (silent, user unaware)
// Generate report and upload to RustFS (silent, user unaware)
try {
const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo()
@@ -276,6 +277,47 @@ export function registerCleanerHandlers(): void {
endTime
})
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) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)

View File

@@ -10,6 +10,7 @@ import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../type
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
import type { CleanerConfig } from '../types/config.schema'
const log = createLogger('SettingsHandler')
@@ -174,4 +175,26 @@ export function registerSettingsHandlers(): void {
}, '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

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

View File

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

View 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
}
}
}
}

View 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)
})

View File

@@ -97,6 +97,15 @@ export const validationConfigSchema = z.object({
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
*/
@@ -122,6 +131,18 @@ export const loggingConfigSchema = z.object({
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
*/
@@ -131,8 +152,10 @@ export const fullConfigSchema = z.object({
paths: pathsConfigSchema,
extraction: extractionConfigSchema,
validation: validationConfigSchema,
cleaner: cleanerConfigSchema,
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 ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
/**
* 验证并解析配置

View File

@@ -21,6 +21,7 @@ import type {
} from '../main/types/settings.types'
import type { IpcResult } from '../main/ipc'
import type { LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
export interface ResolverAPI {
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
@@ -110,6 +111,11 @@ export interface UserErpConfigAPI {
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 {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
}
@@ -137,6 +143,7 @@ declare global {
settings: SettingsAPI
materialType: MaterialTypeAPI
userErpConfig: UserErpConfigAPI
config: ConfigAPI
logger: LoggerAPI
}
api: unknown

View File

@@ -12,6 +12,7 @@ import type {
} from '../main/types/validation.types'
import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
type ErpSettingsPayload = {
erp?: {
@@ -195,6 +196,12 @@ const api = {
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: {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {

View File

@@ -61,6 +61,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
const showProgress = isExecuting && progress
const isProgressing = !!showProgress
// Update timer during progress
React.useEffect(() => {
if (!showProgress || !startTime) return
@@ -71,6 +72,27 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
return () => clearInterval(interval)
}, [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(() => {
if (!showProgress || !startTime || !progress) return null
@@ -204,6 +226,15 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</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 && (
<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">
@@ -307,6 +338,29 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
)}
</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 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm font-semibold text-red-600 mb-2"></div>

View File

@@ -78,18 +78,8 @@ export function useCleaner() {
const saved = sessionStorage.getItem('cleaner_headless')
return saved ? saved === 'true' : true
})
const [queryBatchSize, setQueryBatchSize] = useState(() => {
const saved = sessionStorage.getItem('cleaner_queryBatchSize')
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 [queryBatchSize, setQueryBatchSize] = useState(100)
const [processConcurrency, setProcessConcurrency] = useState(1)
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
// 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(() => {
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
}, [dryRun])
@@ -182,13 +188,15 @@ export function useCleaner() {
sessionStorage.setItem('cleaner_headless', headless.toString())
}, [headless])
useEffect(() => {
sessionStorage.setItem('cleaner_queryBatchSize', queryBatchSize.toString())
}, [queryBatchSize])
useEffect(() => {
sessionStorage.setItem('cleaner_processConcurrency', processConcurrency.toString())
}, [processConcurrency])
const updateProcessConcurrency = async (value: number) => {
const clamped = Math.max(1, Math.min(20, value))
setProcessConcurrency(clamped)
try {
await window.electron.config.updateCleaner({ processConcurrency: clamped })
} catch (err) {
console.error('Failed to update cleaner config:', err)
}
}
useEffect(() => {
sessionStorage.setItem('cleaner_validationMode', valMode)
@@ -463,10 +471,15 @@ export function useCleaner() {
setIsRunning(false)
setIsExecuting(false)
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 () => {
if (filteredResults.length === 0) {
showWarning('没有数据可导出')
@@ -529,6 +542,7 @@ export function useCleaner() {
setQueryBatchSize,
processConcurrency,
setProcessConcurrency,
updateProcessConcurrency,
showSettingsMenu,
setShowSettingsMenu,
filteredResults,
@@ -545,6 +559,7 @@ export function useCleaner() {
handleAssignManagerOnSelect,
progress,
startTime,
resetStartTime,
handleValidation,
handleCheckboxToggle,
handleConfirmDeletion,

View File

@@ -46,10 +46,8 @@ const CleanerPage: React.FC = () => {
setIsTypeDialogOpen,
headless,
setHeadless,
queryBatchSize,
setQueryBatchSize,
processConcurrency,
setProcessConcurrency,
updateProcessConcurrency,
showSettingsMenu,
setShowSettingsMenu,
filteredResults,
@@ -66,6 +64,7 @@ const CleanerPage: React.FC = () => {
handleAssignManagerOnSelect,
progress,
startTime,
resetStartTime,
handleValidation,
handleCheckboxToggle,
handleConfirmDeletion,
@@ -463,41 +462,24 @@ const CleanerPage: React.FC = () => {
</div>
</div>
<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 className="text-sm font-medium text-slate-800"></div>
<div className="text-xs text-slate-500 mt-0.5">
1-20
</div>
<input
type="number"
min={1}
max={20}
value={processConcurrency}
onChange={(e) => {
const raw = Number(e.target.value)
if (!Number.isFinite(raw)) return
setProcessConcurrency(Math.max(1, Math.min(20, 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 className="mt-2 flex items-center gap-3">
<input
type="range"
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>
</div>
</div>
@@ -528,7 +510,10 @@ const CleanerPage: React.FC = () => {
{/* Execution Report Dialog */}
<ExecutionReportDialog
isOpen={isReportDialogOpen}
onClose={() => setIsReportDialogOpen(false)}
onClose={() => {
setIsReportDialogOpen(false)
resetStartTime()
}}
ordersProcessed={reportData?.ordersProcessed}
materialsDeleted={reportData?.materialsDeleted}
materialsSkipped={reportData?.materialsSkipped}

View File

@@ -84,6 +84,12 @@ export const IPC_CHANNELS = {
USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection',
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_FORWARD: 'logger:forward'
} as const