feat: add material type management feature
- Add MaterialTypeManagementDialog component for managing material type keywords - Add MaterialsTypeToBeDeletedDAO for database operations - Add material-type-handler IPC handlers - Update CleanerPage with type management button - Add database fix scripts for AUTO_INCREMENT - Update documentation for settings partial save and validation flow Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
### Bug Description
|
### Bug Description
|
||||||
|
|
||||||
For **User type (non-Admin)** users:
|
For **User type (non-Admin)** users:
|
||||||
|
|
||||||
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
|
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
|
||||||
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
|
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
|
||||||
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
|
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
|
||||||
@@ -20,12 +21,14 @@ For **User type (non-Admin)** users:
|
|||||||
### Root Causes
|
### Root Causes
|
||||||
|
|
||||||
#### 1. "取消" Button (Line 420)
|
#### 1. "取消" Button (Line 420)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Clears ALL selected items
|
// ❌ WRONG: Clears ALL selected items
|
||||||
onClick={() => setSelectedItems(new Set())}
|
onClick={() => setSelectedItems(new Set())}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. `handleConfirmDeletion` Function (Line 165)
|
#### 2. `handleConfirmDeletion` Function (Line 165)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Iterates ALL validation results
|
// ❌ WRONG: Iterates ALL validation results
|
||||||
for (const result of validationResults) {
|
for (const result of validationResults) {
|
||||||
@@ -87,6 +90,7 @@ graph TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What Changed**:
|
**What Changed**:
|
||||||
|
|
||||||
- Before: `setSelectedItems(new Set())` - clears everything
|
- Before: `setSelectedItems(new Set())` - clears everything
|
||||||
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
||||||
- Preserves selections for items not currently visible (e.g., other users' data)
|
- Preserves selections for items not currently visible (e.g., other users' data)
|
||||||
@@ -115,6 +119,7 @@ const handleConfirmDeletion = async () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What Changed**:
|
**What Changed**:
|
||||||
|
|
||||||
- Before: `for (const result of validationResults)` - processes all 1000 items
|
- Before: `for (const result of validationResults)` - processes all 1000 items
|
||||||
- After: `for (const result of resultsToProcess)` where:
|
- After: `for (const result of resultsToProcess)` where:
|
||||||
- `Admin` → processes `validationResults` (all items)
|
- `Admin` → processes `validationResults` (all items)
|
||||||
@@ -127,20 +132,24 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 1: User Unchecks Own Data Only
|
### Scenario 1: User Unchecks Own Data Only
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- User A logs in (non-Admin)
|
- User A logs in (non-Admin)
|
||||||
- 100 materials visible (assigned to User A)
|
- 100 materials visible (assigned to User A)
|
||||||
- 900 materials invisible (assigned to other users)
|
- 900 materials invisible (assigned to other users)
|
||||||
- All 1000 materials are initially checked
|
- All 1000 materials are initially checked
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. User A clicks "取消"
|
1. User A clicks "取消"
|
||||||
2. Table shows all checkboxes unchecked
|
2. Table shows all checkboxes unchecked
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ User A's 100 materials are unchecked
|
- ✅ User A's 100 materials are unchecked
|
||||||
- ✅ Other users' 900 materials **remain checked** (not affected)
|
- ✅ Other users' 900 materials **remain checked** (not affected)
|
||||||
|
|
||||||
**Verification**:
|
**Verification**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before fix: selectedItems.size === 0
|
// Before fix: selectedItems.size === 0
|
||||||
// After fix: selectedItems.size === 900 (other users' items still checked)
|
// After fix: selectedItems.size === 900 (other users' items still checked)
|
||||||
@@ -149,16 +158,19 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 2: User Confirms Deletion
|
### Scenario 2: User Confirms Deletion
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- User A logs in (non-Admin)
|
- User A logs in (non-Admin)
|
||||||
- User A unchecks 50 of their 100 materials
|
- User A unchecks 50 of their 100 materials
|
||||||
- 50 items checked (User A's)
|
- 50 items checked (User A's)
|
||||||
- 900 items checked (other users')
|
- 900 items checked (other users')
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. User A clicks "确认删除"
|
1. User A clicks "确认删除"
|
||||||
2. Confirm dialog shows: "写入/更新 50 条记录"
|
2. Confirm dialog shows: "写入/更新 50 条记录"
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ Only User A's 50 materials are upserted to database
|
- ✅ Only User A's 50 materials are upserted to database
|
||||||
- ✅ Other users' 900 materials are **NOT touched**
|
- ✅ Other users' 900 materials are **NOT touched**
|
||||||
- ✅ No materials are deleted (since other users' items aren't processed)
|
- ✅ No materials are deleted (since other users' items aren't processed)
|
||||||
@@ -166,15 +178,18 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 3: Admin Behavior Unchanged
|
### Scenario 3: Admin Behavior Unchanged
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- Admin logs in
|
- Admin logs in
|
||||||
- All 1000 materials visible
|
- All 1000 materials visible
|
||||||
- All filtered by selected managers
|
- All filtered by selected managers
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. Admin clicks "取消" → all visible items unchecked
|
1. Admin clicks "取消" → all visible items unchecked
|
||||||
2. Admin clicks "确认删除" → processes all filtered items
|
2. Admin clicks "确认删除" → processes all filtered items
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ Admin behavior unchanged (can manage all data)
|
- ✅ Admin behavior unchanged (can manage all data)
|
||||||
- ✅ Admin can still filter by managers and process filtered results
|
- ✅ Admin can still filter by managers and process filtered results
|
||||||
|
|
||||||
@@ -183,6 +198,7 @@ const handleConfirmDeletion = async () => {
|
|||||||
## Security & Scope Implications
|
## Security & Scope Implications
|
||||||
|
|
||||||
### Before Fix (Vulnerability)
|
### Before Fix (Vulnerability)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
UserA[User A] --> Sees[Sees 100 items]
|
UserA[User A] --> Sees[Sees 100 items]
|
||||||
@@ -193,6 +209,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
### After Fix (Secure)
|
### After Fix (Secure)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
UserA[User A] --> Sees[Sees 100 items]
|
UserA[User A] --> Sees[Sees 100 items]
|
||||||
@@ -209,9 +226,9 @@ flowchart LR
|
|||||||
|
|
||||||
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
||||||
|
|
||||||
| Line | Change | Description |
|
| Line | Change | Description |
|
||||||
|------|--------|-------------|
|
| ------- | -------------------------------- | ----------------------------------------- |
|
||||||
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
||||||
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
||||||
|
|
||||||
### Variables Used
|
### Variables Used
|
||||||
@@ -227,6 +244,7 @@ flowchart LR
|
|||||||
## Verification Steps
|
## Verification Steps
|
||||||
|
|
||||||
1. **Test as User A**:
|
1. **Test as User A**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login as user1
|
# Login as user1
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -237,6 +255,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Test as User B**:
|
2. **Test as User B**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login as user2
|
# Login as user2
|
||||||
# Verify user1's changes didn't affect user2's data
|
# Verify user1's changes didn't affect user2's data
|
||||||
|
|||||||
@@ -1245,15 +1245,15 @@ flowchart TB
|
|||||||
|
|
||||||
## 文件索引
|
## 文件索引
|
||||||
|
|
||||||
| 文件路径 | 说明 | 关键行号 |
|
| 文件路径 | 说明 | 关键行号 |
|
||||||
| ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
||||||
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)<br>407-420 (materials:upsertBatch)<br>425-447 (materials:delete) |
|
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)<br>407-420 (materials:upsertBatch)<br>425-447 (materials:delete) |
|
||||||
| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
|
| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
|
||||||
| `src/main/ipc/validation-handler.ts` | 物料匹配算法 | 343-382 (优先级1-3匹配逻辑) |
|
| `src/main/ipc/validation-handler.ts` | 物料匹配算法 | 343-382 (优先级1-3匹配逻辑) |
|
||||||
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 191-227 (queryAllDistinctByMaterialCode) |
|
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 191-227 (queryAllDistinctByMaterialCode) |
|
||||||
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 294-377 (queryBySourceNumbersDistinct) |
|
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 294-377 (queryBySourceNumbersDistinct) |
|
||||||
| `src/main/services/database/materials-to-be-deleted-dao.ts` | 待删除物料DAO | 180-240 (upsertBatch)<br>248-268 (getAllMaterialCodes)<br>539-586 (deleteByMaterialCodes) |
|
| `src/main/services/database/materials-to-be-deleted-dao.ts` | 待删除物料DAO | 180-240 (upsertBatch)<br>248-268 (getAllMaterialCodes)<br>539-586 (deleteByMaterialCodes) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
> **更新日期**: 2026-03-03
|
> **更新日期**: 2026-03-03
|
||||||
> **适用范围**: ERPAuto v1.0+
|
> **适用范围**: ERPAuto v1.0+
|
||||||
> **相关文件**:
|
> **相关文件**:
|
||||||
|
>
|
||||||
> - `src/renderer/src/pages/ExtractorPage.tsx` (UI层)
|
> - `src/renderer/src/pages/ExtractorPage.tsx` (UI层)
|
||||||
> - `src/preload/index.ts` (IPC API 暴露)
|
> - `src/preload/index.ts` (IPC API 暴露)
|
||||||
> - `src/main/ipc/extractor-handler.ts` (IPC处理层)
|
> - `src/main/ipc/extractor-handler.ts` (IPC处理层)
|
||||||
@@ -298,15 +299,15 @@ stateDiagram-v2
|
|||||||
|
|
||||||
### 状态变量说明
|
### 状态变量说明
|
||||||
|
|
||||||
| 状态变量 | 类型 | 说明 | 持久化 |
|
| 状态变量 | 类型 | 说明 | 持久化 |
|
||||||
|---------|------|------|--------|
|
| -------------- | ------------------------- | ------------------------------------- | ----------------- |
|
||||||
| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage |
|
| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage |
|
||||||
| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage |
|
| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage |
|
||||||
| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 |
|
| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 |
|
||||||
| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 |
|
| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 |
|
||||||
| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 |
|
| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 |
|
||||||
| `error` | string \| null | 错误信息 | ❌ 内存状态 |
|
| `error` | string \| null | 错误信息 | ❌ 内存状态 |
|
||||||
| `logs` | string[] | 执行日志列表 | ❌ 内存状态 |
|
| `logs` | string[] | 执行日志列表 | ❌ 内存状态 |
|
||||||
|
|
||||||
> **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorService` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。
|
> **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorService` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。
|
||||||
|
|
||||||
@@ -381,13 +382,13 @@ flowchart TD
|
|||||||
|
|
||||||
### 错误类型与处理策略
|
### 错误类型与处理策略
|
||||||
|
|
||||||
| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 |
|
| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 |
|
||||||
|---------|---------|---------|---------|
|
| -------------------- | ------------------------------------ | ------------------------ | -------------- |
|
||||||
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
|
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
|
||||||
| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 |
|
| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 |
|
||||||
| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
|
| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
|
||||||
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
|
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
|
||||||
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
|
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -471,6 +472,7 @@ flowchart LR
|
|||||||
### 数据转换详情
|
### 数据转换详情
|
||||||
|
|
||||||
**阶段1: 用户输入 → Production IDs**
|
**阶段1: 用户输入 → Production IDs**
|
||||||
|
|
||||||
```
|
```
|
||||||
输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
|
输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
|
||||||
↓ 分割 + trim + 过滤
|
↓ 分割 + trim + 过滤
|
||||||
@@ -480,6 +482,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
**阶段2: Production IDs → 生产订单号**
|
**阶段2: Production IDs → 生产订单号**
|
||||||
|
|
||||||
```
|
```
|
||||||
输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"]
|
输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"]
|
||||||
↓ MySQL查询 (production_order表)
|
↓ MySQL查询 (production_order表)
|
||||||
@@ -494,6 +497,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
**阶段3: 生产订单号 → 批次**
|
**阶段3: 生产订单号 → 批次**
|
||||||
|
|
||||||
```
|
```
|
||||||
输入: ["MO-001", "MO-002", ..., "MO-250"] (250个)
|
输入: ["MO-001", "MO-002", ..., "MO-250"] (250个)
|
||||||
批次大小: 100
|
批次大小: 100
|
||||||
@@ -504,6 +508,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
**阶段4: 批次 → ERP查询字符串**
|
**阶段4: 批次 → ERP查询字符串**
|
||||||
|
|
||||||
```
|
```
|
||||||
批次: ["MO-001", "MO-002", "MO-003"]
|
批次: ["MO-001", "MO-002", "MO-003"]
|
||||||
↓ 逗号连接
|
↓ 逗号连接
|
||||||
@@ -582,6 +587,7 @@ useEffect(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **设计说明**: 订单号通过两种方式存储到共享状态:
|
> **设计说明**: 订单号通过两种方式存储到共享状态:
|
||||||
|
>
|
||||||
> 1. `useEffect` 在用户输入时实时更新
|
> 1. `useEffect` 在用户输入时实时更新
|
||||||
> 2. `handleExtract` 在提取开始前再次确认存储
|
> 2. `handleExtract` 在提取开始前再次确认存储
|
||||||
>
|
>
|
||||||
@@ -609,7 +615,7 @@ ipcMain.handle(
|
|||||||
|
|
||||||
// 2. 使用数据库工厂创建服务实例 (支持 MySQL 和 SQL Server)
|
// 2. 使用数据库工厂创建服务实例 (支持 MySQL 和 SQL Server)
|
||||||
try {
|
try {
|
||||||
dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库
|
dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseQueryError('数据库连接失败', 'DB_CONNECTION_FAILED', error)
|
throw new DatabaseQueryError('数据库连接失败', 'DB_CONNECTION_FAILED', error)
|
||||||
}
|
}
|
||||||
@@ -659,7 +665,7 @@ ipcMain.handle(
|
|||||||
* 支持 MySQL 和 SQL Server 双数据库
|
* 支持 MySQL 和 SQL Server 双数据库
|
||||||
*/
|
*/
|
||||||
export async function create(type?: DatabaseType): Promise<IDatabaseService> {
|
export async function create(type?: DatabaseType): Promise<IDatabaseService> {
|
||||||
const dbType = type || getDatabaseType() // 从 DB_TYPE 环境变量读取
|
const dbType = type || getDatabaseType() // 从 DB_TYPE 环境变量读取
|
||||||
|
|
||||||
// 返回缓存的实例(单例模式)
|
// 返回缓存的实例(单例模式)
|
||||||
const cached = instances.get(dbType)
|
const cached = instances.get(dbType)
|
||||||
@@ -677,7 +683,7 @@ export async function create(type?: DatabaseType): Promise<IDatabaseService> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await service.connect()
|
await service.connect()
|
||||||
instances.set(dbType, service) // 缓存实例
|
instances.set(dbType, service) // 缓存实例
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
@@ -835,7 +841,7 @@ extractor: {
|
|||||||
export interface ExtractorInput {
|
export interface ExtractorInput {
|
||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
batchSize?: number
|
batchSize?: number
|
||||||
onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
|
onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractorResult {
|
export interface ExtractorResult {
|
||||||
@@ -918,12 +924,14 @@ export interface ExtractorResult {
|
|||||||
**原因**: IPC 通信无法序列化函数,`onProgress` 回调无法传递到主进程。
|
**原因**: IPC 通信无法序列化函数,`onProgress` 回调无法传递到主进程。
|
||||||
|
|
||||||
**当前实现**:
|
**当前实现**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// extractor.ts 中调用但无效
|
// extractor.ts 中调用但无效
|
||||||
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
||||||
```
|
```
|
||||||
|
|
||||||
**建议实现方案**:
|
**建议实现方案**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 方案: 使用 IPC 事件通道
|
// 方案: 使用 IPC 事件通道
|
||||||
|
|
||||||
@@ -941,7 +949,7 @@ extractor: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electron.extractor.onProgress((data) => {
|
window.electron.extractor.onProgress((data) => {
|
||||||
setProgress(data)
|
setProgress(data)
|
||||||
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
|
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
||||||
|
|
||||||
**数据流问题:**
|
**数据流问题:**
|
||||||
|
|
||||||
```
|
```
|
||||||
SettingsPage (只修改 ERP URL)
|
SettingsPage (只修改 ERP URL)
|
||||||
↓ 发送完整的 settings 对象
|
↓ 发送完整的 settings 对象
|
||||||
@@ -69,11 +70,11 @@ ConfigManager.saveAllSettings()
|
|||||||
|
|
||||||
### 改动点
|
### 改动点
|
||||||
|
|
||||||
| 文件 | 改动类型 | 说明 |
|
| 文件 | 改动类型 | 说明 |
|
||||||
|------|---------|------|
|
| -------------------------------------------- | -------- | ------------------------------------------------ |
|
||||||
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
||||||
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
||||||
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|||||||
const UI_EDITABLE_FIELDS: string[] = [
|
const UI_EDITABLE_FIELDS: string[] = [
|
||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password'
|
||||||
// 未来扩展:
|
// 未来扩展:
|
||||||
// 'database.dbType',
|
// 'database.dbType',
|
||||||
// 'paths.dataDir',
|
// 'paths.dataDir',
|
||||||
@@ -305,6 +306,7 @@ ipcMain.handle(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**关键改动:**
|
**关键改动:**
|
||||||
|
|
||||||
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
||||||
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
||||||
|
|
||||||
@@ -409,9 +411,9 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // 新增
|
'database.dbType', // 新增
|
||||||
'paths.dataDir', // 新增
|
'paths.dataDir', // 新增
|
||||||
'extraction.batchSize', // 新增
|
'extraction.batchSize' // 新增
|
||||||
// ...
|
// ...
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -425,10 +427,7 @@ const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
|
|||||||
Guest: []
|
Guest: []
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEditableFields(
|
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
|
||||||
settings: Partial<SettingsData>,
|
|
||||||
userType: UserType
|
|
||||||
) {
|
|
||||||
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
||||||
// 验证逻辑...
|
// 验证逻辑...
|
||||||
}
|
}
|
||||||
@@ -464,12 +463,12 @@ interface ConfigChange {
|
|||||||
|
|
||||||
## 风险与缓解
|
## 风险与缓解
|
||||||
|
|
||||||
| 风险 | 影响 | 缓解措施 |
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|------|------|---------|
|
| ---------------- | ---------- | ----------------------------- |
|
||||||
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
||||||
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
||||||
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
||||||
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
|
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
## Task 1: Add Utility Functions to ConfigManager
|
## Task 1: Add Utility Functions to ConfigManager
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write failing test for deep merge**
|
**Step 1: Write failing test for deep merge**
|
||||||
@@ -31,11 +32,38 @@ describe('ConfigManager - deep merge utilities', () => {
|
|||||||
|
|
||||||
// Setup initial state
|
// Setup initial state
|
||||||
const initial: SettingsData = {
|
const initial: SettingsData = {
|
||||||
erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' },
|
url: 'http://old.com',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
headless: true,
|
||||||
|
ignoreHttpsErrors: true,
|
||||||
|
autoCloseBrowser: true
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: 'localhost',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'db',
|
||||||
|
username: 'user',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' },
|
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' },
|
||||||
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
|
batchSize: 100,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 2000,
|
||||||
|
matchMode: 'substring',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||||
execution: { dryRun: false }
|
execution: { dryRun: false }
|
||||||
}
|
}
|
||||||
@@ -113,7 +141,7 @@ function deepMerge<T>(source: T, target: Partial<T>): T {
|
|||||||
const UI_EDITABLE_FIELDS: string[] = [
|
const UI_EDITABLE_FIELDS: string[] = [
|
||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password'
|
||||||
// Add more fields as UI expands
|
// Add more fields as UI expands
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -163,6 +191,7 @@ git commit -m "feat: add deep merge and validation utility functions to ConfigMa
|
|||||||
## Task 2: Add Backup and Restore Mechanism
|
## Task 2: Add Backup and Restore Mechanism
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write test for backup functionality**
|
**Step 1: Write test for backup functionality**
|
||||||
@@ -302,6 +331,7 @@ git commit -m "feat: add backup and restore mechanism to ConfigManager"
|
|||||||
## Task 3: Implement savePartialSettings Method
|
## Task 3: Implement savePartialSettings Method
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write comprehensive test for savePartialSettings**
|
**Step 1: Write comprehensive test for savePartialSettings**
|
||||||
@@ -316,11 +346,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
|
|
||||||
// Setup initial state with multiple categories
|
// Setup initial state with multiple categories
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' },
|
url: 'http://old.com',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
headless: true,
|
||||||
|
ignoreHttpsErrors: true,
|
||||||
|
autoCloseBrowser: true
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: '192.168.1.1',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'testdb',
|
||||||
|
username: 'dbuser',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' },
|
batchSize: 50,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 1000,
|
||||||
|
matchMode: 'exact',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
||||||
execution: { dryRun: true }
|
execution: { dryRun: true }
|
||||||
})
|
})
|
||||||
@@ -367,11 +424,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
await manager.initialize()
|
await manager.initialize()
|
||||||
|
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' },
|
url: 'http://test.com',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
headless: false,
|
||||||
|
ignoreHttpsErrors: false,
|
||||||
|
autoCloseBrowser: false
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: 'localhost',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'db',
|
||||||
|
username: 'user',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
|
batchSize: 100,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 2000,
|
||||||
|
matchMode: 'substring',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||||
execution: { dryRun: false }
|
execution: { dryRun: false }
|
||||||
})
|
})
|
||||||
@@ -516,6 +600,7 @@ git commit -m "feat: implement savePartialSettings with validation and rollback"
|
|||||||
## Task 4: Update IPC Handler to Use Partial Save
|
## Task 4: Update IPC Handler to Use Partial Save
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/ipc/settings-handler.ts`
|
- Modify: `src/main/ipc/settings-handler.ts`
|
||||||
|
|
||||||
**Step 1: Update settings:saveSettings handler**
|
**Step 1: Update settings:saveSettings handler**
|
||||||
@@ -523,42 +608,42 @@ git commit -m "feat: implement savePartialSettings with validation and rollback"
|
|||||||
Find the `settings:saveSettings` handler (around line 83) and replace it:
|
Find the `settings:saveSettings` handler (around line 83) and replace it:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/**
|
/**
|
||||||
* Save settings (updated to use partial save)
|
* Save settings (updated to use partial save)
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'settings:saveSettings',
|
'settings:saveSettings',
|
||||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
||||||
try {
|
try {
|
||||||
log.info('Saving settings', {
|
log.info('Saving settings', {
|
||||||
sections: Object.keys(settings)
|
sections: Object.keys(settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use partial save method
|
||||||
|
const result = await configManager.savePartialSettings(settings)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log.info('Settings saved successfully')
|
||||||
|
return { success: true }
|
||||||
|
} else {
|
||||||
|
log.warn('Failed to save settings', {
|
||||||
|
error: result.error
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use partial save method
|
|
||||||
const result = await configManager.savePartialSettings(settings)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
log.info('Settings saved successfully')
|
|
||||||
return { success: true }
|
|
||||||
} else {
|
|
||||||
log.warn('Failed to save settings', {
|
|
||||||
error: result.error
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: result.error || '保存设置失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Error saving settings', { error: message })
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `保存设置失败:${message}`
|
error: result.error || '保存设置失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Error saving settings', { error: message })
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `保存设置失败:${message}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Run typecheck**
|
**Step 2: Run typecheck**
|
||||||
@@ -580,6 +665,7 @@ git commit -m "feat: update settings handler to use savePartialSettings"
|
|||||||
## Task 5: Update Frontend to Send Only Necessary Fields
|
## Task 5: Update Frontend to Send Only Necessary Fields
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/renderer/src/pages/SettingsPage.tsx`
|
- Modify: `src/renderer/src/pages/SettingsPage.tsx`
|
||||||
|
|
||||||
**Step 1: Update handleSaveSettings to send partial settings**
|
**Step 1: Update handleSaveSettings to send partial settings**
|
||||||
@@ -587,29 +673,29 @@ git commit -m "feat: update settings handler to use savePartialSettings"
|
|||||||
Find the `handleSaveSettings` function (around line 61) and replace it:
|
Find the `handleSaveSettings` function (around line 61) and replace it:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const handleSaveSettings = async () => {
|
const handleSaveSettings = async () => {
|
||||||
try {
|
try {
|
||||||
// Only send UI-supported fields (double safety)
|
// Only send UI-supported fields (double safety)
|
||||||
const partialSettings = {
|
const partialSettings = {
|
||||||
erp: {
|
erp: {
|
||||||
url: settings.erp?.url,
|
url: settings.erp?.url,
|
||||||
username: settings.erp?.username,
|
username: settings.erp?.username,
|
||||||
password: settings.erp?.password
|
password: settings.erp?.password
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electron.settings.saveSettings(partialSettings as any)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setIsModified(false)
|
|
||||||
showMessage('success', '设置保存成功')
|
|
||||||
} else {
|
|
||||||
showMessage('error', result.error || '保存失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('error', '保存设置时发生错误')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await window.electron.settings.saveSettings(partialSettings as any)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setIsModified(false)
|
||||||
|
showMessage('success', '设置保存成功')
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.error || '保存失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage('error', '保存设置时发生错误')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Run typecheck**
|
**Step 2: Run typecheck**
|
||||||
@@ -631,6 +717,7 @@ git commit -m "feat: send only ERP fields from settings page (defensive programm
|
|||||||
## Task 6: Manual Testing and Verification
|
## Task 6: Manual Testing and Verification
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Manual test procedure
|
- Manual test procedure
|
||||||
|
|
||||||
**Step 1: Prepare test environment**
|
**Step 1: Prepare test environment**
|
||||||
@@ -701,6 +788,7 @@ cat .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
Expected results:
|
Expected results:
|
||||||
|
|
||||||
- `ERP_URL` should be `http://modified-test.com` (CHANGED)
|
- `ERP_URL` should be `http://modified-test.com` (CHANGED)
|
||||||
- `DB_TYPE` should still be `mysql` (PRESERVED)
|
- `DB_TYPE` should still be `mysql` (PRESERVED)
|
||||||
- `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED)
|
- `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED)
|
||||||
@@ -790,6 +878,7 @@ git commit -m "test: add manual test report for settings partial save"
|
|||||||
## Task 7: Update Documentation
|
## Task 7: Update Documentation
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `docs/settings-partial-save.md`
|
- Create: `docs/settings-partial-save.md`
|
||||||
- Update: `README.md` (if applicable)
|
- Update: `README.md` (if applicable)
|
||||||
|
|
||||||
@@ -797,7 +886,7 @@ git commit -m "test: add manual test report for settings partial save"
|
|||||||
|
|
||||||
Create `docs/settings-partial-save.md`:
|
Create `docs/settings-partial-save.md`:
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
# Settings Partial Save Feature
|
# Settings Partial Save Feature
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -813,6 +902,7 @@ The settings system now implements partial save functionality to prevent uninten
|
|||||||
## Editable Fields
|
## Editable Fields
|
||||||
|
|
||||||
Currently editable via UI:
|
Currently editable via UI:
|
||||||
|
|
||||||
- `erp.url` - ERP system URL
|
- `erp.url` - ERP system URL
|
||||||
- `erp.username` - ERP login username
|
- `erp.username` - ERP login username
|
||||||
- `erp.password` - ERP login password
|
- `erp.password` - ERP login password
|
||||||
@@ -828,9 +918,10 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // Add new field here
|
'database.dbType' // Add new field here
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
||||||
3. Update `handleSaveSettings` to include the new field
|
3. Update `handleSaveSettings` to include the new field
|
||||||
@@ -844,6 +935,7 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
**Returns:** `{ success: boolean, error?: string }`
|
**Returns:** `{ success: boolean, error?: string }`
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
|
|
||||||
- Checks whitelist before applying changes
|
- Checks whitelist before applying changes
|
||||||
- Returns error for unauthorized fields
|
- Returns error for unauthorized fields
|
||||||
|
|
||||||
@@ -858,7 +950,8 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
Location: `.env.backup` (in project root)
|
Location: `.env.backup` (in project root)
|
||||||
|
|
||||||
Created before every save operation. Used for rollback on failure.
|
Created before every save operation. Used for rollback on failure.
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
**Step 2: Update CLAUDE.md if needed**
|
**Step 2: Update CLAUDE.md if needed**
|
||||||
|
|
||||||
@@ -870,13 +963,14 @@ Add to "Development Commands" or "Architecture Overview" sections if there's rel
|
|||||||
cd D:/Node/ERPAuto-settings-fix
|
cd D:/Node/ERPAuto-settings-fix
|
||||||
git add docs/settings-partial-save.md
|
git add docs/settings-partial-save.md
|
||||||
git commit -m "docs: add settings partial save feature documentation"
|
git commit -m "docs: add settings partial save feature documentation"
|
||||||
```
|
````
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 8: Final Verification and Cleanup
|
## Task 8: Final Verification and Cleanup
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- All modified files
|
- All modified files
|
||||||
|
|
||||||
**Step 1: Run full test suite**
|
**Step 1: Run full test suite**
|
||||||
@@ -937,6 +1031,7 @@ This implementation plan fixes the settings save issue through:
|
|||||||
**Total estimated implementation time:** 2-3 hours
|
**Total estimated implementation time:** 2-3 hours
|
||||||
|
|
||||||
**Key files modified:**
|
**Key files modified:**
|
||||||
|
|
||||||
- `src/main/services/config/config-manager.ts` (core logic)
|
- `src/main/services/config/config-manager.ts` (core logic)
|
||||||
- `src/main/ipc/settings-handler.ts` (IPC layer)
|
- `src/main/ipc/settings-handler.ts` (IPC layer)
|
||||||
- `src/renderer/src/pages/SettingsPage.tsx` (frontend)
|
- `src/renderer/src/pages/SettingsPage.tsx` (frontend)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ The settings system now implements partial save functionality to prevent uninten
|
|||||||
## Editable Fields
|
## Editable Fields
|
||||||
|
|
||||||
Currently editable via UI:
|
Currently editable via UI:
|
||||||
|
|
||||||
- `erp.url` - ERP system URL
|
- `erp.url` - ERP system URL
|
||||||
- `erp.username` - ERP login username
|
- `erp.username` - ERP login username
|
||||||
- `erp.password` - ERP login password
|
- `erp.password` - ERP login password
|
||||||
@@ -28,7 +29,7 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // Add new field here
|
'database.dbType' // Add new field here
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
**Returns:** `{ success: boolean, error?: string }`
|
**Returns:** `{ success: boolean, error?: string }`
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
|
|
||||||
- Checks whitelist before applying changes
|
- Checks whitelist before applying changes
|
||||||
- Returns error for unauthorized fields
|
- Returns error for unauthorized fields
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# 系统设置保存按钮工作流程分析
|
# 系统设置保存按钮工作流程分析
|
||||||
|
|
||||||
# System Settings Save Button Workflow Analysis
|
# System Settings Save Button Workflow Analysis
|
||||||
|
|
||||||
## 文档概述 / Document Overview
|
## 文档概述 / Document Overview
|
||||||
@@ -171,6 +172,7 @@ graph LR
|
|||||||
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- 用户界面渲染和交互
|
- 用户界面渲染和交互
|
||||||
- 本地状态管理(settings, isModified, message)
|
- 本地状态管理(settings, isModified, message)
|
||||||
- 调用 IPC 通信
|
- 调用 IPC 通信
|
||||||
@@ -183,7 +185,7 @@ const handleSaveSettings = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electron.settings.saveSettings(settings as any)
|
const result = await window.electron.settings.saveSettings(settings as any)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsModified(false) // 清除修改标记
|
setIsModified(false) // 清除修改标记
|
||||||
showMessage('success', '设置保存成功')
|
showMessage('success', '设置保存成功')
|
||||||
} else {
|
} else {
|
||||||
showMessage('error', result.error || '保存失败')
|
showMessage('error', result.error || '保存失败')
|
||||||
@@ -196,12 +198,12 @@ const handleSaveSettings = async () => {
|
|||||||
|
|
||||||
**状态管理 / State Management:**
|
**状态管理 / State Management:**
|
||||||
|
|
||||||
| 状态变量 | 类型 | 用途 |
|
| 状态变量 | 类型 | 用途 |
|
||||||
|---------|------|------|
|
| ------------ | ---------------- | ----------------------------------------------------------- |
|
||||||
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
||||||
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
||||||
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
||||||
| `message` | `object \| null` | 临时消息,3秒后自动消失 |
|
| `message` | `object \| null` | 临时消息,3秒后自动消失 |
|
||||||
|
|
||||||
**UI 交互逻辑 / UI Interaction Logic:**
|
**UI 交互逻辑 / UI Interaction Logic:**
|
||||||
|
|
||||||
@@ -232,6 +234,7 @@ stateDiagram-v2
|
|||||||
#### preload/index.ts (`src/preload/index.ts`)
|
#### preload/index.ts (`src/preload/index.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- 安全桥梁,暴露受限 API 到渲染进程
|
- 安全桥梁,暴露受限 API 到渲染进程
|
||||||
- 类型安全的 IPC 通道定义
|
- 类型安全的 IPC 通道定义
|
||||||
|
|
||||||
@@ -273,6 +276,7 @@ graph TB
|
|||||||
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- IPC 通道注册和处理
|
- IPC 通道注册和处理
|
||||||
- 权限验证(基于用户类型)
|
- 权限验证(基于用户类型)
|
||||||
- 业务逻辑协调
|
- 业务逻辑协调
|
||||||
@@ -334,19 +338,20 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
|
|||||||
|
|
||||||
**权限控制矩阵 / Permission Control Matrix:**
|
**权限控制矩阵 / Permission Control Matrix:**
|
||||||
|
|
||||||
| 功能 / Feature | Admin | User | Guest |
|
| 功能 / Feature | Admin | User | Guest |
|
||||||
|---------------|-------|------|-------|
|
| -------------- | ----- | ------- | ----- |
|
||||||
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
||||||
| 保存设置 | ✅ | ✅ | ❌ |
|
| 保存设置 | ✅ | ✅ | ❌ |
|
||||||
| 恢复默认值 | ✅ | ❌ | ❌ |
|
| 恢复默认值 | ✅ | ❌ | ❌ |
|
||||||
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
|
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
|
||||||
| 测试数据库连接 | ✅ | ✅ | ❌ |
|
| 测试数据库连接 | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
### 4. 配置管理服务 / Configuration Manager Service
|
### 4. 配置管理服务 / Configuration Manager Service
|
||||||
|
|
||||||
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- .env 文件读写
|
- .env 文件读写
|
||||||
- 配置缓存管理
|
- 配置缓存管理
|
||||||
- 默认值管理
|
- 默认值管理
|
||||||
@@ -356,10 +361,10 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
private static instance: ConfigManager | null = null // 单例模式
|
private static instance: ConfigManager | null = null // 单例模式
|
||||||
private envPath: string // .env 文件路径
|
private envPath: string // .env 文件路径
|
||||||
private configCache: Map<string, string> // 内存缓存
|
private configCache: Map<string, string> // 内存缓存
|
||||||
private initialized: boolean = false // 初始化标记
|
private initialized: boolean = false // 初始化标记
|
||||||
|
|
||||||
// 单例获取方法
|
// 单例获取方法
|
||||||
public static getInstance(): ConfigManager
|
public static getInstance(): ConfigManager
|
||||||
@@ -519,6 +524,7 @@ graph TB
|
|||||||
### IPC 通信数据格式 / IPC Communication Data Format
|
### IPC 通信数据格式 / IPC Communication Data Format
|
||||||
|
|
||||||
**请求格式 / Request Format:**
|
**请求格式 / Request Format:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"erp": {
|
"erp": {
|
||||||
@@ -539,6 +545,7 @@ graph TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
**响应格式 / Response Format:**
|
**响应格式 / Response Format:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 成功 / Success
|
// 成功 / Success
|
||||||
{
|
{
|
||||||
@@ -594,13 +601,13 @@ graph TB
|
|||||||
|
|
||||||
### 错误场景分析 / Error Scenario Analysis
|
### 错误场景分析 / Error Scenario Analysis
|
||||||
|
|
||||||
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
||||||
|--------------------------|-------------------|-------------------|----------------------|
|
| ------------------------- | ------------------- | --------------------- | ------------------------ |
|
||||||
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
||||||
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
||||||
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
||||||
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
|
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
|
||||||
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
|
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
|
||||||
|
|
||||||
### 日志记录策略 / Logging Strategy
|
### 日志记录策略 / Logging Strategy
|
||||||
|
|
||||||
@@ -613,6 +620,7 @@ log.error('Error saving settings', { error: message })
|
|||||||
```
|
```
|
||||||
|
|
||||||
**日志级别使用 / Log Level Usage:**
|
**日志级别使用 / Log Level Usage:**
|
||||||
|
|
||||||
- `info`: 正常操作流程
|
- `info`: 正常操作流程
|
||||||
- `warn`: 潜在问题(如保存失败但未崩溃)
|
- `warn`: 潜在问题(如保存失败但未崩溃)
|
||||||
- `error`: 严重错误(如异常抛出)
|
- `error`: 严重错误(如异常抛出)
|
||||||
@@ -680,14 +688,14 @@ sequenceDiagram
|
|||||||
|
|
||||||
### 文件位置索引 / File Location Index
|
### 文件位置索引 / File Location Index
|
||||||
|
|
||||||
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
||||||
|-----------------|---------------------|-------------------|
|
| ---------------- | -------------------------------------------- | --------------------- |
|
||||||
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
||||||
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
||||||
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
||||||
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
|
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
|
||||||
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
|
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
|
||||||
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
|
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
|
||||||
|
|
||||||
### 性能特性 / Performance Characteristics
|
### 性能特性 / Performance Characteristics
|
||||||
|
|
||||||
@@ -743,11 +751,12 @@ const updateSettings = (category: string, key: string, value: any) => {
|
|||||||
[key]: value
|
[key]: value
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
setIsModified(true) // 标记为已修改
|
setIsModified(true) // 标记为已修改
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 不可变更新模式(Immutable Update Pattern)
|
- 不可变更新模式(Immutable Update Pattern)
|
||||||
- 使用展开运算符保持对象引用
|
- 使用展开运算符保持对象引用
|
||||||
- 自动启用保存按钮
|
- 自动启用保存按钮
|
||||||
@@ -768,6 +777,7 @@ public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 先更新内存,后写入磁盘
|
- 先更新内存,后写入磁盘
|
||||||
- 失败时缓存保持不变
|
- 失败时缓存保持不变
|
||||||
- 返回布尔值表示成功/失败
|
- 返回布尔值表示成功/失败
|
||||||
@@ -792,6 +802,7 @@ public async save(): Promise<boolean> {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 添加注释分隔符提高可读性
|
- 添加注释分隔符提高可读性
|
||||||
- 使用默认值作为后备
|
- 使用默认值作为后备
|
||||||
- 同步写入确保一致性
|
- 同步写入确保一致性
|
||||||
@@ -820,6 +831,7 @@ public async save(): Promise<boolean> {
|
|||||||
### 长期改进 / Long-term Improvements
|
### 长期改进 / Long-term Improvements
|
||||||
|
|
||||||
1. **安全性增强 / Security Enhancement**
|
1. **安全性增强 / Security Enhancement**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 建议实现密码加密
|
// 建议实现密码加密
|
||||||
interface SecureSettingsData extends SettingsData {
|
interface SecureSettingsData extends SettingsData {
|
||||||
@@ -851,7 +863,9 @@ public async save(): Promise<boolean> {
|
|||||||
describe('ConfigManager', () => {
|
describe('ConfigManager', () => {
|
||||||
it('should save settings successfully', async () => {
|
it('should save settings successfully', async () => {
|
||||||
const manager = ConfigManager.getInstance()
|
const manager = ConfigManager.getInstance()
|
||||||
const settings: SettingsData = { /* mock data */ }
|
const settings: SettingsData = {
|
||||||
|
/* mock data */
|
||||||
|
}
|
||||||
const result = await manager.saveAllSettings(settings)
|
const result = await manager.saveAllSettings(settings)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -883,16 +897,16 @@ describe('Settings Save Flow', () => {
|
|||||||
|
|
||||||
### 完整配置字段列表 / Complete Configuration Field List
|
### 完整配置字段列表 / Complete Configuration Field List
|
||||||
|
|
||||||
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
||||||
|---------------|---------------------|-------------------|
|
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
|
||||||
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
||||||
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
||||||
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
||||||
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
|
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
|
||||||
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
|
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
|
||||||
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
|
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
|
||||||
| Execution | 1 | dryRun |
|
| Execution | 1 | dryRun |
|
||||||
| **总计 / Total** | **30** | |
|
| **总计 / Total** | **30** | |
|
||||||
|
|
||||||
### 相关文档 / Related Documentation
|
### 相关文档 / Related Documentation
|
||||||
|
|
||||||
@@ -902,9 +916,9 @@ describe('Settings Save Flow', () => {
|
|||||||
|
|
||||||
### 版本历史 / Version History
|
### 版本历史 / Version History
|
||||||
|
|
||||||
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
||||||
|---------------|------------|--------------|
|
| -------------- | ----------- | -------------------------- |
|
||||||
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
|||||||
```
|
```
|
||||||
|
|
||||||
**说明**:
|
**说明**:
|
||||||
|
|
||||||
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
||||||
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
||||||
- 如果用户未登录,返回错误响应
|
- 如果用户未登录,返回错误响应
|
||||||
@@ -68,6 +69,7 @@ if (!isAdmin && username) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**匹配逻辑**:
|
**匹配逻辑**:
|
||||||
|
|
||||||
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
||||||
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
||||||
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
||||||
@@ -109,11 +111,13 @@ flowchart TB
|
|||||||
### 场景1: User 用户匹配到自己的 typeKeyword
|
### 场景1: User 用户匹配到自己的 typeKeyword
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -126,6 +130,7 @@ flowchart TB
|
|||||||
### 场景2: User 用户覆盖其他用户的匹配
|
### 场景2: User 用户覆盖其他用户的匹配
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
@@ -138,12 +143,14 @@ flowchart TB
|
|||||||
### 场景3: User 用户无匹配关键词
|
### 场景3: User 用户无匹配关键词
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -152,11 +159,13 @@ flowchart TB
|
|||||||
"isMarkedForDeletion": false
|
"isMarkedForDeletion": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: 保持优先级2的匹配结果
|
**说明**: 保持优先级2的匹配结果
|
||||||
|
|
||||||
### 场景4: Admin 用户不执行覆盖
|
### 场景4: Admin 用户不执行覆盖
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `admin` (isAdmin=true)
|
- 当前用户: `admin` (isAdmin=true)
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
@@ -164,6 +173,7 @@ flowchart TB
|
|||||||
- `{ materialName: "螺丝", managerName: "admin" }`
|
- `{ materialName: "螺丝", managerName: "admin" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -172,16 +182,19 @@ flowchart TB
|
|||||||
"isMarkedForDeletion": false
|
"isMarkedForDeletion": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
||||||
|
|
||||||
### 场景5: 优先级1匹配不受影响
|
### 场景5: 优先级1匹配不受影响
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料代码: `MAT001`
|
- 物料代码: `MAT001`
|
||||||
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialCode": "MAT001",
|
"materialCode": "MAT001",
|
||||||
@@ -190,6 +203,7 @@ flowchart TB
|
|||||||
"matchedTypeKeyword": undefined
|
"matchedTypeKeyword": undefined
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: 优先级1的精确匹配不受覆盖影响
|
**说明**: 优先级1的精确匹配不受覆盖影响
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -198,22 +212,22 @@ flowchart TB
|
|||||||
|
|
||||||
### MaterialsTypeToBeDeleted 表数据
|
### MaterialsTypeToBeDeleted 表数据
|
||||||
|
|
||||||
| MaterialName | ManagerName | 说明 |
|
| MaterialName | ManagerName | 说明 |
|
||||||
|--------------|-------------|------|
|
| ------------ | ----------- | ------------------------------ |
|
||||||
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
||||||
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
||||||
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
||||||
| 电缆 | admin | admin 负责电缆类物料 |
|
| 电缆 | admin | admin 负责电缆类物料 |
|
||||||
|
|
||||||
### 匹配结果示例
|
### 匹配结果示例
|
||||||
|
|
||||||
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
||||||
|------------|---------|----------------|----------------|
|
| -------- | -------- | ---------------- | ----------------- |
|
||||||
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
||||||
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
||||||
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
||||||
| 电缆 5m | user1 | admin | user1 (无匹配) |
|
| 电缆 5m | user1 | admin | user1 (无匹配) |
|
||||||
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
|
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -233,6 +247,7 @@ const filteredResults = React.useMemo(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**协同效果**:
|
**协同效果**:
|
||||||
|
|
||||||
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
||||||
2. 前端过滤器只显示属于当前用户或未分配的物料
|
2. 前端过滤器只显示属于当前用户或未分配的物料
|
||||||
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1772462852547,
|
|
||||||
"name": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\app-2026-03-02.log",
|
|
||||||
"hash": "baa4ab4c2dfd6ec62a003e44496d2f428a4ad4037ce2529a8da14da1b91ef7a2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
117
scripts/fix-auto-increment.js
Normal file
117
scripts/fix-auto-increment.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||||
|
*
|
||||||
|
* This script modifies the ID column to be AUTO_INCREMENT while preserving data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = {
|
||||||
|
host: '192.168.31.83',
|
||||||
|
port: 3306,
|
||||||
|
user: 'remote_user',
|
||||||
|
password: '3.1415926Beeke',
|
||||||
|
database: 'BLD_DB'
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Connecting to MySQL...');
|
||||||
|
connection = await mysql.createConnection(config);
|
||||||
|
console.log('Connected successfully!\n');
|
||||||
|
|
||||||
|
// Step 1: Check current table structure
|
||||||
|
console.log('=== Step 1: Current table structure ===');
|
||||||
|
const [columns] = await connection.execute(`
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_KEY,
|
||||||
|
COLUMN_DEFAULT,
|
||||||
|
EXTRA
|
||||||
|
FROM
|
||||||
|
INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
AND TABLE_SCHEMA = DATABASE()
|
||||||
|
ORDER BY
|
||||||
|
ORDINAL_POSITION
|
||||||
|
`);
|
||||||
|
console.table(columns);
|
||||||
|
|
||||||
|
// Step 2: Count records before modification
|
||||||
|
console.log('\n=== Step 2: Record count before modification ===');
|
||||||
|
const [countBefore] = await connection.execute(
|
||||||
|
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||||
|
);
|
||||||
|
console.log(`Total records: ${countBefore[0].total}`);
|
||||||
|
|
||||||
|
// Step 3: Show sample data
|
||||||
|
console.log('\n=== Step 3: Sample data ===');
|
||||||
|
const [sample] = await connection.execute(
|
||||||
|
'SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 5'
|
||||||
|
);
|
||||||
|
console.table(sample);
|
||||||
|
|
||||||
|
// Step 4: Check if ID is already AUTO_INCREMENT
|
||||||
|
const idColumn = columns.find((col) => col.COLUMN_NAME === 'ID');
|
||||||
|
if (idColumn && idColumn.EXTRA.includes('auto_increment')) {
|
||||||
|
console.log('\n=== ID is already AUTO_INCREMENT! No modification needed. ===');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Modify the ID column
|
||||||
|
console.log('\n=== Step 4: Modifying ID column to AUTO_INCREMENT ===');
|
||||||
|
await connection.execute(`
|
||||||
|
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||||
|
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT
|
||||||
|
`);
|
||||||
|
console.log('Modification completed successfully!\n');
|
||||||
|
|
||||||
|
// Step 6: Verify the change
|
||||||
|
console.log('=== Step 5: Verify modification ===');
|
||||||
|
const [columnsAfter] = await connection.execute(`
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_KEY,
|
||||||
|
EXTRA
|
||||||
|
FROM
|
||||||
|
INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
AND TABLE_SCHEMA = DATABASE()
|
||||||
|
AND COLUMN_NAME = 'ID'
|
||||||
|
`);
|
||||||
|
console.table(columnsAfter);
|
||||||
|
|
||||||
|
// Step 7: Verify data is still intact
|
||||||
|
console.log('\n=== Step 6: Verify data integrity ===');
|
||||||
|
const [countAfter] = await connection.execute(
|
||||||
|
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||||
|
);
|
||||||
|
console.log(`Total records after modification: ${countAfter[0].total}`);
|
||||||
|
|
||||||
|
if (countBefore[0].total === countAfter[0].total) {
|
||||||
|
console.log('\n✅ SUCCESS: All data preserved, AUTO_INCREMENT added to ID column!');
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ WARNING: Record count changed! Please check data.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Error:', error.message);
|
||||||
|
if (error.code) {
|
||||||
|
console.error('Error code:', error.code);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (connection) {
|
||||||
|
await connection.end();
|
||||||
|
console.log('\nConnection closed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
82
scripts/fix-materials-type-auto-increment.sql
Normal file
82
scripts/fix-materials-type-auto-increment.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Script: Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||||
|
-- Description: Modify the ID column to be AUTO_INCREMENT while preserving data
|
||||||
|
-- Database: MySQL
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Step 1: Check current table structure
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_KEY,
|
||||||
|
COLUMN_DEFAULT,
|
||||||
|
EXTRA
|
||||||
|
FROM
|
||||||
|
INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
AND TABLE_SCHEMA = DATABASE()
|
||||||
|
ORDER BY
|
||||||
|
ORDINAL_POSITION;
|
||||||
|
|
||||||
|
-- Step 2: View current data before modification
|
||||||
|
SELECT COUNT(*) AS total_records FROM dbo_MaterialsTypeToBeDeleted;
|
||||||
|
SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 10;
|
||||||
|
|
||||||
|
-- Step 3: Check if ID is already AUTO_INCREMENT
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
EXTRA
|
||||||
|
FROM
|
||||||
|
INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
AND TABLE_SCHEMA = DATABASE()
|
||||||
|
AND COLUMN_NAME = 'ID';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Step 4: Modify the ID column to AUTO_INCREMENT
|
||||||
|
-- Note: This assumes ID is already the PRIMARY KEY
|
||||||
|
-- If not, you may need to add PRIMARY KEY constraint first
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Option A: If ID is already PRIMARY KEY (most likely case)
|
||||||
|
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||||
|
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
-- Option B: If ID is NOT PRIMARY KEY (uncomment if needed)
|
||||||
|
-- First check if there's an existing primary key
|
||||||
|
-- SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
-- WHERE TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
-- AND TABLE_SCHEMA = DATABASE() AND COLUMN_KEY = 'PRI';
|
||||||
|
--
|
||||||
|
-- If no primary key exists:
|
||||||
|
-- ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||||
|
-- MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY;
|
||||||
|
|
||||||
|
-- Step 5: Verify the change
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_KEY,
|
||||||
|
EXTRA
|
||||||
|
FROM
|
||||||
|
INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||||
|
AND TABLE_SCHEMA = DATABASE()
|
||||||
|
AND COLUMN_NAME = 'ID';
|
||||||
|
|
||||||
|
-- Step 6: Verify data is still intact
|
||||||
|
SELECT COUNT(*) AS total_records_after FROM dbo_MaterialsTypeToBeDeleted;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Expected Results:
|
||||||
|
-- After running this script, the ID column should show:
|
||||||
|
-- EXTRA: 'auto_increment'
|
||||||
|
--
|
||||||
|
-- This will allow INSERT statements to omit the ID field, and MySQL will
|
||||||
|
-- automatically generate the next sequential ID value.
|
||||||
|
-- ============================================================================
|
||||||
@@ -11,6 +11,7 @@ import { registerResolverHandlers } from './resolver-handler'
|
|||||||
import { registerAuthHandlers } from './auth-handler'
|
import { registerAuthHandlers } from './auth-handler'
|
||||||
import { registerValidationHandlers } from './validation-handler'
|
import { registerValidationHandlers } from './validation-handler'
|
||||||
import { registerSettingsHandlers } from './settings-handler'
|
import { registerSettingsHandlers } from './settings-handler'
|
||||||
|
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
|
|
||||||
@@ -73,5 +74,6 @@ export function registerIpcHandlers(): void {
|
|||||||
registerAuthHandlers()
|
registerAuthHandlers()
|
||||||
registerValidationHandlers()
|
registerValidationHandlers()
|
||||||
registerSettingsHandlers()
|
registerSettingsHandlers()
|
||||||
|
registerMaterialTypeHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/main/ipc/material-type-handler.ts
Normal file
154
src/main/ipc/material-type-handler.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* IPC handlers for material type management operations
|
||||||
|
*
|
||||||
|
* Provides endpoints for:
|
||||||
|
* - Getting all material type records
|
||||||
|
* - Getting records by manager
|
||||||
|
* - Getting list of managers
|
||||||
|
* - Upserting (insert/update) records
|
||||||
|
* - Deleting records
|
||||||
|
* - Batch operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import {
|
||||||
|
MaterialsTypeToBeDeletedDAO,
|
||||||
|
type MaterialTypeRecord,
|
||||||
|
type MaterialTypeBatchRequest
|
||||||
|
} from '../services/database/materials-type-to-be-deleted-dao'
|
||||||
|
import { createLogger } from '../services/logger'
|
||||||
|
|
||||||
|
const log = createLogger('MaterialTypeHandler')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register IPC handlers for material type operations
|
||||||
|
*/
|
||||||
|
export function registerMaterialTypeHandlers(): void {
|
||||||
|
const dao = new MaterialsTypeToBeDeletedDAO()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material type records
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:getAll',
|
||||||
|
async (): Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const records = await dao.getAllMaterials()
|
||||||
|
return { success: true, data: records }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Get all material types error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get material types by manager
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:getByManager',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
managerName: string
|
||||||
|
): Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const records = await dao.getMaterialsByManager(managerName)
|
||||||
|
return { success: true, data: records }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Get material types by manager error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of managers
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:getManagers',
|
||||||
|
async (): Promise<{ success: boolean; data?: string[]; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const managers = await dao.getManagers()
|
||||||
|
return { success: true, data: managers }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Get managers error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert (insert or update) a material type record
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:upsert',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||||
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const result = await dao.upsertMaterial(materialName, managerName)
|
||||||
|
if (!result) {
|
||||||
|
return { success: false, error: 'Failed to upsert material type' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Upsert material type error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a material type record
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:delete',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||||
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const result = await dao.deleteMaterial(materialName, managerName)
|
||||||
|
if (!result) {
|
||||||
|
return { success: false, error: 'Failed to delete material type' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Delete material type error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch operation for material types (insert, update, delete)
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materialType:upsertBatch',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
request: MaterialTypeBatchRequest
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
stats?: { total: number; success: number; failed: number }
|
||||||
|
error?: string
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const stats = await dao.upsertBatch(request)
|
||||||
|
return { success: true, stats }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Batch upsert material types error', { error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Material type handlers registered')
|
||||||
|
}
|
||||||
@@ -291,12 +291,8 @@ export class ConfigManager {
|
|||||||
lines.push('# ===========================')
|
lines.push('# ===========================')
|
||||||
lines.push('# 数据库配置 - MySQL (切换时使用)')
|
lines.push('# 数据库配置 - MySQL (切换时使用)')
|
||||||
lines.push('# ===========================')
|
lines.push('# ===========================')
|
||||||
lines.push(
|
lines.push(`DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}`)
|
||||||
`DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}`
|
lines.push(`DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}`)
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}`
|
|
||||||
)
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}`
|
`DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}`
|
||||||
)
|
)
|
||||||
|
|||||||
376
src/main/services/database/materials-type-to-be-deleted-dao.ts
Normal file
376
src/main/services/database/materials-type-to-be-deleted-dao.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* Data Access Object for MaterialsTypeToBeDeleted table
|
||||||
|
*
|
||||||
|
* Manages material type keywords for identifying materials to be deleted.
|
||||||
|
* Used for matching material names against type keywords to assign managers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create, type IDatabaseService } from './index'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('MaterialsTypeToBeDeletedDAO')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material type record interface
|
||||||
|
*/
|
||||||
|
export interface MaterialTypeRecord {
|
||||||
|
id?: number
|
||||||
|
materialName: string
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch update request
|
||||||
|
*/
|
||||||
|
export interface MaterialTypeBatchRequest {
|
||||||
|
toInsert: MaterialTypeRecord[]
|
||||||
|
toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[]
|
||||||
|
toDelete: MaterialTypeRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for MaterialsTypeToBeDeleted table
|
||||||
|
*/
|
||||||
|
export const MATERIALS_TYPE_TO_BE_DELETED_CONFIG = {
|
||||||
|
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsTypeToBeDeleted]',
|
||||||
|
TABLE_NAME_MYSQL: 'dbo_MaterialsTypeToBeDeleted',
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
MATERIAL_NAME: 'MaterialName',
|
||||||
|
MANAGER_NAME: 'ManagerName'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MaterialsTypeToBeDeleted DAO Class
|
||||||
|
*/
|
||||||
|
export class MaterialsTypeToBeDeletedDAO {
|
||||||
|
private dbService: IDatabaseService | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate table name based on database type
|
||||||
|
*/
|
||||||
|
private getTableName(): string {
|
||||||
|
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||||
|
return isSqlServer
|
||||||
|
? MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||||
|
: MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database service instance using DatabaseFactory
|
||||||
|
*/
|
||||||
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||||
|
if (this.dbService && this.dbService.isConnected()) {
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dbService = await create()
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== READ ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material type records
|
||||||
|
* @returns List of all material type records
|
||||||
|
*/
|
||||||
|
async getAllMaterials(): Promise<MaterialTypeRecord[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
SELECT ID, MaterialName, ManagerName
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE MaterialName IS NOT NULL
|
||||||
|
ORDER BY ManagerName, MaterialName
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
materialName: row.MaterialName as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get all materials error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all materials for a specific manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns List of materials for the manager
|
||||||
|
*/
|
||||||
|
async getMaterialsByManager(managerName: string): Promise<MaterialTypeRecord[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
SELECT ID, MaterialName, ManagerName
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE ManagerName = ${placeholder} AND MaterialName IS NOT NULL
|
||||||
|
ORDER BY MaterialName
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [managerName])
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
materialName: row.MaterialName as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get materials by manager error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of unique manager names
|
||||||
|
* @returns List of unique manager names
|
||||||
|
*/
|
||||||
|
async getManagers(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
SELECT DISTINCT ManagerName
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE ManagerName IS NOT NULL
|
||||||
|
ORDER BY ManagerName
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
|
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get managers error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UPSERT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a material type record
|
||||||
|
* @param materialName - Material name (type keyword)
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async upsertMaterial(materialName: string, managerName: string): Promise<boolean> {
|
||||||
|
if (!materialName || !materialName.trim()) {
|
||||||
|
log.error('MaterialName cannot be empty')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const name = materialName.trim()
|
||||||
|
const manager = managerName?.trim() || null
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
// SQL Server MERGE statement
|
||||||
|
const sqlString = `
|
||||||
|
MERGE ${tableName} AS target
|
||||||
|
USING (VALUES (@p0, @p1)) AS source (MaterialName, ManagerName)
|
||||||
|
ON target.MaterialName = source.MaterialName
|
||||||
|
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||||
|
WHEN NOT MATCHED THEN INSERT (MaterialName, ManagerName) VALUES (source.MaterialName, source.ManagerName);
|
||||||
|
`
|
||||||
|
|
||||||
|
await dbService.query(sqlString, [name, manager])
|
||||||
|
} else {
|
||||||
|
// MySQL ON DUPLICATE KEY UPDATE
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName} (MaterialName, ManagerName)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||||
|
`
|
||||||
|
|
||||||
|
await dbService.query(sqlString, [name, manager])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Upsert material error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific material type record
|
||||||
|
* @param materialName - Material name
|
||||||
|
* @param managerName - Manager name (optional, for verification)
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async deleteMaterial(materialName: string, managerName?: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const name = materialName.trim()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
let sqlString: string
|
||||||
|
let params: (string | null)[]
|
||||||
|
|
||||||
|
if (managerName) {
|
||||||
|
const placeholder1 = isSqlServer ? '@p0' : '?'
|
||||||
|
const placeholder2 = isSqlServer ? '@p1' : '?'
|
||||||
|
sqlString = `
|
||||||
|
DELETE FROM ${tableName}
|
||||||
|
WHERE MaterialName = ${placeholder1} AND ManagerName = ${placeholder2}
|
||||||
|
`
|
||||||
|
params = [name, managerName.trim()]
|
||||||
|
} else {
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
sqlString = `
|
||||||
|
DELETE FROM ${tableName}
|
||||||
|
WHERE MaterialName = ${placeholder}
|
||||||
|
`
|
||||||
|
params = [name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, params)
|
||||||
|
return result.rowCount > 0
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Delete material error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UPDATE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a material type record (change name and/or manager)
|
||||||
|
* @param oldName - Current material name
|
||||||
|
* @param oldManager - Current manager name
|
||||||
|
* @param newName - New material name
|
||||||
|
* @param newManager - New manager name
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async updateMaterial(
|
||||||
|
oldName: string,
|
||||||
|
oldManager: string,
|
||||||
|
newName: string,
|
||||||
|
newManager: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET MaterialName = @p0, ManagerName = @p1
|
||||||
|
WHERE MaterialName = @p2 AND ManagerName = @p3
|
||||||
|
`
|
||||||
|
const result = await dbService.query(sqlString, [
|
||||||
|
newName.trim(),
|
||||||
|
newManager.trim(),
|
||||||
|
oldName.trim(),
|
||||||
|
oldManager.trim()
|
||||||
|
])
|
||||||
|
return result.rowCount > 0
|
||||||
|
} else {
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET MaterialName = ?, ManagerName = ?
|
||||||
|
WHERE MaterialName = ? AND ManagerName = ?
|
||||||
|
`
|
||||||
|
const result = await dbService.query(sqlString, [
|
||||||
|
newName.trim(),
|
||||||
|
newManager.trim(),
|
||||||
|
oldName.trim(),
|
||||||
|
oldManager.trim()
|
||||||
|
])
|
||||||
|
return result.rowCount > 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Update material error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BATCH OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process batch changes (insert, update, delete)
|
||||||
|
* @param request - Batch request with toInsert, toUpdate, toDelete arrays
|
||||||
|
* @returns Statistics object
|
||||||
|
*/
|
||||||
|
async upsertBatch(
|
||||||
|
request: MaterialTypeBatchRequest
|
||||||
|
): Promise<{ total: number; success: number; failed: number }> {
|
||||||
|
const stats = { total: 0, success: 0, failed: 0 }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process inserts
|
||||||
|
for (const record of request.toInsert) {
|
||||||
|
stats.total++
|
||||||
|
const success = await this.upsertMaterial(record.materialName, record.managerName)
|
||||||
|
if (success) stats.success++
|
||||||
|
else stats.failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process updates
|
||||||
|
for (const update of request.toUpdate) {
|
||||||
|
stats.total++
|
||||||
|
const success = await this.updateMaterial(
|
||||||
|
update.old.materialName,
|
||||||
|
update.old.managerName,
|
||||||
|
update.new.materialName,
|
||||||
|
update.new.managerName
|
||||||
|
)
|
||||||
|
if (success) stats.success++
|
||||||
|
else stats.failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process deletes
|
||||||
|
for (const record of request.toDelete) {
|
||||||
|
stats.total++
|
||||||
|
const success = await this.deleteMaterial(record.materialName, record.managerName)
|
||||||
|
if (success) stats.success++
|
||||||
|
else stats.failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Batch upsert error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.dbService) {
|
||||||
|
await this.dbService.disconnect()
|
||||||
|
this.dbService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,3 +103,21 @@ export interface MaterialRecordSummary {
|
|||||||
managerName: string
|
managerName: string
|
||||||
isMarked: boolean
|
isMarked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material type record for MaterialsTypeToBeDeleted table
|
||||||
|
*/
|
||||||
|
export interface MaterialTypeRecord {
|
||||||
|
id?: number
|
||||||
|
materialName: string
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material type batch request for batch operations
|
||||||
|
*/
|
||||||
|
export interface MaterialTypeBatchRequest {
|
||||||
|
toInsert: MaterialTypeRecord[]
|
||||||
|
toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[]
|
||||||
|
toDelete: MaterialTypeRecord[]
|
||||||
|
}
|
||||||
|
|||||||
51
src/preload/index.d.ts
vendored
51
src/preload/index.d.ts
vendored
@@ -8,7 +8,12 @@ import type {
|
|||||||
UserSelectionResponse,
|
UserSelectionResponse,
|
||||||
CurrentUserResponse
|
CurrentUserResponse
|
||||||
} from '../main/ipc/auth-handler'
|
} from '../main/ipc/auth-handler'
|
||||||
import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types'
|
import type {
|
||||||
|
ValidationRequest,
|
||||||
|
ValidationResponse,
|
||||||
|
MaterialTypeRecord,
|
||||||
|
MaterialTypeBatchRequest
|
||||||
|
} from '../main/types/validation.types'
|
||||||
import type {
|
import type {
|
||||||
SettingsData,
|
SettingsData,
|
||||||
UserType,
|
UserType,
|
||||||
@@ -178,6 +183,49 @@ export interface SettingsAPI {
|
|||||||
testDbConnection: () => Promise<ConnectionTestResult>
|
testDbConnection: () => Promise<ConnectionTestResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Type API
|
||||||
|
*/
|
||||||
|
export interface MaterialTypeAPI {
|
||||||
|
/**
|
||||||
|
* Get all material type records
|
||||||
|
*/
|
||||||
|
getAll: () => Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }>
|
||||||
|
/**
|
||||||
|
* Get material types by manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
*/
|
||||||
|
getByManager: (
|
||||||
|
managerName: string
|
||||||
|
) => Promise<{ success: boolean; data?: MaterialTypeRecord[]; error?: string }>
|
||||||
|
/**
|
||||||
|
* Get list of managers
|
||||||
|
*/
|
||||||
|
getManagers: () => Promise<{ success: boolean; data?: string[]; error?: string }>
|
||||||
|
/**
|
||||||
|
* Upsert (insert or update) a material type record
|
||||||
|
*/
|
||||||
|
upsert: (
|
||||||
|
materialName: string,
|
||||||
|
managerName: string
|
||||||
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
|
/**
|
||||||
|
* Delete a material type record
|
||||||
|
*/
|
||||||
|
delete: (
|
||||||
|
materialName: string,
|
||||||
|
managerName: string
|
||||||
|
) => Promise<{ success: boolean; error?: string }>
|
||||||
|
/**
|
||||||
|
* Batch operation for material types
|
||||||
|
*/
|
||||||
|
upsertBatch: (request: MaterialTypeBatchRequest) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
stats?: { total: number; success: number; failed: number }
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
@@ -205,6 +253,7 @@ declare global {
|
|||||||
validation: ValidationAPI
|
validation: ValidationAPI
|
||||||
materials: MaterialsAPI
|
materials: MaterialsAPI
|
||||||
settings: SettingsAPI
|
settings: SettingsAPI
|
||||||
|
materialType: MaterialTypeAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import type { CleanerInput } from '../main/types/cleaner.types'
|
|||||||
import type { ResolverInput } from '../main/ipc/resolver-handler'
|
import type { ResolverInput } from '../main/ipc/resolver-handler'
|
||||||
import type { LoginRequest } from '../main/ipc/auth-handler'
|
import type { LoginRequest } from '../main/ipc/auth-handler'
|
||||||
import type { UserInfo } from '../main/types/user.types'
|
import type { UserInfo } from '../main/types/user.types'
|
||||||
import type { ValidationRequest } from '../main/types/validation.types'
|
import type {
|
||||||
|
ValidationRequest,
|
||||||
|
MaterialTypeRecord,
|
||||||
|
MaterialTypeBatchRequest
|
||||||
|
} from '../main/types/validation.types'
|
||||||
import type { SettingsData } from '../main/types/settings.types'
|
import type { SettingsData } from '../main/types/settings.types'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
@@ -94,6 +98,20 @@ const api = {
|
|||||||
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
|
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
|
||||||
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
|
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
|
||||||
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
|
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Material Type service
|
||||||
|
materialType: {
|
||||||
|
getAll: () => ipcRenderer.invoke('materialType:getAll'),
|
||||||
|
getByManager: (managerName: string) =>
|
||||||
|
ipcRenderer.invoke('materialType:getByManager', managerName),
|
||||||
|
getManagers: () => ipcRenderer.invoke('materialType:getManagers'),
|
||||||
|
upsert: (materialName: string, managerName: string) =>
|
||||||
|
ipcRenderer.invoke('materialType:upsert', { materialName, managerName }),
|
||||||
|
delete: (materialName: string, managerName: string) =>
|
||||||
|
ipcRenderer.invoke('materialType:delete', { materialName, managerName }),
|
||||||
|
upsertBatch: (request: MaterialTypeBatchRequest) =>
|
||||||
|
ipcRenderer.invoke('materialType:upsertBatch', request)
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
523
src/renderer/src/components/MaterialTypeManagementDialog.tsx
Normal file
523
src/renderer/src/components/MaterialTypeManagementDialog.tsx
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* Material Type Management Dialog
|
||||||
|
*
|
||||||
|
* Provides a dialog for managing material type keywords used to identify
|
||||||
|
* materials for deletion. Admin users can see all records and filter by manager.
|
||||||
|
* Regular users can only see and edit their own records.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
|
||||||
|
interface MaterialTypeRecord {
|
||||||
|
id?: number
|
||||||
|
materialName: string
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowState {
|
||||||
|
record: MaterialTypeRecord
|
||||||
|
state: 'original' | 'new' | 'modified' | 'deleted'
|
||||||
|
originalRecord?: MaterialTypeRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaterialTypeManagementDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
isAdmin: boolean
|
||||||
|
currentUsername: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
isAdmin,
|
||||||
|
currentUsername
|
||||||
|
}) => {
|
||||||
|
const [rows, setRows] = useState<RowState[]>([])
|
||||||
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
|
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const tableRef = useRef<HTMLTableElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Calculate pending changes count
|
||||||
|
const pendingCount = rows.filter(
|
||||||
|
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Load data when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}, [isOpen, isAdmin, currentUsername])
|
||||||
|
|
||||||
|
// Focus input when editing starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCell && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}, [editingCell])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// Load managers list
|
||||||
|
const managersResult = await window.electron.materialType.getManagers()
|
||||||
|
if (managersResult.success && managersResult.data) {
|
||||||
|
setManagers(managersResult.data)
|
||||||
|
if (isAdmin) {
|
||||||
|
setSelectedManagers(new Set(managersResult.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load records
|
||||||
|
let records: MaterialTypeRecord[] = []
|
||||||
|
if (isAdmin) {
|
||||||
|
const result = await window.electron.materialType.getAll()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
records = result.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await window.electron.materialType.getByManager(currentUsername)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
records = result.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(
|
||||||
|
records.map((record) => ({
|
||||||
|
record,
|
||||||
|
state: 'original' as const,
|
||||||
|
originalRecord: { ...record }
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setSelectedRowIndex(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load material types:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
if (editingCell) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
saveEdit()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
cancelEdit()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Insert') {
|
||||||
|
event.preventDefault()
|
||||||
|
insertNewRow()
|
||||||
|
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
|
||||||
|
event.preventDefault()
|
||||||
|
deleteRow(selectedRowIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingCell, selectedRowIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Insert new row
|
||||||
|
const insertNewRow = () => {
|
||||||
|
const newRow: RowState = {
|
||||||
|
record: {
|
||||||
|
materialName: '',
|
||||||
|
managerName: isAdmin ? '' : currentUsername
|
||||||
|
},
|
||||||
|
state: 'new'
|
||||||
|
}
|
||||||
|
setRows((prev) => [...prev, newRow])
|
||||||
|
// Start editing the material name cell
|
||||||
|
const newIndex = rows.length
|
||||||
|
setTimeout(() => {
|
||||||
|
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
|
||||||
|
setEditValue('')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete row
|
||||||
|
const deleteRow = (index: number) => {
|
||||||
|
setRows((prev) => {
|
||||||
|
const newRows = [...prev]
|
||||||
|
const row = newRows[index]
|
||||||
|
if (row.state === 'new') {
|
||||||
|
// Remove new rows directly
|
||||||
|
newRows.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
// Mark existing rows as deleted
|
||||||
|
newRows[index] = { ...row, state: 'deleted' }
|
||||||
|
}
|
||||||
|
return newRows
|
||||||
|
})
|
||||||
|
setSelectedRowIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing a cell
|
||||||
|
const startEdit = (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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save edit
|
||||||
|
const saveEdit = () => {
|
||||||
|
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,
|
||||||
|
record: {
|
||||||
|
...row.record,
|
||||||
|
[field]: newValue
|
||||||
|
},
|
||||||
|
state: row.state === 'new' ? 'new' : 'modified'
|
||||||
|
}
|
||||||
|
return newRows
|
||||||
|
})
|
||||||
|
|
||||||
|
setEditingCell(null)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel edit
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingCell(null)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all changes
|
||||||
|
const handleSave = async () => {
|
||||||
|
const toInsert: MaterialTypeRecord[] = []
|
||||||
|
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
||||||
|
const toDelete: MaterialTypeRecord[] = []
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.state === 'new' && row.record.materialName.trim()) {
|
||||||
|
toInsert.push(row.record)
|
||||||
|
} else if (row.state === 'modified' && row.originalRecord) {
|
||||||
|
toUpdate.push({ old: row.originalRecord, new: row.record })
|
||||||
|
} else if (row.state === 'deleted' && row.originalRecord) {
|
||||||
|
toDelete.push(row.originalRecord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toInsert.length === 0 && toUpdate.length === 0 && toDelete.length === 0) {
|
||||||
|
alert('没有需要保存的更改')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmParts: string[] = []
|
||||||
|
if (toInsert.length > 0) confirmParts.push(`新增 ${toInsert.length} 条记录`)
|
||||||
|
if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.length} 条记录`)
|
||||||
|
if (toDelete.length > 0) confirmParts.push(`删除 ${toDelete.length} 条记录`)
|
||||||
|
|
||||||
|
if (!window.confirm(`确认以下操作?\n\n${confirmParts.join('\n')}`)) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electron.materialType.upsertBatch({
|
||||||
|
toInsert,
|
||||||
|
toUpdate,
|
||||||
|
toDelete
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(
|
||||||
|
`保存完成!\n成功:${result.stats?.success || 0} 条\n失败:${result.stats?.failed || 0} 条`
|
||||||
|
)
|
||||||
|
await loadData()
|
||||||
|
} else {
|
||||||
|
alert(`保存失败:${result.error || '未知错误'}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset changes
|
||||||
|
const handleReset = () => {
|
||||||
|
if (pendingCount === 0) return
|
||||||
|
if (!window.confirm('确定要放弃所有未保存的更改吗?')) return
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle close with unsaved changes warning
|
||||||
|
const handleClose = () => {
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
if (!window.confirm('有未保存的更改,确定要关闭吗?')) return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get row background color
|
||||||
|
const getRowStyle = (row: RowState): React.CSSProperties => {
|
||||||
|
if (row.state === 'deleted') {
|
||||||
|
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
|
||||||
|
}
|
||||||
|
if (row.state === 'new') {
|
||||||
|
return { backgroundColor: '#dcfce7' }
|
||||||
|
}
|
||||||
|
if (row.state === 'modified') {
|
||||||
|
return { backgroundColor: '#fef9c3' }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={handleClose} title="物料类型管理" size="2xl">
|
||||||
|
<div onKeyDown={handleKeyDown}>
|
||||||
|
{/* Manager filter (admin only) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<Users size={16} />
|
||||||
|
按负责人筛选
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedManagers(new Set(managers))}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedManagers(new Set())}
|
||||||
|
className="text-xs text-slate-500 hover:underline"
|
||||||
|
>
|
||||||
|
取消全选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{managers.map((manager) => (
|
||||||
|
<label
|
||||||
|
key={manager}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded text-blue-600"
|
||||||
|
checked={selectedManagers.has(manager)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedManagers((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (e.target.checked) newSet.add(manager)
|
||||||
|
else newSet.delete(manager)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{manager}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={insertNewRow}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> 新增 (Insert)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
|
||||||
|
disabled={selectedRowIndex === null}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 删除 (Delete)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={pendingCount === 0}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} /> 重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
||||||
|
{pendingCount} 项待保存
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || pendingCount === 0}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save size={14} /> {saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-slate-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<table ref={tableRef} className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
|
||||||
|
物料名称关键词
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-slate-700">负责人</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
|
||||||
|
暂无数据,点击"新增"按钮添加物料类型关键词
|
||||||
|
</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={inputRef as any}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
|
<div className="mt-3 text-xs text-slate-500 flex justify-between">
|
||||||
|
<span>
|
||||||
|
双击单元格编辑 | Insert 新增 | Delete 删除
|
||||||
|
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
|
||||||
|
</span>
|
||||||
|
<span>共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaterialTypeManagementDialog
|
||||||
@@ -12,7 +12,7 @@ interface ModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
title?: string
|
title?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@ const sizeStyles: Record<string, string> = {
|
|||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm',
|
||||||
md: 'max-w-md',
|
md: 'max-w-md',
|
||||||
lg: 'max-w-lg',
|
lg: 'max-w-lg',
|
||||||
xl: 'max-w-xl'
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
'3xl': 'max-w-3xl'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
FileSpreadsheet
|
FileSpreadsheet
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material validation result interface
|
* Material validation result interface
|
||||||
@@ -60,6 +61,9 @@ const CleanerPage: React.FC = () => {
|
|||||||
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
|
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
|
||||||
console.log(sharedProductionIdsCount)
|
console.log(sharedProductionIdsCount)
|
||||||
|
|
||||||
|
// Material type management dialog state
|
||||||
|
const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Check admin status and get shared Production IDs on mount
|
// Check admin status and get shared Production IDs on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const initializePage = async () => {
|
const initializePage = async () => {
|
||||||
@@ -470,7 +474,10 @@ const CleanerPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5">
|
<button
|
||||||
|
onClick={() => setIsTypeDialogOpen(true)}
|
||||||
|
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
<Settings2 size={14} /> 类型管理
|
<Settings2 size={14} /> 类型管理
|
||||||
</button>
|
</button>
|
||||||
<button className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium">
|
<button className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium">
|
||||||
@@ -606,6 +613,14 @@ const CleanerPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Material Type Management Dialog */}
|
||||||
|
<MaterialTypeManagementDialog
|
||||||
|
isOpen={isTypeDialogOpen}
|
||||||
|
onClose={() => setIsTypeDialogOpen(false)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
currentUsername={currentUsername}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{result.mergedFile && (
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
|
||||||
|
<span className="text-slate-500 text-sm block mb-1">合并文件路径</span>
|
||||||
|
<span className="text-sm font-mono text-slate-700 select-all break-all">
|
||||||
|
{result.mergedFile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
|
|
||||||
// Setup initial state with multiple categories
|
// Setup initial state with multiple categories
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' },
|
url: 'http://old.com',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
headless: true,
|
||||||
|
ignoreHttpsErrors: true,
|
||||||
|
autoCloseBrowser: true
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: '192.168.1.1',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'testdb',
|
||||||
|
username: 'dbuser',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' },
|
batchSize: 50,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 1000,
|
||||||
|
matchMode: 'exact',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
||||||
execution: { dryRun: true }
|
execution: { dryRun: true }
|
||||||
})
|
})
|
||||||
@@ -160,11 +187,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
await manager.save()
|
await manager.save()
|
||||||
|
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' },
|
url: 'http://test.com',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
headless: false,
|
||||||
|
ignoreHttpsErrors: false,
|
||||||
|
autoCloseBrowser: false
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: 'localhost',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'db',
|
||||||
|
username: 'user',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
|
batchSize: 100,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 2000,
|
||||||
|
matchMode: 'substring',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||||
execution: { dryRun: false }
|
execution: { dryRun: false }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user