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:
@@ -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<IpcResult<{ deleted: boolean }>>
|
||||
|
||||
/**
|
||||
* Search cleaner history records by keyword
|
||||
* @param options - Search options (query, usernames, limit)
|
||||
*/
|
||||
searchHistoryRecords: (
|
||||
options: SearchCleanerHistoryOptions
|
||||
) => Promise<IpcResult<CleanerHistorySearchResult>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<boolean>
|
||||
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<ExecutionRecord[]>([])
|
||||
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
||||
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
|
||||
@@ -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<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 () => {
|
||||
if (!canStartHistoryLoad(detailsLoadState)) return
|
||||
|
||||
@@ -492,10 +538,14 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
||||
{index + 1}
|
||||
</td>
|
||||
<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 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 className="px-4 py-2">
|
||||
<span
|
||||
@@ -573,7 +623,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{materials.map((mat, idx) => (
|
||||
<MaterialDetailRow key={idx} index={idx} material={mat} />
|
||||
<MaterialDetailRow key={idx} index={idx} material={mat} searchQuery={searchQuery} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 (
|
||||
<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 font-mono text-gray-700">{material.materialCode}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">
|
||||
{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">
|
||||
{resultDisplay.icon ? (
|
||||
@@ -623,7 +678,9 @@ const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.J
|
||||
<span className="text-gray-500">{resultDisplay.title}</span>
|
||||
)}
|
||||
</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">
|
||||
{material.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">{material.attemptCount}</span>
|
||||
@@ -648,6 +705,10 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
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 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 = () => {
|
||||
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
@@ -771,6 +868,38 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||
<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 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
||||
@@ -840,46 +969,91 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
|
||||
{/* Batch list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||
{searchQuery ? (
|
||||
// Search mode
|
||||
isSearching ? (
|
||||
<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">
|
||||
{batches.map((batch) => (
|
||||
<BatchItem
|
||||
key={batch.batchId}
|
||||
batch={batch}
|
||||
isAdmin={isAdmin}
|
||||
onDelete={handleDeleteBatch}
|
||||
onRequestDelete={requestDeleteConfirmation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
// Browse mode (existing logic unchanged)
|
||||
<>
|
||||
{loading && batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{batches.map((batch) => (
|
||||
<BatchItem
|
||||
key={batch.batchId}
|
||||
batch={batch}
|
||||
isAdmin={isAdmin}
|
||||
onDelete={handleDeleteBatch}
|
||||
onRequestDelete={requestDeleteConfirmation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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="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} 页
|
||||
{searchQuery && searchResult ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
找到 {searchResult.totalMatches} 个匹配批次
|
||||
{searchResult.totalMatches > searchResult.batches.length &&
|
||||
`(显示前 ${searchResult.batches.length} 个)`}
|
||||
</span>
|
||||
<button
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||
onClick={clearSearch}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
</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 className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
||||
<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={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>
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
|
||||
Reference in New Issue
Block a user