refactor: split cleaner page layout

This commit is contained in:
Misaka
2026-03-21 19:15:35 +08:00
parent 2baf55bd2e
commit 1fc2eab816
5 changed files with 609 additions and 438 deletions

View File

@@ -0,0 +1,121 @@
import React from 'react'
import { Play, Settings2, ToggleLeft, ToggleRight } from 'lucide-react'
interface CleanerExecutionBarProps {
filteredCount: number
selectedCount: number
dryRun: boolean
setDryRun: (value: boolean) => void
headless: boolean
setHeadless: (value: boolean) => void
processConcurrency: number
updateProcessConcurrency: (value: number) => void
showSettingsMenu: boolean
setShowSettingsMenu: (open: boolean) => void
handleExecuteDeletion: () => Promise<void>
isRunning: boolean
executeButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function CleanerExecutionBar({
filteredCount,
selectedCount,
dryRun,
setDryRun,
headless,
setHeadless,
processConcurrency,
updateProcessConcurrency,
showSettingsMenu,
setShowSettingsMenu,
handleExecuteDeletion,
isRunning,
executeButtonRef
}: CleanerExecutionBarProps): React.JSX.Element {
return (
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm text-slate-600 font-medium">
{filteredCount} | {' '}
<span className="text-blue-600">{selectedCount}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative" data-settings-menu>
<button
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
className="text-sm text-slate-600 bg-slate-100 hover:bg-slate-200 px-5 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<Settings2 size={16} />
</button>
{showSettingsMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-white rounded-lg shadow-lg border border-slate-200 py-3 px-4 min-w-[280px] z-50">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800"> (Dry-Run)</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setDryRun(!dryRun)}
className={`transition-colors flex-shrink-0 ml-4 ${dryRun ? 'text-amber-500' : 'text-slate-300'}`}
>
{dryRun ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
<div className="border-t border-slate-100 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800"> (Headless)</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setHeadless(!headless)}
className={`transition-colors flex-shrink-0 ml-4 ${headless ? 'text-blue-500' : 'text-slate-300'}`}
>
{headless ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
</div>
<div className="border-t border-slate-100 pt-3 space-y-3">
<div>
<div className="text-sm font-medium text-slate-800"></div>
<div className="text-xs text-slate-500 mt-0.5">
1-20
</div>
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={1}
max={20}
value={processConcurrency}
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<span className="text-sm font-medium text-slate-700 w-8 text-center">
{processConcurrency}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<button
onClick={() => void handleExecuteDeletion()}
ref={executeButtonRef}
disabled={isRunning}
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
>
<Play size={18} fill="currentColor" /> {dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
import React from 'react'
import { CheckSquare, Square } from 'lucide-react'
import type { ValidationResult } from '../../hooks/cleaner/types'
interface CleanerResultsTableProps {
validationResults: ValidationResult[]
filteredResults: ValidationResult[]
selectedItems: Set<string>
isAdmin: boolean
managers: string[]
editingCell: { rowIndex: number; field: string } | null
editValue: string
setEditValue: (value: string) => void
inputRef: React.RefObject<HTMLInputElement | HTMLSelectElement | null>
handleCheckboxToggle: (materialCode: string) => void
handleAssignManagerOnSelect: (materialCode: string) => void
startEdit: (rowIndex: number, field: string) => void
saveEdit: () => void
cancelEdit: () => void
}
export function CleanerResultsTable({
validationResults,
filteredResults,
selectedItems,
isAdmin,
managers,
editingCell,
editValue,
setEditValue,
inputRef,
handleCheckboxToggle,
handleAssignManagerOnSelect,
startEdit,
saveEdit,
cancelEdit
}: CleanerResultsTableProps): React.JSX.Element {
const handleSelectionChange = (materialCode: string) => {
handleCheckboxToggle(materialCode)
if (!isAdmin) {
handleAssignManagerOnSelect(materialCode)
}
}
return (
<div className="flex-1 overflow-y-auto">
<table className="w-full text-left text-sm whitespace-nowrap table-fixed">
<thead className="bg-slate-100 text-slate-600 sticky top-0 shadow-sm z-10 text-xs font-semibold">
<tr>
<th className="px-4 py-3 w-16 text-center"></th>
<th className="px-4 py-3 w-64"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 w-40">
<span className="text-[10px] font-normal text-slate-400">()</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredResults.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-400 text-sm">
{validationResults.length === 0
? '暂无数据,请点击左侧按钮获取并校验物料'
: '当前筛选条件下暂无数据'}
</td>
</tr>
) : (
filteredResults.map((result, rowIndex) => {
const isChecked = selectedItems.has(result.materialCode)
const noManager = !result.managerName?.trim()
const isEditingManager =
editingCell?.rowIndex === rowIndex && editingCell?.field === 'managerName'
const rowClassName = `${isChecked ? 'bg-blue-50/30' : 'hover:bg-slate-50'} transition-colors ${noManager && isChecked ? 'bg-amber-50/20' : ''}`
const managerCellClassName = noManager
? 'text-amber-500 text-xs italic'
: 'text-slate-700'
return (
<tr key={result.materialCode} className={rowClassName}>
<td className="px-4 py-3 text-center truncate">
{isChecked ? (
<CheckSquare
onClick={() => handleSelectionChange(result.materialCode)}
size={16}
className="text-blue-600 inline cursor-pointer"
/>
) : (
<Square
onClick={() => handleSelectionChange(result.materialCode)}
size={16}
className="text-slate-300 inline cursor-pointer"
/>
)}
</td>
<td
className="px-4 py-3 font-medium text-slate-800 truncate"
title={result.materialName}
>
{result.materialName}
</td>
<td
className="px-4 py-3 font-mono text-xs text-slate-600 truncate"
title={result.materialCode}
>
{result.materialCode}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.specification}
>
{result.specification || '-'}
</td>
<td className="px-4 py-3 text-slate-500 text-xs truncate" title={result.model}>
{result.model || '-'}
</td>
<td
className={`px-4 py-3 truncate ${managerCellClassName}`}
title={result.managerName || '空 (待分配)'}
>
{isAdmin && isEditingManager ? (
<select
ref={inputRef as React.Ref<HTMLSelectElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
>
<option value=""></option>
{managers.map((manager) => (
<option key={manager} value={manager}>
{manager}
</option>
))}
</select>
) : isAdmin ? (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={(e) => {
e.stopPropagation()
startEdit(rowIndex, 'managerName')
}}
>
{result.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
) : (
result.managerName || '空 (待分配)'
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import React from 'react'
import { DatabaseZap, Layers, Users } from 'lucide-react'
interface CleanerSidebarProps {
valMode: 'full' | 'filtered'
setValMode: (mode: 'full' | 'filtered') => void
managers: string[]
selectedManagers: Set<string>
setSelectedManagers: React.Dispatch<React.SetStateAction<Set<string>>>
}
export function CleanerSidebar({
valMode,
setValMode,
managers,
selectedManagers,
setSelectedManagers
}: CleanerSidebarProps): React.JSX.Element {
return (
<div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800">
<DatabaseZap size={18} className="text-blue-500" />
</h3>
<div className="space-y-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'full'}
onChange={() => setValMode('full')}
/>
<div>
<div className="text-sm font-medium text-slate-800"> - </div>
<div className="text-xs text-slate-500 mt-0.5"></div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'filtered'}
onChange={() => setValMode('filtered')}
/>
<div className="w-full">
<div className="text-sm font-medium text-slate-800"> - ProductionID </div>
<div className="text-xs text-slate-500 mt-0.5"></div>
{valMode === 'filtered' && (
<div className="mt-3 text-xs text-blue-700 bg-blue-100/50 rounded p-2 flex items-start gap-1.5">
<Layers size={14} className="mt-0.5 flex-shrink-0" />
<span>
使<strong></strong>
</span>
</div>
)}
</div>
</label>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800">
<Users size={18} className="text-indigo-500" />
()
</h3>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-3 max-h-[120px] overflow-y-auto pr-1">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers((prev) => {
const next = new Set(prev)
if (e.target.checked) next.add(manager)
else next.delete(manager)
return next
})
}}
/>
{manager || '未分配'}
</label>
))}
{managers.length === 0 && <div className="text-sm text-slate-400"></div>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import {
CheckSquare,
Eye,
EyeOff,
FileSpreadsheet,
FileText,
HardDrive,
Search,
Settings2,
Square
} from 'lucide-react'
import type { ValidationResult } from '../../hooks/cleaner/types'
interface CleanerToolbarProps {
validationResults: ValidationResult[]
filteredResults: ValidationResult[]
selectedItems: Set<string>
setSelectedItems: React.Dispatch<React.SetStateAction<Set<string>>>
setHiddenItems: React.Dispatch<React.SetStateAction<Set<string>>>
isValidationRunning: boolean
isRunning: boolean
isExporting: boolean
isTypeDialogOpen: boolean
setIsTypeDialogOpen: (open: boolean) => void
handleValidation: () => Promise<void>
handleConfirmDeletion: () => Promise<void>
handleExportResults: () => Promise<void>
setIsReportViewerOpen: (open: boolean) => void
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function CleanerToolbar({
validationResults,
filteredResults,
selectedItems,
setSelectedItems,
setHiddenItems,
isValidationRunning,
isRunning,
isExporting,
setIsTypeDialogOpen,
handleValidation,
handleConfirmDeletion,
handleExportResults,
setIsReportViewerOpen,
typeManagementButtonRef
}: CleanerToolbarProps): React.JSX.Element {
return (
<>
<div className="p-4 border-b border-slate-200 flex-shrink-0">
<button
onClick={() => void handleValidation()}
disabled={isValidationRunning}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50"
>
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
</button>
</div>
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center flex-shrink-0">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<CheckSquare size={14} className="text-blue-600" />
</button>
<button
onClick={() => {
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => {
const next = new Set(prev)
for (const code of visibleCodes) {
next.delete(code)
}
return next
})
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400" />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => {
const checkedCodes = filteredResults
.filter((r) => selectedItems.has(r.materialCode))
.map((r) => r.materialCode)
if (checkedCodes.length) {
setHiddenItems((prev) => new Set([...prev, ...checkedCodes]))
}
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<EyeOff size={14} />
</button>
<button
onClick={() => setHiddenItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Eye size={14} />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => void handleConfirmDeletion()}
disabled={isRunning || validationResults.length === 0}
className="text-xs bg-indigo-50 border border-indigo-200 text-indigo-700 px-3 py-1.5 rounded shadow-sm hover:bg-indigo-100 flex items-center gap-1.5 font-medium transition-colors disabled:opacity-50"
>
<HardDrive size={14} /> ()
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsTypeDialogOpen(true)}
ref={typeManagementButtonRef}
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
>
<Settings2 size={14} />
</button>
<button
onClick={() => void handleExportResults()}
disabled={isExporting || filteredResults.length === 0}
className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium disabled:opacity-50"
>
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button>
<button
onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
>
<FileText size={14} />
</button>
</div>
</div>
</>
)
}

View File

@@ -1,21 +1,8 @@
import React from 'react'
import {
Play,
ToggleLeft,
ToggleRight,
DatabaseZap,
Layers,
Users,
Search,
CheckSquare,
Square,
EyeOff,
Eye,
HardDrive,
Settings2,
FileSpreadsheet,
FileText
} from 'lucide-react'
import { CleanerExecutionBar } from '../components/cleaner/CleanerExecutionBar'
import { CleanerResultsTable } from '../components/cleaner/CleanerResultsTable'
import { CleanerSidebar } from '../components/cleaner/CleanerSidebar'
import { CleanerToolbar } from '../components/cleaner/CleanerToolbar'
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
import ExecutionReportDialog from '../components/ExecutionReportDialog'
import ReportViewerDialog from '../components/ReportViewerDialog'
@@ -79,433 +66,67 @@ const CleanerPage: React.FC = () => {
return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
{isAdmin && (
<div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto">
{/* 1. 数据来源选择 */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800">
<DatabaseZap size={18} className="text-blue-500" />
</h3>
<div className="space-y-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'full'}
onChange={() => setValMode('full')}
<CleanerSidebar
valMode={valMode}
setValMode={setValMode}
managers={managers}
selectedManagers={selectedManagers}
setSelectedManagers={setSelectedManagers}
/>
<div>
<div className="text-sm font-medium text-slate-800"> - </div>
<div className="text-xs text-slate-500 mt-0.5"></div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'filtered'}
onChange={() => setValMode('filtered')}
/>
<div className="w-full">
<div className="text-sm font-medium text-slate-800">
- ProductionID
</div>
<div className="text-xs text-slate-500 mt-0.5"></div>
{valMode === 'filtered' && (
<div className="mt-3 text-xs text-blue-700 bg-blue-100/50 rounded p-2 flex items-start gap-1.5">
<Layers size={14} className="mt-0.5 flex-shrink-0" />
<span>
使<strong></strong>
</span>
</div>
)}
</div>
</label>
</div>
</div>
{/* 2. 负责人筛选 */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800">
<Users size={18} className="text-indigo-500" />
()
</h3>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-3 max-h-[120px] overflow-y-auto pr-1">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
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>
))}
{managers.length === 0 && (
<div className="text-sm text-slate-400"></div>
)}
</div>
</div>
</div>
)}
{/* 右栏:结果表格与工具栏 */}
<div className="flex-1 bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
{/* 获取物料状态按钮 */}
<div className="p-4 border-b border-slate-200 flex-shrink-0">
<button
onClick={handleValidation}
disabled={isValidationRunning}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50"
>
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
</button>
</div>
{/* 顶部操作条 */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center flex-shrink-0">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<CheckSquare size={14} className="text-blue-600" />
</button>
<button
onClick={() => {
// Only uncheck items that are visible in filteredResults
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => {
const newSet = new Set(prev)
for (const code of visibleCodes) {
newSet.delete(code)
}
return newSet
})
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400" />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => {
const checkedCodes = filteredResults
.filter((r) => selectedItems.has(r.materialCode))
.map((r) => r.materialCode)
if (checkedCodes.length)
setHiddenItems((prev) => new Set([...prev, ...checkedCodes]))
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<EyeOff size={14} />
</button>
<button
onClick={() => setHiddenItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Eye size={14} />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={handleConfirmDeletion}
disabled={isRunning || validationResults.length === 0}
className="text-xs bg-indigo-50 border border-indigo-200 text-indigo-700 px-3 py-1.5 rounded shadow-sm hover:bg-indigo-100 flex items-center gap-1.5 font-medium transition-colors disabled:opacity-50"
>
<HardDrive size={14} /> ()
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsTypeDialogOpen(true)}
ref={typeManagementButtonRef}
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
>
<Settings2 size={14} />
</button>
<button
onClick={handleExportResults}
disabled={isExporting || filteredResults.length === 0}
className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium disabled:opacity-50"
>
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button>
<button
onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
>
<FileText size={14} />
</button>
</div>
</div>
{/* 表格区域 */}
<div className="flex-1 overflow-y-auto">
<table className="w-full text-left text-sm whitespace-nowrap table-fixed">
<thead className="bg-slate-100 text-slate-600 sticky top-0 shadow-sm z-10 text-xs font-semibold">
<tr>
<th className="px-4 py-3 w-16 text-center"></th>
<th className="px-4 py-3 w-64"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 w-40">
<span className="text-[10px] font-normal text-slate-400">()</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredResults.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-400 text-sm">
{validationResults.length === 0
? '暂无数据,请点击左侧按钮获取并校验物料'
: '当前筛选条件下暂无数据'}
</td>
</tr>
) : (
filteredResults.map((result, rowIndex) => {
const isChecked = selectedItems.has(result.materialCode)
const trClass = isChecked ? 'bg-blue-50/30' : 'hover:bg-slate-50'
const noManager = !result.managerName?.trim()
const managerCellClass = noManager
? 'text-amber-500 text-xs italic'
: 'text-slate-700'
const isEditingManager =
editingCell?.rowIndex === rowIndex && editingCell?.field === 'managerName'
return (
<tr
key={result.materialCode}
className={`${trClass} transition-colors ${noManager && isChecked ? 'bg-amber-50/20' : ''}`}
>
<td className="px-4 py-3 text-center truncate">
{isChecked ? (
<CheckSquare
onClick={() => {
handleCheckboxToggle(result.materialCode)
if (!isAdmin) {
handleAssignManagerOnSelect(result.materialCode)
}
}}
size={16}
className="text-blue-600 inline cursor-pointer"
<CleanerToolbar
validationResults={validationResults}
filteredResults={filteredResults}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
setHiddenItems={setHiddenItems}
isValidationRunning={isValidationRunning}
isRunning={isRunning}
isExporting={isExporting}
isTypeDialogOpen={isTypeDialogOpen}
setIsTypeDialogOpen={setIsTypeDialogOpen}
handleValidation={handleValidation}
handleConfirmDeletion={handleConfirmDeletion}
handleExportResults={handleExportResults}
setIsReportViewerOpen={setIsReportViewerOpen}
typeManagementButtonRef={typeManagementButtonRef}
/>
) : (
<Square
onClick={() => {
handleCheckboxToggle(result.materialCode)
if (!isAdmin) {
handleAssignManagerOnSelect(result.materialCode)
}
}}
size={16}
className="text-slate-300 inline cursor-pointer"
/>
)}
</td>
<td
className="px-4 py-3 font-medium text-slate-800 truncate"
title={result.materialName}
>
{result.materialName}
</td>
<td
className="px-4 py-3 font-mono text-xs text-slate-600 truncate"
title={result.materialCode}
>
{result.materialCode}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.specification}
>
{result.specification || '-'}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.model}
>
{result.model || '-'}
</td>
<td
className={`px-4 py-3 truncate ${managerCellClass}`}
title={result.managerName || '空 (待分配)'}
>
{isAdmin && isEditingManager ? (
<select
ref={inputRef as React.Ref<HTMLSelectElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
>
<option value=""></option>
{managers.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : isAdmin ? (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={(e) => {
e.stopPropagation()
startEdit(rowIndex, 'managerName')
}}
>
{result.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
) : (
result.managerName || '空 (待分配)'
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* 底部状态与执行区 */}
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm text-slate-600 font-medium">
{filteredResults.length} | {' '}
<span className="text-blue-600">{selectedItems.size}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative" data-settings-menu>
<button
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
className="text-sm text-slate-600 bg-slate-100 hover:bg-slate-200 px-5 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<Settings2 size={16} />
</button>
{showSettingsMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-white rounded-lg shadow-lg border border-slate-200 py-3 px-4 min-w-[280px] z-50">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800"> (Dry-Run)</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setDryRun(!dryRun)}
className={`transition-colors flex-shrink-0 ml-4 ${dryRun ? 'text-amber-500' : 'text-slate-300'}`}
>
{dryRun ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
<div className="border-t border-slate-100 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800">
(Headless)
</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setHeadless(!headless)}
className={`transition-colors flex-shrink-0 ml-4 ${headless ? 'text-blue-500' : 'text-slate-300'}`}
>
{headless ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
</div>
<div className="border-t border-slate-100 pt-3 space-y-3">
<div>
<div className="text-sm font-medium text-slate-800"></div>
<div className="text-xs text-slate-500 mt-0.5">
1-20
</div>
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={1}
max={20}
value={processConcurrency}
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
<CleanerResultsTable
validationResults={validationResults}
filteredResults={filteredResults}
selectedItems={selectedItems}
isAdmin={isAdmin}
managers={managers}
editingCell={editingCell}
editValue={editValue}
setEditValue={setEditValue}
inputRef={inputRef}
handleCheckboxToggle={handleCheckboxToggle}
handleAssignManagerOnSelect={handleAssignManagerOnSelect}
startEdit={startEdit}
saveEdit={saveEdit}
cancelEdit={cancelEdit}
/>
<CleanerExecutionBar
filteredCount={filteredResults.length}
selectedCount={selectedItems.size}
dryRun={dryRun}
setDryRun={setDryRun}
headless={headless}
setHeadless={setHeadless}
processConcurrency={processConcurrency}
updateProcessConcurrency={updateProcessConcurrency}
showSettingsMenu={showSettingsMenu}
setShowSettingsMenu={setShowSettingsMenu}
handleExecuteDeletion={handleExecuteDeletion}
isRunning={isRunning}
executeButtonRef={executeButtonRef}
/>
<span className="text-sm font-medium text-slate-700 w-8 text-center">
{processConcurrency}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<button
onClick={handleExecuteDeletion}
ref={executeButtonRef}
disabled={isRunning}
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
>
<Play size={18} fill="currentColor" /> {dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
</button>
</div>
</div>
</div>
{/* Material Type Management Dialog */}