diff --git a/src/renderer/src/components/CleanerOperationHistoryModal.tsx b/src/renderer/src/components/CleanerOperationHistoryModal.tsx index 4a178ca..4327d29 100644 --- a/src/renderer/src/components/CleanerOperationHistoryModal.tsx +++ b/src/renderer/src/components/CleanerOperationHistoryModal.tsx @@ -6,7 +6,7 @@ * Admin users see all users' records, regular users see only their own. */ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import { Modal } from './ui/Modal' import { useLogger } from '../hooks/useLogger' import { @@ -56,6 +56,12 @@ interface CleanerOperationHistoryModalProps { user?: { username: string; userType: string } | null } +interface BatchItemProps { + batch: CleanerHistoryBatchStats + isAdmin: boolean + onDelete: (batchId: string) => void +} + const statusStyles: Record = { success: 'bg-green-100 text-green-700', partial: 'bg-amber-100 text-amber-700', @@ -118,6 +124,541 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date return `${hours}时${remainMinutes}分` } +// ====== BatchItem Component ====== +// Extracted from the modal so that expanding one batch doesn't re-render siblings. +// Each BatchItem manages its own details, orders, and material state locally. +const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { + const [isExpanded, setIsExpanded] = useState(false) + const [executions, setExecutions] = useState([]) + const [orders, setOrders] = useState([]) + const [currentAttempt, setCurrentAttempt] = useState() + const [expandedOrders, setExpandedOrders] = useState>(() => new Set()) + const [orderMaterials, setOrderMaterials] = useState>( + () => new Map() + ) + const [loadingMaterials, setLoadingMaterials] = useState>(() => new Set()) + const [isDeleting, setIsDeleting] = useState(false) + + const detailsLoadedRef = useRef(false) + const loadedMaterialsRef = useRef>(new Set()) + const logger = useLogger('BatchItem') + + // Fetch batch details when first expanded + useEffect(() => { + if (!isExpanded || detailsLoadedRef.current) return + detailsLoadedRef.current = true + + const fetchDetails = async () => { + try { + const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId) + if (result.success && result.data) { + setExecutions(result.data.executions) + setOrders(result.data.orders) + + const execs = result.data.executions + if (execs.length > 0) { + setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber))) + } + } + } catch (err) { + logger.error('Failed to fetch batch details', { + error: err instanceof Error ? err.message : String(err), + batchId: batch.batchId + }) + } + } + + void fetchDetails() + }, [isExpanded, batch.batchId, logger]) + + const fetchMaterials = useCallback( + async (attemptNumber: number, orderNumber: string) => { + const cacheKey = `${attemptNumber}:${orderNumber}` + if (loadedMaterialsRef.current.has(cacheKey)) return + loadedMaterialsRef.current.add(cacheKey) + + setLoadingMaterials((prev) => new Set(prev).add(cacheKey)) + try { + const result = await window.electron.cleaner.getHistoryMaterialDetails( + batch.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: batch.batchId, + attemptNumber, + orderNumber + }) + } finally { + setLoadingMaterials((prev) => { + const newSet = new Set(prev) + newSet.delete(cacheKey) + return newSet + }) + } + }, + [batch.batchId, logger] + ) + + const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => { + const cacheKey = `${attemptNumber}:${orderNumber}` + const isCurrentlyExpanded = expandedOrders.has(cacheKey) + setExpandedOrders((prev) => { + const newSet = new Set(prev) + if (newSet.has(cacheKey)) { + newSet.delete(cacheKey) + } else { + newSet.add(cacheKey) + } + return newSet + }) + if (!isCurrentlyExpanded) { + void fetchMaterials(attemptNumber, orderNumber) + } + } + + const handleDelete = async () => { + if (isDeleting) return + const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。') + if (!confirmed) return + + setIsDeleting(true) + try { + const result = await window.electron.cleaner.deleteHistoryBatch(batch.batchId) + if (result.success) { + onDelete(batch.batchId) + } else { + alert(result.error || '删除失败') + } + } catch (err) { + alert(err instanceof Error ? err.message : '删除失败') + } finally { + setIsDeleting(false) + } + } + + const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => { + const filtered = + currentAttempt !== undefined + ? orders.filter((o) => o.attemptNumber === currentAttempt) + : orders + const values = filtered + .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 filteredOrders = + currentAttempt !== undefined + ? orders.filter((o) => o.attemptNumber === currentAttempt) + : orders + + return ( +
+ {/* Batch summary */} +
setIsExpanded((prev) => !prev)} + > +
+ + +
+
+
操作时间
+
+ {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 = `${currentAttempt ?? order.attemptNumber}:${order.orderNumber}` + const isOrderExpanded = expandedOrders.has(orderKey) + const materials = orderMaterials.get(orderKey) || [] + const isLoadingMaterials = loadingMaterials.has(orderKey) + + return ( + + + toggleOrderExpansion( + currentAttempt ?? order.attemptNumber, + order.orderNumber + ) + } + > + + + + + + + + + + + + {/* Material details */} + {isOrderExpanded && ( + + + + )} + + ) + })} + +
+ +
+ 订单号 + +
+
+ 状态 + + 重试 + + 已删除 + + 已跳过 + + 失败 + + 不确定 + + 错误信息 +
+ {isOrderExpanded ? ( + + ) : ( + + )} + + {order.orderNumber} + + + {statusIcons[order.status]} + {statusLabels[order.status] || order.status} + + + {order.retryCount > 0 ? ( +
+ + + 重试{order.retryCount}次 + + {order.retrySuccess && ( + + + 成功 + + )} + {!order.retrySuccess && ( + + + 失败 + + )} +
+ ) : ( + - + )} +
+ {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' + )} +
+ ) : ( +
+ 暂无物料详情 +
+ )} +
+
+ ) : ( +
+ 暂无订单记录 +
+ )} +
+ )} +
+ ) +}) + +BatchItem.displayName = 'BatchItem' + +// ====== Main Modal Component ====== export const CleanerOperationHistoryModal: React.FC = ({ isOpen, onClose, @@ -126,20 +667,8 @@ export const CleanerOperationHistoryModal: React.FC([]) 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>( - 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' @@ -180,68 +709,6 @@ export const CleanerOperationHistoryModal: React.FC { - 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() @@ -251,94 +718,9 @@ export const CleanerOperationHistoryModal: React.FC { - 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 handleDeleteBatch = useCallback((batchId: string) => { + setBatches((prev) => prev.filter((b) => b.batchId !== batchId)) + }, []) const toggleUserFilter = (username: string) => { setSelectedUsers((prev) => @@ -432,414 +814,14 @@ export const CleanerOperationHistoryModal: React.FC暂无操作记录 ) : (
- {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.retryCount > 0 ? ( -
- - - 重试{order.retryCount}次 - - {order.retrySuccess && ( - - - 成功 - - )} - {!order.retrySuccess && ( - - - 失败 - - )} -
- ) : ( - - - )} -
- {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' - )} -
- ) : ( -
- 暂无物料详情 -
- )} -
-
- ) : ( -
- 暂无订单记录 -
- )} -
- )} -
- ) - })} + {batches.map((batch) => ( + + ))}
)}