diff --git a/src/main/ipc/extractor-handler.ts b/src/main/ipc/extractor-handler.ts index 37232d2..f0a76a1 100644 --- a/src/main/ipc/extractor-handler.ts +++ b/src/main/ipc/extractor-handler.ts @@ -6,14 +6,20 @@ import { create, type IDatabaseService } from '../services/database' import { createLogger } from '../services/logger' import { withErrorHandling, type IpcResult } from './index' import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors' -import type { ExtractorInput, ExtractorResult } from '../types/extractor.types' +import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../types/extractor.types' const log = createLogger('ExtractorHandler') -function sendProgress(windowId: number, message: string, progress: number): void { +function sendProgress( + windowId: number, + message: string, + progress: number, + extra?: Partial +): void { try { + const progressData = { message, progress, ...extra } webContents.getAllWebContents().forEach((wc) => { - wc.send('extractor:progress', { message, progress }) + wc.send('extractor:progress', progressData) }) } catch (error) { log.warn('Failed to send progress event', { error }) @@ -63,7 +69,10 @@ export function registerExtractorHandlers(): void { // Create database service using factory log.info('Connecting to database for order resolution...') - sendProgress(windowId, '连接数据库...', 5) + sendProgress(windowId, '连接数据库...', 3.33, { + phase: 'login', + subProgress: { step: '连接数据库', current: 1, total: 3 } + }) sendLog(windowId, 'system', '正在连接数据库...') try { @@ -77,7 +86,10 @@ export function registerExtractorHandlers(): void { } // Resolve order numbers (convert productionIDs to 生产订单号) - sendProgress(windowId, '解析订单号...', 10) + sendProgress(windowId, '解析订单号...', 6.67, { + phase: 'login', + subProgress: { step: '解析订单号', current: 2, total: 3 } + }) sendLog(windowId, 'info', '正在解析订单号...') const resolver = new OrderNumberResolver(dbService) @@ -109,7 +121,10 @@ export function registerExtractorHandlers(): void { headless: true }) - sendProgress(windowId, '登录 ERP 系统...', 15) + sendProgress(windowId, '登录 ERP 系统...', 9.99, { + phase: 'login', + subProgress: { step: '登录 ERP 系统', current: 3, total: 3 } + }) sendLog(windowId, 'system', '正在登录 ERP 系统...') log.info('Logging in to ERP...') @@ -132,8 +147,8 @@ export function registerExtractorHandlers(): void { const modifiedInput: ExtractorInput = { ...input, orderNumbers: validOrderNumbers, - onProgress: (message, progress) => { - sendProgress(windowId, message, progress) + onProgress: (message, progress, extra) => { + sendProgress(windowId, message, progress, extra) sendLog(windowId, 'info', message) }, onLog: (level, message) => { @@ -141,9 +156,6 @@ export function registerExtractorHandlers(): void { } } - 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-core.ts b/src/main/services/erp/extractor-core.ts index 0cb8817..77ecd1b 100644 --- a/src/main/services/erp/extractor-core.ts +++ b/src/main/services/erp/extractor-core.ts @@ -1,7 +1,11 @@ import path from 'path' import { ERP_LOCATORS } from './locators' import type { ErpSession } from '../../types/erp.types' -import type { ExtractorCoreInput, ExtractorCoreResult } from '../../types/extractor.types' +import type { + ExtractorCoreInput, + ExtractorCoreResult, + ExtractionProgress +} from '../../types/extractor.types' /** * ExtractorCore - Handles all web page operations for data extraction @@ -22,17 +26,25 @@ export class ExtractorCore { errors: [] } - // Navigate to extractor page and get popup page + work frame + const totalBatches = this.createBatches(input.orderNumbers, input.batchSize).length + const totalPoints = 1 + totalBatches + 2 + const progressPerPoint = 100 / totalPoints + const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session) - // Process orders in batches const batches = this.createBatches(input.orderNumbers, input.batchSize) for (let i = 0; i < batches.length; i++) { const batch = batches[i] - const progress = ((i + 1) / batches.length) * 100 + const progress = (1 + (i + 1)) * progressPerPoint - input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress) + const progressExtra: Partial = { + phase: 'downloading', + currentBatch: i + 1, + totalBatches + } + + input.onProgress?.(`处理批次 ${i + 1}/${totalBatches}`, progress, progressExtra) try { const filePath = await this.downloadBatch( diff --git a/src/main/services/erp/extractor.ts b/src/main/services/erp/extractor.ts index c23225a..ab20922 100644 --- a/src/main/services/erp/extractor.ts +++ b/src/main/services/erp/extractor.ts @@ -7,7 +7,8 @@ import type { ExtractorInput, ExtractorResult, ImportResult, - LogLevel + LogLevel, + ExtractionProgress } from '../../types/extractor.types' import { DataImportService } from '../database/data-importer' @@ -64,7 +65,16 @@ export class ExtractorService { // Merge downloaded files (original logic preserved) if (result.downloadedFiles.length > 0) { - input.onProgress?.('正在合并文件...', 95) + const totalBatches = result.downloadedFiles.length + const totalPoints = 1 + totalBatches + 2 + const progressPerPoint = 100 / totalPoints + const mergeProgress = (1 + totalBatches) * progressPerPoint + const importProgress = (1 + totalBatches + 1) * progressPerPoint + + input.onProgress?.('正在合并文件...', mergeProgress, { + phase: 'merging', + totalBatches + }) const mergeResult = await this.mergeFiles(result.downloadedFiles) result.mergedFile = mergeResult.mergedFile result.recordCount = mergeResult.recordCount @@ -79,7 +89,11 @@ export class ExtractorService { // Auto-import to database if merge was successful if (result.mergedFile) { - input.onProgress?.('正在写入数据库...', 98) + const importProgress = (1 + totalBatches + 1) * progressPerPoint + input.onProgress?.('正在写入数据库...', importProgress, { + phase: 'importing', + totalBatches + }) const importResult = await this.importToDatabaseWithLogging( result.mergedFile, input.onLog diff --git a/src/main/types/extractor.types.ts b/src/main/types/extractor.types.ts index 6fceea0..a6009c1 100644 --- a/src/main/types/extractor.types.ts +++ b/src/main/types/extractor.types.ts @@ -2,10 +2,25 @@ import type { ErpSession } from './erp.types' export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system' +export type ExtractionPhase = 'login' | 'downloading' | 'merging' | 'importing' + +export interface ExtractionProgress { + message: string + progress: number + phase?: ExtractionPhase + currentBatch?: number + totalBatches?: number + subProgress?: { + step: string + current: number + total: number + } +} + export interface ExtractorInput { orderNumbers: string[] batchSize?: number - onProgress?: (message: string, progress: number) => void + onProgress?: (message: string, progress: number, extra?: Partial) => void onLog?: (level: LogLevel, message: string) => void } @@ -43,7 +58,7 @@ export interface ExtractorCoreInput { orderNumbers: string[] downloadDir: string batchSize: number - onProgress?: (message: string, progress: number) => void + onProgress?: (message: string, progress: number, extra?: Partial) => void } /** diff --git a/src/main/types/ipc-api.types.ts b/src/main/types/ipc-api.types.ts index 037901b..ce9b2ca 100644 --- a/src/main/types/ipc-api.types.ts +++ b/src/main/types/ipc-api.types.ts @@ -3,7 +3,7 @@ * These types define the API exposed to the renderer process via contextBridge */ -import type { ExtractorInput, ExtractorResult } from './extractor.types' +import type { ExtractorInput, ExtractorResult, ExtractionProgress } from './extractor.types' import type { CleanerInput, CleanerResult, @@ -82,7 +82,7 @@ export interface ExtractorAPI { * @param callback - Callback function receiving progress data * @returns Unsubscribe function */ - onProgress: (callback: (data: { message: string; progress: number }) => void) => () => void + onProgress: (callback: (data: ExtractionProgress) => void) => () => void /** * Subscribe to log messages * @param callback - Callback function receiving log data diff --git a/src/preload/index.ts b/src/preload/index.ts index ad4f57c..2718c08 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { MySqlConfig, SqlServerConfig } from '../main/types/ipc-api.types' -import type { ExtractorInput } from '../main/types/extractor.types' +import type { ExtractorInput, ExtractionProgress } from '../main/types/extractor.types' import type { CleanerInput, ExportResultItem } from '../main/types/cleaner.types' import type { ResolverInput } from '../main/ipc/resolver-handler' import type { LoginRequest } from '../main/ipc/auth-handler' @@ -28,11 +28,9 @@ const api = { // Extractor service extractor: { 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) + onProgress: (callback: (data: ExtractionProgress) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: ExtractionProgress) => + callback(data) ipcRenderer.on('extractor:progress', subscription) return () => ipcRenderer.removeListener('extractor:progress', subscription) }, diff --git a/src/renderer/src/components/ui/LogPanel.tsx b/src/renderer/src/components/ui/LogPanel.tsx index 427d65c..8e7a537 100644 --- a/src/renderer/src/components/ui/LogPanel.tsx +++ b/src/renderer/src/components/ui/LogPanel.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from 'react' import { Terminal } from 'lucide-react' -import type { LogEntry, LogLevel, ExtractorProgress } from '../../stores/extractorStore' +import type { LogEntry, LogLevel } from '../../stores/extractorStore' +import type { ExtractionProgress } from '../../stores/extractorStore' interface LogPanelProps { logs: LogEntry[] - progress: ExtractorProgress | null + progress: ExtractionProgress | null onClear: () => void } diff --git a/src/renderer/src/components/ui/SegmentedProgressBar.tsx b/src/renderer/src/components/ui/SegmentedProgressBar.tsx new file mode 100644 index 0000000..741da37 --- /dev/null +++ b/src/renderer/src/components/ui/SegmentedProgressBar.tsx @@ -0,0 +1,181 @@ +import React from 'react' +import type { ExtractionPhase } from '../../stores/extractorStore' + +interface SegmentedProgressBarProps { + progress: number + phase?: ExtractionPhase + currentBatch?: number + totalBatches?: number + subProgress?: { + step: string + current: number + total: number + } +} + +const PHASES: { key: ExtractionPhase; label: string; color: string }[] = [ + { key: 'login', label: '登录', color: 'bg-purple-500' }, + { key: 'downloading', label: '下载', color: 'bg-blue-500' }, + { key: 'merging', label: '合并', color: 'bg-amber-500' }, + { key: 'importing', label: '入库', color: 'bg-emerald-500' } +] + +export const SegmentedProgressBar: React.FC = ({ + progress, + phase, + currentBatch, + totalBatches, + subProgress +}) => { + // Calculate phase boundaries + const getPhaseBoundaries = () => { + if (!totalBatches) { + return { loginEnd: 10, downloadingEnd: 90, mergingEnd: 95 } + } + const totalPoints = 1 + totalBatches + 2 + const progressPerPoint = 100 / totalPoints + const loginEnd = progressPerPoint + const downloadingEnd = (1 + totalBatches) * progressPerPoint + const mergingEnd = (1 + totalBatches + 1) * progressPerPoint + return { loginEnd, downloadingEnd, mergingEnd } + } + + const { loginEnd, downloadingEnd, mergingEnd } = getPhaseBoundaries() + + const getCurrentPhaseIndex = (): number => { + if (phase === 'downloading') return 1 + if (phase === 'merging') return 2 + if (phase === 'importing') return 3 + return 0 + } + + const currentPhaseIndex = getCurrentPhaseIndex() + + const getPhaseStatus = (index: number) => { + if (index < currentPhaseIndex) return 'completed' + if (index === currentPhaseIndex) return 'active' + return 'pending' + } + + const getStatusDot = (status: string) => { + if (status === 'completed') return 'bg-emerald-600' + if (status === 'active') return 'bg-blue-600 animate-pulse' + return 'bg-slate-300' + } + + const getStatusText = (status: string) => { + if (status === 'completed') return 'text-emerald-600' + if (status === 'active') return 'text-blue-600 font-semibold' + return 'text-slate-400' + } + + const getDetailText = () => { + if (phase === 'login' && subProgress) { + return `${subProgress.step} (${subProgress.current}/${subProgress.total})` + } + if (phase === 'downloading' && currentBatch !== undefined && totalBatches !== undefined) { + return `批次 ${currentBatch}/${totalBatches}` + } + if (phase === 'merging') { + return '正在合并 Excel 文件...' + } + if (phase === 'importing') { + return '正在写入数据库...' + } + return '准备中...' + } + + // Calculate segment fills based on current phase + const getSegments = () => { + // Login phase: simple 0-10% range + if (phase === 'login') { + return [{ end: 10, fill: progress }] + } + + // Unknown phase or missing data: simple progress bar + if (!phase || !totalBatches) { + return [{ end: 100, fill: progress }] + } + + // Multi-phase progress + return [ + { + end: loginEnd, + fill: Math.min(progress, loginEnd) + }, + { + end: downloadingEnd, + fill: Math.min(Math.max(progress, loginEnd), downloadingEnd) + }, + { + end: mergingEnd, + fill: Math.min(Math.max(progress, downloadingEnd), mergingEnd) + }, + { + end: 100, + fill: Math.min(Math.max(progress, mergingEnd), 100) + } + ] + } + + const segments = getSegments() + + return ( +
+ {/* 阶段标签 */} +
+ {PHASES.map((p, index) => { + const status = getPhaseStatus(index) + return ( +
+
+ {p.label} +
+ ) + })} +
+ + {/* 分段进度条 */} +
+ {segments.map((segment, index) => { + const prevEnd = index === 0 ? 0 : segments[index - 1].end + const segmentWidth = segment.end - prevEnd + const filledWidth = Math.max(0, segment.fill - prevEnd) + + return ( +
+
0 ? (filledWidth / segmentWidth) * 100 : 0}%` + }} + /> + {index < PHASES.length - 1 && ( +
+ )} +
+ ) + })} +
+ + {/* 详细信息 */} +
+
+ 当前进度: + {getDetailText()} +
+
{Math.round(progress)}%
+
+
+ ) +} diff --git a/src/renderer/src/hooks/useExtractor.ts b/src/renderer/src/hooks/useExtractor.ts index eeacad8..4e3818d 100644 --- a/src/renderer/src/hooks/useExtractor.ts +++ b/src/renderer/src/hooks/useExtractor.ts @@ -17,7 +17,14 @@ export function useExtractor() { useEffect(() => { const unsubscribeProgress = window.electron.extractor.onProgress((data) => { - setProgress({ message: data.message, progress: data.progress }) + setProgress({ + message: data.message, + progress: data.progress, + phase: data.phase, + currentBatch: data.currentBatch, + totalBatches: data.totalBatches, + subProgress: data.subProgress + }) }) const unsubscribeLog = window.electron.extractor.onLog((data) => { diff --git a/src/renderer/src/pages/ExtractorPage.tsx b/src/renderer/src/pages/ExtractorPage.tsx index 49e1997..51db56f 100644 --- a/src/renderer/src/pages/ExtractorPage.tsx +++ b/src/renderer/src/pages/ExtractorPage.tsx @@ -3,21 +3,14 @@ import { Download, Play } from 'lucide-react' import OrderNumberInput from '../components/OrderNumberInput' import { useExtractor } from '../hooks/useExtractor' import LogPanel from '../components/ui/LogPanel' +import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar' const ExtractorPage: React.FC = () => { const [orderNumbers, setOrderNumbers] = useState(() => { return sessionStorage.getItem('extractor_orderNumbers') || '' }) - const { - isRunning, - progress, - error, - logs, - startExtraction, - clearLogs, - setError - } = useExtractor() + const { isRunning, progress, error, logs, startExtraction, clearLogs, setError } = useExtractor() useEffect(() => { sessionStorage.setItem('extractor_orderNumbers', orderNumbers) @@ -82,10 +75,20 @@ const ExtractorPage: React.FC = () => {
+ {isRunning && progress && ( + + )} +
) } -export default ExtractorPage \ No newline at end of file +export default ExtractorPage diff --git a/src/renderer/src/stores/extractorStore.ts b/src/renderer/src/stores/extractorStore.ts index de192aa..9a08656 100644 --- a/src/renderer/src/stores/extractorStore.ts +++ b/src/renderer/src/stores/extractorStore.ts @@ -2,27 +2,37 @@ import { create } from 'zustand' export type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system' +export type ExtractionPhase = 'login' | 'downloading' | 'merging' | 'importing' + export interface LogEntry { timestamp: string level: LogLevel message: string } -export interface ExtractorProgress { +export interface ExtractionProgress { message: string progress: number + phase?: ExtractionPhase + currentBatch?: number + totalBatches?: number + subProgress?: { + step: string + current: number + total: number + } } export interface ExtractorState { isRunning: boolean - progress: ExtractorProgress | null + progress: ExtractionProgress | null error: string | null logs: LogEntry[] } export interface ExtractorActions { setRunning: (isRunning: boolean) => void - setProgress: (progress: ExtractorProgress | null) => void + setProgress: (progress: ExtractionProgress | null) => void setError: (error: string | null) => void addLog: (level: LogLevel, message: string) => void clearLogs: () => void @@ -41,7 +51,7 @@ export const useExtractorStore = create((set) setRunning: (isRunning: boolean) => set({ isRunning }), - setProgress: (progress: ExtractorProgress | null) => set({ progress }), + setProgress: (progress: ExtractionProgress | null) => set({ progress }), setError: (error: string | null) => set({ error }),