refactor: optimize ExtractorPage layout and UX

- Use OrderNumberInput component with format statistics
- Add collapsible sidebar with smooth animation
- Improve log system with level-based coloring and auto-scroll
- Remove result display cards for cleaner interface
- Add file:openPath IPC handler for opening files in explorer
This commit is contained in:
Misaka_Company
2026-03-04 15:52:41 +08:00
parent 50041d2a7b
commit dc3d577f6f
5 changed files with 184 additions and 348 deletions

View File

@@ -1,15 +1,11 @@
import { ipcMain } from 'electron' import { ipcMain, shell } from 'electron'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
const log = createLogger('FileHandler') const log = createLogger('FileHandler')
/**
* Register IPC handlers for file operations
*/
export function registerFileHandlers(): void { export function registerFileHandlers(): void {
// Read file content
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => { ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
try { try {
log.debug('Reading file', { filePath }) log.debug('Reading file', { filePath })
@@ -21,11 +17,9 @@ export function registerFileHandlers(): void {
} }
}) })
// Write content to file
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => { ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
try { try {
log.debug('Writing file', { filePath }) log.debug('Writing file', { filePath })
// Ensure directory exists
const dir = path.dirname(filePath) const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, content, 'utf-8') await fs.writeFile(filePath, content, 'utf-8')
@@ -36,7 +30,6 @@ export function registerFileHandlers(): void {
} }
}) })
// Check if file exists
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => { ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
try { try {
await fs.access(filePath) await fs.access(filePath)
@@ -46,7 +39,6 @@ export function registerFileHandlers(): void {
} }
}) })
// List files in directory
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => { ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
try { try {
log.debug('Listing directory', { dirPath }) log.debug('Listing directory', { dirPath })
@@ -61,4 +53,15 @@ export function registerFileHandlers(): void {
throw new Error(message) throw new Error(message)
} }
}) })
ipcMain.handle('file:openPath', async (_event, filePath: string): Promise<void> => {
try {
log.debug('Opening path in explorer', { filePath })
await shell.openPath(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to open path'
log.error('Failed to open path', { filePath, error: message })
throw new Error(message)
}
})
} }

View File

@@ -59,30 +59,11 @@ export interface SqlServerQueryResult {
* File operation APIs * File operation APIs
*/ */
export interface FileAPI { export interface FileAPI {
/**
* Read file content as text
* @param filePath - Path to the file
*/
readFile: (filePath: string) => Promise<string> readFile: (filePath: string) => Promise<string>
/**
* Write content to file
* @param filePath - Path to the file
* @param content - Content to write
*/
writeFile: (filePath: string, content: string) => Promise<void> writeFile: (filePath: string, content: string) => Promise<void>
/**
* Check if file exists
* @param filePath - Path to the file
*/
fileExists: (filePath: string) => Promise<boolean> fileExists: (filePath: string) => Promise<boolean>
/**
* Get list of files in directory
* @param dirPath - Directory path
*/
listFiles: (dirPath: string) => Promise<string[]> listFiles: (dirPath: string) => Promise<string[]>
openPath: (filePath: string) => Promise<void>
} }
/** /**

View File

@@ -21,7 +21,8 @@ const api = {
writeFile: (filePath: string, content: string) => writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('file:write', filePath, content), ipcRenderer.invoke('file:write', filePath, content),
fileExists: (filePath: string) => ipcRenderer.invoke('file:exists', filePath), fileExists: (filePath: string) => ipcRenderer.invoke('file:exists', filePath),
listFiles: (dirPath: string) => ipcRenderer.invoke('file:list', dirPath) listFiles: (dirPath: string) => ipcRenderer.invoke('file:list', dirPath),
openPath: (filePath: string) => ipcRenderer.invoke('file:openPath', filePath)
}, },
// Extractor service // Extractor service

View File

@@ -5,7 +5,10 @@ interface OrderNumberInputProps {
onChange: (value: string) => void onChange: (value: string) => void
placeholder?: string placeholder?: string
label?: string label?: string
enableFormatStats?: boolean // Whether to show format statistics enableFormatStats?: boolean
disabled?: boolean
showReset?: boolean
onReset?: () => void
} }
interface FormatStats { interface FormatStats {
@@ -14,24 +17,20 @@ interface FormatStats {
unknownCount: number unknownCount: number
} }
// Regular expression patterns for order number recognition
const ORDER_PATTERNS = { const ORDER_PATTERNS = {
// productionID: 2 digits + 1 letter + serial number (1+)
PRODUCTION_ID: /^\d{2}[A-Za-z]\d+$/, PRODUCTION_ID: /^\d{2}[A-Za-z]\d+$/,
// 生产订单号SC + 14 digits
ORDER_NUMBER: /^SC\d{14}$/ ORDER_NUMBER: /^SC\d{14}$/
} }
/** const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
* OrderNumberInput - A textarea component for entering line-separated order numbers
* Supports automatic recognition of productionID and 生产订单号 formats
*/
export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
value, value,
onChange, onChange,
placeholder = '请输入订单号,每行一个\n支持两种格式:\n- productionID: 22A1, 22A123\n- 生产订单号SC70202602120085', placeholder = '每行输入一个订单号\n支持格式\n- 总排号: 22A1, 22A123\n- 生产订单号: SC70202602120085',
label = '订单号列表', label = '订单号列表',
enableFormatStats = true enableFormatStats = true,
disabled = false,
showReset = false,
onReset
}) => { }) => {
const [count, setCount] = useState(0) const [count, setCount] = useState(0)
const [stats, setStats] = useState<FormatStats>({ const [stats, setStats] = useState<FormatStats>({
@@ -43,22 +42,15 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
const recognizeType = (input: string): 'productionId' | 'orderNumber' | 'unknown' => { const recognizeType = (input: string): 'productionId' | 'orderNumber' | 'unknown' => {
const trimmed = input.trim() const trimmed = input.trim()
if (!trimmed) return 'unknown' if (!trimmed) return 'unknown'
if (ORDER_PATTERNS.ORDER_NUMBER.test(trimmed)) return 'orderNumber'
if (ORDER_PATTERNS.ORDER_NUMBER.test(trimmed)) { if (ORDER_PATTERNS.PRODUCTION_ID.test(trimmed)) return 'productionId'
return 'orderNumber'
}
if (ORDER_PATTERNS.PRODUCTION_ID.test(trimmed)) {
return 'productionId'
}
return 'unknown' return 'unknown'
} }
useEffect(() => { useEffect(() => {
// Count non-empty lines and categorize by format
const lines = value.split('\n').filter((line) => line.trim().length > 0) const lines = value.split('\n').filter((line) => line.trim().length > 0)
setCount(lines.length) setCount(lines.length)
// Calculate format statistics
const newStats: FormatStats = { const newStats: FormatStats = {
productionIdCount: 0, productionIdCount: 0,
orderNumberCount: 0, orderNumberCount: 0,
@@ -67,13 +59,9 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
for (const line of lines) { for (const line of lines) {
const type = recognizeType(line) const type = recognizeType(line)
if (type === 'productionId') { if (type === 'productionId') newStats.productionIdCount++
newStats.productionIdCount++ else if (type === 'orderNumber') newStats.orderNumberCount++
} else if (type === 'orderNumber') { else newStats.unknownCount++
newStats.orderNumberCount++
} else {
newStats.unknownCount++
}
} }
setStats(newStats) setStats(newStats)
@@ -84,96 +72,54 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
} }
return ( return (
<div className="order-number-input"> <div className="flex flex-col h-full">
<div className="input-header"> <div className="flex items-center justify-between mb-2">
<label>{label}</label> <label className="text-sm font-medium text-slate-700">{label}</label>
<div className="stats-wrapper"> <div className="flex items-center gap-1.5">
<span className="count-badge">{count} </span> <span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full text-xs font-medium">
{count}
</span>
{enableFormatStats && stats.productionIdCount > 0 && ( {enableFormatStats && stats.productionIdCount > 0 && (
<span className="stat-badge production-id">{stats.productionIdCount} </span> <span className="bg-emerald-50 text-emerald-600 px-2 py-0.5 rounded-full text-xs font-medium">
{stats.productionIdCount}
</span>
)} )}
{enableFormatStats && stats.orderNumberCount > 0 && ( {enableFormatStats && stats.orderNumberCount > 0 && (
<span className="stat-badge order-number">{stats.orderNumberCount} </span> <span className="bg-amber-50 text-amber-600 px-2 py-0.5 rounded-full text-xs font-medium">
{stats.orderNumberCount}
</span>
)} )}
{enableFormatStats && stats.unknownCount > 0 && ( {enableFormatStats && stats.unknownCount > 0 && (
<span className="stat-badge unknown">{stats.unknownCount} </span> <span className="bg-red-50 text-red-500 px-2 py-0.5 rounded-full text-xs font-medium">
{stats.unknownCount}
</span>
)} )}
</div> </div>
</div> </div>
<textarea <textarea
value={value} value={value}
onChange={handleChange} onChange={handleChange}
placeholder={placeholder} placeholder={placeholder}
rows={10} disabled={disabled}
className="order-textarea" className="flex-1 w-full border border-slate-300 rounded-lg p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
userSelect: disabled ? 'none' : 'text',
cursor: disabled ? 'not-allowed' : 'text'
}}
/> />
<style>{`
.order-number-input { {showReset && (
margin-bottom: 16px; <div className="flex items-center justify-end mt-2">
} <button
.input-header { onClick={onReset}
display: flex; disabled={disabled}
justify-content: space-between; className="text-xs text-slate-400 hover:text-slate-600 disabled:opacity-50 disabled:cursor-not-allowed"
align-items: center; >
margin-bottom: 8px;
} </button>
.input-header label { </div>
font-weight: 600; )}
font-size: 14px;
color: #333;
}
.stats-wrapper {
display: flex;
gap: 6px;
align-items: center;
}
.count-badge {
background: #e6f7ff;
color: #1890ff;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.stat-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.stat-badge.production-id {
background: #f6ffed;
color: #52c41a;
}
.stat-badge.order-number {
background: #fff7e6;
color: #fa8c16;
}
.stat-badge.unknown {
background: #fff1f0;
color: #ff4d4f;
}
.order-textarea {
width: 100%;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
transition: border-color 0.3s;
box-sizing: border-box;
}
.order-textarea:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.order-textarea::placeholder {
color: #bfbfbf;
}
`}</style>
</div> </div>
) )
} }

View File

@@ -1,51 +1,48 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { Download, Play, Terminal, Database } from 'lucide-react' import { Download, Play, Terminal, PanelLeftClose, PanelLeft } from 'lucide-react'
import OrderNumberInput from '../components/OrderNumberInput'
// Import result type (matches the type from main process)
interface ImportResult {
success: boolean
recordsRead: number
recordsDeleted: number
recordsImported: number
uniqueSourceNumbers: number
errors: string[]
}
// Extractor result type (matches the type from main process)
interface ExtractorResult {
downloadedFiles: string[]
mergedFile: string | null
recordCount: number
errors: string[]
importResult?: ImportResult
}
interface ExtractorProgress { interface ExtractorProgress {
message: string message: string
progress: number progress: number
} }
/** type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system'
* ExtractorPage - Main page for ERP data extraction
*/ interface LogEntry {
timestamp: string
level: LogLevel
message: string
}
const getLogColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'text-red-400'
case 'warning':
return 'text-amber-400'
case 'success':
return 'text-emerald-400'
case 'system':
return 'text-blue-400'
default:
return 'text-slate-400'
}
}
const ExtractorPage: React.FC = () => { const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState(() => { const [orderNumbers, setOrderNumbers] = useState(() => {
// Restore from sessionStorage on mount
return sessionStorage.getItem('extractor_orderNumbers') || '' return sessionStorage.getItem('extractor_orderNumbers') || ''
}) })
const [batchSize, setBatchSize] = useState(() => {
const saved = sessionStorage.getItem('extractor_batchSize')
return saved ? parseInt(saved, 10) : 100
})
const [isRunning, setIsRunning] = useState(false) const [isRunning, setIsRunning] = useState(false)
const [progress, setProgress] = useState<ExtractorProgress | null>(null) const [progress, setProgress] = useState<ExtractorProgress | null>(null)
const [result, setResult] = useState<ExtractorResult | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const logsEndRef = useRef<HTMLDivElement>(null)
// Save to sessionStorage when orderNumbers changes
useEffect(() => { useEffect(() => {
sessionStorage.setItem('extractor_orderNumbers', orderNumbers) sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
// Update shared Production IDs when orderNumbers changes
if (orderNumbers.trim()) { if (orderNumbers.trim()) {
const orderNumberList = orderNumbers const orderNumberList = orderNumbers
.split('\n') .split('\n')
@@ -55,10 +52,14 @@ const ExtractorPage: React.FC = () => {
} }
}, [orderNumbers]) }, [orderNumbers])
// Save to sessionStorage when batchSize changes
useEffect(() => { useEffect(() => {
sessionStorage.setItem('extractor_batchSize', batchSize.toString()) logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [batchSize]) }, [logs])
const addLog = (level: LogLevel, message: string) => {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
setLogs((prev) => [...prev, { timestamp, level, message }])
}
const handleExtract = async () => { const handleExtract = async () => {
if (!orderNumbers.trim()) { if (!orderNumbers.trim()) {
@@ -68,8 +69,10 @@ const ExtractorPage: React.FC = () => {
setIsRunning(true) setIsRunning(true)
setProgress(null) setProgress(null)
setResult(null)
setError(null) setError(null)
setLogs([])
addLog('system', '提取引擎启动,准备执行...')
try { try {
const orderNumberList = orderNumbers const orderNumberList = orderNumbers
@@ -77,208 +80,113 @@ const ExtractorPage: React.FC = () => {
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0) .filter((line) => line.length > 0)
// Store Production IDs for sharing with cleaner page (before extraction starts)
await window.electron.validation.setSharedProductionIds(orderNumberList) await window.electron.validation.setSharedProductionIds(orderNumberList)
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`) addLog('info', `已存储 ${orderNumberList.length} 个订单号用于跨模块共享`)
// Call extractor API through electron
const response = await window.electron.extractor.runExtractor({ const response = await window.electron.extractor.runExtractor({
orderNumbers: orderNumberList, orderNumbers: orderNumberList
batchSize
}) })
if (response.success && response.data) { if (response.success && response.data) {
setResult(response.data) addLog(
'success',
`提取完成:下载 ${response.data.downloadedFiles.length} 个文件,共 ${response.data.recordCount} 条记录`
)
if (response.data.errors.length > 0) {
addLog('warning', `存在 ${response.data.errors.length} 个错误`)
}
} else { } else {
setError(response.error || '提取失败') setError(response.error || '提取失败')
addLog('error', response.error || '提取失败')
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '发生未知错误') const errMsg = err instanceof Error ? err.message : '发生未知错误'
setError(errMsg)
addLog('error', errMsg)
} finally { } finally {
setIsRunning(false) setIsRunning(false)
setProgress(null) setProgress(null)
} }
} }
const handleReset = () => {
setOrderNumbers('')
setBatchSize(100)
setResult(null)
setError(null)
setProgress(null)
}
const [logs, setLogs] = useState<string[]>([
'[10:00:01] [System] 提取引擎已就绪。',
'[10:00:02] [Info] 等待读取生产订单列表...'
])
useEffect(() => { useEffect(() => {
if (progress) { if (progress) {
setLogs((prev) => [ addLog('info', progress.message)
...prev,
`[${new Date().toLocaleTimeString()}] [Info] ${progress.message}`
])
} }
}, [progress]) }, [progress])
const handleReset = () => {
setOrderNumbers('')
setError(null)
setProgress(null)
setLogs([])
}
return ( return (
<div className="flex h-full gap-6"> <div className="flex h-full gap-4 relative">
{/* 左侧:共享数据区 (仅在数据提取页面显示) */} {!sidebarCollapsed && (
<aside className="w-80 bg-white border border-slate-200 flex flex-col shadow-sm z-10 flex-shrink-0 animate-in slide-in-from-left duration-300 rounded-xl overflow-hidden h-full"> <aside className="w-80 flex-shrink-0 bg-white border border-slate-200 flex flex-col shadow-sm rounded-xl overflow-hidden h-full animate-in slide-in-from-left duration-300">
<div className="flex-1 flex flex-col p-5 space-y-3 h-full"> <div className="p-4 border-b border-slate-100">
<div> <h3 className="text-sm font-semibold text-slate-800"></h3>
<label className="text-sm font-medium text-slate-700"> <p className="text-xs text-slate-500 mt-1">
"数据提取""物料清理"
</label>
<p className="text-xs text-slate-500 leading-relaxed mt-1">
</p> </p>
</div> </div>
<div className="flex-1 flex flex-col p-4 min-h-0">
<textarea <OrderNumberInput
className="flex-1 w-full border border-slate-300 rounded-lg p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none shadow-inner bg-slate-50 h-full"
style={{ userSelect: 'text', cursor: 'text' }}
placeholder="PO-20231024-001&#10;PO-20231024-002&#10;PO-20231024-003..."
value={orderNumbers} value={orderNumbers}
onChange={(e) => setOrderNumbers(e.target.value)} onChange={setOrderNumbers}
label=""
enableFormatStats={true}
disabled={isRunning} disabled={isRunning}
></textarea> showReset={true}
onReset={handleReset}
<div className="flex items-center justify-between text-xs text-slate-500 pt-2"> />
<span>
:{' '}
<strong className="text-slate-700">
{orderNumbers.split('\n').filter((l) => l.trim()).length}
</strong>{' '}
</span>
<button
className="text-slate-400 hover:text-slate-600"
onClick={handleReset}
disabled={isRunning}
>
</button>
</div>
</div> </div>
</aside> </aside>
)}
{/* 右侧:动态功能面板 */} <button
<div className="flex-1 max-w-4xl space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex items-center justify-between"> className="absolute left-0 top-1/2 -translate-y-1/2 z-20 bg-white border border-slate-200 rounded-r-lg p-1.5 shadow-sm hover:bg-slate-50 transition-colors"
<div> style={{ left: sidebarCollapsed ? 0 : '320px' }}
title={sidebarCollapsed ? '展开侧栏' : '收起侧栏'}
>
{sidebarCollapsed ? (
<PanelLeft size={18} className="text-slate-600" />
) : (
<PanelLeftClose size={18} className="text-slate-600" />
)}
</button>
<div className="flex-1 min-w-0 flex flex-col gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5 flex items-center justify-between">
<div className="flex-1">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800 mb-1"> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800 mb-1">
<Download size={20} className="text-blue-600" /> <Download size={20} className="text-blue-600" />
</h2> </h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500"></p>
</p>
{error && <p className="text-sm text-red-500 mt-2">{error}</p>} {error && <p className="text-sm text-red-500 mt-2">{error}</p>}
</div> </div>
<div className="flex items-center gap-4">
<button <button
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-8 py-3 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors text-base" className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
onClick={handleExtract} onClick={handleExtract}
disabled={isRunning || !orderNumbers.trim()} disabled={isRunning || !orderNumbers.trim()}
> >
<Play size={20} fill="currentColor" /> <Play size={18} fill="currentColor" />
{isRunning ? '提取中...' : '开始提取'} {isRunning ? '提取中...' : '开始提取'}
</button> </button>
</div> </div>
</div>
{/* 结果展示 */} <div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col min-h-[300px] flex-1">
{result && ( <div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700 flex-shrink-0">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
<h3 className="text-emerald-600 font-semibold text-lg border-b pb-2"></h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">
{result.downloadedFiles.length}
</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">{result.recordCount}</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span
className={`text-2xl font-bold ${result.errors.length > 0 ? 'text-red-500' : 'text-slate-800'}`}
>
{result.errors.length}
</span>
</div>
</div>
{result.mergedFile && (
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
<span className="text-slate-500 text-sm block mb-1"></span>
<span className="text-sm font-mono text-slate-700 select-all break-all">
{result.mergedFile}
</span>
</div>
)}
</div>
)}
{/* Database import results */}
{result?.importResult && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
<h3 className="font-semibold text-lg border-b pb-2 flex items-center gap-2">
<Database
size={20}
className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'}
/>
<span className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'}>
</span>
</h3>
<div className="grid grid-cols-4 gap-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">
{result.importResult.recordsRead}
</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-amber-600">
{result.importResult.recordsDeleted}
</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-emerald-600">
{result.importResult.recordsImported}
</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-blue-600">
{result.importResult.uniqueSourceNumbers}
</span>
</div>
</div>
{result.importResult.errors.length > 0 && (
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<span className="text-red-600 text-sm font-medium block mb-1"></span>
<ul className="text-sm text-red-500 list-disc list-inside">
{result.importResult.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col h-[500px]">
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700">
<div className="flex items-center gap-2 text-slate-400 text-sm"> <div className="flex items-center gap-2 text-slate-400 text-sm">
<Terminal size={16} /> <Terminal size={16} />
<span> (Console)</span> <span></span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-slate-500">: {progress?.progress || 0}%</span> <span className="text-xs text-slate-500">: {progress?.progress || 0}%</span>
@@ -291,20 +199,17 @@ const ExtractorPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed"> <div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
{logs.map((log, index) => ( {logs.length === 0 ? (
<div <div className="text-slate-500 text-center py-8">...</div>
key={index} ) : (
className={ logs.map((log, index) => (
log.includes('[System]') <div key={index} className={getLogColor(log.level)}>
? 'text-emerald-500' <span className="text-slate-600">[{log.timestamp}]</span>{' '}
: log.includes('error') || log.includes('失败') <span className="text-slate-500">[{log.level.toUpperCase()}]</span> {log.message}
? 'text-red-400'
: 'text-slate-400'
}
>
{log}
</div> </div>
))} ))
)}
<div ref={logsEndRef} />
</div> </div>
</div> </div>
</div> </div>