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

View File

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

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)
})
})