14 Commits

Author SHA1 Message Date
Misaka_Company
723d6de0ae 1.13.0 2026-04-17 12:43:45 +08:00
Misaka_Company
9d7fe8f4e7 docs: add release notes for version 1.13.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:43:39 +08:00
Misaka_Company
f49f99fc0c perf(cleaner-history): parallelize batch fetching in searchBatches
Replace sequential for-loop with Promise.all so that matched batches
are fetched concurrently instead of one-by-one, reducing total query
latency from O(n) serial round-trips to a single parallel batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:42:07 +08:00
Misaka_Company
622543fff4 fix(cleaner-history): highlight username and status in batch summary during search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:30:08 +08:00
Misaka_Company
74d9b4042e 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>
2026-04-17 11:21:02 +08:00
Misaka_Company
ed9058c93d feat(cleaner-history): add renderer search types and highlight utility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:11:26 +08:00
Misaka_Company
9167359c6e feat(cleaner-history): expose search API in preload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:09:31 +08:00
Misaka_Company
5faf26df3f feat(cleaner-history): add search IPC handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:07:20 +08:00
Misaka_Company
3a30694684 feat(cleaner-history): add searchBatches DAO method
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:05:17 +08:00
Misaka_Company
c61d62fd98 feat(cleaner-history): add search types and IPC channel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:59:46 +08:00
Misaka_Company
aeb3595b36 docs: add implementation plan for cleaner history search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:52:05 +08:00
Misaka_Company
b8925926cb docs: add design for cleaner history full-level search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 10:46:34 +08:00
Misaka_Company
2936f1fca3 perf: optimize MaterialTypeManagementDialog with memo, parallel fetch, and stable callbacks
- Use Promise.all for parallel managers + records loading (async-parallel)
- Wrap KeywordCard in memo to skip unnecessary list item re-renders
- Stabilize handlers with useCallback + functional setState pattern
- Hoist generateId to module scope to avoid per-render recreation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:25:00 +08:00
Misaka_Company
f57fdf69f7 refactor: modernize MaterialTypeManagementDialog UI and fix admin hover overlap
Restructure the dialog with a card-grid layout, KeywordCard subcomponent,
and smooth hover animations. Fix admin view where manager badge and delete
button overlapped by using flex layout with translate and max-width transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:55:08 +08:00
15 changed files with 1682 additions and 454 deletions

View File

@@ -0,0 +1,94 @@
# Cleaner Operation History - Full-Level Search Design
Date: 2026-04-17
## Summary
Add a full-level search feature to `CleanerOperationHistoryModal` that allows users to search across batches, orders, and materials by entering a single keyword. A new backend search API returns pre-joined three-level nested data, and the frontend renders it with keyword highlighting.
## Interaction Design
- **Search bar**: placed in the toolbar area, above the user filter chips, with a search icon and clear button.
- **Trigger**: press Enter or click the search button (no per-keystroke requests).
- **Search mode behavior**:
- Hides pagination controls (results are cross-page).
- Matching batches auto-expand with orders and materials displayed directly.
- Non-matching levels are hidden.
- Clearing the search box returns to normal browse mode.
- **Highlighting**: matched text wrapped in `<mark>` with yellow background.
- **Empty result**: shows "未找到匹配的记录" message.
- **Result cap**: backend limits to 20 batches; if truncated, shows a hint.
## Search Fields
| Level | Searchable fields |
|-------|-------------------|
| Batch | `batchId`, `username`, `status` |
| Order | `orderNumber`, `productionId` |
| Material | `materialCode`, `materialName` |
## Data Flow
```
renderer: window.electron.cleaner.searchHistoryRecords(query, options)
→ preload: expose searchHistoryRecords
→ main IPC handler: cleaner:searchHistoryRecords
→ service/DAO: searchCleanerHistory(searchQuery, options)
→ DB query (JOIN batches + orders + materials, LIKE filter)
```
### Input Types
```typescript
interface SearchCleanerHistoryOptions {
query: string
usernames?: string[] // admin-only user scope
limit?: number // default 20
}
```
### Response Type
```typescript
interface CleanerHistorySearchResult {
batches: Array<{
batch: CleanerHistoryBatchStats
executions: ExecutionRecord[]
orders: Array<{
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}>
}>
totalMatches: number
}
```
## Frontend Changes
1. **Modal top-level**: add `searchMode` / `searchQuery` state; switch data source between search API and paginated API.
2. **Toolbar**: add search input with Search icon and clear button.
3. **BatchItem**: accept optional pre-loaded `orders` + `materials` props; skip lazy-loading in search mode.
4. **Highlight utility**: `highlightText(text: string, query: string)` wraps matches in `<mark>` tags.
5. **Footer**: hide pagination in search mode; show "找到 X 个批次" + "清除搜索" button.
## Backend Changes
| File | Change |
|------|--------|
| `src/main/types/cleaner-history.types.ts` | Add `SearchCleanerHistoryOptions`, `CleanerHistorySearchResult` types |
| DAO (cleaner history) | Add `searchCleanerHistory` method with SQL LIKE across joined tables |
| Service (cleaner) | Add `searchHistoryRecords` method |
| IPC handler | Register `cleaner:searchHistoryRecords` channel |
| Preload | Expose `searchHistoryRecords` method |
| `src/renderer/src/hooks/cleaner/types.ts` | Sync search result types |
## Affected Files
- `src/main/types/cleaner-history.types.ts` — new types
- `src/main/services/database/cleaner-history-dao.ts` (or similar) — new search method
- `src/main/services/cleaner-service.ts` (or similar) — new search method
- `src/main/ipc/cleaner-handler.ts` (or similar) — new IPC channel
- `src/preload/index.ts` (or cleaner-specific) — expose search API
- `src/renderer/src/hooks/cleaner/types.ts` — sync types
- `src/renderer/src/components/CleanerOperationHistoryModal.tsx` — search UI + state
- `src/renderer/src/components/cleaner-history-highlight.ts` — highlight utility (new file)

View File

@@ -0,0 +1,675 @@
# Cleaner History Full-Level Search Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add full-level search (batch + order + material) to the CleanerOperationHistoryModal, using a new backend search API that returns pre-joined three-level nested data, with keyword highlighting in the frontend.
**Architecture:** New `searchHistoryRecords` IPC channel goes through the existing DAO pattern. The DAO method performs a UNION-based SQL query across all three tables to find matching BatchIds, then fetches the full nested data for those batches. The frontend switches between browse mode (paginated) and search mode (full results) based on whether a search query is active.
**Tech Stack:** TypeScript, React, SQL (MySQL/PostgreSQL/SQL Server via dialect abstraction), Electron IPC
---
### Task 1: Add search types to main process
**Files:**
- Modify: `src/main/types/cleaner-history.types.ts` (append at end)
- Modify: `src/shared/ipc-channels.ts` (add new channel)
**Step 1: Add search types to cleaner-history.types.ts**
Append after the existing `GetCleanerBatchesOptions` interface:
```typescript
/** Search options for full-level history search */
export interface SearchCleanerHistoryOptions {
query: string
usernames?: string[]
limit?: number
}
/** A single batch's full nested data for search results */
export interface CleanerSearchBatchResult {
batch: CleanerBatchStats
executions: CleanerExecutionRecord[]
orders: Array<{
order: CleanerOrderRecord
materials: CleanerMaterialRecord[]
}>
}
/** Search response */
export interface CleanerHistorySearchResult {
batches: CleanerSearchBatchResult[]
totalMatches: number
}
```
**Step 2: Add IPC channel to ipc-channels.ts**
In the `// Cleaner operation history` section, after `CLEANER_HISTORY_DELETE_BATCH`, add:
```typescript
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
Expected: No new errors related to these types
**Step 4: Commit**
```bash
git add src/main/types/cleaner-history.types.ts src/shared/ipc-channels.ts
git commit -m "feat(cleaner-history): add search types and IPC channel"
```
---
### Task 2: Add search DAO method
**Files:**
- Modify: `src/main/services/database/cleaner-operation-history-dao.ts`
**Step 1: Add imports for new types**
At the top of the file, add to the existing import from `../../types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerSearchBatchResult,
CleanerHistorySearchResult
} from '../../types/cleaner-history.types'
```
**Step 2: Add the searchBatches method to the DAO class**
Add this method after the `getBatches` method (around line 707). The strategy:
1. First, find matching BatchIds via a UNION query across all three tables using LIKE.
2. Then fetch full nested data (batch stats, executions, orders, materials) for those batch IDs.
```typescript
// ==================== QUERY: SEARCH ====================
/**
* Full-level search across batches, orders, and materials
* Uses UNION to find matching BatchIds, then fetches full nested data
*/
async searchBatches(
userId: number | undefined,
options: SearchCleanerHistoryOptions
): Promise<CleanerHistorySearchResult> {
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
const likeValue = `%${options.query}%`
const limit = options.limit ?? 20
// Step 1: Find distinct BatchIds matching the query across all tables
const batchIdSql = `
SELECT DISTINCT BatchId FROM (
SELECT e.BatchId FROM ${execTable} e
WHERE e.BatchId LIKE ${dialect.param(0)}
OR e.Username LIKE ${dialect.param(0)}
OR e.Status LIKE ${dialect.param(0)}
UNION ALL
SELECT o.BatchId FROM ${orderTable} o
WHERE o.OrderNumber LIKE ${dialect.param(0)}
OR o.ProductionId LIKE ${dialect.param(0)}
UNION ALL
SELECT m.BatchId FROM ${materialTable} m
WHERE m.MaterialCode LIKE ${dialect.param(0)}
OR m.MaterialName LIKE ${dialect.param(0)}
) AS matched
${userId !== undefined ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE UserId = ${dialect.param(1)})` : ''}
${options.usernames && options.usernames.length > 0 ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE Username IN (${dialect.params(options.usernames.length)}))` : ''}
`
const batchIdParams: (string | number)[] = [likeValue]
if (userId !== undefined) {
batchIdParams.push(userId)
}
if (options.usernames && options.usernames.length > 0) {
batchIdParams.push(...options.usernames)
}
const batchIdResult = await trackDuration(
async () => await dbService.query(batchIdSql, batchIdParams),
{
operationName: 'CleanerOperationHistoryDAO.searchBatches.batchIds',
context: { operationType: 'SELECT', query: options.query }
}
)
const matchedBatchIds = batchIdResult.result.rows.map((r) => r.BatchId as string)
if (matchedBatchIds.length === 0) {
return { batches: [], totalMatches: 0 }
}
// Apply limit
const limitedBatchIds = matchedBatchIds.slice(0, limit)
// Step 2: For each batch, fetch full nested data in parallel
const batches: CleanerSearchBatchResult[] = []
for (const batchId of limitedBatchIds) {
// Fetch batch stats
const batchStatsArr = await this.getBatches(userId, { limit: 1 })
const batchStats = batchStatsArr.find((b) => b.batchId === batchId)
if (!batchStats) continue
// Fetch executions + orders
const details = await this.getBatchDetails(batchId)
// Fetch materials for all orders
const ordersWithMaterials = await Promise.all(
details.orders.map(async (order) => {
const materials = await this.getMaterialDetails(
batchId,
order.attemptNumber,
order.orderNumber
)
return { order, materials }
})
)
batches.push({
batch: batchStats,
executions: details.executions,
orders: ordersWithMaterials
})
}
return {
batches,
totalMatches: matchedBatchIds.length
}
} catch (error) {
log.error('Search batches error', {
operationType: 'SELECT',
requestId: getRequestId(),
query: options.query,
error: error instanceof Error ? error.message : String(error)
})
return { batches: [], totalMatches: 0 }
}
}
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
Expected: No errors related to the DAO
**Step 4: Commit**
```bash
git add src/main/services/database/cleaner-operation-history-dao.ts
git commit -m "feat(cleaner-history): add searchBatches DAO method"
```
---
### Task 3: Add IPC handler for search
**Files:**
- Modify: `src/main/ipc/cleaner-history-handler.ts`
**Step 1: Add new IPC handler**
In `registerCleanerHistoryHandlers()`, after the `CLEANER_HISTORY_DELETE_BATCH` handler (before the closing log statement at the end), add:
```typescript
/**
* Search across all history levels (batches, orders, materials)
* Admin users search all records, regular users search only their own
*/
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
async (
_event,
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
if (!options.query || options.query.trim().length === 0) {
return { batches: [], totalMatches: 0 }
}
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Searching cleaner history', {
userId: currentUser.id,
userType: currentUser.userType,
query: options.query
})
return await dao.searchBatches(userId, {
...options,
query: options.query.trim()
})
}, 'cleanerHistory:search')
}
)
```
**Step 2: Update imports in cleaner-history-handler.ts**
Add to the existing import from `../../types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../types/cleaner-history.types'
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
**Step 4: Commit**
```bash
git add src/main/ipc/cleaner-history-handler.ts
git commit -m "feat(cleaner-history): add search IPC handler"
```
---
### Task 4: Expose search API in preload
**Files:**
- Modify: `src/preload/api/cleaner.ts`
**Step 1: Add search method to cleanerApi**
After the `deleteHistoryBatch` method, add:
```typescript
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options),
```
**Step 2: Add imports**
Add to the existing import from `../../main/types/cleaner-history.types`:
```typescript
import type {
// ... existing imports ...
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../../main/types/cleaner-history.types'
```
**Step 3: Verify TypeScript compiles**
Run: `npx tsc --noEmit --project src/preload/tsconfig.json 2>&1 | head -20`
**Step 4: Commit**
```bash
git add src/preload/api/cleaner.ts
git commit -m "feat(cleaner-history): expose search API in preload"
```
---
### Task 5: Add renderer-side types and highlight utility
**Files:**
- Modify: `src/renderer/src/hooks/cleaner/types.ts` (add search result types)
- Create: `src/renderer/src/components/cleaner-history-highlight.tsx`
**Step 1: Add search result types to renderer types**
Append to `src/renderer/src/hooks/cleaner/types.ts`:
```typescript
// Search result types (mirrors main process types)
export interface CleanerHistorySearchOrderResult {
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}
export interface CleanerHistorySearchBatchResult {
batch: CleanerHistoryBatchStats
executions: CleanerHistoryExecutionRecord[]
orders: CleanerHistorySearchOrderResult[]
}
export interface CleanerHistorySearchResult {
batches: CleanerHistorySearchBatchResult[]
totalMatches: number
}
```
**Step 2: Create highlight utility**
Create `src/renderer/src/components/cleaner-history-highlight.tsx`:
```tsx
import React from 'react'
/**
* Highlight matching text with a <mark> tag
* Case-insensitive matching of the query within text
*/
export function highlightText(text: string, query: string): React.ReactNode {
if (!query || !text) return text
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const index = lowerText.indexOf(lowerQuery)
if (index === -1) return text
const before = text.substring(0, index)
const match = text.substring(index, index + query.length)
const after = text.substring(index + query.length)
return (
<>
{before}
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
{highlightText(after, query)}
</>
)
}
```
**Step 3: Commit**
```bash
git add src/renderer/src/hooks/cleaner/types.ts src/renderer/src/components/cleaner-history-highlight.tsx
git commit -m "feat(cleaner-history): add renderer search types and highlight utility"
```
---
### Task 6: Integrate search into CleanerOperationHistoryModal
**Files:**
- Modify: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
This is the largest task. The changes are:
1. Add search state variables
2. Add search bar UI to the toolbar
3. Modify BatchItem to accept pre-loaded data in search mode
4. Switch footer between pagination and search result summary
**Step 1: Add new imports**
Add to the lucide-react import:
```typescript
import { Search, X } from 'lucide-react'
```
Add the highlight utility and new types:
```typescript
import { highlightText } from './cleaner-history-highlight'
import type { CleanerHistorySearchResult } from '../hooks/cleaner/types'
```
**Step 2: Add search state in the main modal component**
After the existing state declarations (around line 651), add:
```typescript
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
const [isSearching, setIsSearching] = useState(false)
```
**Step 3: Add the search execution function**
Add after `clearUserFilters`:
```typescript
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) {
setSearchResult(result.data)
} else {
setSearchResult({ batches: [], totalMatches: 0 })
}
} catch {
setSearchResult({ batches: [], totalMatches: 0 })
} finally {
setIsSearching(false)
}
}, [searchInput, isAdmin, selectedUsers])
const clearSearch = () => {
setSearchInput('')
setSearchQuery('')
setSearchResult(null)
}
```
**Step 4: Add search bar UI**
In the toolbar section, before the user filter `<div className="mb-3">` block, add the search input:
```tsx
{/* 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>
```
**Step 5: Modify the batch list section to support search mode**
Replace the batch list section (the `<div className="flex-1 overflow-y-auto">` block) with conditional rendering:
```tsx
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{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>
)
) : (
// Browse mode (existing logic)
<>
{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>
```
**Step 6: Modify footer for search mode**
Replace the footer section to conditionally show pagination or search summary:
```tsx
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-center">
{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>
) : (
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
{/* ... existing pagination buttons ... */}
</div>
)}
</div>
```
**Step 7: Update BatchItem props and search mode rendering**
Update `BatchItemProps` interface to support optional preloaded data:
```typescript
interface BatchItemProps {
batch: CleanerHistoryBatchStats
isAdmin: boolean
onDelete: (batchId: string) => void
onRequestDelete: (batchId: string) => Promise<boolean>
searchQuery?: string
preloadedExecutions?: ExecutionRecord[]
preloadedOrders?: Array<{
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}>
}
```
In the BatchItem component, when `searchQuery` is set and preloaded data is available:
- Start expanded (`isExpanded` initial state: `!!searchQuery`)
- Use preloaded executions/orders directly instead of fetching
- Pass `searchQuery` to text rendering for highlighting
**Step 8: Verify typecheck**
Run: `npm run typecheck`
**Step 9: Commit**
```bash
git add src/renderer/src/components/CleanerOperationHistoryModal.tsx
git commit -m "feat(cleaner-history): integrate search UI into history modal"
```
---
### Task 7: Final verification
**Step 1: Run full typecheck**
Run: `npm run typecheck`
**Step 2: Run linter**
Run: `npm run lint`
**Step 3: Build the project**
Run: `npm run build`
**Step 4: Manual test checklist**
- [ ] Open the Cleaner Operation History Modal
- [ ] Verify search bar appears at top of toolbar
- [ ] Type a keyword and press Enter — results should load
- [ ] Matching batches auto-expand with orders and materials
- [ ] Highlighted text appears with yellow background
- [ ] Clear button (X) resets to browse mode
- [ ] Pagination hidden during search, shown after clearing
- [ ] Admin: user filter combined with search works
- [ ] Regular user: only their own records searched
- [ ] Empty search query does nothing
- [ ] Non-matching query shows "未找到匹配" message

18
docs/releases/1.13.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.13.0
## 清理操作历史
- 新增历史记录全局搜索,支持按批次 ID、订单号、物料编码/名称、用户名、状态等关键字检索。
- 搜索结果高亮显示匹配关键字,快速定位目标记录。
- 搜索结果自动展开批次详情、订单和物料明细,无需逐层手动点击。
- 搜索支持与用户筛选联动,管理员可限定搜索范围到指定用户。
## 界面与交互
- 物料类型管理面板样式更新,改善整体视觉一致性。
- 修复管理员模式下物料类型管理面板的悬浮重叠问题。
## 改进
- 优化历史搜索查询性能,多个批次数据并行获取,减少等待时间。
- 物料类型管理面板加载速度优化,减少不必要的重渲染。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "erpauto",
"version": "1.12.4",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.12.4",
"version": "1.13.0",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.12.4",
"version": "1.13.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",

View File

@@ -19,7 +19,9 @@ import type {
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../types/cleaner-history.types'
const log = createLogger('CleanerHistoryHandler')
@@ -152,5 +154,42 @@ export function registerCleanerHistoryHandlers(): void {
}
)
/**
* Search across all history levels (batches, orders, materials)
* Admin users search all records, regular users search only their own
*/
ipcMain.handle(
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
async (
_event,
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
if (!options.query || options.query.trim().length === 0) {
return { batches: [], totalMatches: 0 }
}
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Searching cleaner history', {
userId: currentUser.id,
userType: currentUser.userType,
query: options.query
})
return await dao.searchBatches(userId, {
...options,
query: options.query.trim()
})
}, 'cleanerHistory:search')
}
)
log.info('Cleaner history IPC handlers registered')
}

View File

@@ -18,7 +18,10 @@ import type {
InsertCleanerExecutionInput,
InsertOrderInput,
InsertMaterialDetailInput,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerSearchBatchResult,
CleanerHistorySearchResult
} from '../../types/cleaner-history.types'
const log = createLogger('CleanerOperationHistoryDAO')
@@ -980,6 +983,172 @@ export class CleanerOperationHistoryDAO {
}
}
// ==================== QUERY: SEARCH BATCHES ====================
/**
* Full-level search across batches, orders, and materials.
* Uses a UNION ALL query to find matching BatchIds, then fetches
* full nested data for each matched batch.
*
* @param userId - Optional user ID for filtering
* @param options - Search options (query string, usernames filter, result limit)
* @returns Search result with matched batches and total count
*/
async searchBatches(
userId: number | undefined,
options: SearchCleanerHistoryOptions
): Promise<CleanerHistorySearchResult> {
const emptyResult: CleanerHistorySearchResult = { batches: [], totalMatches: 0 }
try {
const dbService = await this.getDatabaseService()
const execTable = this.getExecutionTableName()
const orderTable = this.getOrderTableName()
const materialTable = this.getMaterialTableName()
const dialect = this.getDialect()
const { query, limit = 20 } = options
const safeLimit = Math.floor(limit)
const likePattern = `%${query}%`
// Build UNION ALL to find distinct BatchIds matching the query
// Each subquery searches a different table's columns
let paramIndex = 0
const p = () => dialect.param(paramIndex++)
const unionSql = `
SELECT DISTINCT BatchId FROM (
SELECT BatchId FROM ${execTable}
WHERE BatchId LIKE ${p()}
OR Username LIKE ${p()}
OR Status LIKE ${p()}
UNION ALL
SELECT BatchId FROM ${orderTable}
WHERE OrderNumber LIKE ${p()}
OR ProductionId LIKE ${p()}
UNION ALL
SELECT BatchId FROM ${materialTable}
WHERE MaterialCode LIKE ${p()}
OR MaterialName LIKE ${p()}
) AS matched
WHERE BatchId IN (
SELECT BatchId FROM ${execTable}
WHERE 1=1
${userId !== undefined ? `AND UserId = ${p()}` : ''}
${userId === undefined && options.usernames && options.usernames.length > 0 ? `AND Username IN (${dialect.params(options.usernames.length)})` : ''}
)
`
const unionParams: (string | number)[] = [
likePattern, likePattern, likePattern, // exec table: BatchId, Username, Status
likePattern, likePattern, // order table: OrderNumber, ProductionId
likePattern, likePattern // material table: MaterialCode, MaterialName
]
// User filtering params
if (userId !== undefined) {
unionParams.push(userId)
} else if (options.usernames && options.usernames.length > 0) {
unionParams.push(...options.usernames)
}
const { result } = await trackDuration(
async () => await dbService.query(unionSql, unionParams),
{
operationName: 'CleanerOperationHistoryDAO.searchBatches.union',
context: { operationType: 'SELECT', query }
}
)
const matchedBatchIds: string[] = result.rows.map((row) => row.BatchId as string)
const totalMatches = matchedBatchIds.length
const limitedBatchIds = matchedBatchIds.slice(0, safeLimit)
if (limitedBatchIds.length === 0) {
return { batches: [], totalMatches: 0 }
}
// Fetch full nested data for each matched batch (parallel)
const batchResults = await Promise.all(
limitedBatchIds.map(async (batchId) => {
try {
const details = await this.getBatchDetails(batchId)
if (details.executions.length === 0) {
return null
}
// Derive batch stats from execution records
const latestExec = details.executions.reduce((a, b) =>
a.attemptNumber > b.attemptNumber ? a : b
)
const batch: CleanerBatchStats = {
batchId,
userId: latestExec.userId,
username: latestExec.username,
operationTime: latestExec.operationTime.toISOString(),
status: latestExec.status,
totalAttempts: details.executions.length,
totalOrders: latestExec.totalOrders,
ordersProcessed: latestExec.ordersProcessed,
totalMaterialsDeleted: latestExec.totalMaterialsDeleted,
totalMaterialsFailed: latestExec.totalMaterialsFailed,
successCount: details.orders.filter((o) => o.status === 'success').length,
failedCount: details.orders.filter((o) => o.status === 'failed').length,
isDryRun: latestExec.isDryRun
}
// Fetch materials for each order
const ordersWithMaterials = await Promise.all(
details.orders.map(async (order) => {
const materials = await this.getMaterialDetails(
batchId,
order.attemptNumber,
order.orderNumber
)
return { order, materials }
})
)
return {
batch,
executions: details.executions,
orders: ordersWithMaterials
} satisfies CleanerSearchBatchResult
} catch (error) {
log.error('Error fetching batch data for search result', {
operationType: 'SELECT',
batchId,
error: error instanceof Error ? error.message : String(error)
})
return null
}
})
)
const batches = batchResults.filter((r): r is CleanerSearchBatchResult => r !== null)
log.info('Search batches completed', {
operationType: 'SELECT',
requestId: getRequestId(),
query,
totalMatches,
returnedBatches: batches.length
})
return { batches, totalMatches }
} catch (error) {
log.error('Search batches error', {
operationType: 'SELECT',
requestId: getRequestId(),
query: options.query,
error: error instanceof Error ? error.message : String(error)
})
return emptyResult
}
}
// ==================== UTILITIES ====================
/**

View File

@@ -111,3 +111,26 @@ export interface GetCleanerBatchesOptions {
offset?: number
usernames?: string[]
}
/** Search options for full-level history search */
export interface SearchCleanerHistoryOptions {
query: string
usernames?: string[]
limit?: number
}
/** A single batch's full nested data for search results */
export interface CleanerSearchBatchResult {
batch: CleanerBatchStats
executions: CleanerExecutionRecord[]
orders: Array<{
order: CleanerOrderRecord
materials: CleanerMaterialRecord[]
}>
}
/** Search response */
export interface CleanerHistorySearchResult {
batches: CleanerSearchBatchResult[]
totalMatches: number
}

View File

@@ -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>>
}
/**

View File

@@ -8,7 +8,9 @@ import type {
CleanerExecutionRecord,
CleanerOrderRecord,
CleanerMaterialRecord,
GetCleanerBatchesOptions
GetCleanerBatchesOptions,
SearchCleanerHistoryOptions,
CleanerHistorySearchResult
} from '../../main/types/cleaner-history.types'
import type { IpcResult } from '../../main/types/ipc.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
@@ -53,5 +55,10 @@ export const cleanerApi = {
),
deleteHistoryBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId)
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId),
searchHistoryRecords: (
options: SearchCleanerHistoryOptions
): Promise<IpcResult<CleanerHistorySearchResult>> =>
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options)
} as const

View File

@@ -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
@@ -308,7 +354,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
<div className="font-medium text-gray-900">
{searchQuery ? highlightText(batch.username, searchQuery) : batch.username}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
@@ -317,7 +365,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
>
{batchStatusDisplay.label}
{searchQuery
? highlightText(batchStatusDisplay.label, searchQuery)
: batchStatusDisplay.label}
</span>
</div>
</div>
@@ -492,10 +542,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 +627,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 +657,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 +682,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 +709,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 +813,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 +872,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 +973,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} />}

View File

@@ -1,19 +1,30 @@
/**
* Material Type Management Dialog
* Material Type Management Dialog (Modern UI Refactor)
*
* Provides a dialog for managing material type keywords used to identify
* materials for deletion. Admin users can see all records and filter by manager.
* Regular users can only see and edit their own records.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
import { Modal } from './ui/Modal'
import React, { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
X,
Plus,
RotateCcw,
Search,
Trash2,
User,
CloudUpload,
CheckCircle2,
AlertCircle,
Users
} from 'lucide-react'
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
import { useLogger } from '../hooks/useLogger'
// --- Interfaces ---
interface MaterialTypeRecord {
id?: number
materialName: string
@@ -21,6 +32,7 @@ interface MaterialTypeRecord {
}
interface RowState {
localId: string // Stable ID for React mapping during edits/filters
record: MaterialTypeRecord
state: 'original' | 'new' | 'modified' | 'deleted'
originalRecord?: MaterialTypeRecord
@@ -34,40 +46,179 @@ interface MaterialTypeManagementDialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | null>
}
// Stable ID generator for React keys
const generateId = () => Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
// --- KeywordCard Component (Handles individual items) ---
interface KeywordCardProps {
item: RowState
isAdmin: boolean
managers: string[]
onUpdate: (localId: string, newRecord: Partial<MaterialTypeRecord>) => void
onDelete: (localId: string) => void
onRestore: (localId: string) => void
}
const KeywordCard = memo(function KeywordCard({ item, isAdmin, managers, onUpdate, onDelete, onRestore }: KeywordCardProps) {
const [isEditing, setIsEditing] = useState(item.state === 'new');
const [text, setText] = useState(item.record.materialName);
const [manager, setManager] = useState(item.record.managerName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
if (item.state !== 'new') {
inputRef.current.select();
}
}
}, [isEditing, item.state]);
const handleSave = () => {
const trimmedText = text.trim();
if (!trimmedText) {
onDelete(item.localId); // Delete if empty
} else {
setIsEditing(false);
if (trimmedText !== item.record.materialName || manager !== item.record.managerName) {
onUpdate(item.localId, { materialName: trimmedText, managerName: manager });
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') {
setText(item.record.materialName);
setManager(item.record.managerName);
setIsEditing(false);
if (item.state === 'new') onDelete(item.localId);
}
};
// Deleted State View
if (item.state === 'deleted') {
return (
<div className="group relative flex items-center h-10 px-3 bg-red-50/50 border border-red-200 rounded-lg transition-all">
<span className="flex-1 truncate text-sm text-red-700/60 font-medium line-through mr-6">
{item.record.materialName}
</span>
<button
onClick={(e) => { e.stopPropagation(); onRestore(item.localId); }}
className="absolute right-2 p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded-md transition-all"
title="撤销删除"
>
<RotateCcw size={14} />
</button>
</div>
);
}
// Editing State View
if (isEditing) {
return (
<div
className="relative flex items-center h-10 px-3 bg-indigo-50 border border-indigo-400 rounded-lg shadow-sm ring-2 ring-indigo-100 transition-all"
onBlur={(e) => {
// Delay save to allow dropdown clicks. Checks if new focus is outside this container.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
handleSave();
}
}}
>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 mr-2 flex-shrink-0"></div>
<input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="关键词..."
className="flex-1 w-full min-w-[80px] bg-transparent outline-none text-sm font-medium text-indigo-900 placeholder-indigo-300"
/>
{isAdmin && (
<select
value={manager}
onChange={(e) => setManager(e.target.value)}
className="ml-2 bg-white outline-none text-xs text-indigo-700 border border-indigo-200 rounded px-1 py-0.5 focus:ring-2 focus:ring-indigo-200"
>
<option value=""></option>
{managers.map(m => <option key={m} value={m}>{m}</option>)}
</select>
)}
</div>
);
}
// Normal / Modified State View
const isModified = item.state === 'modified' || item.state === 'new';
return (
<div
onClick={() => setIsEditing(true)}
className={`group relative flex items-center h-10 px-3 bg-white border rounded-lg cursor-pointer transition-all duration-200 hover:shadow-md hover:border-indigo-300
${isModified ? 'border-amber-300 bg-amber-50/30' : 'border-slate-200'}
`}
>
<div className={`w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-colors
${isModified ? 'bg-amber-400' : 'bg-slate-300 group-hover:bg-indigo-400'}
`}></div>
<span className="flex-1 truncate text-sm text-slate-700 font-medium group-hover:text-indigo-700 transition-colors mr-2">
{item.record.materialName}
</span>
<div className="flex-shrink-0 flex items-center">
{isAdmin && (
<span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded truncate max-w-[60px] group-hover:bg-indigo-50 group-hover:text-indigo-500 group-hover:-translate-x-2 transition-all duration-300 ease-in-out">
{item.record.managerName || '未指定'}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.localId); }}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md max-w-0 overflow-hidden opacity-0 group-hover:max-w-8 group-hover:opacity-100 transition-all duration-200"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
});
// --- Main Dialog Component ---
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername,
triggerRef
// triggerRef // Retained for compatibility, but layout uses custom overlay
}) => {
const [rows, setRows] = useState<RowState[]>([])
const [managers, setManagers] = useState<string[]>([])
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
const [editValue, setEditValue] = useState('')
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const logger = useLogger('MaterialType')
const tableRef = useRef<HTMLTableElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const selectRef = useRef<HTMLSelectElement>(null)
// Confirmation dialog hook
const { confirm, dialog: confirmDialog } = useConfirmDialog()
// Calculate pending changes count
const pendingCount = rows.filter(
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
).length
const isSynced = pendingCount === 0;
const loadData = useCallback(async () => {
setLoading(true)
try {
// Load managers list
const managersResult = await window.electron.materialType.getManagers()
const [managersResult, recordsResult] = await Promise.all([
window.electron.materialType.getManagers(),
isAdmin
? window.electron.materialType.getAll()
: window.electron.materialType.getByManager(currentUsername)
])
if (managersResult.success && managersResult.data) {
setManagers(managersResult.data)
if (isAdmin) {
@@ -75,28 +226,17 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
}
}
// Load records
let records: MaterialTypeRecord[] = []
if (isAdmin) {
const result = await window.electron.materialType.getAll()
if (result.success && result.data) {
records = result.data
}
} else {
const result = await window.electron.materialType.getByManager(currentUsername)
if (result.success && result.data) {
records = result.data
}
}
if (recordsResult.success && recordsResult.data) records = recordsResult.data
setRows(
records.map((record) => ({
localId: generateId(),
record,
state: 'original' as const,
originalRecord: { ...record }
}))
)
setSelectedRowIndex(null)
} catch (error) {
logger.error('Failed to load material types', {
error: error instanceof Error ? error.message : String(error),
@@ -108,137 +248,73 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
}
}, [currentUsername, isAdmin, logger])
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
void loadData()
}
if (isOpen) void loadData()
}, [isOpen, loadData])
// Focus input when editing starts
useEffect(() => {
const activeElement = inputRef.current ?? selectRef.current
if (editingCell && activeElement) {
activeElement.focus()
if (activeElement instanceof HTMLInputElement) {
activeElement.select()
}
const filteredRows = useMemo(() => {
let result = rows;
if (isAdmin && selectedManagers.size > 0) {
result = result.filter(row => selectedManagers.has(row.record.managerName) || row.state === 'new');
}
}, [editingCell])
if (searchQuery) {
const lowerQuery = searchQuery.toLowerCase();
result = result.filter(row => row.record.materialName.toLowerCase().includes(lowerQuery));
}
return result;
}, [rows, isAdmin, selectedManagers, searchQuery])
// Filter rows by selected managers (admin only)
const filteredRows = React.useMemo(() => {
if (!isAdmin) return rows
if (selectedManagers.size === 0) return rows
return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new')
}, [rows, isAdmin, selectedManagers])
// Insert new row
const insertNewRow = useCallback(() => {
// --- Row Actions ---
const handleAdd = useCallback(() => {
const newRow: RowState = {
record: {
materialName: '',
managerName: isAdmin ? '' : currentUsername
},
localId: generateId(),
record: { materialName: '', managerName: isAdmin ? '' : currentUsername },
state: 'new'
}
// 在 setRows 回调中计算索引并设置编辑状态
setRows((prev) => {
const newIndex = prev.length
setTimeout(() => {
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
setEditValue('')
}, 0)
return [...prev, newRow]
})
setRows(prev => [newRow, ...prev])
setSearchQuery('')
}, [isAdmin, currentUsername])
// Delete row
const deleteRow = useCallback((index: number) => {
setRows((prev) => {
const newRows = [...prev]
const row = newRows[index]
if (row.state === 'new') {
// Remove new rows directly
newRows.splice(index, 1)
} else {
// Mark existing rows as deleted
newRows[index] = { ...row, state: 'deleted' }
}
return newRows
})
setSelectedRowIndex(null)
}, [])
// Start editing a cell
const startEdit = useCallback(
(rowIndex: number, field: string) => {
const row = rows[rowIndex]
if (row.state === 'deleted') return
setEditingCell({ rowIndex, field })
setEditValue(row.record[field as keyof MaterialTypeRecord] as string)
},
[rows]
)
// Save edit
const saveEdit = useCallback(() => {
if (!editingCell) return
const { rowIndex, field } = editingCell
setRows((prev) => {
const newRows = [...prev]
const row = newRows[rowIndex]
const newValue = editValue.trim()
// Update the record
newRows[rowIndex] = {
const handleUpdate = useCallback((localId: string, updates: Partial<MaterialTypeRecord>) => {
setRows(prev => prev.map(row => {
if (row.localId !== localId) return row;
return {
...row,
record: {
...row.record,
[field]: newValue
},
record: { ...row.record, ...updates },
state: row.state === 'new' ? 'new' : 'modified'
}
return newRows
})
setEditingCell(null)
setEditValue('')
}, [editingCell, editValue])
// Cancel edit
const cancelEdit = useCallback(() => {
setEditingCell(null)
setEditValue('')
}))
}, [])
// Handle keyboard events
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (editingCell) {
if (event.key === 'Enter') {
saveEdit()
} else if (event.key === 'Escape') {
cancelEdit()
}
return
}
const handleDelete = useCallback((localId: string) => {
setRows(prev => {
const row = prev.find(r => r.localId === localId);
if (!row) return prev;
if (row.state === 'new') return prev.filter(r => r.localId !== localId);
return prev.map(r => r.localId === localId ? { ...r, state: 'deleted' } : r);
})
}, [])
if (event.key === 'Insert') {
event.preventDefault()
insertNewRow()
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
event.preventDefault()
deleteRow(selectedRowIndex)
}
},
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
)
const handleRestore = useCallback((localId: string) => {
setRows(prev => prev.map(r => {
if (r.localId !== localId) return r;
const wasModified = r.originalRecord?.materialName !== r.record.materialName ||
r.originalRecord?.managerName !== r.record.managerName;
return { ...r, state: wasModified ? 'modified' : 'original' }
}))
}, [])
const handleReset = async () => {
if (pendingCount === 0) return
const confirmed = await confirm({
title: '确认重置',
message: '确定要放弃所有未保存的更改吗?',
variant: 'warning'
})
if (confirmed) void loadData()
}
// Save all changes
const handleSave = async () => {
const toInsert: MaterialTypeRecord[] = []
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
@@ -273,19 +349,11 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
setSaving(true)
try {
const result = await window.electron.materialType.upsertBatch({
toInsert,
toUpdate,
toDelete
})
const payload = result.success
? (result.data as { stats?: { success?: number; failed?: number } } | undefined)
: undefined
const result = await window.electron.materialType.upsertBatch({ toInsert, toUpdate, toDelete })
const payload = result.success ? (result.data as { stats?: { success?: number; failed?: number } } | undefined) : undefined
if (result.success) {
showSuccess(
`保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}`
)
showSuccess(`保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}`)
await loadData()
} else {
showError(`保存失败:${result.error || '未知错误'}`)
@@ -294,29 +362,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
logger.error('Failed to save material types', {
error: error instanceof Error ? error.message : String(error),
inserts: toInsert.length,
updates: toUpdate.length,
deletes: toDelete.length
inserts: toInsert.length, updates: toUpdate.length, deletes: toDelete.length
})
} finally {
setSaving(false)
}
}
// Reset changes
const handleReset = async () => {
if (pendingCount === 0) return
const confirmed = await confirm({
title: '确认重置',
message: '确定要放弃所有未保存的更改吗?',
variant: 'warning'
})
if (confirmed) {
void loadData()
}
}
// Handle close with unsaved changes warning
const handleClose = async () => {
if (pendingCount > 0) {
const confirmed = await confirm({
@@ -329,253 +381,170 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
onClose()
}
// Get row background color
const getRowStyle = (row: RowState): React.CSSProperties => {
if (row.state === 'deleted') {
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
}
if (row.state === 'new') {
return { backgroundColor: '#dcfce7' }
}
if (row.state === 'modified') {
return { backgroundColor: '#fef9c3' }
}
return {}
}
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="物料类型管理"
size="2xl"
triggerRef={triggerRef}
>
<div onKeyDown={handleKeyDown}>
{/* Manager filter (admin only) */}
{isAdmin && (
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Users size={16} />
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4 md:p-8 font-sans">
<div className="w-full max-w-5xl bg-white rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden flex flex-col h-[85vh] animate-in fade-in zoom-in-95 duration-200">
{/* --- Header --- */}
<header className="px-6 py-5 border-b border-slate-100 flex justify-between items-start bg-slate-50/50">
<div>
<div className="flex items-center space-x-2 text-xs font-semibold text-slate-400 tracking-wider mb-1 uppercase">
<span>Material Type Management</span>
</div>
<h1 className="text-xl font-bold text-slate-800 tracking-tight"></h1>
<p className="text-sm text-slate-500 mt-1">
{isAdmin ? '集中维护和管理所有负责人的物料关键词。' : '管理属于您的物料关键词。'}
</p>
</div>
<button
onClick={handleClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<X size={20} />
</button>
</header>
{/* --- Toolbar --- */}
<div className="px-6 py-4 border-b border-slate-100 bg-white flex flex-col sm:flex-row justify-between items-center gap-6 z-10">
<div className="flex items-center space-x-6 w-full sm:w-auto">
<button
onClick={handleAdd}
className="flex items-center justify-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg shadow-sm shadow-indigo-200 transition-all active:scale-95"
>
<Plus size={16} className="mr-1.5" />
</button>
<button
onClick={handleReset}
disabled={pendingCount === 0 || loading}
className="flex items-center justify-center px-3 py-2 bg-white border border-slate-200 hover:bg-slate-50 text-slate-600 text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={16} className="mr-1.5 text-slate-400" />
</button>
<div className="relative hidden sm:block">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search size={14} className="text-slate-400" />
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
<input
type="text"
placeholder="搜索关键词..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-4 py-2 bg-slate-50 border border-transparent outline-none focus:border-indigo-200 focus:bg-white rounded-lg text-sm text-slate-700 w-56 transition-all"
/>
</div>
</div>
<div className="flex items-center space-x-6 w-full sm:w-auto justify-end">
<div className="flex items-center bg-slate-50 rounded-lg p-1 border border-slate-100">
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
<span className="text-slate-400 mr-1.5"></span>
<span className="font-semibold text-slate-800">{filteredRows.length}</span>
</div>
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
<User size={12} className="mr-1.5 text-slate-400" />
<span className="truncate max-w-[80px]">{isAdmin ? '管理员视图' : currentUsername}</span>
</div>
<div className={`flex items-center px-4 py-1.5 text-xs font-medium ${isSynced ? 'text-emerald-600' : 'text-amber-600'}`}>
{isSynced ? <><CheckCircle2 size={14} className="mr-1" /> </> : <><AlertCircle size={14} className="mr-1" /> {pendingCount}</>}
</div>
</div>
<div className="flex flex-wrap gap-2">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers((prev) => {
const newSet = new Set(prev)
if (e.target.checked) newSet.add(manager)
else newSet.delete(manager)
return newSet
})
<button
onClick={handleSave}
disabled={pendingCount === 0 || saving}
className={`flex items-center justify-center px-5 py-2 text-sm font-medium rounded-lg transition-all duration-300
${pendingCount > 0 ? 'bg-slate-800 hover:bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-400 cursor-not-allowed'}
`}
>
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
{saving ? '保存中...' : '保存更改'}
</button>
</div>
</div>
{/* --- Admin Manager Filter --- */}
{isAdmin && managers.length > 0 && (
<div className="px-6 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center overflow-x-auto hide-scrollbar">
<div className="flex items-center gap-3 text-xs font-medium text-slate-500 mr-8 flex-shrink-0">
<Users size={14} />
</div>
<div className="flex gap-3 flex-nowrap">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
selectedManagers.size === managers.length ? 'bg-slate-800 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
}`}
>
</button>
{managers.map((manager) => {
const isSelected = selectedManagers.has(manager);
return (
<button
key={manager}
onClick={() => {
const newSet = new Set(selectedManagers);
isSelected ? newSet.delete(manager) : newSet.add(manager);
setSelectedManagers(newSet);
}}
/>
{manager}
</label>
))}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors ${
isSelected ? 'bg-indigo-100 text-indigo-700 border border-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
}`}
>
{manager}
</button>
)
})}
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<button
onClick={insertNewRow}
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
>
<Plus size={14} /> (Insert)
</button>
<button
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
disabled={selectedRowIndex === null}
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={14} /> (Delete)
</button>
<button
onClick={handleReset}
disabled={pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={14} />
</button>
</div>
<div className="flex items-center gap-3">
{pendingCount > 0 && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
{pendingCount}
</span>
)}
<button
onClick={handleSave}
disabled={saving || pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={14} /> {saving ? '保存中...' : '保存'}
</button>
</div>
</div>
{/* Table */}
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
{/* --- Main Content Grid --- */}
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
{loading ? (
<div className="flex items-center justify-center py-12 text-slate-500">...</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center text-slate-400">
<RotateCcw size={24} className="animate-spin mb-3 opacity-50" />
<span className="text-sm">...</span>
</div>
</div>
) : filteredRows.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 auto-rows-max">
{filteredRows.map(row => (
<KeywordCard
key={row.localId}
item={row}
isAdmin={isAdmin}
managers={managers}
onUpdate={handleUpdate}
onDelete={handleDelete}
onRestore={handleRestore}
/>
))}
</div>
) : (
<table ref={tableRef} className="w-full text-sm">
<thead className="bg-slate-100 sticky top-0">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
</th>
<th className="px-4 py-2 text-left font-medium text-slate-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
<tr>
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
&quot;&quot;
</td>
</tr>
) : (
filteredRows
.filter((r) => r.state !== 'deleted')
.map((row, index) => {
const originalIndex = rows.indexOf(row)
const isSelected = selectedRowIndex === originalIndex
const isEditingMaterial =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'materialName'
const isEditingManager =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'managerName'
return (
<tr
key={index}
style={getRowStyle(row)}
className={`${isSelected ? 'ring-2 ring-blue-300 ring-inset' : ''} hover:bg-slate-50 cursor-pointer`}
onClick={() => setSelectedRowIndex(originalIndex)}
>
<td className="px-4 py-2 border-r border-slate-100">
{isEditingMaterial ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'materialName')}
>
{row.record.materialName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
<td className="px-4 py-2">
{isEditingManager ? (
isAdmin ? (
<select
ref={selectRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{managers.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'managerName')}
>
{row.record.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-3">
<Search size={32} className="opacity-20" />
<p className="text-sm"></p>
</div>
)}
</div>
{/* Footer info */}
<div className="mt-3 text-xs text-slate-500 flex justify-between">
<span>
| Insert | Delete
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
</span>
<span> {filteredRows.filter((r) => r.state !== 'deleted').length} </span>
</div>
</div>
{/* --- Footer --- */}
<footer className="px-6 py-3 bg-white border-t border-slate-100 flex justify-between items-center text-xs text-slate-400">
<p></p>
<p> <span className="font-semibold text-slate-600">{filteredRows.length}</span> / {rows.length} </p>
</footer>
{/* Confirmation Dialog */}
</div>
{/* Retain the original confirmation dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal>
</div>
)
}
export default MaterialTypeManagementDialog
export default MaterialTypeManagementDialog

View File

@@ -0,0 +1,27 @@
import React from 'react'
/**
* Highlight matching text with a <mark> tag
* Case-insensitive matching of the query within text
*/
export function highlightText(text: string, query: string): React.ReactNode {
if (!query || !text) return text
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const index = lowerText.indexOf(lowerQuery)
if (index === -1) return text
const before = text.substring(0, index)
const match = text.substring(index, index + query.length)
const after = text.substring(index + query.length)
return (
<>
{before}
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
{highlightText(after, query)}
</>
)
}

View File

@@ -129,3 +129,21 @@ export interface CleanerHistoryMaterialRecord {
attemptCount: number
finalErrorCategory: string | null
}
// Search result types (mirrors main process types)
export interface CleanerHistorySearchOrderResult {
order: CleanerHistoryOrderRecord
materials: CleanerHistoryMaterialRecord[]
}
export interface CleanerHistorySearchBatchResult {
batch: CleanerHistoryBatchStats
executions: CleanerHistoryExecutionRecord[]
orders: CleanerHistorySearchOrderResult[]
}
export interface CleanerHistorySearchResult {
batches: CleanerHistorySearchBatchResult[]
totalMatches: number
}

View File

@@ -26,6 +26,7 @@ export const IPC_CHANNELS = {
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
// Database service - MySQL
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',