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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-17 11:21:02 +08:00
parent ed9058c93d
commit 74d9b4042e
2 changed files with 229 additions and 45 deletions

View File

@@ -15,7 +15,9 @@ import type {
CleanerBatchStats, CleanerBatchStats,
CleanerExecutionRecord, CleanerExecutionRecord,
CleanerOrderRecord, CleanerOrderRecord,
CleanerMaterialRecord CleanerMaterialRecord,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from './cleaner-history.types' } from './cleaner-history.types'
import type { IpcResult } from './ipc.types' import type { IpcResult } from './ipc.types'
@@ -158,6 +160,14 @@ export interface CleanerAPI {
* @param batchId - Batch ID * @param batchId - Batch ID
*/ */
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>> deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
/**
* Search cleaner history records by keyword
* @param options - Search options (query, usernames, limit)
*/
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
) => Promise<IpcResult<CleanerHistorySearchResult>>
} }
/** /**

View File

@@ -19,19 +19,23 @@ import {
CheckCircle, CheckCircle,
XCircle, XCircle,
Copy, Copy,
FlaskConical FlaskConical,
Search,
X
} from 'lucide-react' } from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog' import type { UserInfo } from './UserSelectionDialog'
import type { import type {
CleanerHistoryBatchStats, CleanerHistoryBatchStats,
CleanerHistoryOrderRecord, CleanerHistoryOrderRecord,
CleanerHistoryMaterialRecord CleanerHistoryMaterialRecord,
CleanerHistorySearchResult
} from '../hooks/cleaner/types' } from '../hooks/cleaner/types'
import { import {
canStartHistoryLoad, canStartHistoryLoad,
getNextHistoryLoadState, getNextHistoryLoadState,
type HistoryLoadState type HistoryLoadState
} from './cleaner-history-load-state' } from './cleaner-history-load-state'
import { highlightText } from './cleaner-history-highlight'
import { import {
getCleanerHistoryStatusDisplay, getCleanerHistoryStatusDisplay,
getCleanerMaterialResultDisplay getCleanerMaterialResultDisplay
@@ -71,6 +75,12 @@ interface BatchItemProps {
isAdmin: boolean isAdmin: boolean
onDelete: (batchId: string) => void onDelete: (batchId: string) => void
onRequestDelete: (batchId: string) => Promise<boolean> onRequestDelete: (batchId: string) => Promise<boolean>
searchQuery?: string
preloadedExecutions?: ExecutionRecord[]
preloadedOrders?: Array<{
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}>
} }
const BATCH_PAGE_SIZE = 5 const BATCH_PAGE_SIZE = 5
@@ -116,8 +126,8 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
// ====== BatchItem Component ====== // ====== BatchItem Component ======
// Extracted from the modal so that expanding one batch doesn't re-render siblings. // 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. // Each BatchItem manages its own details, orders, and material state locally.
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: BatchItemProps) => { const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete, searchQuery, preloadedExecutions, preloadedOrders }: BatchItemProps) => {
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(() => !!searchQuery)
const [executions, setExecutions] = useState<ExecutionRecord[]>([]) const [executions, setExecutions] = useState<ExecutionRecord[]>([])
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([]) const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>() const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
@@ -134,6 +144,42 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
const logger = useLogger('BatchItem') 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<string, CleanerHistoryMaterialRecord[]>()
const expandedSet = new Set<string>()
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<string, HistoryLoadState>()
for (const { order } of preloadedOrders) {
const cacheKey = `${order.attemptNumber}:${order.orderNumber}`
loadStatesMap.set(cacheKey, 'success')
}
setMaterialLoadStates(loadStatesMap)
}
}, [searchQuery, preloadedExecutions, preloadedOrders])
const fetchDetails = useCallback(async () => { const fetchDetails = useCallback(async () => {
if (!canStartHistoryLoad(detailsLoadState)) return if (!canStartHistoryLoad(detailsLoadState)) return
@@ -492,10 +538,14 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
{index + 1} {index + 1}
</td> </td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs"> <td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.productionId || '-'} {order.productionId
? (searchQuery ? highlightText(order.productionId, searchQuery) : order.productionId)
: '-'}
</td> </td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs"> <td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.status === 'not_found' ? '-' : order.orderNumber} {order.status === 'not_found'
? '-'
: (searchQuery ? highlightText(order.orderNumber, searchQuery) : order.orderNumber)}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<span <span
@@ -573,7 +623,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{materials.map((mat, idx) => ( {materials.map((mat, idx) => (
<MaterialDetailRow key={idx} index={idx} material={mat} /> <MaterialDetailRow key={idx} index={idx} material={mat} searchQuery={searchQuery} />
))} ))}
</tbody> </tbody>
</table> </table>
@@ -603,16 +653,21 @@ BatchItem.displayName = 'BatchItem'
interface MaterialDetailRowProps { interface MaterialDetailRowProps {
index: number index: number
material: CleanerHistoryMaterialRecord 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) const resultDisplay = getCleanerMaterialResultDisplay(material.result)
return ( return (
<tr className="hover:bg-gray-50"> <tr className="hover:bg-gray-50">
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td> <td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
<td className="px-3 py-1.5 font-mono text-gray-700">{material.materialCode}</td> <td className="px-3 py-1.5 font-mono text-gray-700">
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td> {searchQuery ? highlightText(material.materialCode, searchQuery) : material.materialCode}
</td>
<td className="px-3 py-1.5 text-gray-700">
{searchQuery ? highlightText(material.materialName, searchQuery) : material.materialName}
</td>
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td> <td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
<td className="px-3 py-1.5"> <td className="px-3 py-1.5">
{resultDisplay.icon ? ( {resultDisplay.icon ? (
@@ -623,7 +678,9 @@ const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.J
<span className="text-gray-500">{resultDisplay.title}</span> <span className="text-gray-500">{resultDisplay.title}</span>
)} )}
</td> </td>
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">{material.reason || '-'}</td> <td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
{material.reason ? (searchQuery ? highlightText(material.reason, searchQuery) : material.reason) : '-'}
</td>
<td className="px-3 py-1.5 text-gray-600"> <td className="px-3 py-1.5 text-gray-600">
{material.attemptCount > 1 ? ( {material.attemptCount > 1 ? (
<span className="text-amber-600">{material.attemptCount}</span> <span className="text-amber-600">{material.attemptCount}</span>
@@ -648,6 +705,10 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
const [selectedUsers, setSelectedUsers] = useState<string[]>([]) const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [currentPage, setCurrentPage] = useState(0) const [currentPage, setCurrentPage] = useState(0)
const [isFilterPending, startFilterTransition] = useTransition() const [isFilterPending, startFilterTransition] = useTransition()
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
const [isSearching, setIsSearching] = useState(false)
const { confirm, dialog: confirmDialog } = useConfirmDialog() const { confirm, dialog: confirmDialog } = useConfirmDialog()
const logger = useLogger('CleanerOperationHistory') const logger = useLogger('CleanerOperationHistory')
@@ -748,6 +809,42 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
}) })
} }
const executeSearch = useCallback(async () => {
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 = () => { const goToPreviousPage = () => {
setCurrentPage((prev) => Math.max(0, prev - 1)) setCurrentPage((prev) => Math.max(0, prev - 1))
} }
@@ -771,6 +868,38 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200"> <div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1"> <div className="flex-1">
{/* Search bar */}
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchInput}
onChange={(e) => 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 && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={clearSearch}
>
<X size={16} />
</button>
)}
</div>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
onClick={() => void executeSearch()}
disabled={isSearching || !searchInput.trim()}
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
{isAdmin && allUsers.length > 0 && ( {isAdmin && allUsers.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div> <div className="text-xs text-gray-500 mb-2"></div>
@@ -840,46 +969,91 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
{/* Batch list */} {/* Batch list */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? ( {searchQuery ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div> // Search mode
) : batches.length === 0 ? ( isSearching ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div> <div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : searchResult && searchResult.batches.length > 0 ? (
<div className="flex flex-col gap-3">
{searchResult.batches.map((result) => (
<BatchItem
key={result.batch.batchId}
batch={result.batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
onRequestDelete={requestDeleteConfirmation}
searchQuery={searchQuery}
preloadedExecutions={result.executions}
preloadedOrders={result.orders}
/>
))}
</div>
) : (
<div className="flex items-center justify-center h-32 text-gray-500">
{searchQuery}
</div>
)
) : ( ) : (
<div className="flex flex-col gap-3"> // Browse mode (existing logic unchanged)
{batches.map((batch) => ( <>
<BatchItem {loading && batches.length === 0 ? (
key={batch.batchId} <div className="flex items-center justify-center h-32 text-gray-500">...</div>
batch={batch} ) : batches.length === 0 ? (
isAdmin={isAdmin} <div className="flex items-center justify-center h-32 text-gray-500"></div>
onDelete={handleDeleteBatch} ) : (
onRequestDelete={requestDeleteConfirmation} <div className="flex flex-col gap-3">
/> {batches.map((batch) => (
))} <BatchItem
</div> key={batch.batchId}
batch={batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
onRequestDelete={requestDeleteConfirmation}
/>
))}
</div>
)}
</>
)} )}
</div> </div>
{/* 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-center">
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm"> {searchQuery && searchResult ? (
<button <div className="flex items-center gap-4">
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" <span className="text-sm text-gray-600">
onClick={goToPreviousPage} {searchResult.totalMatches}
disabled={loading || !hasPreviousPage} {searchResult.totalMatches > searchResult.batches.length &&
> `(显示前 ${searchResult.batches.length} 个)`}
</span>
</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"> className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 underline"
{currentPage + 1} onClick={clearSearch}
>
</button>
</div> </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" <div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
onClick={goToNextPage} <button
disabled={loading || !hasNextPage} 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>
</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>
{confirmDialog && <ConfirmDialog {...confirmDialog} />} {confirmDialog && <ConfirmDialog {...confirmDialog} />}