Feat: Add real-time progress and logging to data extractor

- Implement IPC event system for pushing progress updates from main to renderer
- Add Zustand store for centralized extractor state management
- Refactor useExtractor hook to use store pattern
- Add chromium-bidi dependency and externalize Playwright for build compatibility
- Show detailed logs during extraction (DB connection, order resolution, ERP login, data import)
This commit is contained in:
Misaka
2026-03-04 20:51:54 +08:00
parent fed76b4fe0
commit 9e1b5530ea
12 changed files with 297 additions and 55 deletions

View File

@@ -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<IpcResult<ExtractorResult>> => {
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
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

View File

@@ -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<ImportResult> {
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]
}
}
}
}

View File

@@ -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
}
/**

View File

@@ -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
}
/**

View File

@@ -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

View File

@@ -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[]

View File

@@ -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<ExtractorProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
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)
}
}

View File

@@ -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<ExtractorState & ExtractorActions>((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)
}))