From 5b43d5a60c81394df1b159201003b03ce5784901 Mon Sep 17 00:00:00 2001 From: Misaka Date: Tue, 14 Apr 2026 20:49:29 +0800 Subject: [PATCH] fix: restore cleaner history in postgresql --- .../database/cleaner-operation-history-dao.ts | 35 +++--- .../CleanerOperationHistoryModal.tsx | 109 +++++++++++++----- .../components/cleaner-history-load-state.ts | 19 +++ src/renderer/src/pages/CleanerPage.tsx | 24 ++-- tests/unit/cleaner-history-load-state.test.ts | 29 +++++ tests/unit/cleaner-page-history-lazy.test.tsx | 107 +++++++++++++++++ .../cleaner-operation-history-dao.test.ts | 108 +++++++++++++++++ 7 files changed, 369 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/cleaner-history-load-state.ts create mode 100644 tests/unit/cleaner-history-load-state.test.ts create mode 100644 tests/unit/cleaner-page-history-lazy.test.tsx create mode 100644 tests/unit/services/database/cleaner-operation-history-dao.test.ts diff --git a/src/main/services/database/cleaner-operation-history-dao.ts b/src/main/services/database/cleaner-operation-history-dao.ts index 4e105e0..da2fc19 100644 --- a/src/main/services/database/cleaner-operation-history-dao.ts +++ b/src/main/services/database/cleaner-operation-history-dao.ts @@ -122,6 +122,15 @@ export class CleanerOperationHistoryDAO { return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail') } + private getIsDryRunAggregateSql(): string { + const dialect = this.getDialect() + if (dialect.dbType === 'postgresql') { + return `MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END)` + } + + return `MAX(CAST(e.IsDryRun AS INT))` + } + /** * Get database service instance using DatabaseFactory */ @@ -619,9 +628,9 @@ export class CleanerOperationHistoryDAO { MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed, MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted, MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed, - MAX(CAST(e.IsDryRun AS INT)) as IsDryRun, - ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount, - ISNULL(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount + ${this.getIsDryRunAggregateSql()} as IsDryRun, + COALESCE(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount, + COALESCE(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount FROM ${execTable} e INNER JOIN ( SELECT BatchId, MAX(AttemptNumber) as max_attempt @@ -906,27 +915,13 @@ export class CleanerOperationHistoryDAO { const materialTable = this.getMaterialTableName() const dialect = this.getDialect() - // Check if batch exists - const checkSql = ` - SELECT TOP 1 UserId - FROM ${execTable} - WHERE BatchId = ${dialect.param(0)} - ` - - const checkResult = await trackDuration( - async () => await dbService.query(checkSql, [batchId]), - { - operationName: 'CleanerOperationHistoryDAO.deleteBatch.check', - context: { operationType: 'SELECT', batchId } - } - ) - - if (checkResult.result.rows.length === 0) { + const details = await this.getBatchDetails(batchId) + if (details.executions.length === 0) { return { success: false, error: '批次不存在' } } // Permission check: non-admin can only delete own batches - const batchUserId = checkResult.result.rows[0].UserId as number + const batchUserId = details.executions[0].userId if (!isAdmin && batchUserId !== requestingUserId) { return { success: false, error: '没有权限删除此批次' } } diff --git a/src/renderer/src/components/CleanerOperationHistoryModal.tsx b/src/renderer/src/components/CleanerOperationHistoryModal.tsx index 610d9b0..3630b18 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, useRef } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { Modal } from './ui/Modal' import { useLogger } from '../hooks/useLogger' import { @@ -28,6 +28,11 @@ import type { CleanerHistoryOrderRecord, CleanerHistoryMaterialRecord } from '../hooks/cleaner/types' +import { + canStartHistoryLoad, + getNextHistoryLoadState, + type HistoryLoadState +} from './cleaner-history-load-state' // 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. @@ -145,46 +150,53 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { () => new Map() ) const [loadingMaterials, setLoadingMaterials] = useState>(() => new Set()) + const [materialLoadStates, setMaterialLoadStates] = useState>( + () => new Map() + ) + const [detailsLoadState, setDetailsLoadState] = useState('idle') 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 = useCallback(async () => { + if (!canStartHistoryLoad(detailsLoadState)) return - 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) + setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start')) + 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))) - } + 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]) + setDetailsLoadState('success') + } else { + setDetailsLoadState('error') + } + } catch (err) { + setDetailsLoadState('error') + logger.error('Failed to fetch batch details', { + error: err instanceof Error ? err.message : String(err), + batchId: batch.batchId + }) + } + }, [batch.batchId, detailsLoadState, logger]) const fetchMaterials = useCallback( async (attemptNumber: number, orderNumber: string) => { const cacheKey = `${attemptNumber}:${orderNumber}` - if (loadedMaterialsRef.current.has(cacheKey)) return - loadedMaterialsRef.current.add(cacheKey) + const loadState = materialLoadStates.get(cacheKey) ?? 'idle' + if (!canStartHistoryLoad(loadState)) return + setMaterialLoadStates((prev) => { + const next = new Map(prev) + next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start')) + return next + }) setLoadingMaterials((prev) => new Set(prev).add(cacheKey)) try { const result = await window.electron.cleaner.getHistoryMaterialDetails( @@ -194,8 +206,24 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { ) if (result.success && result.data) { setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!)) + setMaterialLoadStates((prev) => { + const next = new Map(prev) + next.set(cacheKey, 'success') + return next + }) + } else { + setMaterialLoadStates((prev) => { + const next = new Map(prev) + next.set(cacheKey, 'error') + return next + }) } } catch (err) { + setMaterialLoadStates((prev) => { + const next = new Map(prev) + next.set(cacheKey, 'error') + return next + }) logger.error('Failed to fetch material details', { error: err instanceof Error ? err.message : String(err), batchId: batch.batchId, @@ -210,9 +238,17 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { }) } }, - [batch.batchId, logger] + [batch.batchId, logger, materialLoadStates] ) + const toggleBatchExpansion = () => { + const nextExpanded = !isExpanded + setIsExpanded(nextExpanded) + if (nextExpanded) { + void fetchDetails() + } + } + const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => { const cacheKey = `${attemptNumber}:${orderNumber}` const isCurrentlyExpanded = expandedOrders.has(cacheKey) @@ -281,7 +317,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => { className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${ isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50' }`} - onClick={() => setIsExpanded((prev) => !prev)} + onClick={toggleBatchExpansion} >