feat: implement material validation UI in CleanerPage

New Features:
- Material validation from database (full table or filtered by ProductionID)
- Checkbox selection for materials to delete with auto-select for marked items
- Manager-based filtering (Admin only)
- Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
- Export results to CSV
- Hide/show checked items feature (User mode)
- Share Production IDs between ExtractorPage and CleanerPage
- Persist page state using sessionStorage

Bug Fixes:
- Fix MySQL query rowCount for INSERT/UPDATE/DELETE operations
- Add AUTO_INCREMENT to MaterialsToBeDeleted.ID (preserved 43 records)
- Add UNIQUE constraint on MaterialsToBeDeleted.MaterialCode

Files Added:
- src/main/ipc/validation-handler.ts
- src/main/types/validation.types.ts
- src/main/services/database/materials-to-be-deleted-dao.ts
- src/main/services/database/discrete-material-plan-dao.ts

Files Modified:
- src/renderer/src/pages/CleanerPage.tsx (complete rewrite)
- src/renderer/src/pages/ExtractorPage.tsx
- src/renderer/src/App.tsx (add navigation tabs)
- src/main/services/database/mysql.ts
- src/preload/index.ts + index.d.ts
This commit is contained in:
Misaka
2026-03-01 19:15:38 +08:00
parent fad08796f7
commit 0f1e0a8908
11 changed files with 2729 additions and 107 deletions

View File

@@ -290,7 +290,7 @@ function App(): React.JSX.Element {
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}>
{/* Header with user info and logout */}
{/* Header with user info, navigation and logout */}
<header style={{
display: 'flex',
justifyContent: 'space-between',
@@ -300,13 +300,61 @@ function App(): React.JSX.Element {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
marginBottom: '16px'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
<span style={{
fontSize: '14px',
color: '#666'
}}>
{currentUser?.username} ({currentUser?.userType})
</span>
{/* Navigation tabs */}
<nav style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setCurrentPage('home')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'home' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'home' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
<button
onClick={() => setCurrentPage('extractor')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'extractor' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'extractor' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
<button
onClick={() => setCurrentPage('cleaner')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'cleaner' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'cleaner' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
</nav>
</div>
<div>
{shouldShowLogout && (
@@ -330,34 +378,9 @@ function App(): React.JSX.Element {
{currentPage === 'home' && (
<div>
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}></h1>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
<div onClick={() => setCurrentPage('extractor')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#1890ff' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
<div onClick={() => setCurrentPage('cleaner')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#52c41a' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
</div>
<p style={{ color: '#666', fontSize: '14px' }}>
使
</p>
</div>
)}

View File

@@ -1,38 +1,399 @@
import React, { useState } from 'react'
import { OrderNumberInput } from '../components/OrderNumberInput'
import { MaterialCodeInput } from '../components/MaterialCodeInput'
import React, { useState, useCallback, useEffect } from 'react'
interface CleanerResult {
ordersProcessed: number
materialsDeleted: number
materialsSkipped: number
errors: string[]
details: Array<{
orderNumber: string
materialsDeleted: number
materialsSkipped: number
errors: string[]
}>
}
interface CleanerProgress {
message: string
progress?: number
/**
* Material validation result interface
*/
interface ValidationResult {
materialName: string
materialCode: string
specification: string
model: string
managerName: string
isMarkedForDeletion: boolean
matchedTypeKeyword?: string
}
/**
* CleanerPage - Main page for ERP material cleaning
* CleanerPage with material validation functionality
*
* This component provides the same functionality as the Python MaterialValidationTab:
* - Material validation from database (full table or filtered by ProductionID)
* - Checkbox selection for materials to delete
* - Manager-based filtering (Admin only)
* - Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
*/
export const CleanerPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState('')
const [materialCodes, setMaterialCodes] = useState('')
const [dryRun, setDryRun] = useState(true)
// State with sessionStorage persistence
const [orderNumbers, setOrderNumbers] = useState(() => {
return sessionStorage.getItem('cleaner_orderNumbers') || ''
})
const [materialCodes, setMaterialCodes] = useState(() => {
return sessionStorage.getItem('cleaner_materialCodes') || ''
})
const [dryRun, setDryRun] = useState(() => {
const saved = sessionStorage.getItem('cleaner_dryRun')
return saved ? saved === 'true' : true
})
const [validationMode, setValidationMode] = useState<'database_full' | 'database_filtered'>(() => {
const saved = sessionStorage.getItem('cleaner_validationMode') as 'database_full' | 'database_filtered'
return saved || 'database_filtered'
})
const [useSharedProductionIds, setUseSharedProductionIds] = useState(() => {
const saved = sessionStorage.getItem('cleaner_useSharedProductionIds')
return saved ? saved === 'true' : true
})
const [isRunning, setIsRunning] = useState(false)
const [progress, setProgress] = useState<CleanerProgress | null>(null)
const [result, setResult] = useState<CleanerResult | null>(null)
const [progress, setProgress] = useState<{ message: string; progress?: number } | null>(null)
const [result, setResult] = useState<{
ordersProcessed: number
materialsDeleted: number
materialsSkipped: number
errors: string[]
details: Array<{
orderNumber: string
materialsDeleted: number
materialsSkipped: number
errors: string[]
}>
} | null>(null)
const [error, setError] = useState<string | null>(null)
const handleClean = async () => {
// Validation state
const [validationResults, setValidationResults] = useState<ValidationResult[]>([])
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
const [managers, setManagers] = useState<string[]>([])
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
const [isAdmin, setIsAdmin] = useState(false)
const [currentUsername, setCurrentUsername] = useState<string>('')
const [isValidationRunning, setIsValidationRunning] = useState(false)
const [validationStats, setValidationStats] = useState<{
totalRecords: number
matchedCount: number
markedCount: number
} | null>(null)
// Hidden items state (for user mode hide checked feature)
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set())
// Filter validation results by selected managers and hidden items
const filteredResults = React.useMemo(() => {
let results = validationResults
if (!isAdmin && currentUsername) {
// Non-admin users only see their own data
results = results.filter(r => r.managerName === currentUsername || !r.managerName)
} else if (managers.length > 0 && selectedManagers.size > 0) {
// Admin users see selected managers' data
results = results.filter(
r => selectedManagers.has(r.managerName) || !r.managerName
)
}
// Filter out hidden items
results = results.filter(r => !hiddenItems.has(r.materialCode))
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
useEffect(() => {
sessionStorage.setItem('cleaner_orderNumbers', orderNumbers)
}, [orderNumbers])
useEffect(() => {
sessionStorage.setItem('cleaner_materialCodes', materialCodes)
}, [materialCodes])
useEffect(() => {
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
}, [dryRun])
useEffect(() => {
sessionStorage.setItem('cleaner_validationMode', validationMode)
}, [validationMode])
useEffect(() => {
sessionStorage.setItem('cleaner_useSharedProductionIds', useSharedProductionIds.toString())
}, [useSharedProductionIds])
// Shared Production IDs state
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
// Check admin status and get shared Production IDs on mount
React.useEffect(() => {
const initializePage = async () => {
const admin = await window.electron.auth.isAdmin()
const user = await window.electron.auth.getCurrentUser()
setIsAdmin(admin)
if (user) {
setCurrentUsername(user.username)
if (!admin) {
// Non-admin users can only see their own data
setSelectedManagers(new Set([user.username]))
}
}
// Get shared Production IDs
try {
const result = await window.electron.validation.getSharedProductionIds()
setSharedProductionIdsCount(result.productionIds.length)
} catch (err) {
console.error('Failed to get shared Production IDs:', err)
}
}
initializePage()
}, [])
// Load managers for filter (Admin only)
const loadManagers = useCallback(async () => {
if (!isAdmin) return
try {
const response = await window.electron.materials.getManagers()
setManagers(response.managers)
// Select all managers by default
setSelectedManagers(new Set(response.managers))
} catch (err) {
console.error('Failed to load managers:', err)
}
}, [isAdmin])
// Handle validation
const handleValidation = useCallback(async () => {
setIsValidationRunning(true)
setError(null)
setValidationResults([])
setSelectedItems(new Set())
setValidationStats(null)
try {
const response = await window.electron.validation.validate({
mode: validationMode,
useSharedProductionIds: validationMode === 'database_filtered' ? useSharedProductionIds : undefined
})
if (response.success && response.results) {
setValidationResults(response.results)
setValidationStats(response.stats || null)
// Auto-select items that are already marked for deletion
const markedCodes = new Set(
response.results
.filter(r => r.isMarkedForDeletion)
.map(r => r.materialCode)
)
setSelectedItems(markedCodes)
// Auto-select managers for admin users
if (isAdmin) {
const uniqueManagers = new Set(
response.results
.map(r => r.managerName)
.filter(Boolean)
)
setManagers([...uniqueManagers])
setSelectedManagers(uniqueManagers)
}
} else {
setError(response.error || '校验失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '校验过程中发生未知错误')
} finally {
setIsValidationRunning(false)
}
}, [validationMode, useSharedProductionIds, isAdmin])
// Handle checkbox toggle
const handleCheckboxToggle = useCallback((materialCode: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev)
if (newSet.has(materialCode)) {
newSet.delete(materialCode)
} else {
newSet.add(materialCode)
}
return newSet
})
}, [])
// Handle select all
const handleSelectAll = useCallback(() => {
setSelectedItems(new Set(filteredResults.map(r => r.materialCode)))
}, [filteredResults])
// Handle deselect all
const handleDeselectAll = useCallback(() => {
setSelectedItems(new Set())
}, [])
// Handle confirm deletion (mark materials in database)
const handleConfirmDeletion = useCallback(async () => {
if (validationResults.length === 0) {
alert('没有可处理的数据')
return
}
// Collect all data
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
const materialsToDelete: string[] = []
const missingManager: string[] = []
for (const result of validationResults) {
// Skip empty material codes
if (!result.materialCode || !result.materialCode.trim()) {
continue
}
const materialCode = result.materialCode.trim()
const isChecked = selectedItems.has(materialCode)
if (isChecked) {
// Checked: needs to be upserted
if (!result.managerName || !result.managerName.trim()) {
missingManager.push(materialCode)
} else {
materialsToUpsert.push({
materialCode,
managerName: result.managerName.trim()
})
}
} else {
// Unchecked: needs to be deleted
materialsToDelete.push(materialCode)
}
}
// Validate: checked items must have manager names
if (missingManager.length > 0) {
const msg = `以下已勾选的记录缺少负责人信息,无法保存:\n\n` +
missingManager.slice(0, 10).join('\n') +
(missingManager.length > 10 ? `\n... 共 ${missingManager.length}` : '')
alert(msg)
return
}
// Check if there's anything to do
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) {
alert('没有需要处理的记录')
return
}
// Build confirmation message
const confirmParts: string[] = []
if (materialsToUpsert.length > 0) {
confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`)
}
if (materialsToDelete.length > 0) {
confirmParts.push(`删除 ${materialsToDelete.length} 条记录`)
}
const confirmed = window.confirm(`确认以下操作吗?\n\n${confirmParts.join('\n')}`)
if (!confirmed) {
return
}
// Execute the operation
try {
setIsRunning(true)
// Upsert selected materials
let upsertSuccess = 0
let upsertFailed = 0
if (materialsToUpsert.length > 0) {
const upsertResult = await window.electron.materials.upsertBatch(materialsToUpsert)
if (upsertResult.success) {
upsertSuccess = upsertResult.stats?.success || 0
upsertFailed = upsertResult.stats?.failed || 0
} else {
throw new Error(upsertResult.error || '写入物料失败')
}
}
// Delete unselected materials
let deleteCount = 0
if (materialsToDelete.length > 0) {
const deleteResult = await window.electron.materials.delete(materialsToDelete)
if (deleteResult.success) {
deleteCount = deleteResult.count || 0
} else {
throw new Error(deleteResult.error || '删除物料失败')
}
}
// Show result message
const msgParts: string[] = []
if (upsertSuccess > 0) {
msgParts.push(`写入/更新成功:${upsertSuccess}`)
}
if (upsertFailed > 0) {
msgParts.push(`写入/更新失败:${upsertFailed}`)
}
if (deleteCount > 0) {
msgParts.push(`删除成功:${deleteCount}`)
}
const msg = `操作完成!\n\n${msgParts.join('\n')}`
if (upsertFailed > 0) {
alert(`完成(部分失败)\n\n${msg}`)
} else {
alert(msg)
}
// Reload managers to reflect changes
await loadManagers()
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败')
} finally {
setIsRunning(false)
}
}, [selectedItems, validationResults, loadManagers])
// Handle export results
const handleExportResults = useCallback(async () => {
if (validationResults.length === 0) {
setError('没有数据可导出')
return
}
try {
// Prepare data for export
const data = validationResults.map(result => ({
'选择': selectedItems.has(result.materialCode) ? '是' : '否',
'材料名称': result.materialName,
'材料代码': result.materialCode,
'规格': result.specification,
'型号': result.model,
'负责人': result.managerName || ''
}))
// Create CSV content
const headers = ['选择', '材料名称', '材料代码', '规格', '型号', '负责人']
const csvContent = [
headers.join(','),
...data.map(row =>
headers.map(header => {
const value = row[header as keyof typeof row]
// Escape quotes and wrap in quotes if contains comma
const escaped = String(value).replace(/"/g, '""')
return escaped.includes(',') ? `"${escaped}"` : escaped
}).join(',')
)
].join('\n')
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `物料状态校验结果_${new Date().getTime()}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (err) {
setError(err instanceof Error ? err.message : '导出失败')
}
}, [validationResults, selectedItems])
// Handle execute deletion
const handleExecuteDeletion = useCallback(async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
@@ -42,6 +403,11 @@ export const CleanerPage: React.FC = () => {
return
}
if (!dryRun) {
const confirmed = window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')
if (!confirmed) return
}
setIsRunning(true)
setProgress(null)
setResult(null)
@@ -50,15 +416,14 @@ export const CleanerPage: React.FC = () => {
try {
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map(line => line.trim())
.filter(line => line.length > 0)
const materialCodeList = materialCodes
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map(line => line.trim())
.filter(line => line.length > 0)
// Call cleaner API through electron
const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList,
materialCodes: materialCodeList,
@@ -76,37 +441,273 @@ export const CleanerPage: React.FC = () => {
setIsRunning(false)
setProgress(null)
}
}
}, [orderNumbers, materialCodes, dryRun])
const handleReset = () => {
// Handle clean (alias for execute deletion)
const handleClean = useCallback(handleExecuteDeletion, [handleExecuteDeletion])
// Handle reset
const handleReset = useCallback(() => {
setOrderNumbers('')
setMaterialCodes('')
setDryRun(true)
setResult(null)
setError(null)
setProgress(null)
}
}, [])
// Handle hide checked items (for user mode)
const handleHideChecked = useCallback(() => {
const checkedCodes = filteredResults
.filter(r => selectedItems.has(r.materialCode))
.map(r => r.materialCode)
if (checkedCodes.length === 0) {
setError('没有已勾选的物料')
return
}
setHiddenItems(prev => new Set([...prev, ...checkedCodes]))
}, [filteredResults, selectedItems])
// Handle show all items
const handleShowAll = useCallback(() => {
setHiddenItems(new Set())
}, [])
return (
<div className="cleaner-page">
<h1 className="page-title">ERP </h1>
<div className="cleaner-content">
{/* Input Section */}
<div className="input-section">
<OrderNumberInput
value={orderNumbers}
onChange={setOrderNumbers}
label="订单号列表"
placeholder="请输入订单号,每行一个"
/>
{/* Validation Section (Admin only for mode selection) */}
<div className="validation-section">
<h2></h2>
<MaterialCodeInput
value={materialCodes}
onChange={setMaterialCodes}
label="物料代码列表"
placeholder="请输入物料代码,每行一个"
/>
{isAdmin && (
<div className="validation-mode-selector">
<label>
<input
type="radio"
checked={validationMode === 'database_full'}
onChange={() => setValidationMode('database_full')}
disabled={isValidationRunning}
/>
-
</label>
<label>
<input
type="radio"
checked={validationMode === 'database_filtered'}
onChange={() => setValidationMode('database_filtered')}
disabled={isValidationRunning}
/>
- ProductionID
</label>
{validationMode === 'database_filtered' && (
<div style={{ marginTop: '8px', marginLeft: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={useSharedProductionIds}
onChange={(e) => setUseSharedProductionIds(e.target.checked)}
disabled={isValidationRunning}
/>
使 Production ID
</label>
{useSharedProductionIds && (
<p style={{ margin: '4px 0 0 0', fontSize: '12px', color: sharedProductionIdsCount > 0 ? '#52c41a' : '#fa8c16' }}>
{sharedProductionIdsCount > 0
? `当前有 ${sharedProductionIdsCount} 个共享的 Production ID`
: '暂无共享的 Production ID请先在数据提取页面输入'}
</p>
)}
</div>
)}
</div>
)}
{!isAdmin && (
<p style={{ fontSize: '14px', color: '#666', marginBottom: '16px' }}>
- ProductionID 使
</p>
)}
<div className="button-group">
<button
className="btn btn-primary"
onClick={handleValidation}
disabled={isValidationRunning}
>
{isValidationRunning ? '校验中...' : '开始校验'}
</button>
{isAdmin && (
<>
<button
className="btn btn-secondary"
onClick={handleSelectAll}
disabled={filteredResults.length === 0}
>
</button>
<button
className="btn btn-secondary"
onClick={handleDeselectAll}
disabled={selectedItems.size === 0}
>
</button>
</>
)}
<button
className="btn btn-warning"
onClick={handleConfirmDeletion}
disabled={selectedItems.size === 0 || isRunning}
>
</button>
</div>
{validationStats && (
<div className="validation-stats">
<span>{validationStats.totalRecords}</span>
<span>{validationStats.matchedCount}</span>
<span>{validationStats.markedCount}</span>
</div>
)}
{/* Additional action buttons */}
<div className="button-group" style={{ marginTop: '12px' }}>
<button
className="btn btn-secondary"
onClick={handleExportResults}
disabled={validationResults.length === 0}
>
</button>
{isAdmin && (
<button
className="btn btn-primary"
onClick={handleExecuteDeletion}
disabled={!orderNumbers.trim() || !materialCodes.trim() || isRunning}
>
{isRunning ? '清理中...' : '执行删除'}
</button>
)}
</div>
</div>
{/* Manager Filter (Admin only) */}
{isAdmin && managers.length > 0 && (
<div className="manager-filter-section">
<h3></h3>
<div className="manager-checkboxes">
{managers.map(manager => (
<label key={manager}>
<input
type="checkbox"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers(prev => {
const newSet = new Set(prev)
if (e.target.checked) {
newSet.add(manager)
} else {
newSet.delete(manager)
}
return newSet
})
}}
/>
{manager}
</label>
))}
</div>
</div>
)}
{/* Validation Results Table */}
{filteredResults.length > 0 && (
<div className="validation-results-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ margin: 0 }}></h2>
{/* User mode buttons: Hide Checked / Show All */}
{!isAdmin && (
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-secondary"
onClick={handleHideChecked}
disabled={selectedItems.size === 0}
>
</button>
<button
className="btn btn-secondary"
onClick={handleShowAll}
disabled={hiddenItems.size === 0}
>
</button>
</div>
)}
</div>
<table className="validation-results-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredResults.map(result => (
<tr key={result.materialCode}>
<td>
<input
type="checkbox"
checked={selectedItems.has(result.materialCode)}
onChange={() => handleCheckboxToggle(result.materialCode)}
/>
</td>
<td>{result.materialName}</td>
<td>{result.materialCode}</td>
<td>{result.specification}</td>
<td>{result.model}</td>
<td>{result.managerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Simple Input Section */}
<div className="input-section">
<h2></h2>
<div className="input-row">
<div className="input-group">
<label></label>
<textarea
value={orderNumbers}
onChange={(e) => setOrderNumbers(e.target.value)}
placeholder="请输入订单号,每行一个"
rows={5}
/>
</div>
<div className="input-group">
<label></label>
<textarea
value={materialCodes}
onChange={(e) => setMaterialCodes(e.target.value)}
placeholder="请输入物料代码,每行一个"
rows={5}
/>
</div>
</div>
<div className="dry-run-option">
<label>
@@ -150,7 +751,13 @@ export const CleanerPage: React.FC = () => {
{error && (
<div className="error-section">
<h3></h3>
<p>{error}</p>
<div className="error-message" title="双击可选中复制" onDoubleClick={(e) => {
const text = e.currentTarget.textContent
navigator.clipboard.writeText(text || '')
}}>
{error}
</div>
<p className="error-hint">💡 </p>
</div>
)}
@@ -226,7 +833,7 @@ export const CleanerPage: React.FC = () => {
<style>{`
.cleaner-page {
padding: 24px;
max-width: 800px;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
@@ -241,16 +848,23 @@ export const CleanerPage: React.FC = () => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.input-section {
.validation-section, .manager-filter-section, .input-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #e8e8e8;
}
.dry-run-option {
.validation-section h2, .manager-filter-section h3, .input-section h2 {
font-size: 18px;
color: #333;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
}
.dry-run-option label {
.validation-mode-selector {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.validation-mode-selector label {
display: flex;
align-items: center;
gap: 8px;
@@ -258,17 +872,10 @@ export const CleanerPage: React.FC = () => {
font-size: 14px;
color: #666;
}
.dry-run-option input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.dry-run-option input:disabled {
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.btn {
padding: 10px 24px;
@@ -297,6 +904,113 @@ export const CleanerPage: React.FC = () => {
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.btn-warning {
background: #fa8c16;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: #ffc069;
}
.validation-stats {
display: flex;
gap: 24px;
padding: 12px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
font-size: 14px;
color: #52c41a;
}
.validation-stats span {
font-weight: 500;
}
.manager-filter-section {
background: #fafafa;
padding: 16px;
border-radius: 6px;
}
.manager-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.manager-checkboxes label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: #666;
background: #fff;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.validation-results-section {
margin-bottom: 24px;
}
.validation-results-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.validation-results-table th, .validation-results-table td {
padding: 12px;
border: 1px solid #e8e8e8;
text-align: left;
color: #000;
}
.validation-results-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.validation-results-table tr:nth-child(even) {
background: #fafafa;
}
.validation-results-table tr:hover {
background: #e6f7ff;
}
.input-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 16px;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.input-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
}
.input-group textarea:focus {
outline: none;
border-color: #52c41a;
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2);
}
.dry-run-option {
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
}
.dry-run-option label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #666;
}
.progress-section {
margin-top: 24px;
padding: 16px;
@@ -332,9 +1046,27 @@ export const CleanerPage: React.FC = () => {
margin: 0 0 8px 0;
font-size: 16px;
}
.error-section p {
color: #666;
margin: 0;
.error-message {
background: #fff;
padding: 12px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: #333;
border: 1px solid #ffccc7;
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-all;
}
.error-message:hover {
background: #fffbfc;
}
.error-hint {
color: #999;
font-size: 12px;
margin: 8px 0 0 0;
}
.result-section {
margin-top: 24px;
@@ -434,6 +1166,11 @@ export const CleanerPage: React.FC = () => {
font-size: 13px;
color: #fa8c16;
}
@media (max-width: 768px) {
.input-row {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
)

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { OrderNumberInput } from '../components/OrderNumberInput'
// Extractor result type (matches the type from main process)
@@ -18,12 +18,60 @@ interface ExtractorProgress {
* ExtractorPage - Main page for ERP data extraction
*/
export const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState('')
const [batchSize, setBatchSize] = useState(100)
const [orderNumbers, setOrderNumbers] = useState(() => {
// Restore from sessionStorage on mount
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 [progress, setProgress] = useState<ExtractorProgress | null>(null)
const [result, setResult] = useState<ExtractorResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('unsaved')
// Save to sessionStorage when orderNumbers changes
useEffect(() => {
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
// Update shared Production IDs when orderNumbers changes
if (orderNumbers.trim()) {
const orderNumberList = orderNumbers
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
window.electron.validation.setSharedProductionIds(orderNumberList)
}
}, [orderNumbers])
// Save to sessionStorage when batchSize changes
useEffect(() => {
sessionStorage.setItem('extractor_batchSize', batchSize.toString())
}, [batchSize])
const saveSharedProductionIds = async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
}
setSaveStatus('saving')
try {
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
await window.electron.validation.setSharedProductionIds(orderNumberList)
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
setSaveStatus('saved')
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败')
setSaveStatus('unsaved')
}
}
const handleExtract = async () => {
if (!orderNumbers.trim()) {
@@ -42,6 +90,10 @@ export const ExtractorPage: React.FC = () => {
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Store Production IDs for sharing with cleaner page (before extraction starts)
await window.electron.validation.setSharedProductionIds(orderNumberList)
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
// Call extractor API through electron
const response = await window.electron.extractor.runExtractor({
orderNumbers: orderNumberList,
@@ -104,6 +156,14 @@ export const ExtractorPage: React.FC = () => {
>
{isRunning ? '提取中...' : '开始提取'}
</button>
<button
className="btn btn-secondary"
onClick={saveSharedProductionIds}
disabled={isRunning || !orderNumbers.trim() || saveStatus === 'saved'}
title="保存订单号以便在清理页面使用"
>
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : '保存为共享 ID'}
</button>
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
</button>
@@ -254,6 +314,10 @@ export const ExtractorPage: React.FC = () => {
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.btn-secondary[data-saved="true"] {
background: #52c41a;
color: #fff;
}
.progress-section {
margin-top: 24px;
padding: 16px;