Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
723d6de0ae | ||
|
|
9d7fe8f4e7 | ||
|
|
f49f99fc0c | ||
|
|
622543fff4 | ||
|
|
74d9b4042e | ||
|
|
ed9058c93d | ||
|
|
9167359c6e | ||
|
|
5faf26df3f | ||
|
|
3a30694684 | ||
|
|
c61d62fd98 | ||
|
|
aeb3595b36 | ||
|
|
b8925926cb | ||
|
|
2936f1fca3 | ||
|
|
f57fdf69f7 |
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal file
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal 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)
|
||||||
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal file
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal 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
18
docs/releases/1.13.0.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 1.13.0
|
||||||
|
|
||||||
|
## 清理操作历史
|
||||||
|
|
||||||
|
- 新增历史记录全局搜索,支持按批次 ID、订单号、物料编码/名称、用户名、状态等关键字检索。
|
||||||
|
- 搜索结果高亮显示匹配关键字,快速定位目标记录。
|
||||||
|
- 搜索结果自动展开批次详情、订单和物料明细,无需逐层手动点击。
|
||||||
|
- 搜索支持与用户筛选联动,管理员可限定搜索范围到指定用户。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 物料类型管理面板样式更新,改善整体视觉一致性。
|
||||||
|
- 修复管理员模式下物料类型管理面板的悬浮重叠问题。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 优化历史搜索查询性能,多个批次数据并行获取,减少等待时间。
|
||||||
|
- 物料类型管理面板加载速度优化,减少不必要的重渲染。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.12.4",
|
"version": "1.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.12.4",
|
"version": "1.13.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.12.4",
|
"version": "1.13.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import type {
|
|||||||
CleanerExecutionRecord,
|
CleanerExecutionRecord,
|
||||||
CleanerOrderRecord,
|
CleanerOrderRecord,
|
||||||
CleanerMaterialRecord,
|
CleanerMaterialRecord,
|
||||||
GetCleanerBatchesOptions
|
GetCleanerBatchesOptions,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
} from '../types/cleaner-history.types'
|
} from '../types/cleaner-history.types'
|
||||||
|
|
||||||
const log = createLogger('CleanerHistoryHandler')
|
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')
|
log.info('Cleaner history IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import type {
|
|||||||
InsertCleanerExecutionInput,
|
InsertCleanerExecutionInput,
|
||||||
InsertOrderInput,
|
InsertOrderInput,
|
||||||
InsertMaterialDetailInput,
|
InsertMaterialDetailInput,
|
||||||
GetCleanerBatchesOptions
|
GetCleanerBatchesOptions,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerSearchBatchResult,
|
||||||
|
CleanerHistorySearchResult
|
||||||
} from '../../types/cleaner-history.types'
|
} from '../../types/cleaner-history.types'
|
||||||
|
|
||||||
const log = createLogger('CleanerOperationHistoryDAO')
|
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 ====================
|
// ==================== UTILITIES ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -111,3 +111,26 @@ export interface GetCleanerBatchesOptions {
|
|||||||
offset?: number
|
offset?: number
|
||||||
usernames?: string[]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import type {
|
|||||||
CleanerBatchStats,
|
CleanerBatchStats,
|
||||||
CleanerExecutionRecord,
|
CleanerExecutionRecord,
|
||||||
CleanerOrderRecord,
|
CleanerOrderRecord,
|
||||||
CleanerMaterialRecord
|
CleanerMaterialRecord,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
} from './cleaner-history.types'
|
} from './cleaner-history.types'
|
||||||
import type { IpcResult } from './ipc.types'
|
import type { IpcResult } from './ipc.types'
|
||||||
|
|
||||||
@@ -158,6 +160,14 @@ export interface CleanerAPI {
|
|||||||
* @param batchId - Batch ID
|
* @param batchId - Batch ID
|
||||||
*/
|
*/
|
||||||
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search cleaner history records by keyword
|
||||||
|
* @param options - Search options (query, usernames, limit)
|
||||||
|
*/
|
||||||
|
searchHistoryRecords: (
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
) => Promise<IpcResult<CleanerHistorySearchResult>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type {
|
|||||||
CleanerExecutionRecord,
|
CleanerExecutionRecord,
|
||||||
CleanerOrderRecord,
|
CleanerOrderRecord,
|
||||||
CleanerMaterialRecord,
|
CleanerMaterialRecord,
|
||||||
GetCleanerBatchesOptions
|
GetCleanerBatchesOptions,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
} from '../../main/types/cleaner-history.types'
|
} from '../../main/types/cleaner-history.types'
|
||||||
import type { IpcResult } from '../../main/types/ipc.types'
|
import type { IpcResult } from '../../main/types/ipc.types'
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
@@ -53,5 +55,10 @@ export const cleanerApi = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
deleteHistoryBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
|
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
|
} as const
|
||||||
|
|||||||
@@ -19,19 +19,23 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Copy,
|
Copy,
|
||||||
FlaskConical
|
FlaskConical,
|
||||||
|
Search,
|
||||||
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { UserInfo } from './UserSelectionDialog'
|
import type { UserInfo } from './UserSelectionDialog'
|
||||||
import type {
|
import type {
|
||||||
CleanerHistoryBatchStats,
|
CleanerHistoryBatchStats,
|
||||||
CleanerHistoryOrderRecord,
|
CleanerHistoryOrderRecord,
|
||||||
CleanerHistoryMaterialRecord
|
CleanerHistoryMaterialRecord,
|
||||||
|
CleanerHistorySearchResult
|
||||||
} from '../hooks/cleaner/types'
|
} from '../hooks/cleaner/types'
|
||||||
import {
|
import {
|
||||||
canStartHistoryLoad,
|
canStartHistoryLoad,
|
||||||
getNextHistoryLoadState,
|
getNextHistoryLoadState,
|
||||||
type HistoryLoadState
|
type HistoryLoadState
|
||||||
} from './cleaner-history-load-state'
|
} from './cleaner-history-load-state'
|
||||||
|
import { highlightText } from './cleaner-history-highlight'
|
||||||
import {
|
import {
|
||||||
getCleanerHistoryStatusDisplay,
|
getCleanerHistoryStatusDisplay,
|
||||||
getCleanerMaterialResultDisplay
|
getCleanerMaterialResultDisplay
|
||||||
@@ -71,6 +75,12 @@ interface BatchItemProps {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
onDelete: (batchId: string) => void
|
onDelete: (batchId: string) => void
|
||||||
onRequestDelete: (batchId: string) => Promise<boolean>
|
onRequestDelete: (batchId: string) => Promise<boolean>
|
||||||
|
searchQuery?: string
|
||||||
|
preloadedExecutions?: ExecutionRecord[]
|
||||||
|
preloadedOrders?: Array<{
|
||||||
|
order: CleanerHistoryOrderRecord
|
||||||
|
materials: CleanerHistoryMaterialRecord[]
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
const BATCH_PAGE_SIZE = 5
|
const BATCH_PAGE_SIZE = 5
|
||||||
@@ -116,8 +126,8 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
|
|||||||
// ====== BatchItem Component ======
|
// ====== BatchItem Component ======
|
||||||
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
|
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
|
||||||
// Each BatchItem manages its own details, orders, and material state locally.
|
// Each BatchItem manages its own details, orders, and material state locally.
|
||||||
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: BatchItemProps) => {
|
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete, searchQuery, preloadedExecutions, preloadedOrders }: BatchItemProps) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(() => !!searchQuery)
|
||||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
|
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
|
||||||
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
||||||
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
|
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
|
||||||
@@ -134,6 +144,42 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
|||||||
|
|
||||||
const logger = useLogger('BatchItem')
|
const logger = useLogger('BatchItem')
|
||||||
|
|
||||||
|
// Initialize from preloaded data in search mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery && preloadedExecutions && preloadedOrders) {
|
||||||
|
setExecutions(preloadedExecutions)
|
||||||
|
setDetailsLoadState('success')
|
||||||
|
|
||||||
|
const orders = preloadedOrders.map(p => p.order)
|
||||||
|
setOrders(orders)
|
||||||
|
|
||||||
|
if (preloadedExecutions.length > 0) {
|
||||||
|
setCurrentAttempt(Math.max(...preloadedExecutions.map(e => e.attemptNumber)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-populate materials map
|
||||||
|
const materialsMap = new Map<string, CleanerHistoryMaterialRecord[]>()
|
||||||
|
const expandedSet = new Set<string>()
|
||||||
|
for (const { order, materials } of preloadedOrders) {
|
||||||
|
if (materials.length > 0) {
|
||||||
|
const cacheKey = `${order.attemptNumber}:${order.orderNumber}`
|
||||||
|
materialsMap.set(cacheKey, materials)
|
||||||
|
expandedSet.add(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOrderMaterials(materialsMap)
|
||||||
|
setExpandedOrders(expandedSet)
|
||||||
|
|
||||||
|
// Mark all materials as loaded
|
||||||
|
const loadStatesMap = new Map<string, HistoryLoadState>()
|
||||||
|
for (const { order } of preloadedOrders) {
|
||||||
|
const cacheKey = `${order.attemptNumber}:${order.orderNumber}`
|
||||||
|
loadStatesMap.set(cacheKey, 'success')
|
||||||
|
}
|
||||||
|
setMaterialLoadStates(loadStatesMap)
|
||||||
|
}
|
||||||
|
}, [searchQuery, preloadedExecutions, preloadedOrders])
|
||||||
|
|
||||||
const fetchDetails = useCallback(async () => {
|
const fetchDetails = useCallback(async () => {
|
||||||
if (!canStartHistoryLoad(detailsLoadState)) return
|
if (!canStartHistoryLoad(detailsLoadState)) return
|
||||||
|
|
||||||
@@ -308,7 +354,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-500 text-xs">操作用户</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>
|
<div>
|
||||||
<div className="text-gray-500 text-xs">状态</div>
|
<div className="text-gray-500 text-xs">状态</div>
|
||||||
@@ -317,7 +365,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
|||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
|
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
|
||||||
>
|
>
|
||||||
{batchStatusDisplay.label}
|
{searchQuery
|
||||||
|
? highlightText(batchStatusDisplay.label, searchQuery)
|
||||||
|
: batchStatusDisplay.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,10 +542,14 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||||
{order.productionId || '-'}
|
{order.productionId
|
||||||
|
? (searchQuery ? highlightText(order.productionId, searchQuery) : order.productionId)
|
||||||
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||||
{order.status === 'not_found' ? '-' : order.orderNumber}
|
{order.status === 'not_found'
|
||||||
|
? '-'
|
||||||
|
: (searchQuery ? highlightText(order.orderNumber, searchQuery) : order.orderNumber)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span
|
<span
|
||||||
@@ -573,7 +627,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: Bat
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{materials.map((mat, idx) => (
|
{materials.map((mat, idx) => (
|
||||||
<MaterialDetailRow key={idx} index={idx} material={mat} />
|
<MaterialDetailRow key={idx} index={idx} material={mat} searchQuery={searchQuery} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -603,16 +657,21 @@ BatchItem.displayName = 'BatchItem'
|
|||||||
interface MaterialDetailRowProps {
|
interface MaterialDetailRowProps {
|
||||||
index: number
|
index: number
|
||||||
material: CleanerHistoryMaterialRecord
|
material: CleanerHistoryMaterialRecord
|
||||||
|
searchQuery?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.JSX.Element => {
|
const MaterialDetailRow = ({ index, material, searchQuery }: MaterialDetailRowProps): React.JSX.Element => {
|
||||||
const resultDisplay = getCleanerMaterialResultDisplay(material.result)
|
const resultDisplay = getCleanerMaterialResultDisplay(material.result)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-gray-50">
|
<tr className="hover:bg-gray-50">
|
||||||
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
|
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
|
||||||
<td className="px-3 py-1.5 font-mono text-gray-700">{material.materialCode}</td>
|
<td className="px-3 py-1.5 font-mono text-gray-700">
|
||||||
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td>
|
{searchQuery ? highlightText(material.materialCode, searchQuery) : material.materialCode}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-700">
|
||||||
|
{searchQuery ? highlightText(material.materialName, searchQuery) : material.materialName}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
|
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
{resultDisplay.icon ? (
|
{resultDisplay.icon ? (
|
||||||
@@ -623,7 +682,9 @@ const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.J
|
|||||||
<span className="text-gray-500">{resultDisplay.title}</span>
|
<span className="text-gray-500">{resultDisplay.title}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">{material.reason || '-'}</td>
|
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
|
||||||
|
{material.reason ? (searchQuery ? highlightText(material.reason, searchQuery) : material.reason) : '-'}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-1.5 text-gray-600">
|
<td className="px-3 py-1.5 text-gray-600">
|
||||||
{material.attemptCount > 1 ? (
|
{material.attemptCount > 1 ? (
|
||||||
<span className="text-amber-600">{material.attemptCount}</span>
|
<span className="text-amber-600">{material.attemptCount}</span>
|
||||||
@@ -648,6 +709,10 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
const [isFilterPending, startFilterTransition] = useTransition()
|
const [isFilterPending, startFilterTransition] = useTransition()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
const logger = useLogger('CleanerOperationHistory')
|
const logger = useLogger('CleanerOperationHistory')
|
||||||
|
|
||||||
@@ -748,6 +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 = () => {
|
const goToPreviousPage = () => {
|
||||||
setCurrentPage((prev) => Math.max(0, prev - 1))
|
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||||
}
|
}
|
||||||
@@ -771,6 +872,38 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void executeSearch()
|
||||||
|
}}
|
||||||
|
placeholder="搜索批次ID、订单号、物料编码/名称..."
|
||||||
|
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
onClick={() => void executeSearch()}
|
||||||
|
disabled={isSearching || !searchInput.trim()}
|
||||||
|
>
|
||||||
|
{isSearching ? '搜索中...' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isAdmin && allUsers.length > 0 && (
|
{isAdmin && allUsers.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
||||||
@@ -840,46 +973,91 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
|||||||
|
|
||||||
{/* Batch list */}
|
{/* Batch list */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && batches.length === 0 ? (
|
{searchQuery ? (
|
||||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
// Search mode
|
||||||
) : batches.length === 0 ? (
|
isSearching ? (
|
||||||
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
<div className="flex items-center justify-center h-32 text-gray-500">搜索中...</div>
|
||||||
|
) : searchResult && searchResult.batches.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{searchResult.batches.map((result) => (
|
||||||
|
<BatchItem
|
||||||
|
key={result.batch.batchId}
|
||||||
|
batch={result.batch}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteBatch}
|
||||||
|
onRequestDelete={requestDeleteConfirmation}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
preloadedExecutions={result.executions}
|
||||||
|
preloadedOrders={result.orders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
未找到匹配「{searchQuery}」的记录
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
// Browse mode (existing logic unchanged)
|
||||||
{batches.map((batch) => (
|
<>
|
||||||
<BatchItem
|
{loading && batches.length === 0 ? (
|
||||||
key={batch.batchId}
|
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||||
batch={batch}
|
) : batches.length === 0 ? (
|
||||||
isAdmin={isAdmin}
|
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||||
onDelete={handleDeleteBatch}
|
) : (
|
||||||
onRequestDelete={requestDeleteConfirmation}
|
<div className="flex flex-col gap-3">
|
||||||
/>
|
{batches.map((batch) => (
|
||||||
))}
|
<BatchItem
|
||||||
</div>
|
key={batch.batchId}
|
||||||
|
batch={batch}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteBatch}
|
||||||
|
onRequestDelete={requestDeleteConfirmation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="pt-4 border-t border-gray-200 flex justify-center">
|
<div className="pt-4 border-t border-gray-200 flex justify-center">
|
||||||
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
{searchQuery && searchResult ? (
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
<span className="text-sm text-gray-600">
|
||||||
onClick={goToPreviousPage}
|
找到 {searchResult.totalMatches} 个匹配批次
|
||||||
disabled={loading || !hasPreviousPage}
|
{searchResult.totalMatches > searchResult.batches.length &&
|
||||||
>
|
`(显示前 ${searchResult.batches.length} 个)`}
|
||||||
上一页
|
</span>
|
||||||
</button>
|
<button
|
||||||
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||||
第 {currentPage + 1} 页
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : (
|
||||||
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
||||||
onClick={goToNextPage}
|
<button
|
||||||
disabled={loading || !hasNextPage}
|
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||||
>
|
onClick={goToPreviousPage}
|
||||||
下一页
|
disabled={loading || !hasPreviousPage}
|
||||||
</button>
|
>
|
||||||
</div>
|
上一页
|
||||||
|
</button>
|
||||||
|
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
|
||||||
|
第 {currentPage + 1} 页
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={loading || !hasNextPage}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
|
|||||||
@@ -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
|
* Provides a dialog for managing material type keywords used to identify
|
||||||
* materials for deletion. Admin users can see all records and filter by manager.
|
* materials for deletion. Admin users can see all records and filter by manager.
|
||||||
* Regular users can only see and edit their own records.
|
* Regular users can only see and edit their own records.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
|
import {
|
||||||
import { Modal } from './ui/Modal'
|
X,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
CloudUpload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react'
|
||||||
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
||||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||||
import { useConfirmDialog } from './ui/useConfirmDialog'
|
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||||
import { useLogger } from '../hooks/useLogger'
|
import { useLogger } from '../hooks/useLogger'
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
interface MaterialTypeRecord {
|
interface MaterialTypeRecord {
|
||||||
id?: number
|
id?: number
|
||||||
materialName: string
|
materialName: string
|
||||||
@@ -21,6 +32,7 @@ interface MaterialTypeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RowState {
|
interface RowState {
|
||||||
|
localId: string // Stable ID for React mapping during edits/filters
|
||||||
record: MaterialTypeRecord
|
record: MaterialTypeRecord
|
||||||
state: 'original' | 'new' | 'modified' | 'deleted'
|
state: 'original' | 'new' | 'modified' | 'deleted'
|
||||||
originalRecord?: MaterialTypeRecord
|
originalRecord?: MaterialTypeRecord
|
||||||
@@ -34,40 +46,179 @@ interface MaterialTypeManagementDialogProps {
|
|||||||
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
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> = ({
|
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername,
|
currentUsername,
|
||||||
triggerRef
|
// triggerRef // Retained for compatibility, but layout uses custom overlay
|
||||||
}) => {
|
}) => {
|
||||||
const [rows, setRows] = useState<RowState[]>([])
|
const [rows, setRows] = useState<RowState[]>([])
|
||||||
const [managers, setManagers] = useState<string[]>([])
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editValue, setEditValue] = useState('')
|
|
||||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
|
||||||
const logger = useLogger('MaterialType')
|
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()
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
|
|
||||||
// Calculate pending changes count
|
|
||||||
const pendingCount = rows.filter(
|
const pendingCount = rows.filter(
|
||||||
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
||||||
).length
|
).length
|
||||||
|
const isSynced = pendingCount === 0;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// Load managers list
|
const [managersResult, recordsResult] = await Promise.all([
|
||||||
const managersResult = await window.electron.materialType.getManagers()
|
window.electron.materialType.getManagers(),
|
||||||
|
isAdmin
|
||||||
|
? window.electron.materialType.getAll()
|
||||||
|
: window.electron.materialType.getByManager(currentUsername)
|
||||||
|
])
|
||||||
|
|
||||||
if (managersResult.success && managersResult.data) {
|
if (managersResult.success && managersResult.data) {
|
||||||
setManagers(managersResult.data)
|
setManagers(managersResult.data)
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -75,28 +226,17 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load records
|
|
||||||
let records: MaterialTypeRecord[] = []
|
let records: MaterialTypeRecord[] = []
|
||||||
if (isAdmin) {
|
if (recordsResult.success && recordsResult.data) records = recordsResult.data
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
|
localId: generateId(),
|
||||||
record,
|
record,
|
||||||
state: 'original' as const,
|
state: 'original' as const,
|
||||||
originalRecord: { ...record }
|
originalRecord: { ...record }
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
setSelectedRowIndex(null)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load material types', {
|
logger.error('Failed to load material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -108,137 +248,73 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}, [currentUsername, isAdmin, logger])
|
}, [currentUsername, isAdmin, logger])
|
||||||
|
|
||||||
// Load data when dialog opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) void loadData()
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
}, [isOpen, loadData])
|
}, [isOpen, loadData])
|
||||||
|
|
||||||
// Focus input when editing starts
|
const filteredRows = useMemo(() => {
|
||||||
useEffect(() => {
|
let result = rows;
|
||||||
const activeElement = inputRef.current ?? selectRef.current
|
if (isAdmin && selectedManagers.size > 0) {
|
||||||
if (editingCell && activeElement) {
|
result = result.filter(row => selectedManagers.has(row.record.managerName) || row.state === 'new');
|
||||||
activeElement.focus()
|
|
||||||
if (activeElement instanceof HTMLInputElement) {
|
|
||||||
activeElement.select()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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
|
// --- Row Actions ---
|
||||||
const insertNewRow = useCallback(() => {
|
const handleAdd = useCallback(() => {
|
||||||
const newRow: RowState = {
|
const newRow: RowState = {
|
||||||
record: {
|
localId: generateId(),
|
||||||
materialName: '',
|
record: { materialName: '', managerName: isAdmin ? '' : currentUsername },
|
||||||
managerName: isAdmin ? '' : currentUsername
|
|
||||||
},
|
|
||||||
state: 'new'
|
state: 'new'
|
||||||
}
|
}
|
||||||
|
setRows(prev => [newRow, ...prev])
|
||||||
// 在 setRows 回调中计算索引并设置编辑状态
|
setSearchQuery('')
|
||||||
setRows((prev) => {
|
|
||||||
const newIndex = prev.length
|
|
||||||
setTimeout(() => {
|
|
||||||
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
|
|
||||||
setEditValue('')
|
|
||||||
}, 0)
|
|
||||||
return [...prev, newRow]
|
|
||||||
})
|
|
||||||
}, [isAdmin, currentUsername])
|
}, [isAdmin, currentUsername])
|
||||||
|
|
||||||
// Delete row
|
const handleUpdate = useCallback((localId: string, updates: Partial<MaterialTypeRecord>) => {
|
||||||
const deleteRow = useCallback((index: number) => {
|
setRows(prev => prev.map(row => {
|
||||||
setRows((prev) => {
|
if (row.localId !== localId) return row;
|
||||||
const newRows = [...prev]
|
return {
|
||||||
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] = {
|
|
||||||
...row,
|
...row,
|
||||||
record: {
|
record: { ...row.record, ...updates },
|
||||||
...row.record,
|
|
||||||
[field]: newValue
|
|
||||||
},
|
|
||||||
state: row.state === 'new' ? 'new' : 'modified'
|
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 handleDelete = useCallback((localId: string) => {
|
||||||
const handleKeyDown = useCallback(
|
setRows(prev => {
|
||||||
(event: React.KeyboardEvent) => {
|
const row = prev.find(r => r.localId === localId);
|
||||||
if (editingCell) {
|
if (!row) return prev;
|
||||||
if (event.key === 'Enter') {
|
if (row.state === 'new') return prev.filter(r => r.localId !== localId);
|
||||||
saveEdit()
|
return prev.map(r => r.localId === localId ? { ...r, state: 'deleted' } : r);
|
||||||
} else if (event.key === 'Escape') {
|
})
|
||||||
cancelEdit()
|
}, [])
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Insert') {
|
const handleRestore = useCallback((localId: string) => {
|
||||||
event.preventDefault()
|
setRows(prev => prev.map(r => {
|
||||||
insertNewRow()
|
if (r.localId !== localId) return r;
|
||||||
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
|
const wasModified = r.originalRecord?.materialName !== r.record.materialName ||
|
||||||
event.preventDefault()
|
r.originalRecord?.managerName !== r.record.managerName;
|
||||||
deleteRow(selectedRowIndex)
|
return { ...r, state: wasModified ? 'modified' : 'original' }
|
||||||
}
|
}))
|
||||||
},
|
}, [])
|
||||||
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
|
|
||||||
)
|
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 handleSave = async () => {
|
||||||
const toInsert: MaterialTypeRecord[] = []
|
const toInsert: MaterialTypeRecord[] = []
|
||||||
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
||||||
@@ -273,19 +349,11 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.materialType.upsertBatch({
|
const result = await window.electron.materialType.upsertBatch({ toInsert, toUpdate, toDelete })
|
||||||
toInsert,
|
const payload = result.success ? (result.data as { stats?: { success?: number; failed?: number } } | undefined) : undefined
|
||||||
toUpdate,
|
|
||||||
toDelete
|
|
||||||
})
|
|
||||||
const payload = result.success
|
|
||||||
? (result.data as { stats?: { success?: number; failed?: number } } | undefined)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(
|
showSuccess(`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`)
|
||||||
`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`
|
|
||||||
)
|
|
||||||
await loadData()
|
await loadData()
|
||||||
} else {
|
} else {
|
||||||
showError(`保存失败:${result.error || '未知错误'}`)
|
showError(`保存失败:${result.error || '未知错误'}`)
|
||||||
@@ -294,29 +362,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
logger.error('Failed to save material types', {
|
logger.error('Failed to save material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
inserts: toInsert.length,
|
inserts: toInsert.length, updates: toUpdate.length, deletes: toDelete.length
|
||||||
updates: toUpdate.length,
|
|
||||||
deletes: toDelete.length
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
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 () => {
|
const handleClose = async () => {
|
||||||
if (pendingCount > 0) {
|
if (pendingCount > 0) {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
@@ -329,253 +381,170 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get row background color
|
if (!isOpen) return null;
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<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">
|
||||||
isOpen={isOpen}
|
<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">
|
||||||
onClose={handleClose}
|
|
||||||
title="物料类型管理"
|
{/* --- Header --- */}
|
||||||
size="2xl"
|
<header className="px-6 py-5 border-b border-slate-100 flex justify-between items-start bg-slate-50/50">
|
||||||
triggerRef={triggerRef}
|
<div>
|
||||||
>
|
<div className="flex items-center space-x-2 text-xs font-semibold text-slate-400 tracking-wider mb-1 uppercase">
|
||||||
<div onKeyDown={handleKeyDown}>
|
<span>Material Type Management</span>
|
||||||
{/* Manager filter (admin only) */}
|
</div>
|
||||||
{isAdmin && (
|
<h1 className="text-xl font-bold text-slate-800 tracking-tight">物料类型管理</h1>
|
||||||
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
{isAdmin ? '集中维护和管理所有负责人的物料关键词。' : '管理属于您的物料关键词。'}
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
</p>
|
||||||
<Users size={16} />
|
</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>
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<button
|
type="text"
|
||||||
onClick={() => setSelectedManagers(new Set(managers))}
|
placeholder="搜索关键词..."
|
||||||
className="text-xs text-blue-600 hover:underline"
|
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"
|
||||||
</button>
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setSelectedManagers(new Set())}
|
</div>
|
||||||
className="text-xs text-slate-500 hover:underline"
|
|
||||||
>
|
<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">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{managers.map((manager) => (
|
<button
|
||||||
<label
|
onClick={handleSave}
|
||||||
key={manager}
|
disabled={pendingCount === 0 || saving}
|
||||||
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
|
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'}
|
||||||
<input
|
`}
|
||||||
type="checkbox"
|
>
|
||||||
className="rounded text-blue-600"
|
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
|
||||||
checked={selectedManagers.has(manager)}
|
{saving ? '保存中...' : '保存更改'}
|
||||||
onChange={(e) => {
|
</button>
|
||||||
setSelectedManagers((prev) => {
|
</div>
|
||||||
const newSet = new Set(prev)
|
</div>
|
||||||
if (e.target.checked) newSet.add(manager)
|
|
||||||
else newSet.delete(manager)
|
{/* --- Admin Manager Filter --- */}
|
||||||
return newSet
|
{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);
|
||||||
}}
|
}}
|
||||||
/>
|
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors ${
|
||||||
{manager}
|
isSelected ? 'bg-indigo-100 text-indigo-700 border border-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
</label>
|
}`}
|
||||||
))}
|
>
|
||||||
|
{manager}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* --- Main Content Grid --- */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
|
||||||
<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">
|
|
||||||
{loading ? (
|
{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">
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-3">
|
||||||
<thead className="bg-slate-100 sticky top-0">
|
<Search size={32} className="opacity-20" />
|
||||||
<tr>
|
<p className="text-sm">未找到匹配的关键词记录</p>
|
||||||
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
|
</div>
|
||||||
物料名称关键词
|
|
||||||
</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">
|
|
||||||
暂无数据,点击"新增"按钮添加物料类型关键词
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* --- Footer --- */}
|
||||||
<div className="mt-3 text-xs text-slate-500 flex justify-between">
|
<footer className="px-6 py-3 bg-white border-t border-slate-100 flex justify-between items-center text-xs text-slate-400">
|
||||||
<span>
|
<p>支持直接点击卡片编辑名称,回车快速保存,清空内容即为删除。</p>
|
||||||
双击单元格编辑 | Insert 新增 | Delete 删除
|
<p>展示 <span className="font-semibold text-slate-600">{filteredRows.length}</span> / {rows.length} 条记录</p>
|
||||||
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
|
</footer>
|
||||||
</span>
|
|
||||||
<span>共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
</div>
|
||||||
|
|
||||||
|
{/* Retain the original confirmation dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</Modal>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaterialTypeManagementDialog
|
export default MaterialTypeManagementDialog
|
||||||
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal file
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal 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)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -129,3 +129,21 @@ export interface CleanerHistoryMaterialRecord {
|
|||||||
attemptCount: number
|
attemptCount: number
|
||||||
finalErrorCategory: string | null
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const IPC_CHANNELS = {
|
|||||||
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
|
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
|
||||||
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
|
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
|
||||||
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
|
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
|
||||||
|
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
|
||||||
|
|
||||||
// Database service - MySQL
|
// Database service - MySQL
|
||||||
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
|
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
|
||||||
|
|||||||
Reference in New Issue
Block a user