From 4c40457c71ae0fa22a4b7551a461dca50ac09ee4 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Mon, 13 Apr 2026 12:23:57 +0800 Subject: [PATCH] feat(cleaner): add operation history modal with database-backed records Co-Authored-By: Claude Opus 4.6 --- .../CleanerOperationHistoryModal.tsx | 840 ++++++++++++++++++ .../src/components/cleaner/CleanerToolbar.tsx | 9 + src/renderer/src/pages/CleanerPage.tsx | 17 + 3 files changed, 866 insertions(+) create mode 100644 src/renderer/src/components/CleanerOperationHistoryModal.tsx diff --git a/src/renderer/src/components/CleanerOperationHistoryModal.tsx b/src/renderer/src/components/CleanerOperationHistoryModal.tsx new file mode 100644 index 0000000..c422076 --- /dev/null +++ b/src/renderer/src/components/CleanerOperationHistoryModal.tsx @@ -0,0 +1,840 @@ +/** + * Cleaner Operation History Modal + * + * Displays cleaner operation history with batch statistics, execution records, + * order details, and material-level details. + * Admin users see all users' records, regular users see only their own. + */ + +import React, { useState, useEffect, useCallback } from 'react' +import { Modal } from './ui/Modal' +import { useLogger } from '../hooks/useLogger' +import { + RefreshCw, + Trash2, + ChevronDown, + ChevronRight, + CheckCircle, + XCircle, + Clock, + Copy, + FlaskConical +} from 'lucide-react' +import type { UserInfo } from './UserSelectionDialog' +import type { + CleanerHistoryBatchStats, + CleanerHistoryOrderRecord, + CleanerHistoryMaterialRecord +} from '../hooks/cleaner/types' + +// The preload API returns Date for time fields, but IPC serialization converts them to strings. +// Use a local type that accommodates both to satisfy TypeScript. +interface ExecutionRecord { + id?: number + batchId: string + attemptNumber: number + userId: number + username: string + operationTime: string | Date + endTime: string | Date | null + status: string + isDryRun: boolean + totalOrders: number + ordersProcessed: number + totalMaterialsDeleted: number + totalMaterialsSkipped: number + totalMaterialsFailed: number + totalUncertainDeletions: number + errorMessage: string | null + appVersion: string | null +} +import { showSuccess, showError, showWarning } from '../stores/useAppStore' + +interface CleanerOperationHistoryModalProps { + isOpen: boolean + onClose: () => void + user?: { username: string; userType: string } | null +} + +const statusStyles: Record = { + success: 'bg-green-100 text-green-700', + partial: 'bg-amber-100 text-amber-700', + failed: 'bg-red-100 text-red-700', + crashed: 'bg-red-100 text-red-700', + pending: 'bg-gray-100 text-gray-700' +} + +const statusLabels: Record = { + success: '成功', + partial: '部分成功', + failed: '失败', + crashed: '崩溃', + pending: '进行中' +} + +const statusIcons: Record = { + success: , + partial: , + failed: , + crashed: , + pending: +} + +const formatDateTime = (dateStr: string | Date | null | undefined): string => { + if (!dateStr) return '-' + const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr + + if (isNaN(date.getTime())) { + return String(dateStr) + } + + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + const hours = String(date.getUTCHours()).padStart(2, '0') + const minutes = String(date.getUTCMinutes()).padStart(2, '0') + const seconds = String(date.getUTCSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +const formatDuration = ( + startTime: string | Date | null, + endTime: string | Date | null +) => { + if (!startTime || !endTime) return '-' + const start = typeof startTime === 'string' ? new Date(startTime) : startTime + const end = typeof endTime === 'string' ? new Date(endTime) : endTime + if (isNaN(start.getTime()) || isNaN(end.getTime())) return '-' + + const diffMs = end.getTime() - start.getTime() + const seconds = Math.floor(diffMs / 1000) + if (seconds < 60) return `${seconds}秒` + + const minutes = Math.floor(seconds / 60) + const remainSeconds = seconds % 60 + if (minutes < 60) return `${minutes}分${remainSeconds}秒` + + const hours = Math.floor(minutes / 60) + const remainMinutes = minutes % 60 + return `${hours}时${remainMinutes}分` +} + +export const CleanerOperationHistoryModal: React.FC = ({ + isOpen, + onClose, + user +}) => { + const [batches, setBatches] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedBatches, setExpandedBatches] = useState>(new Set()) + const [expandedOrders, setExpandedOrders] = useState>(new Set()) + const [batchExecutions, setBatchExecutions] = useState>( + new Map() + ) + const [batchOrders, setBatchOrders] = useState>( + new Map() + ) + const [orderMaterials, setOrderMaterials] = useState< + Map + >(new Map()) + const [selectedAttempt, setSelectedAttempt] = useState>(new Map()) + const [deleting, setDeleting] = useState>(new Set()) + const [allUsers, setAllUsers] = useState([]) + const [selectedUsers, setSelectedUsers] = useState([]) + const [loadingMaterials, setLoadingMaterials] = useState>(new Set()) + const logger = useLogger('CleanerOperationHistory') + + const isAdmin = user?.userType === 'Admin' + + const fetchBatches = useCallback(async () => { + setLoading(true) + setError(null) + try { + const options = + isAdmin && selectedUsers.length > 0 + ? { limit: 100, usernames: selectedUsers } + : { limit: 100 } + + const result = await window.electron.cleaner.getHistoryBatches(options) + if (result.success && result.data) { + setBatches(result.data) + } else { + setError(result.error || '获取历史记录失败') + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取历史记录失败') + } finally { + setLoading(false) + } + }, [isAdmin, selectedUsers]) + + const fetchAllUsers = useCallback(async () => { + try { + const result = await window.electron.auth.getAllUsers() + if (result.success && result.data) { + const usernames = result.data.map((u: UserInfo) => u.username) + setAllUsers(usernames) + } + } catch (err) { + logger.error('Failed to fetch users list', { + error: err instanceof Error ? err.message : String(err) + }) + } + }, [logger]) + + const fetchBatchDetails = useCallback( + async (batchId: string) => { + if (batchExecutions.has(batchId) && batchOrders.has(batchId)) { + return + } + + try { + const result = await window.electron.cleaner.getHistoryBatchDetails(batchId) + if (result.success && result.data) { + setBatchExecutions((prev) => new Map(prev).set(batchId, result.data!.executions)) + setBatchOrders((prev) => new Map(prev).set(batchId, result.data!.orders)) + + // Set default selected attempt to the latest (max attempt number) + const executions = result.data.executions + if (executions.length > 0) { + const maxAttempt = Math.max(...executions.map((e) => e.attemptNumber)) + setSelectedAttempt((prev) => new Map(prev).set(batchId, maxAttempt)) + } + } + } catch (err) { + logger.error('Failed to fetch batch details', { + error: err instanceof Error ? err.message : String(err), + batchId + }) + } + }, + [batchExecutions, batchOrders, logger] + ) + + const fetchMaterialDetails = useCallback( + async (batchId: string, attemptNumber: number, orderNumber: string) => { + const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}` + if (orderMaterials.has(cacheKey)) return + + setLoadingMaterials((prev) => new Set(prev).add(cacheKey)) + try { + const result = await window.electron.cleaner.getHistoryMaterialDetails( + batchId, + attemptNumber, + orderNumber + ) + if (result.success && result.data) { + setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!)) + } + } catch (err) { + logger.error('Failed to fetch material details', { + error: err instanceof Error ? err.message : String(err), + batchId, + attemptNumber, + orderNumber + }) + } finally { + setLoadingMaterials((prev) => { + const newSet = new Set(prev) + newSet.delete(cacheKey) + return newSet + }) + } + }, + [orderMaterials, logger] + ) + + useEffect(() => { + if (isOpen) { + void fetchBatches() + if (isAdmin) { + void fetchAllUsers() + } + } + }, [isOpen, fetchBatches, fetchAllUsers, isAdmin]) + + const toggleBatchExpansion = (batchId: string) => { + setExpandedBatches((prev) => { + const newSet = new Set(prev) + if (newSet.has(batchId)) { + newSet.delete(batchId) + } else { + newSet.add(batchId) + void fetchBatchDetails(batchId) + } + return newSet + }) + } + + const toggleOrderExpansion = (batchId: string, attemptNumber: number, orderNumber: string) => { + const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}` + setExpandedOrders((prev) => { + const newSet = new Set(prev) + if (newSet.has(cacheKey)) { + newSet.delete(cacheKey) + } else { + newSet.add(cacheKey) + void fetchMaterialDetails(batchId, attemptNumber, orderNumber) + } + return newSet + }) + } + + const handleDeleteBatch = async (batchId: string) => { + if (deleting.has(batchId)) return + + const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。') + if (!confirmed) return + + setDeleting((prev) => new Set(prev).add(batchId)) + + try { + const result = await window.electron.cleaner.deleteHistoryBatch(batchId) + if (result.success) { + setBatches((prev) => prev.filter((b) => b.batchId !== batchId)) + setBatchExecutions((prev) => { + const newMap = new Map(prev) + newMap.delete(batchId) + return newMap + }) + setBatchOrders((prev) => { + const newMap = new Map(prev) + newMap.delete(batchId) + return newMap + }) + setExpandedBatches((prev) => { + const newSet = new Set(prev) + newSet.delete(batchId) + return newSet + }) + } else { + alert(result.error || '删除失败') + } + } catch (err) { + alert(err instanceof Error ? err.message : '删除失败') + } finally { + setDeleting((prev) => { + const newSet = new Set(prev) + newSet.delete(batchId) + return newSet + }) + } + } + + const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord, batchId: string) => { + const attempt = selectedAttempt.get(batchId) + const orders = batchOrders.get(batchId) || [] + const filteredOrders = + attempt !== undefined ? orders.filter((o) => o.attemptNumber === attempt) : orders + const values = filteredOrders + .map((o) => String(o[field] ?? '')) + .filter((v) => v && v !== '-') + .join('\n') + + if (!values) { + showWarning('没有可复制的数据') + return + } + + navigator.clipboard + .writeText(values) + .then(() => showSuccess(`已复制 ${values.split('\n').length} 条数据`)) + .catch(() => showError('复制失败,请手动复制')) + } + + const toggleUserFilter = (username: string) => { + setSelectedUsers((prev) => + prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username] + ) + } + + const clearUserFilters = () => { + setSelectedUsers([]) + } + + if (!isOpen) return null + + return ( + +
+ {/* Toolbar */} +
+
+ {isAdmin && allUsers.length > 0 && ( +
+
筛选用户:
+
+ {allUsers.map((username) => { + const isSelected = selectedUsers.includes(username) + return ( + + ) + })} + {selectedUsers.length > 0 && ( + + )} +
+
+ )} +
+ + {isAdmin ? ( + + 管理员模式: + {selectedUsers.length > 0 + ? `已选择 ${selectedUsers.length} 个用户` + : '显示所有用户记录'} + + ) : ( + 仅显示您的操作记录 + )} + + {batches.length > 0 && ( + 共 {batches.length} 条批次 + )} +
+
+ +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Batch list */} +
+ {loading && batches.length === 0 ? ( +
加载中...
+ ) : batches.length === 0 ? ( +
暂无操作记录
+ ) : ( +
+ {batches.map((batch) => { + const isExpanded = expandedBatches.has(batch.batchId) + const executions = batchExecutions.get(batch.batchId) || [] + const orders = batchOrders.get(batch.batchId) || [] + const isDeleting = deleting.has(batch.batchId) + const currentAttempt = selectedAttempt.get(batch.batchId) + const filteredOrders = + currentAttempt !== undefined + ? orders.filter((o) => o.attemptNumber === currentAttempt) + : orders + + return ( +
+ {/* Batch summary */} +
toggleBatchExpansion(batch.batchId)} + > +
+ + +
+
+
操作时间
+
+ {formatDateTime(batch.operationTime)} +
+
+
+
操作用户
+
{batch.username}
+
+
+
状态
+
+ {statusIcons[batch.status] || statusIcons.pending} + + {statusLabels[batch.status] || batch.status} + +
+
+
+
订单数
+
{batch.totalOrders}
+
+
+
已删除
+
+ {batch.totalMaterialsDeleted} +
+
+
+
失败
+
+ {batch.totalMaterialsFailed > 0 ? ( + {batch.totalMaterialsFailed} + ) : ( + '0' + )} +
+
+
+ {batch.isDryRun && ( + + + 试运行 + + )} + {batch.totalAttempts > 1 && ( + + {batch.totalAttempts}次尝试 + + )} +
+
+
+ + {isAdmin && ( + + )} +
+ + {/* Batch details */} + {isExpanded && ( +
+ {/* Execution records */} + {executions.length > 0 && ( +
+
+ 执行记录 +
+ {executions.length > 1 && ( +
+ {executions.map((exec) => ( + + ))} +
+ )} +
+ {executions + .filter( + (e) => + currentAttempt === undefined || + e.attemptNumber === currentAttempt + ) + .map((exec) => ( + + + 耗时:{formatDuration(exec.operationTime, exec.endTime)} + + + 订单:{exec.ordersProcessed}/{exec.totalOrders} + + 删除:{exec.totalMaterialsDeleted} + {exec.totalMaterialsFailed > 0 && ( + + 失败:{exec.totalMaterialsFailed} + + )} + {exec.totalUncertainDeletions > 0 && ( + + 不确定:{exec.totalUncertainDeletions} + + )} + {exec.errorMessage && ( + + 错误:{exec.errorMessage.substring(0, 80)} + {exec.errorMessage.length > 80 ? '...' : ''} + + )} + {exec.appVersion && ( + v{exec.appVersion} + )} + + ))} +
+
+ )} + + {/* Order table */} + {filteredOrders.length > 0 ? ( +
+ + + + + + + + + + + + + + {filteredOrders.map((order) => { + const orderKey = `${batch.batchId}:${currentAttempt ?? order.attemptNumber}:${order.orderNumber}` + const isOrderExpanded = expandedOrders.has(orderKey) + const materials = orderMaterials.get(orderKey) || [] + const isLoadingMaterials = + loadingMaterials.has(orderKey) + + return ( + + + toggleOrderExpansion( + batch.batchId, + currentAttempt ?? order.attemptNumber, + order.orderNumber + ) + } + > + + + + + + + + + + + {/* Material details */} + {isOrderExpanded && ( + + + + )} + + )})} + +
+ +
+ 订单号 + +
+
+ 状态 + + 已删除 + + 已跳过 + + 失败 + + 不确定 + + 错误信息 +
+ {isOrderExpanded ? ( + + ) : ( + + )} + + {order.orderNumber} + + + {statusIcons[order.status]} + {statusLabels[order.status] || order.status} + + + {order.materialsDeleted} + + {order.materialsSkipped} + + {order.materialsFailed || '-'} + + {order.uncertainDeletions || '-'} + + {order.errorMessage || '-'} +
+ {isLoadingMaterials ? ( +
+ 加载物料详情... +
+ ) : materials.length > 0 ? ( + + + + + + + + + + + + + {materials.map((mat, idx) => ( + + + + + + + + + ))} + +
+ 物料编码 + + 物料名称 + + 行号 + + 结果 + + 原因 + + 尝试次数 +
+ {mat.materialCode} + + {mat.materialName} + + {mat.rowNumber} + + + {mat.result === 'deleted' + ? '已删除' + : mat.result === 'skipped' + ? '已跳过' + : mat.result === 'failed' + ? '失败' + : mat.result === 'uncertain' + ? '不确定' + : mat.result} + + + {mat.reason || '-'} + + {mat.attemptCount > 1 ? ( + + {mat.attemptCount} + + ) : ( + '1' + )} +
+ ) : ( +
+ 暂无物料详情 +
+ )} +
+
+ ) : ( +
+ 暂无订单记录 +
+ )} +
+ )} +
+ ) + })} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + +export default CleanerOperationHistoryModal diff --git a/src/renderer/src/components/cleaner/CleanerToolbar.tsx b/src/renderer/src/components/cleaner/CleanerToolbar.tsx index 93292f0..8d8d9ab 100644 --- a/src/renderer/src/components/cleaner/CleanerToolbar.tsx +++ b/src/renderer/src/components/cleaner/CleanerToolbar.tsx @@ -6,6 +6,7 @@ import { FileSpreadsheet, FileText, HardDrive, + History, Search, Settings2, Square @@ -27,6 +28,7 @@ interface CleanerToolbarProps { handleConfirmDeletion: () => Promise handleExportResults: () => Promise setIsReportViewerOpen: (open: boolean) => void + setShowHistoryModal: (open: boolean) => void typeManagementButtonRef: React.RefObject } @@ -44,6 +46,7 @@ export function CleanerToolbar({ handleConfirmDeletion, handleExportResults, setIsReportViewerOpen, + setShowHistoryModal, typeManagementButtonRef }: CleanerToolbarProps): React.JSX.Element { return ( @@ -123,6 +126,12 @@ export function CleanerToolbar({ > 类型管理 +