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:
Misaka
2026-03-03 22:51:11 +08:00
parent 7aa1abbc22
commit e23cf71f78
23 changed files with 1764 additions and 213 deletions

View File

@@ -12,6 +12,7 @@
### Bug Description
For **User type (non-Admin)** users:
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
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
#### 1. "取消" Button (Line 420)
```typescript
// ❌ WRONG: Clears ALL selected items
onClick={() => setSelectedItems(new Set())}
```
#### 2. `handleConfirmDeletion` Function (Line 165)
```typescript
// ❌ WRONG: Iterates ALL validation results
for (const result of validationResults) {
@@ -87,6 +90,7 @@ graph TB
```
**What Changed**:
- Before: `setSelectedItems(new Set())` - clears everything
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
- Preserves selections for items not currently visible (e.g., other users' data)
@@ -115,6 +119,7 @@ const handleConfirmDeletion = async () => {
```
**What Changed**:
- Before: `for (const result of validationResults)` - processes all 1000 items
- After: `for (const result of resultsToProcess)` where:
- `Admin` → processes `validationResults` (all items)
@@ -127,20 +132,24 @@ const handleConfirmDeletion = async () => {
### Scenario 1: User Unchecks Own Data Only
**Setup**:
- User A logs in (non-Admin)
- 100 materials visible (assigned to User A)
- 900 materials invisible (assigned to other users)
- All 1000 materials are initially checked
**Actions**:
1. User A clicks "取消"
2. Table shows all checkboxes unchecked
**Expected**:
- ✅ User A's 100 materials are unchecked
- ✅ Other users' 900 materials **remain checked** (not affected)
**Verification**:
```typescript
// Before fix: selectedItems.size === 0
// After fix: selectedItems.size === 900 (other users' items still checked)
@@ -149,16 +158,19 @@ const handleConfirmDeletion = async () => {
### Scenario 2: User Confirms Deletion
**Setup**:
- User A logs in (non-Admin)
- User A unchecks 50 of their 100 materials
- 50 items checked (User A's)
- 900 items checked (other users')
**Actions**:
1. User A clicks "确认删除"
2. Confirm dialog shows: "写入/更新 50 条记录"
**Expected**:
- ✅ Only User A's 50 materials are upserted to database
- ✅ Other users' 900 materials are **NOT touched**
- ✅ No materials are deleted (since other users' items aren't processed)
@@ -166,15 +178,18 @@ const handleConfirmDeletion = async () => {
### Scenario 3: Admin Behavior Unchanged
**Setup**:
- Admin logs in
- All 1000 materials visible
- All filtered by selected managers
**Actions**:
1. Admin clicks "取消" → all visible items unchecked
2. Admin clicks "确认删除" → processes all filtered items
**Expected**:
- ✅ Admin behavior unchanged (can manage all data)
- ✅ Admin can still filter by managers and process filtered results
@@ -183,6 +198,7 @@ const handleConfirmDeletion = async () => {
## Security & Scope Implications
### Before Fix (Vulnerability)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
@@ -193,6 +209,7 @@ flowchart LR
```
### After Fix (Secure)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
@@ -209,9 +226,9 @@ flowchart LR
### File: `src/renderer/src/pages/CleanerPage.tsx`
| Line | Change | Description |
|------|--------|-------------|
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
| Line | Change | Description |
| ------- | -------------------------------- | ----------------------------------------- |
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
### Variables Used
@@ -227,6 +244,7 @@ flowchart LR
## Verification Steps
1. **Test as User A**:
```bash
# Login as user1
npm run dev
@@ -237,6 +255,7 @@ flowchart LR
```
2. **Test as User B**:
```bash
# Login as user2
# Verify user1's changes didn't affect user2's data

View File

@@ -1245,15 +1245,15 @@ flowchart TB
## 文件索引
| 文件路径 | 说明 | 关键行号 |
| ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
| `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` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
| `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 | 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/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` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
| `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 | 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) |
---

View File

@@ -4,6 +4,7 @@
> **更新日期**: 2026-03-03
> **适用范围**: ERPAuto v1.0+
> **相关文件**:
>
> - `src/renderer/src/pages/ExtractorPage.tsx` (UI层)
> - `src/preload/index.ts` (IPC API 暴露)
> - `src/main/ipc/extractor-handler.ts` (IPC处理层)
@@ -298,15 +299,15 @@ stateDiagram-v2
### 状态变量说明
| 状态变量 | 类型 | 说明 | 持久化 |
|---------|------|------|--------|
| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage |
| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage |
| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 |
| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 |
| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 |
| `error` | string \| null | 错误信息 | ❌ 内存状态 |
| `logs` | string[] | 执行日志列表 | ❌ 内存状态 |
| 状态变量 | 类型 | 说明 | 持久化 |
| -------------- | ------------------------- | ------------------------------------- | ----------------- |
| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage |
| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage |
| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 |
| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 |
| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 |
| `error` | string \| null | 错误信息 | ❌ 内存状态 |
| `logs` | string[] | 执行日志列表 | ❌ 内存状态 |
> **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorService` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。
@@ -381,13 +382,13 @@ flowchart TD
### 错误类型与处理策略
| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 |
|---------|---------|---------|---------|
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 |
| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 |
| -------------------- | ------------------------------------ | ------------------------ | -------------- |
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 |
| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
---
@@ -471,6 +472,7 @@ flowchart LR
### 数据转换详情
**阶段1: 用户输入 → Production IDs**
```
输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
↓ 分割 + trim + 过滤
@@ -480,6 +482,7 @@ flowchart LR
```
**阶段2: Production IDs → 生产订单号**
```
输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"]
↓ MySQL查询 (production_order表)
@@ -494,6 +497,7 @@ flowchart LR
```
**阶段3: 生产订单号 → 批次**
```
输入: ["MO-001", "MO-002", ..., "MO-250"] (250个)
批次大小: 100
@@ -504,6 +508,7 @@ flowchart LR
```
**阶段4: 批次 → ERP查询字符串**
```
批次: ["MO-001", "MO-002", "MO-003"]
↓ 逗号连接
@@ -582,6 +587,7 @@ useEffect(() => {
```
> **设计说明**: 订单号通过两种方式存储到共享状态:
>
> 1. `useEffect` 在用户输入时实时更新
> 2. `handleExtract` 在提取开始前再次确认存储
>
@@ -609,7 +615,7 @@ ipcMain.handle(
// 2. 使用数据库工厂创建服务实例 (支持 MySQL 和 SQL Server)
try {
dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库
dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库
} catch (error) {
throw new DatabaseQueryError('数据库连接失败', 'DB_CONNECTION_FAILED', error)
}
@@ -659,7 +665,7 @@ ipcMain.handle(
* 支持 MySQL 和 SQL Server 双数据库
*/
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)
@@ -677,7 +683,7 @@ export async function create(type?: DatabaseType): Promise<IDatabaseService> {
}
await service.connect()
instances.set(dbType, service) // 缓存实例
instances.set(dbType, service) // 缓存实例
return service
}
@@ -835,7 +841,7 @@ extractor: {
export interface ExtractorInput {
orderNumbers: string[]
batchSize?: number
onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
}
export interface ExtractorResult {
@@ -918,12 +924,14 @@ export interface ExtractorResult {
**原因**: IPC 通信无法序列化函数,`onProgress` 回调无法传递到主进程。
**当前实现**:
```typescript
// extractor.ts 中调用但无效
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
```
**建议实现方案**:
```typescript
// 方案: 使用 IPC 事件通道
@@ -941,7 +949,7 @@ extractor: {
useEffect(() => {
window.electron.extractor.onProgress((data) => {
setProgress(data)
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
})
}, [])
```

View File

@@ -15,6 +15,7 @@
`config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
**数据流问题:**
```
SettingsPage (只修改 ERP URL)
↓ 发送完整的 settings 对象
@@ -69,11 +70,11 @@ ConfigManager.saveAllSettings()
### 改动点
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
| 文件 | 改动类型 | 说明 |
| -------------------------------------------- | -------- | ------------------------------------------------ |
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
| `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[] = [
'erp.url',
'erp.username',
'erp.password',
'erp.password'
// 未来扩展:
// 'database.dbType',
// 'paths.dataDir',
@@ -305,6 +306,7 @@ ipcMain.handle(
```
**关键改动:**
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
@@ -409,9 +411,9 @@ const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'database.dbType', // 新增
'paths.dataDir', // 新增
'extraction.batchSize', // 新增
'database.dbType', // 新增
'paths.dataDir', // 新增
'extraction.batchSize' // 新增
// ...
]
```
@@ -425,10 +427,7 @@ const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
Guest: []
}
function validateEditableFields(
settings: Partial<SettingsData>,
userType: UserType
) {
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
// 验证逻辑...
}
@@ -464,12 +463,12 @@ interface ConfigChange {
## 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
| 风险 | 影响 | 缓解措施 |
| ---------------- | ---------- | ----------------------------- |
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
---

View File

@@ -13,6 +13,7 @@
## Task 1: Add Utility Functions to ConfigManager
**Files:**
- Modify: `src/main/services/config/config-manager.ts`
**Step 1: Write failing test for deep merge**
@@ -31,11 +32,38 @@ describe('ConfigManager - deep merge utilities', () => {
// Setup initial state
const initial: SettingsData = {
erp: { 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: '' },
erp: {
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' },
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
extraction: {
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 },
execution: { dryRun: false }
}
@@ -113,7 +141,7 @@ function deepMerge<T>(source: T, target: Partial<T>): T {
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'erp.password'
// 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
**Files:**
- Modify: `src/main/services/config/config-manager.ts`
**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
**Files:**
- Modify: `src/main/services/config/config-manager.ts`
**Step 1: Write comprehensive test for savePartialSettings**
@@ -316,11 +346,38 @@ describe('ConfigManager.savePartialSettings', () => {
// Setup initial state with multiple categories
await manager.saveAllSettings({
erp: { 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: '' },
erp: {
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' },
extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' },
extraction: {
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 },
execution: { dryRun: true }
})
@@ -367,11 +424,38 @@ describe('ConfigManager.savePartialSettings', () => {
await manager.initialize()
await manager.saveAllSettings({
erp: { 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: '' },
erp: {
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' },
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
extraction: {
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 },
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
**Files:**
- Modify: `src/main/ipc/settings-handler.ts`
**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:
```typescript
/**
* Save settings (updated to use partial save)
*/
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
try {
log.info('Saving settings', {
sections: Object.keys(settings)
/**
* Save settings (updated to use partial save)
*/
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
try {
log.info('Saving 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 {
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**
@@ -580,6 +665,7 @@ git commit -m "feat: update settings handler to use savePartialSettings"
## Task 5: Update Frontend to Send Only Necessary Fields
**Files:**
- Modify: `src/renderer/src/pages/SettingsPage.tsx`
**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:
```typescript
const handleSaveSettings = async () => {
try {
// Only send UI-supported fields (double safety)
const partialSettings = {
erp: {
url: settings.erp?.url,
username: settings.erp?.username,
password: settings.erp?.password
}
const handleSaveSettings = async () => {
try {
// Only send UI-supported fields (double safety)
const partialSettings = {
erp: {
url: settings.erp?.url,
username: settings.erp?.username,
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**
@@ -631,6 +717,7 @@ git commit -m "feat: send only ERP fields from settings page (defensive programm
## Task 6: Manual Testing and Verification
**Files:**
- Manual test procedure
**Step 1: Prepare test environment**
@@ -701,6 +788,7 @@ cat .env
```
Expected results:
- `ERP_URL` should be `http://modified-test.com` (CHANGED)
- `DB_TYPE` should still be `mysql` (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
**Files:**
- Create: `docs/settings-partial-save.md`
- 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`:
```markdown
````markdown
# Settings Partial Save Feature
## Overview
@@ -813,6 +902,7 @@ The settings system now implements partial save functionality to prevent uninten
## Editable Fields
Currently editable via UI:
- `erp.url` - ERP system URL
- `erp.username` - ERP login username
- `erp.password` - ERP login password
@@ -828,9 +918,10 @@ const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'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`
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 }`
**Validation:**
- Checks whitelist before applying changes
- Returns error for unauthorized fields
@@ -858,7 +950,8 @@ Saves only the provided fields, preserving all existing configuration.
Location: `.env.backup` (in project root)
Created before every save operation. Used for rollback on failure.
```
````
**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
git add docs/settings-partial-save.md
git commit -m "docs: add settings partial save feature documentation"
```
````
---
## Task 8: Final Verification and Cleanup
**Files:**
- All modified files
**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
**Key files modified:**
- `src/main/services/config/config-manager.ts` (core logic)
- `src/main/ipc/settings-handler.ts` (IPC layer)
- `src/renderer/src/pages/SettingsPage.tsx` (frontend)

View File

@@ -13,6 +13,7 @@ The settings system now implements partial save functionality to prevent uninten
## Editable Fields
Currently editable via UI:
- `erp.url` - ERP system URL
- `erp.username` - ERP login username
- `erp.password` - ERP login password
@@ -28,7 +29,7 @@ const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'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 }`
**Validation:**
- Checks whitelist before applying changes
- Returns error for unauthorized fields

View File

@@ -1,4 +1,5 @@
# 系统设置保存按钮工作流程分析
# System Settings Save Button Workflow Analysis
## 文档概述 / Document Overview
@@ -171,6 +172,7 @@ graph LR
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
**主要职责 / Main Responsibilities:**
- 用户界面渲染和交互
- 本地状态管理settings, isModified, message
- 调用 IPC 通信
@@ -183,7 +185,7 @@ const handleSaveSettings = async () => {
try {
const result = await window.electron.settings.saveSettings(settings as any)
if (result.success) {
setIsModified(false) // 清除修改标记
setIsModified(false) // 清除修改标记
showMessage('success', '设置保存成功')
} else {
showMessage('error', result.error || '保存失败')
@@ -196,12 +198,12 @@ const handleSaveSettings = async () => {
**状态管理 / State Management:**
| 状态变量 | 类型 | 用途 |
|---------|------|------|
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
| `message` | `object \| null` | 临时消息3秒后自动消失 |
| 状态变量 | 类型 | 用途 |
| ------------ | ---------------- | ----------------------------------------------------------- |
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
| `message` | `object \| null` | 临时消息3秒后自动消失 |
**UI 交互逻辑 / UI Interaction Logic:**
@@ -232,6 +234,7 @@ stateDiagram-v2
#### preload/index.ts (`src/preload/index.ts`)
**主要职责 / Main Responsibilities:**
- 安全桥梁,暴露受限 API 到渲染进程
- 类型安全的 IPC 通道定义
@@ -273,6 +276,7 @@ graph TB
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
**主要职责 / Main Responsibilities:**
- IPC 通道注册和处理
- 权限验证(基于用户类型)
- 业务逻辑协调
@@ -334,19 +338,20 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
**权限控制矩阵 / Permission Control Matrix:**
| 功能 / Feature | Admin | User | Guest |
|---------------|-------|------|-------|
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
| 保存设置 | ✅ | ✅ | ❌ |
| 恢复默认值 | ✅ | ❌ | ❌ |
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
| 测试数据库连接 | ✅ | ✅ | ❌ |
| 功能 / Feature | Admin | User | Guest |
| -------------- | ----- | ------- | ----- |
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
| 保存设置 | ✅ | ✅ | ❌ |
| 恢复默认值 | ✅ | ❌ | ❌ |
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
| 测试数据库连接 | ✅ | ✅ | ❌ |
### 4. 配置管理服务 / Configuration Manager Service
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
**主要职责 / Main Responsibilities:**
- .env 文件读写
- 配置缓存管理
- 默认值管理
@@ -356,10 +361,10 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
```typescript
export class ConfigManager {
private static instance: ConfigManager | null = null // 单例模式
private envPath: string // .env 文件路径
private configCache: Map<string, string> // 内存缓存
private initialized: boolean = false // 初始化标记
private static instance: ConfigManager | null = null // 单例模式
private envPath: string // .env 文件路径
private configCache: Map<string, string> // 内存缓存
private initialized: boolean = false // 初始化标记
// 单例获取方法
public static getInstance(): ConfigManager
@@ -519,6 +524,7 @@ graph TB
### IPC 通信数据格式 / IPC Communication Data Format
**请求格式 / Request Format:**
```json
{
"erp": {
@@ -539,6 +545,7 @@ graph TB
```
**响应格式 / Response Format:**
```json
// 成功 / Success
{
@@ -594,13 +601,13 @@ graph TB
### 错误场景分析 / Error Scenario Analysis
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|--------------------------|-------------------|-------------------|----------------------|
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
| ------------------------- | ------------------- | --------------------- | ------------------------ |
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
### 日志记录策略 / Logging Strategy
@@ -613,6 +620,7 @@ log.error('Error saving settings', { error: message })
```
**日志级别使用 / Log Level Usage:**
- `info`: 正常操作流程
- `warn`: 潜在问题(如保存失败但未崩溃)
- `error`: 严重错误(如异常抛出)
@@ -680,14 +688,14 @@ sequenceDiagram
### 文件位置索引 / File Location Index
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|-----------------|---------------------|-------------------|
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
| ---------------- | -------------------------------------------- | --------------------- |
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
### 性能特性 / Performance Characteristics
@@ -743,11 +751,12 @@ const updateSettings = (category: string, key: string, value: any) => {
[key]: value
}
}))
setIsModified(true) // 标记为已修改
setIsModified(true) // 标记为已修改
}
```
**设计要点 / Design Points:**
- 不可变更新模式Immutable Update Pattern
- 使用展开运算符保持对象引用
- 自动启用保存按钮
@@ -768,6 +777,7 @@ public async saveAllSettings(settings: SettingsData): Promise<boolean> {
```
**设计要点 / Design Points:**
- 先更新内存,后写入磁盘
- 失败时缓存保持不变
- 返回布尔值表示成功/失败
@@ -792,6 +802,7 @@ public async save(): Promise<boolean> {
```
**设计要点 / Design Points:**
- 添加注释分隔符提高可读性
- 使用默认值作为后备
- 同步写入确保一致性
@@ -820,6 +831,7 @@ public async save(): Promise<boolean> {
### 长期改进 / Long-term Improvements
1. **安全性增强 / Security Enhancement**
```typescript
// 建议实现密码加密
interface SecureSettingsData extends SettingsData {
@@ -851,7 +863,9 @@ public async save(): Promise<boolean> {
describe('ConfigManager', () => {
it('should save settings successfully', async () => {
const manager = ConfigManager.getInstance()
const settings: SettingsData = { /* mock data */ }
const settings: SettingsData = {
/* mock data */
}
const result = await manager.saveAllSettings(settings)
expect(result).toBe(true)
})
@@ -883,16 +897,16 @@ describe('Settings Save Flow', () => {
### 完整配置字段列表 / Complete Configuration Field List
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|---------------|---------------------|-------------------|
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
| Paths | 3 | dataDir, defaultOutput, validationOutput |
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
| Execution | 1 | dryRun |
| **总计 / Total** | **30** | |
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
| Paths | 3 | dataDir, defaultOutput, validationOutput |
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
| Execution | 1 | dryRun |
| **总计 / Total** | **30** | |
### 相关文档 / Related Documentation
@@ -902,9 +916,9 @@ describe('Settings Save Flow', () => {
### 版本历史 / Version History
| 版本 / Version | 日期 / Date | 变更 / Changes |
|---------------|------------|--------------|
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
| 版本 / Version | 日期 / Date | 变更 / Changes |
| -------------- | ----------- | -------------------------- |
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
---

View File

@@ -44,6 +44,7 @@ log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
```
**说明**:
-`validation:validate` handler 开始时获取当前登录用户信息
- 提取 `isAdmin``username` 用于后续匹配逻辑
- 如果用户未登录,返回错误响应
@@ -68,6 +69,7 @@ if (!isAdmin && username) {
```
**匹配逻辑**:
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
@@ -109,11 +111,13 @@ flowchart TB
### 场景1: User 用户匹配到自己的 typeKeyword
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
@@ -126,6 +130,7 @@ flowchart TB
### 场景2: User 用户覆盖其他用户的匹配
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
@@ -138,12 +143,14 @@ flowchart TB
### 场景3: User 用户无匹配关键词
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user2" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
@@ -152,11 +159,13 @@ flowchart TB
"isMarkedForDeletion": false
}
```
**说明**: 保持优先级2的匹配结果
### 场景4: Admin 用户不执行覆盖
**输入**:
- 当前用户: `admin` (isAdmin=true)
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
@@ -164,6 +173,7 @@ flowchart TB
- `{ materialName: "螺丝", managerName: "admin" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
@@ -172,16 +182,19 @@ flowchart TB
"isMarkedForDeletion": false
}
```
**说明**: Admin 不执行优先级3保持原有匹配行为
### 场景5: 优先级1匹配不受影响
**输入**:
- 当前用户: `user1`
- 物料代码: `MAT001`
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
**预期输出**:
```json
{
"materialCode": "MAT001",
@@ -190,6 +203,7 @@ flowchart TB
"matchedTypeKeyword": undefined
}
```
**说明**: 优先级1的精确匹配不受覆盖影响
---
@@ -198,22 +212,22 @@ flowchart TB
### MaterialsTypeToBeDeleted 表数据
| MaterialName | ManagerName | 说明 |
|--------------|-------------|------|
| MaterialName | ManagerName | 说明 |
| ------------ | ----------- | ------------------------------ |
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
| 电缆 | admin | admin 负责电缆类物料 |
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
| 电缆 | admin | admin 负责电缆类物料 |
### 匹配结果示例
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|------------|---------|----------------|----------------|
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
| 电缆 5m | user1 | admin | user1 (无匹配) |
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
| -------- | -------- | ---------------- | ----------------- |
| 螺丝 M6 | user1 | user2 | **user1** |
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
| 垫圈 φ10 | user1 | user2 | **user1** |
| 电缆 5m | user1 | admin | user1 (无匹配) |
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
---
@@ -233,6 +247,7 @@ const filteredResults = React.useMemo(() => {
```
**协同效果**:
1. 后端匹配算法确保 User 用户的物料优先分配给自己
2. 前端过滤器只显示属于当前用户或未分配的物料
3. Admin 用户可以看到所有物料并切换查看不同负责人