diff --git a/package-lock.json b/package-lock.json index b10a7de..f1019b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -167,6 +168,7 @@ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -358,6 +360,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1126,7 +1129,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1148,7 +1150,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1165,7 +1166,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1180,7 +1180,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3055,6 +3054,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3077,6 +3077,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3185,6 +3186,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3618,6 +3620,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3650,6 +3653,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4397,6 +4401,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5123,8 +5128,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5490,6 +5494,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -5716,6 +5721,7 @@ "integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -5904,7 +5910,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5925,7 +5930,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6254,6 +6258,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6314,6 +6319,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10091,6 +10097,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10173,6 +10180,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10196,7 +10204,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10214,7 +10221,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10235,6 +10241,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10367,6 +10374,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11654,7 +11662,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12172,6 +12179,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12406,6 +12414,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12952,6 +12961,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -13160,6 +13170,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -13397,6 +13408,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/main/ipc/extractor-handler.ts b/src/main/ipc/extractor-handler.ts index 8b1e11e..37232d2 100644 --- a/src/main/ipc/extractor-handler.ts +++ b/src/main/ipc/extractor-handler.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { ipcMain, webContents } from 'electron' import { ErpAuthService } from '../services/erp/erp-auth' import { ExtractorService } from '../services/erp/extractor' import { OrderNumberResolver } from '../services/erp/order-resolver' @@ -10,13 +10,35 @@ import type { ExtractorInput, ExtractorResult } from '../types/extractor.types' const log = createLogger('ExtractorHandler') +function sendProgress(windowId: number, message: string, progress: number): void { + try { + webContents.getAllWebContents().forEach((wc) => { + wc.send('extractor:progress', { message, progress }) + }) + } catch (error) { + log.warn('Failed to send progress event', { error }) + } +} + +function sendLog(windowId: number, level: string, message: string): void { + try { + webContents.getAllWebContents().forEach((wc) => { + wc.send('extractor:log', { level, message }) + }) + } catch (error) { + log.warn('Failed to send log event', { error }) + } +} + /** * Register IPC handlers for extractor service */ export function registerExtractorHandlers(): void { ipcMain.handle( 'extractor:run', - async (_event, input: ExtractorInput): Promise> => { + async (event, input: ExtractorInput): Promise> => { + const windowId = event.sender.id + return withErrorHandling(async () => { let authService: ErpAuthService | null = null let dbService: IDatabaseService | null = null @@ -41,6 +63,9 @@ export function registerExtractorHandlers(): void { // Create database service using factory log.info('Connecting to database for order resolution...') + sendProgress(windowId, '连接数据库...', 5) + sendLog(windowId, 'system', '正在连接数据库...') + try { dbService = await create() } catch (error) { @@ -52,6 +77,9 @@ export function registerExtractorHandlers(): void { } // Resolve order numbers (convert productionIDs to 生产订单号) + sendProgress(windowId, '解析订单号...', 10) + sendLog(windowId, 'info', '正在解析订单号...') + const resolver = new OrderNumberResolver(dbService) const mappings = await resolver.resolve(input.orderNumbers) @@ -71,6 +99,7 @@ export function registerExtractorHandlers(): void { } log.info('Resolved order numbers', { count: validOrderNumbers.length }) + sendLog(windowId, 'info', `已解析 ${validOrderNumbers.length} 个有效订单号`) // Create auth service and login authService = new ErpAuthService({ @@ -80,6 +109,9 @@ export function registerExtractorHandlers(): void { headless: true }) + sendProgress(windowId, '登录 ERP 系统...', 15) + sendLog(windowId, 'system', '正在登录 ERP 系统...') + log.info('Logging in to ERP...') try { await authService.login() @@ -91,6 +123,7 @@ export function registerExtractorHandlers(): void { ) } log.info('Login successful') + sendLog(windowId, 'success', 'ERP 登录成功') // Create extractor service and run extraction with resolved order numbers const extractor = new ExtractorService(authService) @@ -98,9 +131,19 @@ export function registerExtractorHandlers(): void { const modifiedInput: ExtractorInput = { ...input, - orderNumbers: validOrderNumbers + orderNumbers: validOrderNumbers, + onProgress: (message, progress) => { + sendProgress(windowId, message, progress) + sendLog(windowId, 'info', message) + }, + onLog: (level, message) => { + sendLog(windowId, level, message) + } } + sendProgress(windowId, '开始提取数据...', 20) + sendLog(windowId, 'system', '提取引擎启动,开始下载数据...') + const result = await extractor.extract(modifiedInput) // Add warnings to result errors if any diff --git a/src/main/services/erp/extractor.ts b/src/main/services/erp/extractor.ts index 57d7a8d..c23225a 100644 --- a/src/main/services/erp/extractor.ts +++ b/src/main/services/erp/extractor.ts @@ -3,7 +3,12 @@ import fs from 'fs/promises' import { ExtractorCore } from './extractor-core' import { ErpAuthService } from './erp-auth' import { ExcelParser } from '../excel/excel-parser' -import type { ExtractorInput, ExtractorResult, ImportResult } from '../../types/extractor.types' +import type { + ExtractorInput, + ExtractorResult, + ImportResult, + LogLevel +} from '../../types/extractor.types' import { DataImportService } from '../database/data-importer' /** @@ -75,7 +80,10 @@ export class ExtractorService { // Auto-import to database if merge was successful if (result.mergedFile) { input.onProgress?.('正在写入数据库...', 98) - const importResult = await this.importToDatabase(result.mergedFile) + const importResult = await this.importToDatabaseWithLogging( + result.mergedFile, + input.onLog + ) result.importResult = importResult if (!importResult.success && importResult.errors.length > 0) { @@ -317,4 +325,55 @@ export class ExtractorService { } } } + + /** + * Import merged Excel data to database with logging + * @param filePath - Path to the merged Excel file + * @param onLog - Optional log callback + * @returns Import result with statistics + */ + private async importToDatabaseWithLogging( + filePath: string, + onLog?: (level: LogLevel, message: string) => void + ): Promise { + console.log(`[Extractor] Starting database import from: ${filePath}`) + onLog?.('info', `开始导入数据到数据库...`) + + const importService = new DataImportService() + + try { + const result = await importService.importFromExcel(filePath, 1000) + + console.log(`[Extractor] Import completed`, { + success: result.success, + recordsRead: result.recordsRead, + recordsDeleted: result.recordsDeleted, + recordsImported: result.recordsImported + }) + + if (result.success) { + onLog?.( + 'success', + `导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported} 条` + ) + } else if (result.errors.length > 0) { + result.errors.forEach((err) => onLog?.('error', err)) + } + + return result + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + console.error(`[Extractor] Import failed: ${errorMsg}`) + onLog?.('error', `导入失败:${errorMsg}`) + + return { + success: false, + recordsRead: 0, + recordsDeleted: 0, + recordsImported: 0, + uniqueSourceNumbers: 0, + errors: [errorMsg] + } + } + } } diff --git a/src/main/types/extractor.types.ts b/src/main/types/extractor.types.ts index 76c0367..6fceea0 100644 --- a/src/main/types/extractor.types.ts +++ b/src/main/types/extractor.types.ts @@ -1,9 +1,12 @@ import type { ErpSession } from './erp.types' +export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system' + export interface ExtractorInput { orderNumbers: string[] batchSize?: number onProgress?: (message: string, progress: number) => void + onLog?: (level: LogLevel, message: string) => void } /** diff --git a/src/main/types/ipc-api.types.ts b/src/main/types/ipc-api.types.ts index 80f43a7..037901b 100644 --- a/src/main/types/ipc-api.types.ts +++ b/src/main/types/ipc-api.types.ts @@ -77,6 +77,18 @@ export interface ExtractorAPI { runExtractor: ( input: ExtractorInput ) => Promise<{ success: boolean; data?: ExtractorResult; error?: string }> + /** + * Subscribe to progress updates + * @param callback - Callback function receiving progress data + * @returns Unsubscribe function + */ + onProgress: (callback: (data: { message: string; progress: number }) => void) => () => void + /** + * Subscribe to log messages + * @param callback - Callback function receiving log data + * @returns Unsubscribe function + */ + onLog: (callback: (data: { level: string; message: string }) => void) => () => void } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b9c6ce..ad4f57c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,7 +27,23 @@ const api = { // Extractor service extractor: { - runExtractor: (input: ExtractorInput) => ipcRenderer.invoke('extractor:run', input) + runExtractor: (input: ExtractorInput) => ipcRenderer.invoke('extractor:run', input), + onProgress: (callback: (data: { message: string; progress: number }) => void) => { + const subscription = ( + _event: Electron.IpcRendererEvent, + data: { message: string; progress: number } + ) => callback(data) + ipcRenderer.on('extractor:progress', subscription) + return () => ipcRenderer.removeListener('extractor:progress', subscription) + }, + onLog: (callback: (data: { level: string; message: string }) => void) => { + const subscription = ( + _event: Electron.IpcRendererEvent, + data: { level: string; message: string } + ) => callback(data) + ipcRenderer.on('extractor:log', subscription) + return () => ipcRenderer.removeListener('extractor:log', subscription) + } }, // Cleaner service diff --git a/src/renderer/src/components/ui/LogPanel.tsx b/src/renderer/src/components/ui/LogPanel.tsx index 51809dc..427d65c 100644 --- a/src/renderer/src/components/ui/LogPanel.tsx +++ b/src/renderer/src/components/ui/LogPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react' import { Terminal } from 'lucide-react' -import type { LogEntry, LogLevel, ExtractorProgress } from '../../hooks/useExtractor' +import type { LogEntry, LogLevel, ExtractorProgress } from '../../stores/extractorStore' interface LogPanelProps { logs: LogEntry[] diff --git a/src/renderer/src/hooks/useExtractor.ts b/src/renderer/src/hooks/useExtractor.ts index c123a5b..eeacad8 100644 --- a/src/renderer/src/hooks/useExtractor.ts +++ b/src/renderer/src/hooks/useExtractor.ts @@ -1,38 +1,34 @@ -import { useState, useEffect } from 'react' - -export interface ExtractorProgress { - message: string - progress: number -} - -export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system' - -export interface LogEntry { - timestamp: string - level: LogLevel - message: string -} +import { useEffect } from 'react' +import { useExtractorStore } from '../stores/extractorStore' export function useExtractor() { - const [isRunning, setIsRunning] = useState(false) - const [progress, setProgress] = useState(null) - const [error, setError] = useState(null) - const [logs, setLogs] = useState([]) - - const addLog = (level: LogLevel, message: string) => { - const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }) - setLogs((prev) => [...prev, { timestamp, level, message }]) - } + const { + isRunning, + progress, + error, + logs, + setRunning, + setProgress, + setError, + addLog, + clearLogs, + resetState + } = useExtractorStore() useEffect(() => { - if (progress) { - addLog('info', progress.message) - } - }, [progress]) + const unsubscribeProgress = window.electron.extractor.onProgress((data) => { + setProgress({ message: data.message, progress: data.progress }) + }) - const clearLogs = () => { - setLogs([]) - } + const unsubscribeLog = window.electron.extractor.onLog((data) => { + addLog(data.level as any, data.message) + }) + + return () => { + unsubscribeProgress() + unsubscribeLog() + } + }, [setProgress, addLog]) const startExtraction = async (orderNumbers: string) => { if (!orderNumbers.trim()) { @@ -40,11 +36,8 @@ export function useExtractor() { return } - setIsRunning(true) - setProgress(null) - setError(null) - setLogs([]) - + resetState() + setRunning(true) addLog('system', '提取引擎启动,准备执行...') try { @@ -77,7 +70,7 @@ export function useExtractor() { setError(errMsg) addLog('error', errMsg) } finally { - setIsRunning(false) + setRunning(false) setProgress(null) } } diff --git a/src/renderer/src/stores/extractorStore.ts b/src/renderer/src/stores/extractorStore.ts new file mode 100644 index 0000000..de192aa --- /dev/null +++ b/src/renderer/src/stores/extractorStore.ts @@ -0,0 +1,59 @@ +import { create } from 'zustand' + +export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system' + +export interface LogEntry { + timestamp: string + level: LogLevel + message: string +} + +export interface ExtractorProgress { + message: string + progress: number +} + +export interface ExtractorState { + isRunning: boolean + progress: ExtractorProgress | null + error: string | null + logs: LogEntry[] +} + +export interface ExtractorActions { + setRunning: (isRunning: boolean) => void + setProgress: (progress: ExtractorProgress | null) => void + setError: (error: string | null) => void + addLog: (level: LogLevel, message: string) => void + clearLogs: () => void + resetState: () => void +} + +const initialState: ExtractorState = { + isRunning: false, + progress: null, + error: null, + logs: [] +} + +export const useExtractorStore = create((set) => ({ + ...initialState, + + setRunning: (isRunning: boolean) => set({ isRunning }), + + setProgress: (progress: ExtractorProgress | null) => set({ progress }), + + setError: (error: string | null) => set({ error }), + + addLog: (level: LogLevel, message: string) => + set((state) => { + const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }) + return { + logs: [...state.logs, { timestamp, level, message }] + } + }), + + clearLogs: () => set({ logs: [] }), + + resetState: () => set(initialState) +}))