feat: improve cleaner history pagination and report states

This commit is contained in:
Misaka
2026-04-14 21:10:05 +08:00
parent 5b43d5a60c
commit b5b8af078d
4 changed files with 256 additions and 65 deletions

View File

@@ -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, useTransition } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { useLogger } from '../hooks/useLogger' import { useLogger } from '../hooks/useLogger'
import { import {
@@ -69,6 +69,8 @@ interface BatchItemProps {
onDelete: (batchId: string) => void onDelete: (batchId: string) => void
} }
const BATCH_PAGE_SIZE = 5
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',
@@ -286,12 +288,15 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
} }
} }
const filteredOrders =
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
const visibleExecutions =
currentAttempt !== undefined
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
: executions
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => { const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
const filtered = const values = filteredOrders
currentAttempt !== undefined
? orders.filter((o) => o.attemptNumber === currentAttempt)
: orders
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')
@@ -307,9 +312,6 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
.catch(() => showError('复制失败,请手动复制')) .catch(() => showError('复制失败,请手动复制'))
} }
const filteredOrders =
currentAttempt !== undefined ? orders.filter((o) => o.attemptNumber === currentAttempt) : orders
return ( return (
<div className="border border-gray-200 rounded-lg overflow-hidden"> <div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Batch summary */} {/* Batch summary */}
@@ -420,32 +422,30 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
</div> </div>
)} )}
<div className="flex flex-wrap gap-4 text-xs text-gray-600"> <div className="flex flex-wrap gap-4 text-xs text-gray-600">
{executions {visibleExecutions.map((exec) => (
.filter((e) => currentAttempt === undefined || e.attemptNumber === currentAttempt) <React.Fragment key={exec.attemptNumber}>
.map((exec) => ( <span>{formatDuration(exec.operationTime, exec.endTime)}</span>
<React.Fragment key={exec.attemptNumber}> <span>
<span>{formatDuration(exec.operationTime, exec.endTime)}</span> {exec.ordersProcessed}/{exec.totalOrders}
<span> </span>
{exec.ordersProcessed}/{exec.totalOrders} <span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span> </span>
<span>{exec.totalMaterialsDeleted}</span> )}
{exec.totalMaterialsFailed > 0 && ( {exec.errorMessage && (
<span className="text-red-600">{exec.totalMaterialsFailed}</span> <span className="text-red-600" title={exec.errorMessage}>
)} {exec.errorMessage.substring(0, 80)}
{exec.totalUncertainDeletions > 0 && ( {exec.errorMessage.length > 80 ? '...' : ''}
<span className="text-amber-600"> </span>
{exec.totalUncertainDeletions} )}
</span> {exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
)} </React.Fragment>
{exec.errorMessage && ( ))}
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
</React.Fragment>
))}
</div> </div>
</div> </div>
)} )}
@@ -711,9 +711,13 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [allUsers, setAllUsers] = useState<string[]>([]) const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([]) const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [currentPage, setCurrentPage] = useState(0)
const [isFilterPending, startFilterTransition] = useTransition()
const logger = useLogger('CleanerOperationHistory') const logger = useLogger('CleanerOperationHistory')
const isAdmin = user?.userType === 'Admin' const isAdmin = user?.userType === 'Admin'
const hasPreviousPage = currentPage > 0
const hasNextPage = batches.length === BATCH_PAGE_SIZE
const fetchBatches = useCallback(async () => { const fetchBatches = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -721,8 +725,12 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
try { try {
const options = const options =
isAdmin && selectedUsers.length > 0 isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers } ? {
: { limit: 100 } limit: BATCH_PAGE_SIZE,
offset: currentPage * BATCH_PAGE_SIZE,
usernames: selectedUsers
}
: { limit: BATCH_PAGE_SIZE, offset: currentPage * BATCH_PAGE_SIZE }
const result = await window.electron.cleaner.getHistoryBatches(options) const result = await window.electron.cleaner.getHistoryBatches(options)
if (result.success && result.data) { if (result.success && result.data) {
@@ -735,7 +743,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [isAdmin, selectedUsers]) }, [currentPage, isAdmin, selectedUsers])
const fetchAllUsers = useCallback(async () => { const fetchAllUsers = useCallback(async () => {
try { try {
@@ -765,13 +773,28 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
}, []) }, [])
const toggleUserFilter = (username: string) => { const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) => startFilterTransition(() => {
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username] setCurrentPage(0)
) setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
})
} }
const clearUserFilters = () => { const clearUserFilters = () => {
setSelectedUsers([]) startFilterTransition(() => {
setCurrentPage(0)
setSelectedUsers([])
})
}
const goToPreviousPage = () => {
setCurrentPage((prev) => Math.max(0, prev - 1))
}
const goToNextPage = () => {
if (!hasNextPage) return
setCurrentPage((prev) => prev + 1)
} }
if (!isOpen) return null if (!isOpen) return null
@@ -833,8 +856,9 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
)} )}
</span> </span>
{batches.length > 0 && ( {batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span> <span className="text-sm text-gray-500"> {batches.length} </span>
)} )}
{isFilterPending && <span className="text-sm text-blue-600">...</span>}
</div> </div>
</div> </div>
<button <button
@@ -875,14 +899,26 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
</div> </div>
{/* Footer */} {/* Footer */}
{/* Footer */} <div className="pt-4 border-t border-gray-200 flex justify-center">
<div className="pt-4 border-t border-gray-200 flex justify-end"> <div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
<button <button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors" className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={onClose} onClick={goToPreviousPage}
> disabled={loading || !hasPreviousPage}
>
</button>
</button>
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
{currentPage + 1}
</div>
<button
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
onClick={goToNextPage}
disabled={loading || !hasNextPage}
>
</button>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -11,6 +11,7 @@ import React from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react' import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import type { CleanerProgress } from '../hooks/cleaner/types' import type { CleanerProgress } from '../hooks/cleaner/types'
import { getExecutionReportState } from './execution-report-state'
interface ExecutionReportDialogProps { interface ExecutionReportDialogProps {
isOpen: boolean isOpen: boolean
@@ -56,6 +57,12 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0 const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
const showProgress = isExecuting && progress const showProgress = isExecuting && progress
const isProgressing = !!showProgress const isProgressing = !!showProgress
const reportState = getExecutionReportState({
dryRun,
errors,
materialsFailed,
uncertainDeletions
})
// Update timer during progress // Update timer during progress
React.useEffect(() => { React.useEffect(() => {
@@ -123,11 +130,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
title={ title={
isProgressing isProgressing
? '正在执行清理...' ? '正在执行清理...'
: dryRun : reportState.title
? '预览执行报告'
: hasErrors
? '执行完成 (有错误)'
: '执行完成'
} }
size={isProgressing ? 'lg' : 'md'} size={isProgressing ? 'lg' : 'md'}
showCloseButton={!isExecuting} showCloseButton={!isExecuting}
@@ -161,19 +164,17 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{dryRun ? ( {dryRun ? (
<Package className="w-12 h-12 text-amber-500" /> <Package className="w-12 h-12 text-amber-500" />
) : hasErrors ? ( ) : reportState.state === 'failure' ? (
<XCircle className="w-12 h-12 text-red-500" /> <XCircle className="w-12 h-12 text-red-500" />
) : reportState.state === 'partial_success' ? (
<AlertTriangle className="w-12 h-12 text-amber-500" />
) : reportState.state === 'manual_review' ? (
<AlertTriangle className="w-12 h-12 text-yellow-500" />
) : ( ) : (
<CheckCircle className="w-12 h-12 text-green-500" /> <CheckCircle className="w-12 h-12 text-green-500" />
)} )}
</div> </div>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">{reportState.summary}</p>
{dryRun
? '预览模式 - 未实际删除数据'
: hasErrors
? '部分操作未能完成,请查看下方错误信息'
: '所有操作已成功完成'}
</p>
</div> </div>
)} )}
@@ -405,19 +406,33 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div> </div>
)} )}
{!hasErrors && !dryRun && ( {reportState.showSuccessBanner && (
<div className="flex items-center justify-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 text-green-700 text-sm"> <div className="flex items-center justify-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 text-green-700 text-sm">
<CheckCircle size={16} className="flex-shrink-0" /> <CheckCircle size={16} className="flex-shrink-0" />
<span> ERP </span> <span> ERP </span>
</div> </div>
)} )}
{!hasErrors && dryRun && ( {reportState.showPreviewBanner && (
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm"> <div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
<Package size={16} className="flex-shrink-0" /> <Package size={16} className="flex-shrink-0" />
<span></span> <span></span>
</div> </div>
)} )}
{reportState.state === 'partial_success' && (
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
<AlertTriangle size={16} className="flex-shrink-0" />
<span></span>
</div>
)}
{reportState.state === 'manual_review' && (
<div className="flex items-center justify-center gap-2 p-3 bg-yellow-50 rounded-lg border border-yellow-200 text-yellow-700 text-sm">
<AlertTriangle size={16} className="flex-shrink-0" />
<span> ERP </span>
</div>
)}
</> </>
)} )}
</Modal> </Modal>

View File

@@ -0,0 +1,80 @@
export type ExecutionReportResultState =
| 'preview'
| 'success'
| 'partial_success'
| 'manual_review'
| 'failure'
export interface ExecutionReportStateInput {
dryRun?: boolean
errors?: string[]
materialsFailed?: number
uncertainDeletions?: number
}
export interface ExecutionReportStateDescriptor {
state: ExecutionReportResultState
title: string
summary: string
showSuccessBanner: boolean
showPreviewBanner: boolean
}
export function getExecutionReportState(
input: ExecutionReportStateInput
): ExecutionReportStateDescriptor {
const {
dryRun = false,
errors = [],
materialsFailed = 0,
uncertainDeletions = 0
} = input
if (dryRun) {
return {
state: 'preview',
title: '预览执行报告',
summary: '预览模式 - 未实际删除数据',
showSuccessBanner: false,
showPreviewBanner: true
}
}
if (errors.length > 0) {
return {
state: 'failure',
title: '执行完成 (失败)',
summary: '执行过程中出现错误,请先处理错误后再继续。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
if (materialsFailed > 0) {
return {
state: 'partial_success',
title: '执行完成 (部分成功)',
summary: '部分物料删除失败,请结合下方统计和历史记录继续排查。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
if (uncertainDeletions > 0) {
return {
state: 'manual_review',
title: '执行完成 (需人工确认)',
summary: '存在不确定删除结果,请人工复核后再判断是否完成。',
showSuccessBanner: false,
showPreviewBanner: false
}
}
return {
state: 'success',
title: '执行完成',
summary: '所有操作已成功完成',
showSuccessBanner: true,
showPreviewBanner: false
}
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { getExecutionReportState } from '../../src/renderer/src/components/execution-report-state'
describe('execution report state helpers', () => {
it('treats dry-run as preview regardless of counters', () => {
const state = getExecutionReportState({
dryRun: true,
errors: ['should be ignored'],
materialsFailed: 1,
uncertainDeletions: 1
})
expect(state.state).toBe('preview')
expect(state.title).toBe('预览执行报告')
})
it('treats runtime errors as failure', () => {
const state = getExecutionReportState({
errors: ['boom'],
materialsFailed: 0,
uncertainDeletions: 0
})
expect(state.state).toBe('failure')
expect(state.title).toBe('执行完成 (失败)')
})
it('treats failed materials as partial success when there are no runtime errors', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 3,
uncertainDeletions: 0
})
expect(state.state).toBe('partial_success')
expect(state.showSuccessBanner).toBe(false)
})
it('treats uncertain deletions as manual review when everything else succeeded', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 0,
uncertainDeletions: 2
})
expect(state.state).toBe('manual_review')
expect(state.showSuccessBanner).toBe(false)
})
it('treats clean completion as success', () => {
const state = getExecutionReportState({
errors: [],
materialsFailed: 0,
uncertainDeletions: 0
})
expect(state.state).toBe('success')
expect(state.showSuccessBanner).toBe(true)
})
})