Feat: Add segmented progress bar for data extractor with dynamic phase calculation
- Add ExtractionProgress type with phase, batch, and subProgress fields - Implement dynamic progress calculation: 1 (login) + N (batches) + 2 (merge/import) - Create SegmentedProgressBar component with 4 colored phases (purple/blue/amber/green) - Show batch-level progress during download phase (e.g., 批次 1/10) - Display sub-progress during login phase (连接数据库/解析订单号/登录 ERP) - Update IPC handler and extractor services to report detailed progress - Add phase status indicators (completed/active/pending) with color-coded dots
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
181
src/renderer/src/components/ui/SegmentedProgressBar.tsx
Normal file
181
src/renderer/src/components/ui/SegmentedProgressBar.tsx
Normal file
@@ -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<SegmentedProgressBarProps> = ({
|
||||
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 (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
{/* 阶段标签 */}
|
||||
<div className="flex justify-between mb-3">
|
||||
{PHASES.map((p, index) => {
|
||||
const status = getPhaseStatus(index)
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className={`flex items-center gap-2 ${getStatusText(status)} transition-colors`}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusDot(status)} transition-colors`} />
|
||||
<span className="text-sm">{p.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分段进度条 */}
|
||||
<div className="relative h-3 bg-slate-100 rounded-full overflow-hidden mb-3">
|
||||
{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 (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute h-full"
|
||||
style={{
|
||||
left: `${prevEnd}%`,
|
||||
width: `${segmentWidth}%`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${PHASES[index].color}`}
|
||||
style={{
|
||||
width: `${segmentWidth > 0 ? (filledWidth / segmentWidth) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
{index < PHASES.length - 1 && (
|
||||
<div className="absolute right-0 top-0 h-full w-px bg-white/50" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="text-slate-500">当前进度:</span>
|
||||
<span className="text-slate-800">{getDetailText()}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-slate-800">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRunning && progress && (
|
||||
<SegmentedProgressBar
|
||||
progress={progress.progress}
|
||||
phase={progress.phase}
|
||||
currentBatch={progress.currentBatch}
|
||||
totalBatches={progress.totalBatches}
|
||||
subProgress={progress.subProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogPanel logs={logs} progress={progress} onClear={clearLogs} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtractorPage
|
||||
export default ExtractorPage
|
||||
|
||||
@@ -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<ExtractorState & ExtractorActions>((set)
|
||||
|
||||
setRunning: (isRunning: boolean) => set({ isRunning }),
|
||||
|
||||
setProgress: (progress: ExtractorProgress | null) => set({ progress }),
|
||||
setProgress: (progress: ExtractionProgress | null) => set({ progress }),
|
||||
|
||||
setError: (error: string | null) => set({ error }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user