refactor(cleaner-history): extract BatchItem with React.memo and fix colSpan bug
- Fix colSpan mismatch: material detail row now correctly spans 9 columns - Use lazy state initialization for Set/Map useState to avoid re-creation - Remove data-duplicating refs (batchExecutionsRef, batchOrdersRef, orderMaterialsRef) and replace with lightweight tracking refs (detailsLoadedRef, loadedMaterialsRef) - Extract per-batch rendering into BatchItem with React.memo to prevent sibling re-renders when expanding/collapsing one batch - Reduce parent component state from 13 to 5 variables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
* Admin users see all users' records, regular users see only their own.
|
* 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 { Modal } from './ui/Modal'
|
||||||
import { useLogger } from '../hooks/useLogger'
|
import { useLogger } from '../hooks/useLogger'
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +56,12 @@ interface CleanerOperationHistoryModalProps {
|
|||||||
user?: { username: string; userType: string } | null
|
user?: { username: string; userType: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchItemProps {
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
isAdmin: boolean
|
||||||
|
onDelete: (batchId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
success: 'bg-green-100 text-green-700',
|
success: 'bg-green-100 text-green-700',
|
||||||
partial: 'bg-amber-100 text-amber-700',
|
partial: 'bg-amber-100 text-amber-700',
|
||||||
@@ -118,106 +124,63 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
|
|||||||
return `${hours}时${remainMinutes}分`
|
return `${hours}时${remainMinutes}分`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
|
// ====== BatchItem Component ======
|
||||||
isOpen,
|
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
|
||||||
onClose,
|
// Each BatchItem manages its own details, orders, and material state locally.
|
||||||
user
|
const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||||
}) => {
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
const [batches, setBatches] = useState<CleanerHistoryBatchStats[]>([])
|
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
|
||||||
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
|
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(() => new Set())
|
||||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set())
|
|
||||||
const [batchExecutions, setBatchExecutions] = useState<Map<string, ExecutionRecord[]>>(new Map())
|
|
||||||
const [batchOrders, setBatchOrders] = useState<Map<string, CleanerHistoryOrderRecord[]>>(
|
|
||||||
new Map()
|
|
||||||
)
|
|
||||||
const [orderMaterials, setOrderMaterials] = useState<Map<string, CleanerHistoryMaterialRecord[]>>(
|
const [orderMaterials, setOrderMaterials] = useState<Map<string, CleanerHistoryMaterialRecord[]>>(
|
||||||
new Map()
|
() => new Map()
|
||||||
)
|
)
|
||||||
const [selectedAttempt, setSelectedAttempt] = useState<Map<string, number>>(new Map())
|
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
|
||||||
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [allUsers, setAllUsers] = useState<string[]>([])
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
|
||||||
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(new Set())
|
|
||||||
const logger = useLogger('CleanerOperationHistory')
|
|
||||||
|
|
||||||
const isAdmin = user?.userType === 'Admin'
|
const detailsLoadedRef = useRef(false)
|
||||||
|
const loadedMaterialsRef = useRef<Set<string>>(new Set())
|
||||||
|
const logger = useLogger('BatchItem')
|
||||||
|
|
||||||
const fetchBatches = useCallback(async () => {
|
// Fetch batch details when first expanded
|
||||||
setLoading(true)
|
useEffect(() => {
|
||||||
setError(null)
|
if (!isExpanded || detailsLoadedRef.current) return
|
||||||
|
detailsLoadedRef.current = true
|
||||||
|
|
||||||
|
const fetchDetails = async () => {
|
||||||
try {
|
try {
|
||||||
const options =
|
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
|
||||||
isAdmin && selectedUsers.length > 0
|
|
||||||
? { limit: 100, usernames: selectedUsers }
|
|
||||||
: { limit: 100 }
|
|
||||||
|
|
||||||
const result = await window.electron.cleaner.getHistoryBatches(options)
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setBatches(result.data)
|
setExecutions(result.data.executions)
|
||||||
} else {
|
setOrders(result.data.orders)
|
||||||
setError(result.error || '获取历史记录失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : '获取历史记录失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [isAdmin, selectedUsers])
|
|
||||||
|
|
||||||
const fetchAllUsers = useCallback(async () => {
|
const execs = result.data.executions
|
||||||
try {
|
if (execs.length > 0) {
|
||||||
const result = await window.electron.auth.getAllUsers()
|
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
|
||||||
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) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch batch details', {
|
logger.error('Failed to fetch batch details', {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
batchId
|
batchId: batch.batchId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[batchExecutions, batchOrders, logger]
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetchMaterialDetails = useCallback(
|
void fetchDetails()
|
||||||
async (batchId: string, attemptNumber: number, orderNumber: string) => {
|
}, [isExpanded, batch.batchId, logger])
|
||||||
const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}`
|
|
||||||
if (orderMaterials.has(cacheKey)) return
|
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))
|
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.cleaner.getHistoryMaterialDetails(
|
const result = await window.electron.cleaner.getHistoryMaterialDetails(
|
||||||
batchId,
|
batch.batchId,
|
||||||
attemptNumber,
|
attemptNumber,
|
||||||
orderNumber
|
orderNumber
|
||||||
)
|
)
|
||||||
@@ -227,7 +190,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch material details', {
|
logger.error('Failed to fetch material details', {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
batchId,
|
batchId: batch.batchId,
|
||||||
attemptNumber,
|
attemptNumber,
|
||||||
orderNumber
|
orderNumber
|
||||||
})
|
})
|
||||||
@@ -239,92 +202,52 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[orderMaterials, logger]
|
[batch.batchId, logger]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
|
||||||
if (isOpen) {
|
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||||
void fetchBatches()
|
const isCurrentlyExpanded = expandedOrders.has(cacheKey)
|
||||||
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) => {
|
setExpandedOrders((prev) => {
|
||||||
const newSet = new Set(prev)
|
const newSet = new Set(prev)
|
||||||
if (newSet.has(cacheKey)) {
|
if (newSet.has(cacheKey)) {
|
||||||
newSet.delete(cacheKey)
|
newSet.delete(cacheKey)
|
||||||
} else {
|
} else {
|
||||||
newSet.add(cacheKey)
|
newSet.add(cacheKey)
|
||||||
void fetchMaterialDetails(batchId, attemptNumber, orderNumber)
|
|
||||||
}
|
}
|
||||||
return newSet
|
return newSet
|
||||||
})
|
})
|
||||||
|
if (!isCurrentlyExpanded) {
|
||||||
|
void fetchMaterials(attemptNumber, orderNumber)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteBatch = async (batchId: string) => {
|
const handleDelete = async () => {
|
||||||
if (deleting.has(batchId)) return
|
if (isDeleting) return
|
||||||
|
|
||||||
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
setDeleting((prev) => new Set(prev).add(batchId))
|
setIsDeleting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.cleaner.deleteHistoryBatch(batchId)
|
const result = await window.electron.cleaner.deleteHistoryBatch(batch.batchId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
onDelete(batch.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 {
|
} else {
|
||||||
alert(result.error || '删除失败')
|
alert(result.error || '删除失败')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : '删除失败')
|
alert(err instanceof Error ? err.message : '删除失败')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting((prev) => {
|
setIsDeleting(false)
|
||||||
const newSet = new Set(prev)
|
|
||||||
newSet.delete(batchId)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord, batchId: string) => {
|
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||||
const attempt = selectedAttempt.get(batchId)
|
const filtered =
|
||||||
const orders = batchOrders.get(batchId) || []
|
currentAttempt !== undefined
|
||||||
const filteredOrders =
|
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||||
attempt !== undefined ? orders.filter((o) => o.attemptNumber === attempt) : orders
|
: orders
|
||||||
const values = filteredOrders
|
const values = filtered
|
||||||
.map((o) => String(o[field] ?? ''))
|
.map((o) => String(o[field] ?? ''))
|
||||||
.filter((v) => v && v !== '-')
|
.filter((v) => v && v !== '-')
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@@ -340,120 +263,19 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
.catch(() => showError('复制失败,请手动复制'))
|
.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 (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="清理操作历史" size="3xl">
|
|
||||||
<div className="flex flex-col h-[70vh]">
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
|
||||||
<div className="flex-1">
|
|
||||||
{isAdmin && allUsers.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{allUsers.map((username) => {
|
|
||||||
const isSelected = selectedUsers.includes(username)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={username}
|
|
||||||
onClick={() => toggleUserFilter(username)}
|
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-blue-600 text-white shadow-sm'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{username}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{selectedUsers.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={clearUserFilters}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
|
|
||||||
>
|
|
||||||
清空筛选
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{isAdmin ? (
|
|
||||||
<span className="text-amber-600 font-medium">
|
|
||||||
管理员模式:
|
|
||||||
{selectedUsers.length > 0
|
|
||||||
? `已选择 ${selectedUsers.length} 个用户`
|
|
||||||
: '显示所有用户记录'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>仅显示您的操作记录</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{batches.length > 0 && (
|
|
||||||
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
|
|
||||||
onClick={() => void fetchBatches()}
|
|
||||||
disabled={loading}
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Batch list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading && batches.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
|
||||||
) : batches.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{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 =
|
const filteredOrders =
|
||||||
currentAttempt !== undefined
|
currentAttempt !== undefined
|
||||||
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||||
: orders
|
: orders
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
key={batch.batchId}
|
|
||||||
className="border border-gray-200 rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Batch summary */}
|
{/* Batch summary */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
||||||
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleBatchExpansion(batch.batchId)}
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<button className="p-1 hover:bg-gray-200 rounded">
|
<button className="p-1 hover:bg-gray-200 rounded">
|
||||||
@@ -525,7 +347,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
void handleDeleteBatch(batch.batchId)
|
void handleDelete()
|
||||||
}}
|
}}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
title="删除批次"
|
title="删除批次"
|
||||||
@@ -552,11 +374,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
onClick={() =>
|
onClick={() => setCurrentAttempt(exec.attemptNumber)}
|
||||||
setSelectedAttempt((prev) =>
|
|
||||||
new Map(prev).set(batch.batchId, exec.attemptNumber)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
第{exec.attemptNumber}次
|
第{exec.attemptNumber}次
|
||||||
</button>
|
</button>
|
||||||
@@ -616,9 +434,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
订单号
|
订单号
|
||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
onClick={() =>
|
onClick={() => handleCopyColumn('orderNumber')}
|
||||||
handleCopyColumn('orderNumber', batch.batchId)
|
|
||||||
}
|
|
||||||
title="复制所有订单号"
|
title="复制所有订单号"
|
||||||
>
|
>
|
||||||
<Copy
|
<Copy
|
||||||
@@ -653,7 +469,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{filteredOrders.map((order) => {
|
{filteredOrders.map((order) => {
|
||||||
const orderKey = `${batch.batchId}:${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
|
const orderKey = `${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
|
||||||
const isOrderExpanded = expandedOrders.has(orderKey)
|
const isOrderExpanded = expandedOrders.has(orderKey)
|
||||||
const materials = orderMaterials.get(orderKey) || []
|
const materials = orderMaterials.get(orderKey) || []
|
||||||
const isLoadingMaterials = loadingMaterials.has(orderKey)
|
const isLoadingMaterials = loadingMaterials.has(orderKey)
|
||||||
@@ -664,7 +480,6 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
className="hover:bg-gray-50 cursor-pointer"
|
className="hover:bg-gray-50 cursor-pointer"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toggleOrderExpansion(
|
toggleOrderExpansion(
|
||||||
batch.batchId,
|
|
||||||
currentAttempt ?? order.attemptNumber,
|
currentAttempt ?? order.attemptNumber,
|
||||||
order.orderNumber
|
order.orderNumber
|
||||||
)
|
)
|
||||||
@@ -734,7 +549,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
{/* Material details */}
|
{/* Material details */}
|
||||||
{isOrderExpanded && (
|
{isOrderExpanded && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="bg-gray-50/50 px-8 py-3">
|
<td colSpan={9} className="bg-gray-50/50 px-8 py-3">
|
||||||
{isLoadingMaterials ? (
|
{isLoadingMaterials ? (
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
加载物料详情...
|
加载物料详情...
|
||||||
@@ -839,7 +654,174 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
BatchItem.displayName = 'BatchItem'
|
||||||
|
|
||||||
|
// ====== Main Modal Component ======
|
||||||
|
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [batches, setBatches] = useState<CleanerHistoryBatchStats[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
|
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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void fetchBatches()
|
||||||
|
if (isAdmin) {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
|
||||||
|
|
||||||
|
const handleDeleteBatch = useCallback((batchId: string) => {
|
||||||
|
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleUserFilter = (username: string) => {
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUserFilters = () => {
|
||||||
|
setSelectedUsers([])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="清理操作历史" size="3xl">
|
||||||
|
<div className="flex flex-col h-[70vh]">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex-1">
|
||||||
|
{isAdmin && allUsers.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allUsers.map((username) => {
|
||||||
|
const isSelected = selectedUsers.includes(username)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={username}
|
||||||
|
onClick={() => toggleUserFilter(username)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearUserFilters}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-amber-600 font-medium">
|
||||||
|
管理员模式:
|
||||||
|
{selectedUsers.length > 0
|
||||||
|
? `已选择 ${selectedUsers.length} 个用户`
|
||||||
|
: '显示所有用户记录'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>仅显示您的操作记录</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{batches.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
onClick={() => void fetchBatches()}
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||||
|
) : batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{batches.map((batch) => (
|
||||||
|
<BatchItem
|
||||||
|
key={batch.batchId}
|
||||||
|
batch={batch}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteBatch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user