From 74d9b4042e02cab92d9e17ddf110672a5575f32e Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Fri, 17 Apr 2026 11:21:02 +0800 Subject: [PATCH] feat(cleaner-history): integrate search UI into history modal Add search bar to CleanerOperationHistoryModal with keyword search across batch IDs, order numbers, and material codes/names. Search results auto-expand with preloaded data and highlight matched text. Also add searchHistoryRecords to the CleanerAPI type definition. Co-Authored-By: Claude Opus 4.6 --- src/main/types/ipc-api.types.ts | 12 +- .../CleanerOperationHistoryModal.tsx | 262 +++++++++++++++--- 2 files changed, 229 insertions(+), 45 deletions(-) diff --git a/src/main/types/ipc-api.types.ts b/src/main/types/ipc-api.types.ts index 0455d43..2ee70ea 100644 --- a/src/main/types/ipc-api.types.ts +++ b/src/main/types/ipc-api.types.ts @@ -15,7 +15,9 @@ import type { CleanerBatchStats, CleanerExecutionRecord, CleanerOrderRecord, - CleanerMaterialRecord + CleanerMaterialRecord, + SearchCleanerHistoryOptions, + CleanerHistorySearchResult } from './cleaner-history.types' import type { IpcResult } from './ipc.types' @@ -158,6 +160,14 @@ export interface CleanerAPI { * @param batchId - Batch ID */ deleteHistoryBatch: (batchId: string) => Promise> + + /** + * Search cleaner history records by keyword + * @param options - Search options (query, usernames, limit) + */ + searchHistoryRecords: ( + options: SearchCleanerHistoryOptions + ) => Promise> } /** diff --git a/src/renderer/src/components/CleanerOperationHistoryModal.tsx b/src/renderer/src/components/CleanerOperationHistoryModal.tsx index ed35f31..4dc557a 100644 --- a/src/renderer/src/components/CleanerOperationHistoryModal.tsx +++ b/src/renderer/src/components/CleanerOperationHistoryModal.tsx @@ -19,19 +19,23 @@ import { CheckCircle, XCircle, Copy, - FlaskConical + FlaskConical, + Search, + X } from 'lucide-react' import type { UserInfo } from './UserSelectionDialog' import type { CleanerHistoryBatchStats, CleanerHistoryOrderRecord, - CleanerHistoryMaterialRecord + CleanerHistoryMaterialRecord, + CleanerHistorySearchResult } from '../hooks/cleaner/types' import { canStartHistoryLoad, getNextHistoryLoadState, type HistoryLoadState } from './cleaner-history-load-state' +import { highlightText } from './cleaner-history-highlight' import { getCleanerHistoryStatusDisplay, getCleanerMaterialResultDisplay @@ -71,6 +75,12 @@ interface BatchItemProps { isAdmin: boolean onDelete: (batchId: string) => void onRequestDelete: (batchId: string) => Promise + searchQuery?: string + preloadedExecutions?: ExecutionRecord[] + preloadedOrders?: Array<{ + order: CleanerHistoryOrderRecord + materials: CleanerHistoryMaterialRecord[] + }> } const BATCH_PAGE_SIZE = 5 @@ -116,8 +126,8 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date // ====== 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, onRequestDelete }: BatchItemProps) => { - const [isExpanded, setIsExpanded] = useState(false) +const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete, searchQuery, preloadedExecutions, preloadedOrders }: BatchItemProps) => { + const [isExpanded, setIsExpanded] = useState(() => !!searchQuery) const [executions, setExecutions] = useState([]) const [orders, setOrders] = useState([]) const [currentAttempt, setCurrentAttempt] = useState() @@ -134,6 +144,42 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat const logger = useLogger('BatchItem') + // Initialize from preloaded data in search mode + useEffect(() => { + if (searchQuery && preloadedExecutions && preloadedOrders) { + setExecutions(preloadedExecutions) + setDetailsLoadState('success') + + const orders = preloadedOrders.map(p => p.order) + setOrders(orders) + + if (preloadedExecutions.length > 0) { + setCurrentAttempt(Math.max(...preloadedExecutions.map(e => e.attemptNumber))) + } + + // Pre-populate materials map + const materialsMap = new Map() + const expandedSet = new Set() + for (const { order, materials } of preloadedOrders) { + if (materials.length > 0) { + const cacheKey = `${order.attemptNumber}:${order.orderNumber}` + materialsMap.set(cacheKey, materials) + expandedSet.add(cacheKey) + } + } + setOrderMaterials(materialsMap) + setExpandedOrders(expandedSet) + + // Mark all materials as loaded + const loadStatesMap = new Map() + for (const { order } of preloadedOrders) { + const cacheKey = `${order.attemptNumber}:${order.orderNumber}` + loadStatesMap.set(cacheKey, 'success') + } + setMaterialLoadStates(loadStatesMap) + } + }, [searchQuery, preloadedExecutions, preloadedOrders]) + const fetchDetails = useCallback(async () => { if (!canStartHistoryLoad(detailsLoadState)) return @@ -492,10 +538,14 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat {index + 1} - {order.productionId || '-'} + {order.productionId + ? (searchQuery ? highlightText(order.productionId, searchQuery) : order.productionId) + : '-'} - {order.status === 'not_found' ? '-' : order.orderNumber} + {order.status === 'not_found' + ? '-' + : (searchQuery ? highlightText(order.orderNumber, searchQuery) : order.orderNumber)} {materials.map((mat, idx) => ( - + ))} @@ -603,16 +653,21 @@ BatchItem.displayName = 'BatchItem' interface MaterialDetailRowProps { index: number material: CleanerHistoryMaterialRecord + searchQuery?: string } -const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.JSX.Element => { +const MaterialDetailRow = ({ index, material, searchQuery }: MaterialDetailRowProps): React.JSX.Element => { const resultDisplay = getCleanerMaterialResultDisplay(material.result) return ( {index + 1} - {material.materialCode} - {material.materialName} + + {searchQuery ? highlightText(material.materialCode, searchQuery) : material.materialCode} + + + {searchQuery ? highlightText(material.materialName, searchQuery) : material.materialName} + {material.rowNumber} {resultDisplay.icon ? ( @@ -623,7 +678,9 @@ const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.J {resultDisplay.title} )} - {material.reason || '-'} + + {material.reason ? (searchQuery ? highlightText(material.reason, searchQuery) : material.reason) : '-'} + {material.attemptCount > 1 ? ( {material.attemptCount} @@ -648,6 +705,10 @@ export const CleanerOperationHistoryModal: React.FC([]) const [currentPage, setCurrentPage] = useState(0) const [isFilterPending, startFilterTransition] = useTransition() + const [searchQuery, setSearchQuery] = useState('') + const [searchInput, setSearchInput] = useState('') + const [searchResult, setSearchResult] = useState(null) + const [isSearching, setIsSearching] = useState(false) const { confirm, dialog: confirmDialog } = useConfirmDialog() const logger = useLogger('CleanerOperationHistory') @@ -748,6 +809,42 @@ export const CleanerOperationHistoryModal: React.FC { + const trimmed = searchInput.trim() + if (!trimmed) { + setSearchQuery('') + setSearchResult(null) + return + } + + setIsSearching(true) + setSearchQuery(trimmed) + try { + const options = + isAdmin && selectedUsers.length > 0 + ? { query: trimmed, usernames: selectedUsers } + : { query: trimmed } + + const result = await window.electron.cleaner.searchHistoryRecords(options) + if (result.success && result.data) { + // IPC serialization converts Date to string, so cast to renderer type + setSearchResult(result.data as unknown as CleanerHistorySearchResult) + } else { + setSearchResult({ batches: [], totalMatches: 0 }) + } + } catch { + setSearchResult({ batches: [], totalMatches: 0 }) + } finally { + setIsSearching(false) + } + }, [searchInput, isAdmin, selectedUsers]) + + const clearSearch = () => { + setSearchInput('') + setSearchQuery('') + setSearchResult(null) + } + const goToPreviousPage = () => { setCurrentPage((prev) => Math.max(0, prev - 1)) } @@ -771,6 +868,38 @@ export const CleanerOperationHistoryModal: React.FC
+ {/* Search bar */} +
+
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void executeSearch() + }} + placeholder="搜索批次ID、订单号、物料编码/名称..." + className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={loading} + /> + {searchInput && ( + + )} +
+ +
{isAdmin && allUsers.length > 0 && (
筛选用户:
@@ -840,46 +969,91 @@ export const CleanerOperationHistoryModal: React.FC - {loading && batches.length === 0 ? ( -
加载中...
- ) : batches.length === 0 ? ( -
暂无操作记录
+ {searchQuery ? ( + // Search mode + isSearching ? ( +
搜索中...
+ ) : searchResult && searchResult.batches.length > 0 ? ( +
+ {searchResult.batches.map((result) => ( + + ))} +
+ ) : ( +
+ 未找到匹配「{searchQuery}」的记录 +
+ ) ) : ( -
- {batches.map((batch) => ( - - ))} -
+ // Browse mode (existing logic unchanged) + <> + {loading && batches.length === 0 ? ( +
加载中...
+ ) : batches.length === 0 ? ( +
暂无操作记录
+ ) : ( +
+ {batches.map((batch) => ( + + ))} +
+ )} + )}
{/* Footer */}
-
- -
- 第 {currentPage + 1} 页 + {searchQuery && searchResult ? ( +
+ + 找到 {searchResult.totalMatches} 个匹配批次 + {searchResult.totalMatches > searchResult.batches.length && + `(显示前 ${searchResult.batches.length} 个)`} + +
- -
+ ) : ( +
+ +
+ 第 {currentPage + 1} 页 +
+ +
+ )}
{confirmDialog && }