Compare commits
20 Commits
a05c8a9037
...
6698d82d6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6698d82d6b | ||
|
|
b98cd46237 | ||
|
|
baaa031dac | ||
|
|
816060444c | ||
|
|
73a49f9de3 | ||
|
|
4d8df61187 | ||
|
|
a967050058 | ||
|
|
f3be0ad01d | ||
|
|
7e37a9b1fc | ||
|
|
b5ef38f486 | ||
|
|
c06880e946 | ||
|
|
765fb95644 | ||
|
|
429357ae8c | ||
|
|
79934a58d0 | ||
|
|
b942b2fb15 | ||
|
|
6ecf03ce11 | ||
|
|
77311edce0 | ||
|
|
74ddef2d7b | ||
|
|
edf696ab39 | ||
|
|
6ec422f357 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,4 +22,7 @@ tests/debug/
|
||||
tests/manual/
|
||||
test-*.mjs
|
||||
test-*.js
|
||||
compare_*.js
|
||||
compare_*.js
|
||||
|
||||
# logs
|
||||
logs
|
||||
272
docs/cleaner-user-scope-fix.md
Normal file
272
docs/cleaner-user-scope-fix.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# CleanerPage User Scope Fix
|
||||
|
||||
**Issue**: User users were affecting other users' data when using "取消" and "确认删除" buttons
|
||||
|
||||
**Date**: 2026-03-03
|
||||
**Branch**: `fix/cleaner-user-scope`
|
||||
|
||||
---
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### 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
|
||||
4. This caused User A to delete User B's materials that User A never saw!
|
||||
|
||||
### 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) {
|
||||
// Processes items user can't even see!
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Backend"
|
||||
A[validationResults<br/>1000 items] --> B[User Filter<br/>currentUsername]
|
||||
end
|
||||
|
||||
subgraph "Frontend Display"
|
||||
B --> C[filteredResults<br/>100 items visible]
|
||||
C --> D[Table Display]
|
||||
end
|
||||
|
||||
subgraph "Bug Behavior (BEFORE FIX)"
|
||||
E[取消 Button] --> F[Clears selectedItems<br/>for ALL 1000 items ❌]
|
||||
G[确认删除 Button] --> H[Processes ALL 1000 items ❌]
|
||||
H --> I[Deletes User B's data ❌]
|
||||
end
|
||||
|
||||
subgraph "Fixed Behavior (AFTER FIX)"
|
||||
E2[取消 Button] --> F2[Clears only visible<br/>100 items ✅]
|
||||
G2[确认删除 Button] --> H2[Processes only<br/>100 items ✅]
|
||||
H2 --> I2[Only affects User A ✅]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: "取消" Button - Only Uncheck Visible Items
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:419-432`
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={() => {
|
||||
// Only uncheck items that are visible in filteredResults
|
||||
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
for (const code of visibleCodes) {
|
||||
newSet.delete(code)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}}
|
||||
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<Square size={14} className="text-slate-400" /> 取消
|
||||
</button>
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
### Fix 2: `handleConfirmDeletion` - Only Process Visible Items (Non-Admin)
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:158-222`
|
||||
|
||||
```typescript
|
||||
const handleConfirmDeletion = async () => {
|
||||
// For non-admin users, only process visible filtered results
|
||||
// For admin users, process all validation results
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
|
||||
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
|
||||
|
||||
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||
const materialsToDelete: string[] = []
|
||||
const missingManager: string[] = []
|
||||
|
||||
for (const result of resultsToProcess) {
|
||||
// ... rest of processing logic
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**What Changed**:
|
||||
- Before: `for (const result of validationResults)` - processes all 1000 items
|
||||
- After: `for (const result of resultsToProcess)` where:
|
||||
- `Admin` → processes `validationResults` (all items)
|
||||
- `User` → processes only `filteredResults` (visible items)
|
||||
|
||||
---
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Security & Scope Implications
|
||||
|
||||
### Before Fix (Vulnerability)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes ALL 1000 items ❌]
|
||||
Deletes --> Impact[User B loses data ❌]
|
||||
```
|
||||
|
||||
### After Fix (Secure)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes 100 items ✅]
|
||||
Sees2 --> Independent[User B's data independent ✅]
|
||||
Deletes --> Safe[User scope isolation ✅]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Summary
|
||||
|
||||
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
|
||||
| Line | Change | Description |
|
||||
|------|--------|-------------|
|
||||
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
||||
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
||||
|
||||
### Variables Used
|
||||
|
||||
- `validationResults`: All materials from backend (1000 items)
|
||||
- `filteredResults`: Materials after user/manager filtering (100 items for User A)
|
||||
- `selectedItems`: Set of checked material codes
|
||||
- `isAdmin`: Boolean, true for Admin users
|
||||
- `currentUsername`: Current logged-in username
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. **Test as User A**:
|
||||
```bash
|
||||
# Login as user1
|
||||
npm run dev
|
||||
# Navigate to CleanerPage
|
||||
# Verify only user1's materials are visible
|
||||
# Click "取消" → only visible items unchecked
|
||||
# Check selectedItems size = other users' checked items
|
||||
```
|
||||
|
||||
2. **Test as User B**:
|
||||
```bash
|
||||
# Login as user2
|
||||
# Verify user1's changes didn't affect user2's data
|
||||
# All user2's materials should still be intact
|
||||
```
|
||||
|
||||
3. **Test as Admin**:
|
||||
```bash
|
||||
# Login as admin
|
||||
# Verify can still see and manage all materials
|
||||
# "取消" and "确认删除" work on all filtered results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Implementation**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **Related**: `src/main/ipc/validation-handler.ts` (backend matching logic)
|
||||
- **Related**: `docs/user-override-match-feature.md` (user override matching)
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Add Confirmation Dialog for Scope**: Show user how many items will be affected
|
||||
2. **Add Audit Logging**: Log which user modified which materials
|
||||
3. **Add Warning for Large Operations**: Warn if user is about to delete many items
|
||||
4. **Backend Validation**: Add backend check to prevent cross-user data modification
|
||||
|
||||
---
|
||||
|
||||
**Document End**
|
||||
@@ -287,7 +287,7 @@ WHERE rn = 1
|
||||
|
||||
### 4. 物料匹配算法
|
||||
|
||||
**位置**: `validation-handler.ts:325-361`
|
||||
**位置**: `validation-handler.ts:343-382`
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -299,14 +299,24 @@ flowchart TB
|
||||
Priority1 -->|materialCode<br/>在markedCodesDict中| SetMarked[设置managerName<br/>isMarkedForDeletion=true]
|
||||
Priority1 -->|未匹配| Priority2{优先级2:<br/>MaterialsTypeToBeDeleted<br/>名称包含匹配?}
|
||||
|
||||
SetMarked --> PushResult[添加到results]
|
||||
Priority2 -->|遍历typeKeywords| CheckContains{typeKeyword.materialName<br/>包含 materialName?}
|
||||
SetMarked --> CheckUser{当前用户类型?}
|
||||
Priority2 -->|遍历typeKeywords| CheckContains{typeKeyword.materialName<br/>匹配?}
|
||||
|
||||
CheckContains -->|是| SetMatched[设置managerName<br/>matchedTypeKeyword<br/>isMarkedForDeletion=false]
|
||||
CheckContains -->|否| SetNull[managerName=null<br/>isMarkedForDeletion=false]
|
||||
CheckContains -->|是| SetMatched[设置managerName<br/>matchedTypeKeyword]
|
||||
CheckContains -->|否| SetNull[managerName=null]
|
||||
|
||||
SetMatched --> PushResult
|
||||
SetNull --> PushResult
|
||||
SetMatched --> CheckUser
|
||||
SetNull --> CheckUser
|
||||
|
||||
CheckUser -->|Admin| Skip[跳过覆盖]
|
||||
CheckUser -->|User| Priority3{优先级3:<br/>用户覆盖匹配?}
|
||||
|
||||
Priority3 -->|匹配成功| Override[覆盖为当前用户<br/>managerName=当前用户]
|
||||
Priority3 -->|未匹配| Keep[保持原结果]
|
||||
|
||||
Skip --> PushResult[添加到results]
|
||||
Override --> PushResult
|
||||
Keep --> PushResult
|
||||
|
||||
PushResult --> Next{还有物料?}
|
||||
Next -->|是| Loop
|
||||
@@ -321,11 +331,14 @@ flowchart TB
|
||||
- 结果: `isMarkedForDeletion = true`, `managerName` 从表中获取
|
||||
|
||||
2. **优先级2 (次高)**: `MaterialsTypeToBeDeleted` 表包含匹配
|
||||
- 匹配条件: `MaterialName` 包含关系 (`typeKeyword.materialName.includes(materialName)`)
|
||||
- 匹配条件: `MaterialName` 包含关系 (`materialName.includes(typeKeyword.materialName)`)
|
||||
- 结果: `isMarkedForDeletion = false`, `managerName` 从表中获取, `matchedTypeKeyword` 记录匹配项
|
||||
|
||||
3. **未匹配**: 无任何匹配
|
||||
- 结果: `isMarkedForDeletion = false`, `managerName = ''`, `matchedTypeKeyword = undefined`
|
||||
3. **优先级3 (User 覆盖)**: 当前用户 typeKeyword 覆盖匹配
|
||||
- 适用范围: 仅对 `User` 类型用户生效,`Admin` 用户跳过此步骤
|
||||
- 匹配条件: 筛选 `managerName === 当前用户名` 的 typeKeywords,使用相同的包含匹配逻辑
|
||||
- 结果: 强制覆盖 `managerName` 和 `matchedTypeKeyword` 为当前用户的值
|
||||
- 无匹配时: 保持优先级2的匹配结果不变
|
||||
|
||||
**核心代码**:
|
||||
|
||||
@@ -344,7 +357,7 @@ for (const record of materialRecords) {
|
||||
// 优先级2: 匹配 MaterialsTypeToBeDeleted (MaterialName 包含匹配)
|
||||
if (!managerName) {
|
||||
for (const typeKeyword of typeKeywords) {
|
||||
if (typeKeyword.materialName && typeKeyword.materialName.includes(materialName)) {
|
||||
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
|
||||
matchedTypeKeyword = typeKeyword.materialName
|
||||
managerName = typeKeyword.managerName
|
||||
break
|
||||
@@ -352,6 +365,18 @@ for (const record of materialRecords) {
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级3: 用户覆盖匹配 (仅限 User 用户)
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // 强制覆盖,只使用第一个匹配
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
materialName,
|
||||
materialCode,
|
||||
@@ -1220,13 +1245,15 @@ flowchart TB
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件路径 | 说明 | 关键行号 |
|
||||
| ----------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
||||
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 209-392 (validation:validate)<br>399-422 (materials:upsertBatch)<br>427-449 (materials:delete) |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
826
docs/extractor-start-button-flow.md
Normal file
826
docs/extractor-start-button-flow.md
Normal file
@@ -0,0 +1,826 @@
|
||||
# 数据提取界面 - 开始按钮工作流程详解
|
||||
|
||||
> **文档版本**: 1.1
|
||||
> **更新日期**: 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处理层)
|
||||
> - `src/main/services/erp/extractor.ts` (业务逻辑层)
|
||||
> - `src/main/services/erp/order-resolver.ts` (订单号解析服务)
|
||||
> - `src/main/services/erp/erp-auth.ts` (ERP认证服务)
|
||||
> - `src/main/types/extractor.types.ts` (类型定义)
|
||||
|
||||
## 目录
|
||||
|
||||
1. [系统架构概览](#系统架构概览)
|
||||
2. [完整执行流程](#完整执行流程)
|
||||
3. [状态管理流程](#状态管理流程)
|
||||
4. [错误处理机制](#错误处理机制)
|
||||
5. [数据流转过程](#数据流转过程)
|
||||
6. [关键代码引用](#关键代码引用)
|
||||
7. [已知限制与待实现功能](#已知限制与待实现功能)
|
||||
|
||||
---
|
||||
|
||||
## 系统架构概览
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Renderer Process (UI层)"
|
||||
A[ExtractorPage.tsx]
|
||||
B[React State Management]
|
||||
C[用户输入: 订单号列表]
|
||||
D[开始按钮]
|
||||
end
|
||||
|
||||
subgraph "IPC Bridge (通信桥梁)"
|
||||
E[electron.extractor.runExtractor]
|
||||
F[extractor:run Channel]
|
||||
end
|
||||
|
||||
subgraph "Main Process (主进程)"
|
||||
G[extractor-handler.ts]
|
||||
H[OrderNumberResolver]
|
||||
I[ErpAuthService]
|
||||
J[ExtractorService]
|
||||
end
|
||||
|
||||
subgraph "External Services (外部服务)"
|
||||
K[(MySQL Database)]
|
||||
L[ERP Web System]
|
||||
M[Playwright Browser]
|
||||
end
|
||||
|
||||
A -->|用户点击| D
|
||||
D -->|调用API| E
|
||||
E -->|IPC通信| F
|
||||
F -->|接收请求| G
|
||||
G -->|解析订单号| H
|
||||
H -->|查询数据| K
|
||||
G -->|登录认证| I
|
||||
I -->|自动化操作| M
|
||||
M -->|访问页面| L
|
||||
G -->|执行提取| J
|
||||
J -->|使用| I
|
||||
J -->|返回结果| G
|
||||
G -->|IPC响应| F
|
||||
F -->|更新UI| A
|
||||
B -->|管理状态| A
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style G fill:#fff4e1
|
||||
style K fill:#e8f5e9
|
||||
style L fill:#f3e5f5
|
||||
```
|
||||
|
||||
### 架构说明
|
||||
|
||||
- **Renderer Process**: 负责UI展示和用户交互,使用React管理状态
|
||||
- **IPC Bridge**: 安全的进程间通信桥梁,通过preload脚本暴露
|
||||
- **Main Process**: 处理业务逻辑、数据库操作、浏览器自动化
|
||||
- **External Services**: MySQL数据库和ERP Web系统
|
||||
|
||||
---
|
||||
|
||||
## 完整执行流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as 👤 用户
|
||||
participant UI as ExtractorPage.tsx
|
||||
participant IPC as electron API
|
||||
participant Handler as extractor-handler.ts
|
||||
participant Resolver as OrderNumberResolver
|
||||
participant MySQL as MySQL Database
|
||||
participant Auth as ErpAuthService
|
||||
participant Extractor as ExtractorService
|
||||
participant Browser as Playwright Browser
|
||||
participant ERP as ERP Web System
|
||||
|
||||
User->>UI: 1. 输入订单号列表<br/>(每行一个)
|
||||
User->>UI: 2. 点击"开始提取"按钮
|
||||
|
||||
Note over UI: 前端验证与准备
|
||||
UI->>UI: 3. 验证订单号非空
|
||||
UI->>UI: 4. 设置isRunning=true
|
||||
UI->>UI: 5. 清空之前的结果和错误
|
||||
UI->>UI: 6. 存储Production IDs到共享状态
|
||||
UI->>IPC: 7. 调用electron.extractor.runExtractor()
|
||||
|
||||
Note over IPC,Handler: IPC通信
|
||||
IPC->>Handler: 8. 发送IPC消息 'extractor:run'
|
||||
|
||||
Note over Handler: 环境配置检查
|
||||
Handler->>Handler: 9. 读取.env配置<br/>(ERP_URL, USERNAME, PASSWORD)
|
||||
Handler->>Handler: 10. 验证配置完整性
|
||||
alt 配置不完整
|
||||
Handler-->>UI: 返回ValidationError
|
||||
UI->>UI: 显示错误提示
|
||||
UI->>UI: 设置isRunning=false
|
||||
end
|
||||
|
||||
Note over Handler,MySQL: 订单号解析阶段
|
||||
Handler->>MySQL: 11. 连接MySQL数据库
|
||||
alt MySQL连接失败
|
||||
Handler-->>UI: 返回DatabaseQueryError
|
||||
end
|
||||
|
||||
Handler->>Resolver: 12. 创建OrderNumberResolver
|
||||
Handler->>Resolver: 13. 调用resolve(orderNumbers)
|
||||
Resolver->>MySQL: 14. 查询生产订单号映射
|
||||
MySQL-->>Resolver: 15. 返回映射结果
|
||||
Resolver-->>Handler: 16. 返回映射结果<br/>(包含有效订单号和警告)
|
||||
|
||||
alt 没有有效订单号
|
||||
Handler-->>UI: 返回ValidationError
|
||||
UI->>UI: 显示错误: "没有有效的生产订单号"
|
||||
end
|
||||
|
||||
Note over Handler,Browser: ERP认证阶段
|
||||
Handler->>Auth: 17. 创建ErpAuthService
|
||||
Handler->>Auth: 18. 调用login()
|
||||
Auth->>Browser: 19. 启动Playwright浏览器
|
||||
Browser->>ERP: 20. 访问ERP登录页面
|
||||
Browser->>ERP: 21. 填写用户名密码
|
||||
Browser->>ERP: 22. 点击登录按钮
|
||||
ERP-->>Browser: 23. 登录成功
|
||||
Browser-->>Auth: 24. 返回session对象
|
||||
Auth-->>Handler: 25. 登录成功
|
||||
alt 登录失败
|
||||
Auth-->>Handler: 抛出异常
|
||||
Handler-->>UI: 返回ErpConnectionError
|
||||
end
|
||||
|
||||
Note over Extractor,ERP: 数据提取阶段
|
||||
Handler->>Extractor: 26. 创建ExtractorService
|
||||
Handler->>Extractor: 27. 调用extract()<br/>传入有效订单号
|
||||
Extractor->>Browser: 28. 使用已有session
|
||||
Extractor->>ERP: 29. 导航到离散备料计划维护页面
|
||||
Extractor->>ERP: 30. 设置查询界面<br/>(订单号查询, 全部标签, 限制5000)
|
||||
|
||||
loop 批处理循环 (每批最多100个订单)
|
||||
Extractor->>Extractor: 31. 创建批次<br/>(按batchSize分组)
|
||||
Note over Extractor: onProgress回调存在但<br/>无法通过IPC传递(函数不可序列化)
|
||||
|
||||
Extractor->>ERP: 32. 填充订单号到搜索框
|
||||
Extractor->>ERP: 33. 点击搜索按钮
|
||||
Extractor->>ERP: 34. 等待加载完成
|
||||
Extractor->>ERP: 35. 点击第一行复选框
|
||||
Extractor->>ERP: 36. 悬停并点击"更多"
|
||||
Extractor->>ERP: 37. 点击"输出"
|
||||
Extractor->>ERP: 38. 设置行数阈值为300000
|
||||
Extractor->>ERP: 39. 点击"确定(Y)"
|
||||
|
||||
Browser->>Browser: 40. 监听下载事件
|
||||
ERP->>Browser: 41. 触发文件下载
|
||||
Browser->>Browser: 42. 保存文件到downloads目录
|
||||
Browser-->>Extractor: 43. 返回文件路径
|
||||
Extractor->>Extractor: 44. 记录下载文件路径
|
||||
end
|
||||
|
||||
Extractor->>Extractor: 45. 汇总结果<br/>(文件列表, 记录数, 错误)
|
||||
Extractor-->>Handler: 46. 返回ExtractorResult
|
||||
Handler->>Handler: 47. 添加解析警告到错误列表
|
||||
|
||||
Note over Handler,IPC: 清理阶段
|
||||
Handler->>Browser: 48. 关闭浏览器
|
||||
Handler->>MySQL: 49. 断开数据库连接
|
||||
|
||||
Note over Handler,UI: 响应阶段
|
||||
Handler-->>IPC: 50. 返回IPC响应<br/>(success: true, data: result)
|
||||
IPC-->>UI: 51. 返回response
|
||||
UI->>UI: 52. 设置result状态
|
||||
UI->>UI: 53. 设置isRunning=false
|
||||
UI->>UI: 54. 清空进度状态
|
||||
UI->>User: 55. 显示提取结果<br/>(文件数, 记录数, 错误数)
|
||||
|
||||
alt 发生任何错误
|
||||
Handler-->>UI: 返回error响应
|
||||
UI->>UI: 设置error状态
|
||||
UI->>UI: 设置isRunning=false
|
||||
UI->>User: 显示错误信息
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 状态管理流程
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle: 初始状态
|
||||
|
||||
Idle --> Validating: 用户点击开始按钮
|
||||
Validating --> Idle: 验证失败<br/>(订单号为空)
|
||||
Validating --> Running: 验证通过
|
||||
|
||||
Running --> Processing: 调用IPC API
|
||||
Processing --> Progress: 收到进度更新
|
||||
Progress --> Processing: 继续处理
|
||||
|
||||
Processing --> Success: 提取成功
|
||||
Processing --> Error: 提取失败
|
||||
|
||||
Success --> Idle: 用户清空或重新输入
|
||||
Error --> Idle: 用户修正后重试
|
||||
|
||||
note right of Validating
|
||||
前端验证阶段:
|
||||
- 检查orderNumbers非空
|
||||
- 解析订单号列表
|
||||
- 存储到sessionStorage
|
||||
- 存储到共享状态
|
||||
end note
|
||||
|
||||
note right of Processing
|
||||
后端处理阶段:
|
||||
- 环境配置检查
|
||||
- 订单号解析
|
||||
- ERP登录
|
||||
- 批量数据提取
|
||||
- 资源清理
|
||||
end note
|
||||
|
||||
note right of Progress
|
||||
进度更新:
|
||||
- 更新进度条百分比
|
||||
- 添加日志到控制台
|
||||
- 保持isRunning=true
|
||||
end note
|
||||
|
||||
note right of Success
|
||||
成功状态:
|
||||
- 显示下载文件数
|
||||
- 显示记录总数
|
||||
- 显示错误数
|
||||
- isRunning=false
|
||||
end note
|
||||
|
||||
note right of Error
|
||||
错误状态:
|
||||
- 显示错误信息
|
||||
- isRunning=false
|
||||
- 保留用户输入
|
||||
end note
|
||||
```
|
||||
|
||||
### 状态变量说明
|
||||
|
||||
| 状态变量 | 类型 | 说明 | 持久化 |
|
||||
|---------|------|------|--------|
|
||||
| `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 事件通道实现实时进度更新。
|
||||
|
||||
---
|
||||
|
||||
## 错误处理机制
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([用户点击开始]) --> Validate{前端验证}
|
||||
Validate -->|订单号为空| ShowEmptyError[显示错误:<br/>请输入至少一个订单号]
|
||||
Validate -->|验证通过| CallIPC[调用IPC API]
|
||||
|
||||
CallIPC --> ConfigCheck{环境配置检查}
|
||||
ConfigCheck -->|配置不完整| ConfigError[返回ValidationError:<br/>ERP配置不完整]
|
||||
ConfigCheck -->|配置完整| ConnectMySQL[连接MySQL]
|
||||
|
||||
ConnectMySQL --> MySQLCheck{连接成功?}
|
||||
MySQLCheck -->|失败| MySQLError[返回DatabaseQueryError:<br/>MySQL连接失败]
|
||||
MySQLCheck -->|成功| ResolveOrders[解析订单号]
|
||||
|
||||
ResolveOrders --> ValidOrders{有有效订单号?}
|
||||
ValidOrders -->|无| NoOrdersError[返回ValidationError:<br/>没有有效的生产订单号]
|
||||
ValidOrders -->|有| LoginERP[ERP登录]
|
||||
|
||||
LoginERP --> LoginCheck{登录成功?}
|
||||
LoginCheck -->|失败| LoginError[返回ErpConnectionError:<br/>ERP登录失败]
|
||||
LoginCheck -->|成功| ExtractData[执行数据提取]
|
||||
|
||||
ExtractData --> BatchLoop[批处理循环]
|
||||
BatchLoop --> BatchError{批次成功?}
|
||||
BatchError -->|失败| RecordError[记录错误到result.errors]
|
||||
BatchError -->|成功| SaveFile[保存文件]
|
||||
RecordError --> NextBatch{还有批次?}
|
||||
SaveFile --> NextBatch
|
||||
|
||||
NextBatch -->|是| BatchLoop
|
||||
NextBatch -->|否| Cleanup[清理资源]
|
||||
|
||||
Cleanup --> CheckWarnings{有警告?}
|
||||
CheckWarnings -->|是| AddWarnings[添加警告到errors]
|
||||
CheckWarnings -->|否| ReturnSuccess[返回成功结果]
|
||||
AddWarnings --> ReturnSuccess
|
||||
|
||||
ShowEmptyError --> ResetState1[设置isRunning=false]
|
||||
ConfigError --> ResetState2[设置isRunning=false]
|
||||
MySQLError --> ResetState3[设置isRunning=false]
|
||||
NoOrdersError --> ResetState4[设置isRunning=false]
|
||||
LoginError --> ResetState5[设置isRunning=false]
|
||||
|
||||
ResetState1 --> End1([结束])
|
||||
ResetState2 --> End2([结束])
|
||||
ResetState3 --> End3([结束])
|
||||
ResetState4 --> End4([结束])
|
||||
ResetState5 --> End5([结束])
|
||||
ReturnSuccess --> End6([显示结果])
|
||||
|
||||
style ShowEmptyError fill:#ffcccc
|
||||
style ConfigError fill:#ffcccc
|
||||
style MySQLError fill:#ffcccc
|
||||
style NoOrdersError fill:#ffcccc
|
||||
style LoginError fill:#ffcccc
|
||||
style RecordError fill:#fff4cc
|
||||
style ReturnSuccess fill:#ccffcc
|
||||
```
|
||||
|
||||
### 错误类型与处理策略
|
||||
|
||||
| 错误类型 | 触发条件 | 用户反馈 | 恢复策略 |
|
||||
|---------|---------|---------|---------|
|
||||
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
|
||||
| `DatabaseQueryError` | MySQL连接失败 | 显示数据库连接错误 | 检查数据库配置 |
|
||||
| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
|
||||
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
|
||||
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
|
||||
|
||||
---
|
||||
|
||||
## 数据流转过程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Input (用户输入)"
|
||||
A1[原始输入<br/>订单号列表]
|
||||
A2[批次大小<br/>batchSize=100]
|
||||
end
|
||||
|
||||
subgraph "Transformation (数据转换)"
|
||||
B1[行解析<br/>按换行符分割]
|
||||
B2[去空白<br/>trim每行]
|
||||
B3[过滤空行<br/>移除空字符串]
|
||||
B4[存储共享状态<br/>Production IDs]
|
||||
end
|
||||
|
||||
subgraph "Resolution (订单号解析)"
|
||||
C1[查询MySQL<br/>查找映射关系]
|
||||
C2[提取生产订单号<br/>获取有效值]
|
||||
C3[收集警告<br/>记录未映射项]
|
||||
end
|
||||
|
||||
subgraph "Processing (批量处理)"
|
||||
D1[批次分组<br/>按batchSize切分]
|
||||
D2[批次迭代<br/>逐批处理]
|
||||
D3[订单拼接<br/>逗号连接]
|
||||
end
|
||||
|
||||
subgraph "Output (结果输出)"
|
||||
E1[下载文件列表<br/>downloadedFiles数组]
|
||||
E2[合并文件<br/>mergedFile TODO]
|
||||
E3[记录总数<br/>recordCount]
|
||||
E4[错误列表<br/>errors数组]
|
||||
end
|
||||
|
||||
A1 --> B1
|
||||
B1 --> B2
|
||||
B2 --> B3
|
||||
B3 --> B4
|
||||
B4 --> C1
|
||||
A2 --> D1
|
||||
C1 --> C2
|
||||
C2 --> D1
|
||||
C3 --> E4
|
||||
D1 --> D2
|
||||
D2 --> D3
|
||||
D3 --> E1
|
||||
E1 --> E3
|
||||
|
||||
style A1 fill:#e3f2fd
|
||||
style A2 fill:#e3f2fd
|
||||
style E1 fill:#e8f5e9
|
||||
style E2 fill:#e8f5e9
|
||||
style E3 fill:#e8f5e9
|
||||
style E4 fill:#fff3e0
|
||||
```
|
||||
|
||||
### 数据转换详情
|
||||
|
||||
**阶段1: 用户输入 → Production IDs**
|
||||
```
|
||||
输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
|
||||
↓ 分割 + trim + 过滤
|
||||
结果: ["PO-20231024-001", "PO-20231024-002", "PO-20231024-003"]
|
||||
↓ 存储到共享状态
|
||||
共享状态: Production IDs (供清理模块使用)
|
||||
```
|
||||
|
||||
**阶段2: Production IDs → 生产订单号**
|
||||
```
|
||||
输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"]
|
||||
↓ MySQL查询 (production_order表)
|
||||
映射结果: {
|
||||
"PO-20231024-001": "MO-20231024-001",
|
||||
"PO-20231024-002": "MO-20231024-002",
|
||||
"INVALID": null
|
||||
}
|
||||
↓ 提取有效值
|
||||
有效订单号: ["MO-20231024-001", "MO-20231024-002"]
|
||||
警告: ["INVALID: 未找到对应的生产订单号"]
|
||||
```
|
||||
|
||||
**阶段3: 生产订单号 → 批次**
|
||||
```
|
||||
输入: ["MO-001", "MO-002", ..., "MO-250"] (250个)
|
||||
批次大小: 100
|
||||
↓ 分组
|
||||
批次1: ["MO-001", ..., "MO-100"]
|
||||
批次2: ["MO-101", ..., "MO-200"]
|
||||
批次3: ["MO-201", ..., "MO-250"]
|
||||
```
|
||||
|
||||
**阶段4: 批次 → ERP查询字符串**
|
||||
```
|
||||
批次: ["MO-001", "MO-002", "MO-003"]
|
||||
↓ 逗号连接
|
||||
查询字符串: "MO-001,MO-002,MO-003"
|
||||
↓ 填充到ERP搜索框
|
||||
ERP操作: 填入搜索框并点击搜索
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键代码引用
|
||||
|
||||
### 1. 前端开始按钮处理 (ExtractorPage.tsx:52-90)
|
||||
|
||||
```typescript
|
||||
const handleExtract = async () => {
|
||||
// 1. 前端验证
|
||||
if (!orderNumbers.trim()) {
|
||||
setError('请输入至少一个订单号')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 设置运行状态
|
||||
setIsRunning(true)
|
||||
setProgress(null)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 3. 解析订单号列表
|
||||
const orderNumberList = orderNumbers
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// 4. 存储到共享状态 (与Cleaner模块共享)
|
||||
await window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||
|
||||
// 5. 调用后端API
|
||||
const response = await window.electron.extractor.runExtractor({
|
||||
orderNumbers: orderNumberList,
|
||||
batchSize
|
||||
})
|
||||
|
||||
// 6. 处理响应
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data)
|
||||
} else {
|
||||
setError(response.error || '提取失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '发生未知错误')
|
||||
} finally {
|
||||
// 7. 重置状态
|
||||
setIsRunning(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.1 订单号实时同步到共享状态 (ExtractorPage.tsx:34-45)
|
||||
|
||||
```typescript
|
||||
// 当用户输入订单号时,实时同步到共享状态
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
|
||||
// 实时更新共享的 Production IDs
|
||||
if (orderNumbers.trim()) {
|
||||
const orderNumberList = orderNumbers
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||
}
|
||||
}, [orderNumbers])
|
||||
```
|
||||
|
||||
> **设计说明**: 订单号通过两种方式存储到共享状态:
|
||||
> 1. `useEffect` 在用户输入时实时更新
|
||||
> 2. `handleExtract` 在提取开始前再次确认存储
|
||||
>
|
||||
> 这确保了即使用户在Cleaner页面刷新,数据也已同步。
|
||||
|
||||
### 2. IPC处理器核心逻辑 (extractor-handler.ts:17-154)
|
||||
|
||||
```typescript
|
||||
ipcMain.handle(
|
||||
'extractor:run',
|
||||
async (_event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let mysqlService: MySqlService | null = null
|
||||
|
||||
try {
|
||||
// 1. 环境配置检查
|
||||
const erpUrl = process.env.ERP_URL || ''
|
||||
const erpUsername = process.env.ERP_USERNAME || ''
|
||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
||||
|
||||
if (!erpUrl || !erpUsername || !erpPassword) {
|
||||
throw new ValidationError('ERP 配置不完整')
|
||||
}
|
||||
|
||||
// 2. 连接MySQL并解析订单号
|
||||
const mysqlConfig = { /* ... */ }
|
||||
mysqlService = new MySqlService(mysqlConfig)
|
||||
await mysqlService.connect()
|
||||
|
||||
const resolver = new OrderNumberResolver(mysqlService)
|
||||
const mappings = await resolver.resolve(input.orderNumbers)
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError('没有有效的生产订单号可处理')
|
||||
}
|
||||
|
||||
// 3. ERP登录
|
||||
authService = new ErpAuthService({ url, username, password, headless: true })
|
||||
await authService.login()
|
||||
|
||||
// 4. 执行提取
|
||||
const extractor = new ExtractorService(authService)
|
||||
const result = await extractor.extract({
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers
|
||||
})
|
||||
|
||||
// 5. 添加警告到结果
|
||||
if (warnings.length > 0) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
// 6. 资源清理
|
||||
if (authService) await authService.close()
|
||||
if (mysqlService) await mysqlService.disconnect()
|
||||
}
|
||||
}, 'extractor:run')
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 提取服务批处理逻辑 (extractor.ts:29-77)
|
||||
|
||||
```typescript
|
||||
async extract(input: ExtractorInput): Promise<ExtractorResult> {
|
||||
const result: ExtractorResult = {
|
||||
downloadedFiles: [],
|
||||
mergedFile: null,
|
||||
recordCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const session = this.authService.getSession()
|
||||
|
||||
// 导航到提取页面并获取工作框架
|
||||
const { popupPage, workFrame } = await this.navigateToExtractorPage(session)
|
||||
|
||||
// 批处理设置
|
||||
const batchSize = input.batchSize || 100
|
||||
const batches = this.createBatches(input.orderNumbers, batchSize)
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i]
|
||||
const progress = ((i + 1) / batches.length) * 100
|
||||
|
||||
// 注意: onProgress 回调存在但无法通过 IPC 传递
|
||||
// 后续可通过 IPC 事件通道实现实时进度
|
||||
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
||||
|
||||
try {
|
||||
const filePath = await this.downloadBatch(
|
||||
session, popupPage, workFrame, batch, i, batches.length
|
||||
)
|
||||
result.downloadedFiles.push(filePath)
|
||||
} catch (error) {
|
||||
// 单批次失败不影响其他批次
|
||||
result.errors.push(`Batch ${i + 1}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 合并文件功能待实现
|
||||
} catch (error) {
|
||||
result.errors.push(`Extraction failed: ${error.message}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 浏览器自动化单批次处理 (extractor.ts:151-194)
|
||||
|
||||
```typescript
|
||||
private async downloadBatch(
|
||||
session: ErpSession,
|
||||
popupPage: any,
|
||||
workFrame: any,
|
||||
orderNumbers: string[],
|
||||
batchIndex: number,
|
||||
totalBatches: number
|
||||
): Promise<string> {
|
||||
// 1. 清空并填充订单号
|
||||
const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
|
||||
await textbox.fill('')
|
||||
await textbox.fill(orderNumbers.join(','))
|
||||
|
||||
// 2. 点击搜索按钮
|
||||
await workFrame.locator('.search-component-searchBtn').click()
|
||||
|
||||
// 3. 等待加载完成
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
// 4. 选择第一行(全选)
|
||||
await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
|
||||
|
||||
// 5. 悬停"更多"按钮并点击"输出"
|
||||
await workFrame.getByRole('button', { name: '更多' }).hover()
|
||||
await workFrame.getByText('输出', { exact: true }).click()
|
||||
|
||||
// 6. 设置行数阈值
|
||||
const thresholdBox = workFrame
|
||||
.locator('div')
|
||||
.filter({ hasText: /^行数阈值$/ })
|
||||
.locator('input[type="text"]')
|
||||
await thresholdBox.fill('300000')
|
||||
|
||||
// 7. 等待下载并保存
|
||||
const downloadPath = path.join(this.downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
|
||||
const downloadPromise = popupPage.waitForEvent('download')
|
||||
await workFrame.getByRole('button', { name: '确定(Y)' }).click()
|
||||
|
||||
const download = await downloadPromise
|
||||
await download.saveAs(downloadPath)
|
||||
|
||||
return downloadPath
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Preload API 暴露 (preload/index.ts:24-26)
|
||||
|
||||
```typescript
|
||||
// Extractor service
|
||||
extractor: {
|
||||
runExtractor: (input: ExtractorInput) => ipcRenderer.invoke('extractor:run', input)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 类型定义 (types/extractor.types.ts)
|
||||
|
||||
```typescript
|
||||
export interface ExtractorInput {
|
||||
orderNumbers: string[]
|
||||
batchSize?: number
|
||||
onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
|
||||
}
|
||||
|
||||
export interface ExtractorResult {
|
||||
downloadedFiles: string[]
|
||||
mergedFile: string | null
|
||||
recordCount: number
|
||||
errors: string[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 流程关键点
|
||||
|
||||
1. **三层验证机制**:
|
||||
- 前端验证: 非空检查
|
||||
- 配置验证: 环境变量完整性
|
||||
- 数据验证: 订单号有效性(通过MySQL查询)
|
||||
|
||||
2. **资源管理策略**:
|
||||
- 使用 try-finally 确保资源清理
|
||||
- 浏览器在使用后立即关闭
|
||||
- 数据库连接在使用后断开
|
||||
- 清理操作在 finally 块中独立 try-catch,避免清理失败影响结果返回
|
||||
|
||||
3. **错误容错设计**:
|
||||
- 单个批次失败不影响其他批次
|
||||
- 警告信息独立收集,不影响主流程
|
||||
- 详细错误信息返回给前端展示
|
||||
- 使用自定义错误类型 (`ValidationError`, `DatabaseQueryError`, `ErpConnectionError`)
|
||||
|
||||
4. **用户体验优化**:
|
||||
- sessionStorage 持久化用户输入(`orderNumbers`, `batchSize`)
|
||||
- 订单号实时同步到共享状态(供 Cleaner 模块使用)
|
||||
- 详细的日志记录
|
||||
- 结果面板显示文件数、记录数、错误数
|
||||
|
||||
### 已知限制
|
||||
|
||||
1. **进度更新未实现**:
|
||||
- `ExtractorInput.onProgress` 回调存在但无法通过 IPC 传递
|
||||
- 前端 `progress` 状态当前未从后端接收实时更新
|
||||
- 后续可通过 IPC 事件通道(`ipcRenderer.on` / `webContents.send`)实现
|
||||
|
||||
2. **文件合并未实现**:
|
||||
- `ExtractorResult.mergedFile` 当前始终为 `null`
|
||||
- 各批次文件独立保存在 `downloads` 目录
|
||||
|
||||
### 性能考虑
|
||||
|
||||
- **批处理**: 默认每批100个订单,平衡性能与稳定性
|
||||
- **异步并发**: 使用 async/await 处理异步操作
|
||||
- **下载监听**: 使用 Playwright 事件监听处理文件下载
|
||||
|
||||
### 扩展性
|
||||
|
||||
- **配置化**: batchSize 可配置
|
||||
- **模块化**: 服务独立,易于测试和维护
|
||||
- **错误类型化**: 使用自定义错误类型便于精确处理
|
||||
- **共享状态**: 通过 `validation.setSharedProductionIds` 实现跨页面数据共享
|
||||
|
||||
---
|
||||
|
||||
## 已知限制与待实现功能
|
||||
|
||||
### 进度更新机制
|
||||
|
||||
**当前状态**: 未实现
|
||||
|
||||
**原因**: IPC 通信无法序列化函数,`onProgress` 回调无法传递到主进程。
|
||||
|
||||
**当前实现**:
|
||||
```typescript
|
||||
// extractor.ts 中调用但无效
|
||||
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
||||
```
|
||||
|
||||
**建议实现方案**:
|
||||
```typescript
|
||||
// 方案: 使用 IPC 事件通道
|
||||
|
||||
// 1. 主进程发送进度
|
||||
event.sender.send('extractor:progress', { message, progress })
|
||||
|
||||
// 2. Preload 暴露事件监听
|
||||
extractor: {
|
||||
onProgress: (callback) => {
|
||||
ipcRenderer.on('extractor:progress', (_event, data) => callback(data))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 渲染进程监听
|
||||
useEffect(() => {
|
||||
window.electron.extractor.onProgress((data) => {
|
||||
setProgress(data)
|
||||
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
|
||||
})
|
||||
}, [])
|
||||
```
|
||||
|
||||
### 文件合并功能
|
||||
|
||||
**当前状态**: 未实现
|
||||
|
||||
**待实现**: 将多个批次下载的文件合并为单一 Excel 文件。
|
||||
|
||||
**相关代码位置**: `extractor.ts:69-70`
|
||||
|
||||
```typescript
|
||||
// TODO: Merge files (implement in separate task)
|
||||
// result.mergedFile = await this.mergeFiles(result.downloadedFiles);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 如代码逻辑变更,请及时更新本文档和相关流程图。
|
||||
488
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
488
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# 配置保存优化设计文档
|
||||
|
||||
**日期:** 2026-03-03
|
||||
**分支:** fix/settings-partial-save
|
||||
**状态:** 设计阶段
|
||||
|
||||
---
|
||||
|
||||
## 问题描述
|
||||
|
||||
当前设置界面只能配置 3 个字段(ERP URL、用户名、密码),但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE`、`VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。
|
||||
|
||||
### 根本原因
|
||||
|
||||
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
||||
|
||||
**数据流问题:**
|
||||
```
|
||||
SettingsPage (只修改 ERP URL)
|
||||
↓ 发送完整的 settings 对象
|
||||
ConfigManager.saveAllSettings()
|
||||
↓ 覆盖所有字段到缓存
|
||||
.env 文件被完全重写(丢失未被 UI 包含的字段)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
采用 **方案 A(深度合并)+ 方案 C(字段白名单)** 的组合策略:
|
||||
|
||||
### 核心策略
|
||||
|
||||
1. **部分更新**:只更新传入的字段,保留其他字段不变
|
||||
2. **白名单验证**:只允许 UI 支持的字段被修改
|
||||
3. **备份机制**:保存前备份,失败可回滚
|
||||
4. **安全日志**:记录所有配置变更操作
|
||||
|
||||
---
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ SettingsPage │
|
||||
│ (Renderer) │
|
||||
└────────┬────────┘
|
||||
│ 只发送支持的字段
|
||||
│ { erp: { url, username, password } }
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Settings Handler│
|
||||
│ (IPC Bridge) │
|
||||
└────────┬────────┘
|
||||
│ 传递部分配置 (Partial<SettingsData>)
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ ConfigManager │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 1. 验证字段白名单 │ │
|
||||
│ │ 2. 深度合并当前配置 │ │
|
||||
│ │ 3. 备份 .env 文件 │ │
|
||||
│ │ 4. 原子写入新配置 │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 改动点
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
||||
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
||||
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
||||
|
||||
---
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 深度合并工具函数
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 深度合并两个对象,只更新 target 中存在的字段
|
||||
* 保留 source 中 target 没有的字段
|
||||
*/
|
||||
function deepMerge<T>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
|
||||
for (const key in target) {
|
||||
if (key in target) {
|
||||
const targetValue = target[key]
|
||||
const sourceValue = result[key]
|
||||
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMerge(sourceValue, targetValue)
|
||||
} else if (targetValue !== undefined) {
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 字段白名单验证
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 定义 UI 可编辑的字段路径
|
||||
* 使用点号表示法:'section.field'
|
||||
*/
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
// 未来扩展:
|
||||
// 'database.dbType',
|
||||
// 'paths.dataDir',
|
||||
// ...
|
||||
]
|
||||
|
||||
/**
|
||||
* 验证配置更新是否只包含允许的字段
|
||||
*/
|
||||
function validateEditableFields(settings: Partial<SettingsData>): {
|
||||
valid: boolean
|
||||
invalidFields: string[]
|
||||
} {
|
||||
const invalidFields: string[] = []
|
||||
|
||||
for (const [section, values] of Object.entries(settings)) {
|
||||
if (values && typeof values === 'object') {
|
||||
for (const field of Object.keys(values)) {
|
||||
const fieldPath = `${section}.${field}`
|
||||
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
|
||||
invalidFields.push(fieldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: invalidFields.length === 0,
|
||||
invalidFields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 部分保存方法
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 保存部分配置(只更新传入的字段)
|
||||
*/
|
||||
public async savePartialSettings(
|
||||
settings: Partial<SettingsData>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 步骤 1: 验证字段白名单
|
||||
const validation = validateEditableFields(settings)
|
||||
if (!validation.valid) {
|
||||
log.warn('Attempted to save non-editable fields', {
|
||||
invalidFields: validation.invalidFields
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤 2: 读取当前配置
|
||||
const currentSettings = this.getAllSettings()
|
||||
|
||||
// 步骤 3: 深度合并
|
||||
const mergedSettings = deepMerge(currentSettings, settings)
|
||||
|
||||
// 步骤 4: 备份并保存
|
||||
const backupSuccess = await this.backupEnvFile()
|
||||
if (!backupSuccess) {
|
||||
log.warn('Failed to backup .env file, proceeding with caution')
|
||||
}
|
||||
|
||||
const saveSuccess = await this.saveAllSettings(mergedSettings)
|
||||
|
||||
if (!saveSuccess) {
|
||||
// 保存失败,尝试恢复备份
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: '保存配置失败,已恢复原配置'
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Settings saved successfully', {
|
||||
updatedFields: Object.keys(settings)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error in savePartialSettings', { error: message })
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: `保存配置时发生错误:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 备份与恢复机制
|
||||
|
||||
```typescript
|
||||
private backupPath: string
|
||||
|
||||
constructor() {
|
||||
// ...
|
||||
this.backupPath = path.resolve(__dirname, '../../.env.backup')
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份当前 .env 文件
|
||||
*/
|
||||
private async backupEnvFile(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
fs.copyFileSync(this.envPath, this.backupPath)
|
||||
log.debug('Backup created', { path: this.backupPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to backup .env file', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从备份恢复 .env 文件
|
||||
*/
|
||||
private async restoreBackup(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.envPath)
|
||||
await this.loadEnvFile() // 重新加载到缓存
|
||||
log.info('Restored from backup')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to restore backup', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IPC 调用链路调整
|
||||
|
||||
### settings-handler.ts
|
||||
|
||||
```typescript
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings', {
|
||||
sections: Object.keys(settings)
|
||||
})
|
||||
|
||||
// 使用新的部分保存方法
|
||||
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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**关键改动:**
|
||||
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
||||
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
||||
|
||||
---
|
||||
|
||||
## 前端优化(双重保险)
|
||||
|
||||
### SettingsPage.tsx
|
||||
|
||||
```typescript
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
// 只发送 UI 支持的字段(双重保险)
|
||||
const partialSettings = {
|
||||
erp: {
|
||||
url: settings.erp?.url,
|
||||
username: settings.erp?.username,
|
||||
password: settings.erp?.password
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electron.settings.saveSettings(partialSettings)
|
||||
|
||||
if (result.success) {
|
||||
setIsModified(false)
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试场景
|
||||
|
||||
```typescript
|
||||
describe('ConfigManager.savePartialSettings', () => {
|
||||
it('应该只更新指定的字段,保留其他字段', async () => {
|
||||
const initial = {
|
||||
erp: { url: 'http://old.com', username: 'user1' },
|
||||
database: { dbType: 'mysql' }
|
||||
}
|
||||
|
||||
const update = {
|
||||
erp: { url: 'http://new.com' }
|
||||
}
|
||||
|
||||
await configManager.savePartialSettings(update)
|
||||
const result = configManager.getAllSettings()
|
||||
|
||||
expect(result.erp.url).toBe('http://new.com')
|
||||
expect(result.erp.username).toBe('user1') // 保留
|
||||
expect(result.database.dbType).toBe('mysql') // 保留
|
||||
})
|
||||
|
||||
it('应该拒绝未授权的字段更新', async () => {
|
||||
const invalidUpdate = {
|
||||
database: { dbType: 'postgres' }
|
||||
}
|
||||
|
||||
const result = await configManager.savePartialSettings(invalidUpdate)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不允许修改')
|
||||
})
|
||||
|
||||
it('保存失败时应该恢复备份', async () => {
|
||||
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
||||
throw new Error('Disk full')
|
||||
})
|
||||
|
||||
const result = await configManager.savePartialSettings({ erp: { url: 'x' } })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 手动验证步骤
|
||||
|
||||
1. 打开 `.env`,记录所有字段值
|
||||
2. 打开设置页面,只修改 ERP URL
|
||||
3. 点击保存
|
||||
4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值
|
||||
|
||||
---
|
||||
|
||||
## 未来扩展性
|
||||
|
||||
### 1. 白名单配置化
|
||||
|
||||
当设置页面需要支持更多配置时:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'database.dbType', // 新增
|
||||
'paths.dataDir', // 新增
|
||||
'extraction.batchSize', // 新增
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 按用户角色分级
|
||||
|
||||
```typescript
|
||||
const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
|
||||
Admin: ['*'],
|
||||
User: ['erp.url', 'erp.username', 'erp.password'],
|
||||
Guest: []
|
||||
}
|
||||
|
||||
function validateEditableFields(
|
||||
settings: Partial<SettingsData>,
|
||||
userType: UserType
|
||||
) {
|
||||
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
||||
// 验证逻辑...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 配置变更审计
|
||||
|
||||
```typescript
|
||||
interface ConfigChange {
|
||||
timestamp: Date
|
||||
user: string
|
||||
field: string
|
||||
oldValue: string
|
||||
newValue: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
下一步将创建详细的实施计划,包括:
|
||||
|
||||
1. 在 ConfigManager 中添加深度合并和验证函数
|
||||
2. 实现 `savePartialSettings()` 方法
|
||||
3. 添加备份与恢复机制
|
||||
4. 更新 IPC handler 调用
|
||||
5. 前端优化(只发送必要字段)
|
||||
6. 编写单元测试
|
||||
7. 集成测试和手动验证
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
||||
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
||||
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
||||
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### 相关文件
|
||||
|
||||
- `src/main/services/config/config-manager.ts` - 配置管理器
|
||||
- `src/main/ipc/settings-handler.ts` - IPC 处理器
|
||||
- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面
|
||||
- `src/main/types/settings.types.ts` - 类型定义
|
||||
|
||||
### 参考
|
||||
|
||||
- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖
|
||||
- 设计原则:安全优先、最小化修改、可扩展性
|
||||
943
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
943
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
@@ -0,0 +1,943 @@
|
||||
# Settings Partial Save Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix settings save to only update modified fields, preventing unintended overwrites of unmodified .env configuration values.
|
||||
|
||||
**Architecture:** Implement partial save strategy using deep merge + whitelist validation. ConfigManager validates fields, merges with current config, backs up .env, and atomically writes changes.
|
||||
|
||||
**Tech Stack:** TypeScript 5.9, Electron 39, Vitest, Node.js fs module
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Utility Functions to ConfigManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/services/config/config-manager.ts`
|
||||
|
||||
**Step 1: Write failing test for deep merge**
|
||||
|
||||
Create test file: `tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ConfigManager } from '@/services/config/config-manager'
|
||||
import type { SettingsData } from '@/types/settings.types'
|
||||
|
||||
describe('ConfigManager - deep merge utilities', () => {
|
||||
it('should deep merge objects, updating only specified fields', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
// 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: '' },
|
||||
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: '' },
|
||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||
execution: { dryRun: false }
|
||||
}
|
||||
|
||||
// Load initial settings
|
||||
await manager.saveAllSettings(initial)
|
||||
|
||||
// Partial update
|
||||
const partial = {
|
||||
erp: { url: 'http://new.com' }
|
||||
}
|
||||
|
||||
const result = await manager.savePartialSettings(partial)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
// Updated field
|
||||
expect(current.erp.url).toBe('http://new.com')
|
||||
|
||||
// Preserved fields
|
||||
expect(current.erp.username).toBe('user1')
|
||||
expect(current.database.dbType).toBe('mysql')
|
||||
expect(current.paths.dataDir).toBe('/data')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd D:/Node/ERPAuto-settings-fix && npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: FAIL - "savePartialSettings is not a function"
|
||||
|
||||
**Step 3: Add helper functions to ConfigManager**
|
||||
|
||||
In `src/main/services/config/config-manager.ts`, add after the DEFAULT_SETTINGS constant (around line 78):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Check if value is a plain object
|
||||
*/
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects, only updating fields present in target
|
||||
* Preserves all fields from source that are not in target
|
||||
*/
|
||||
function deepMerge<T>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
|
||||
for (const key in target) {
|
||||
if (key in target) {
|
||||
const targetValue = target[key]
|
||||
const sourceValue = result[key]
|
||||
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMerge(sourceValue, targetValue)
|
||||
} else if (targetValue !== undefined) {
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* UI editable field whitelist
|
||||
* Fields that can be modified through the settings UI
|
||||
*/
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
// Add more fields as UI expands
|
||||
]
|
||||
|
||||
/**
|
||||
* Validate that settings only contain editable fields
|
||||
*/
|
||||
function validateEditableFields(settings: Partial<SettingsData>): {
|
||||
valid: boolean
|
||||
invalidFields: string[]
|
||||
} {
|
||||
const invalidFields: string[] = []
|
||||
|
||||
for (const [section, values] of Object.entries(settings)) {
|
||||
if (values && typeof values === 'object') {
|
||||
for (const field of Object.keys(values)) {
|
||||
const fieldPath = `${section}.${field}`
|
||||
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
|
||||
invalidFields.push(fieldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: invalidFields.length === 0,
|
||||
invalidFields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it still fails (method not implemented yet)**
|
||||
|
||||
Run: `npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: FAIL - "savePartialSettings is not a function"
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts
|
||||
git commit -m "feat: add deep merge and validation utility functions to ConfigManager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Backup and Restore Mechanism
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/services/config/config-manager.ts`
|
||||
|
||||
**Step 1: Write test for backup functionality**
|
||||
|
||||
Add to `tests/main/services/config/config-manager.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('ConfigManager - backup and restore', () => {
|
||||
it('should create backup before saving', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
const backupSuccess = await manager['backupEnvFile']()
|
||||
|
||||
expect(backupSuccess).toBe(true)
|
||||
|
||||
// Check backup file exists
|
||||
const fs = await import('fs')
|
||||
const path = await import('path')
|
||||
const backupPath = path.resolve(process.cwd(), '.env.backup')
|
||||
|
||||
expect(fs.existsSync(backupPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('should restore from backup when save fails', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
// Create initial state
|
||||
const initial = manager.getAllSettings()
|
||||
const originalUrl = initial.erp.url
|
||||
|
||||
// Mock fs.writeFileSync to fail
|
||||
const fs = await import('fs')
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
||||
throw new Error('Disk full')
|
||||
})
|
||||
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: { url: 'http://should-not-save.com' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
// Restore should have happened
|
||||
const current = manager.getAllSettings()
|
||||
expect(current.erp.url).toBe(originalUrl)
|
||||
|
||||
writeFileSyncSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: FAIL - "backupEnvFile is not a function"
|
||||
|
||||
**Step 3: Add backup path property and methods to ConfigManager class**
|
||||
|
||||
In `ConfigManager` class, modify constructor (around line 88) to add backupPath:
|
||||
|
||||
```typescript
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null
|
||||
private envPath: string
|
||||
private backupPath: string // ADD THIS LINE
|
||||
private configCache: Map<string, string> = new Map()
|
||||
private initialized: boolean = false
|
||||
|
||||
private constructor() {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
this.envPath = path.resolve(__dirname, '../../.env')
|
||||
this.backupPath = path.resolve(__dirname, '../../.env.backup') // ADD THIS LINE
|
||||
this.initialized = true
|
||||
}
|
||||
```
|
||||
|
||||
Add private methods at the end of the class (before `getInstance()`):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Backup current .env file
|
||||
*/
|
||||
private async backupEnvFile(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
fs.copyFileSync(this.envPath, this.backupPath)
|
||||
log.debug('Backup created', { path: this.backupPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to backup .env file', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore .env file from backup
|
||||
*/
|
||||
private async restoreBackup(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.envPath)
|
||||
await this.loadEnvFile()
|
||||
log.info('Restored from backup')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to restore backup', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts
|
||||
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**
|
||||
|
||||
Add to `tests/main/services/config/config-manager.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('ConfigManager.savePartialSettings', () => {
|
||||
it('should save only specified fields and preserve others', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
// 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: '' },
|
||||
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: '' },
|
||||
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
||||
execution: { dryRun: true }
|
||||
})
|
||||
|
||||
// Update only ERP URL
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: { url: 'http://new.com' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
// Verify updated field
|
||||
expect(current.erp.url).toBe('http://new.com')
|
||||
|
||||
// Verify preserved ERP fields
|
||||
expect(current.erp.username).toBe('user1')
|
||||
expect(current.erp.password).toBe('pass1')
|
||||
|
||||
// Verify preserved other categories
|
||||
expect(current.database.dbType).toBe('mysql')
|
||||
expect(current.database.mysqlHost).toBe('192.168.1.1')
|
||||
expect(current.paths.dataDir).toBe('/old/path')
|
||||
expect(current.extraction.batchSize).toBe(50)
|
||||
expect(current.ui.fontFamily).toBe('Tahoma')
|
||||
})
|
||||
|
||||
it('should reject updates to non-whitelisted fields', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
const result = await manager.savePartialSettings({
|
||||
database: { dbType: 'postgres' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不允许修改')
|
||||
expect(result.error).toContain('database.dbType')
|
||||
})
|
||||
|
||||
it('should handle nested object updates correctly', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
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: '' },
|
||||
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: '' },
|
||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||
execution: { dryRun: false }
|
||||
})
|
||||
|
||||
// Update multiple ERP fields at once
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: {
|
||||
url: 'http://updated.com',
|
||||
username: 'newuser',
|
||||
password: 'newpass'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
expect(current.erp.url).toBe('http://updated.com')
|
||||
expect(current.erp.username).toBe('newuser')
|
||||
expect(current.erp.password).toBe('newpass')
|
||||
expect(current.erp.headless).toBe(false) // preserved
|
||||
})
|
||||
|
||||
it('should restore backup on save failure', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
const originalUrl = manager.getAllSettings().erp.url
|
||||
|
||||
// Mock save to fail
|
||||
const originalSave = manager.save.bind(manager)
|
||||
vi.spyOn(manager, 'save').mockResolvedValueOnce(false)
|
||||
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: { url: 'http://should-not-apply.com' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('保存配置失败')
|
||||
|
||||
// Verify rollback
|
||||
expect(manager.getAllSettings().erp.url).toBe(originalUrl)
|
||||
|
||||
manager.save.mockRestore()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: FAIL - "savePartialSettings is not a function" or implementation incomplete
|
||||
|
||||
**Step 3: Implement savePartialSettings method**
|
||||
|
||||
Add this public method to ConfigManager class (after saveAllSettings method, around line 483):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Save partial settings (only update provided fields)
|
||||
* Preserves all existing fields not included in the update
|
||||
*/
|
||||
public async savePartialSettings(
|
||||
settings: Partial<SettingsData>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Step 1: Validate field whitelist
|
||||
const validation = validateEditableFields(settings)
|
||||
if (!validation.valid) {
|
||||
log.warn('Attempted to save non-editable fields', {
|
||||
invalidFields: validation.invalidFields
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read current settings
|
||||
const currentSettings = this.getAllSettings()
|
||||
|
||||
// Step 3: Deep merge
|
||||
const mergedSettings = deepMerge(currentSettings, settings)
|
||||
|
||||
// Step 4: Backup and save
|
||||
const backupSuccess = await this.backupEnvFile()
|
||||
if (!backupSuccess) {
|
||||
log.warn('Failed to backup .env file, proceeding with caution')
|
||||
}
|
||||
|
||||
const saveSuccess = await this.saveAllSettings(mergedSettings)
|
||||
|
||||
if (!saveSuccess) {
|
||||
// Save failed, attempt restore
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: '保存配置失败,已恢复原配置'
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Settings saved successfully', {
|
||||
updatedFields: Object.keys(settings)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error in savePartialSettings', { error: message })
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: `保存配置时发生错误:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- tests/main/services/config/config-manager.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run typecheck**
|
||||
|
||||
Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:node`
|
||||
|
||||
Expected: PASS (no type errors)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts
|
||||
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**
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// 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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Run typecheck**
|
||||
|
||||
Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:node`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add src/main/ipc/settings-handler.ts
|
||||
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**
|
||||
|
||||
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 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**
|
||||
|
||||
Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:web`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add src/renderer/src/pages/SettingsPage.tsx
|
||||
git commit -m "feat: send only ERP fields from settings page (defensive programming)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual Testing and Verification
|
||||
|
||||
**Files:**
|
||||
- Manual test procedure
|
||||
|
||||
**Step 1: Prepare test environment**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
|
||||
# Create a test .env file with all fields
|
||||
cat > .env << 'EOF'
|
||||
# Test Configuration
|
||||
ERP_URL=http://original-test.com
|
||||
ERP_USERNAME=testuser
|
||||
ERP_PASSWORD=testpass
|
||||
ERP_HEADLESS=true
|
||||
ERP_IGNORE_HTTPS_ERRORS=true
|
||||
ERP_AUTO_CLOSE_BROWSER=true
|
||||
|
||||
DB_TYPE=mysql
|
||||
DB_NAME=testdb
|
||||
DB_USERNAME=dbuser
|
||||
DB_PASSWORD=dbpass
|
||||
DB_MYSQL_HOST=192.168.100.50
|
||||
DB_MYSQL_PORT=3306
|
||||
DB_MYSQL_CHARSET=utf8mb4
|
||||
|
||||
PATH_DATA_DIR=/test/data
|
||||
PATH_DEFAULT_OUTPUT=test.xlsx
|
||||
PATH_VALIDATION_OUTPUT=validation.xlsx
|
||||
|
||||
EXTRACTION_BATCH_SIZE=100
|
||||
EXTRACTION_VERBOSE=true
|
||||
EXTRACTION_AUTO_CONVERT=true
|
||||
EXTRACTION_MERGE_BATCHES=true
|
||||
EXTRACTION_ENABLE_DB_PERSISTENCE=true
|
||||
|
||||
VALIDATION_DATA_SOURCE=database_full
|
||||
VALIDATION_USE_DATABASE=true
|
||||
VALIDATION_BATCH_SIZE=2000
|
||||
VALIDATION_ENABLE_CRUD=false
|
||||
VALIDATION_DEFAULT_MANAGER=admin
|
||||
VALIDATION_MATCH_MODE=substring
|
||||
|
||||
UI_FONT_FAMILY=TestFont
|
||||
UI_FONT_SIZE=11
|
||||
UI_PRODUCTION_ID_INPUT_WIDTH=15
|
||||
|
||||
EXECUTION_DRYRUN=false
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 2: Start development server**
|
||||
|
||||
Run: `npm run dev`
|
||||
|
||||
**Step 3: Navigate to settings page**
|
||||
|
||||
1. Login to the application
|
||||
2. Navigate to Settings page
|
||||
3. Modify only ERP URL to `http://modified-test.com`
|
||||
4. Click "保存并应用配置"
|
||||
|
||||
**Step 4: Verify .env file preservation**
|
||||
|
||||
Check `.env` file:
|
||||
|
||||
```bash
|
||||
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)
|
||||
- All other fields should remain unchanged
|
||||
|
||||
**Step 5: Test whitelist validation**
|
||||
|
||||
Add test code to temporarily send invalid field:
|
||||
|
||||
```typescript
|
||||
// In SettingsPage.tsx handleSaveSettings, temporarily add:
|
||||
const partialSettings = {
|
||||
erp: {
|
||||
url: settings.erp?.url,
|
||||
username: settings.erp?.username,
|
||||
password: settings.erp?.password
|
||||
},
|
||||
database: { dbType: 'postgres' } // Should be rejected
|
||||
}
|
||||
```
|
||||
|
||||
Click save, should see error: "包含不允许修改的字段:database.dbType"
|
||||
|
||||
Remove test code after verification.
|
||||
|
||||
**Step 6: Test rollback mechanism**
|
||||
|
||||
Simulate save failure by temporarily making .env read-only:
|
||||
|
||||
```bash
|
||||
chmod -w .env # On Linux/Mac
|
||||
# or on Windows with file properties
|
||||
```
|
||||
|
||||
Attempt to save settings, should see error: "保存配置失败,已恢复原配置"
|
||||
|
||||
Verify .env content unchanged, then restore write permissions:
|
||||
|
||||
```bash
|
||||
chmod +w .env # On Linux/Mac
|
||||
```
|
||||
|
||||
**Step 7: Document test results**
|
||||
|
||||
Create test report:
|
||||
|
||||
```bash
|
||||
cat > docs/test-reports/settings-partial-save-manual-test.md << 'EOF'
|
||||
# Settings Partial Save - Manual Test Report
|
||||
|
||||
**Date:** 2026-03-03
|
||||
**Tester:** [Your Name]
|
||||
**Branch:** fix/settings-partial-save
|
||||
|
||||
## Test Results
|
||||
|
||||
### Test 1: Partial Field Preservation
|
||||
- [x] Modified ERP URL only
|
||||
- [x] Verified DB_TYPE unchanged
|
||||
- [x] Verified all other fields preserved
|
||||
|
||||
### Test 2: Whitelist Validation
|
||||
- [x] Attempted to modify database.dbType
|
||||
- [x] Received error message about unauthorized field
|
||||
- [x] No changes applied to .env
|
||||
|
||||
### Test 3: Backup and Rollback
|
||||
- [x] Backup file created before save
|
||||
- [x] Save failure triggered rollback
|
||||
- [x] Original configuration restored
|
||||
|
||||
## Conclusion
|
||||
All manual tests passed successfully.
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add docs/test-reports/settings-partial-save-manual-test.md
|
||||
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)
|
||||
|
||||
**Step 1: Create feature documentation**
|
||||
|
||||
Create `docs/settings-partial-save.md`:
|
||||
|
||||
```markdown
|
||||
# Settings Partial Save Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Field Whitelist**: Only fields exposed in the UI can be modified
|
||||
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
|
||||
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
|
||||
|
||||
## Editable Fields
|
||||
|
||||
Currently editable via UI:
|
||||
- `erp.url` - ERP system URL
|
||||
- `erp.username` - ERP login username
|
||||
- `erp.password` - ERP login password
|
||||
|
||||
## Adding New Editable Fields
|
||||
|
||||
To add a new field to the UI:
|
||||
|
||||
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'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
|
||||
|
||||
## API
|
||||
|
||||
### savePartialSettings(settings: Partial<SettingsData>)
|
||||
|
||||
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
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Unauthorized field**: Returns error message listing invalid fields
|
||||
- **Save failure**: Automatically restores from backup
|
||||
- **Backup failure**: Logs warning, continues with save
|
||||
|
||||
## Backup File
|
||||
|
||||
Location: `.env.backup` (in project root)
|
||||
|
||||
Created before every save operation. Used for rollback on failure.
|
||||
```
|
||||
|
||||
**Step 2: Update CLAUDE.md if needed**
|
||||
|
||||
Add to "Development Commands" or "Architecture Overview" sections if there's relevant information about config management.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
Run: `cd D:/Node/ERPAuto-settings-fix && npm test`
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 2: Run type checking**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
|
||||
Expected: No type errors
|
||||
|
||||
**Step 3: Run linting**
|
||||
|
||||
Run: `npm run lint`
|
||||
|
||||
Expected: No linting errors (or fix if present)
|
||||
|
||||
**Step 4: Build verification**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: Build succeeds without errors
|
||||
|
||||
**Step 5: Review all changes**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git diff dev --stat
|
||||
```
|
||||
|
||||
Verify all changes are expected.
|
||||
|
||||
**Step 6: Final commit**
|
||||
|
||||
```bash
|
||||
cd D:/Node/ERPAuto-settings-fix
|
||||
git add -A
|
||||
git commit -m "chore: final verification and cleanup for settings partial save feature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This implementation plan fixes the settings save issue through:
|
||||
|
||||
1. ✅ Deep merge utilities that preserve unmodified fields
|
||||
2. ✅ Field whitelist validation to prevent unauthorized changes
|
||||
3. ✅ Backup and rollback mechanism for safe saves
|
||||
4. ✅ Updated IPC handler with partial save support
|
||||
5. ✅ Frontend defensive programming (sends only necessary fields)
|
||||
6. ✅ Comprehensive unit and manual testing
|
||||
7. ✅ Complete documentation
|
||||
|
||||
**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)
|
||||
- `tests/main/services/config/config-manager.test.ts` (tests)
|
||||
60
docs/settings-partial-save.md
Normal file
60
docs/settings-partial-save.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Settings Partial Save Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Field Whitelist**: Only fields exposed in the UI can be modified
|
||||
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
|
||||
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
|
||||
|
||||
## Editable Fields
|
||||
|
||||
Currently editable via UI:
|
||||
- `erp.url` - ERP system URL
|
||||
- `erp.username` - ERP login username
|
||||
- `erp.password` - ERP login password
|
||||
|
||||
## Adding New Editable Fields
|
||||
|
||||
To add a new field to the UI:
|
||||
|
||||
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'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
|
||||
|
||||
## API
|
||||
|
||||
### savePartialSettings(settings: Partial<SettingsData>)
|
||||
|
||||
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
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Unauthorized field**: Returns error message listing invalid fields
|
||||
- **Save failure**: Automatically restores from backup
|
||||
- **Backup failure**: Logs warning, continues with save
|
||||
|
||||
## Backup File
|
||||
|
||||
Location: `.env.backup` (in project root)
|
||||
|
||||
Created before every save operation. Used for rollback on failure.
|
||||
913
docs/settings-save-button-flow.md
Normal file
913
docs/settings-save-button-flow.md
Normal file
@@ -0,0 +1,913 @@
|
||||
# 系统设置保存按钮工作流程分析
|
||||
# System Settings Save Button Workflow Analysis
|
||||
|
||||
## 文档概述 / Document Overview
|
||||
|
||||
本文档详细分析了 ERPAuto 系统设置界面中保存按钮的完整工作流程,包括架构设计、数据流转、技术实现细节以及错误处理机制。
|
||||
|
||||
This document provides a comprehensive analysis of the save button workflow in the ERPAuto system settings interface, including architecture design, data flow, technical implementation details, and error handling mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 目录 / Table of Contents
|
||||
|
||||
1. [架构概览](#架构概览)
|
||||
2. [数据流程图](#数据流程图)
|
||||
3. [组件详解](#组件详解)
|
||||
4. [数据结构](#数据结构)
|
||||
5. [错误处理机制](#错误处理机制)
|
||||
6. [安全考虑](#安全考虑)
|
||||
7. [技术实现细节](#技术实现细节)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览 / Architecture Overview
|
||||
|
||||
### 系统架构 / System Architecture
|
||||
|
||||
系统设置保存功能采用典型的 Electron 三层架构模式:
|
||||
|
||||
The system settings save functionality follows the classic Electron three-tier architecture pattern:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Renderer Process 渲染进程"
|
||||
UI[SettingsPage.tsx<br/>UI Component]
|
||||
end
|
||||
|
||||
subgraph "Preload Script 预加载脚本"
|
||||
BRIDGE[contextBridge API<br/>Security Boundary]
|
||||
end
|
||||
|
||||
subgraph "Main Process 主进程"
|
||||
IPC[settings-handler.ts<br/>IPC Handler]
|
||||
SERVICE[ConfigManager.ts<br/>Configuration Service]
|
||||
FILE[.env File<br/>Persistent Storage]
|
||||
end
|
||||
|
||||
UI -->|IPC Invoke| BRIDGE
|
||||
BRIDGE -->|Secure Channel| IPC
|
||||
IPC -->|Business Logic| SERVICE
|
||||
SERVICE -->|Write| FILE
|
||||
FILE -->|Confirm| SERVICE
|
||||
SERVICE -->|Result| IPC
|
||||
IPC -->|Response| BRIDGE
|
||||
BRIDGE -->|Promise Resolve| UI
|
||||
|
||||
style UI fill:#e1f5ff
|
||||
style BRIDGE fill:#fff4e1
|
||||
style IPC fill:#ffe1f5
|
||||
style SERVICE fill:#e1ffe1
|
||||
style FILE fill:#f5f5f5
|
||||
```
|
||||
|
||||
### 核心设计模式 / Core Design Patterns
|
||||
|
||||
1. **单向数据流**:数据从 UI → Main Process → File,响应沿相反路径返回
|
||||
2. **安全隔离**:Preload 脚本作为安全桥梁,通过 `contextBridge` 暴露受限 API
|
||||
3. **单例模式**:ConfigManager 使用单例确保配置一致性
|
||||
4. **缓存优先**:配置读取优先从内存缓存获取,写入时同步到磁盘
|
||||
|
||||
---
|
||||
|
||||
## 数据流程图 / Data Flow Diagrams
|
||||
|
||||
### 完整保存流程 / Complete Save Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 用户 User
|
||||
participant UI as SettingsPage.tsx
|
||||
participant Preload as preload/index.ts
|
||||
participant IPC as settings-handler.ts
|
||||
participant Config as ConfigManager.ts
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: 点击保存按钮<br/>Click Save Button
|
||||
activate UI
|
||||
|
||||
UI->>UI: handleSaveSettings()
|
||||
Note over UI: 检查是否修改<br/>Check isModified
|
||||
|
||||
UI->>Preload: window.electron.settings<br/>.saveSettings(settings)
|
||||
activate Preload
|
||||
|
||||
Preload->>IPC: ipcRenderer.invoke<br/>('settings:saveSettings', settings)
|
||||
activate IPC
|
||||
|
||||
IPC->>IPC: 验证用户类型<br/>Validate User Type
|
||||
IPC->>Config: configManager<br/>.saveAllSettings(settings)
|
||||
activate Config
|
||||
|
||||
Config->>Config: 更新内存缓存<br/>Update Cache
|
||||
Note over Config: set('erp.url', value)<br/>set('erp.username', value)<br/>... (40+ fields)
|
||||
|
||||
Config->>File: fs.writeFileSync<br/>(.env, content)
|
||||
activate File
|
||||
File-->>Config: true/false
|
||||
deactivate File
|
||||
|
||||
Config-->>IPC: Promise<boolean>
|
||||
deactivate Config
|
||||
|
||||
IPC-->>Preload: {success, error?}
|
||||
deactivate IPC
|
||||
|
||||
Preload-->>UI: Promise resolve
|
||||
deactivate Preload
|
||||
|
||||
alt 保存成功 / Save Success
|
||||
UI->>UI: setIsModified(false)
|
||||
UI->>User: 显示成功消息<br/>Show Success Message
|
||||
else 保存失败 / Save Failed
|
||||
UI->>User: 显示错误消息<br/>Show Error Message
|
||||
end
|
||||
|
||||
deactivate UI
|
||||
```
|
||||
|
||||
### 数据转换流程 / Data Transformation Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "UI State"
|
||||
STATE[Settings Interface<br/>settings.erp.url = 'https://...']
|
||||
end
|
||||
|
||||
subgraph "Type Conversion"
|
||||
T1[SettingsData Object<br/>TypeScript Interface]
|
||||
end
|
||||
|
||||
subgraph "IPC Transport"
|
||||
JSON[JSON Serialization<br/>String Transfer]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
CACHE[Config Cache<br/>Map<string, string>]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
ENV[.env File Format<br/>KEY=VALUE]
|
||||
end
|
||||
|
||||
STATE -->|Object| T1
|
||||
T1 -->|JSON.stringify| JSON
|
||||
JSON -->|Deserialize| T1
|
||||
T1 -->|set key-value| CACHE
|
||||
CACHE -->|Format| ENV
|
||||
|
||||
style STATE fill:#e1f5ff
|
||||
style JSON fill:#fff4e1
|
||||
style CACHE fill:#e1ffe1
|
||||
style ENV fill:#f5f5f5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件详解 / Component Details
|
||||
|
||||
### 1. 渲染进程 / Renderer Process
|
||||
|
||||
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
- 用户界面渲染和交互
|
||||
- 本地状态管理(settings, isModified, message)
|
||||
- 调用 IPC 通信
|
||||
|
||||
**关键函数 / Key Functions:**
|
||||
|
||||
```typescript
|
||||
// 第 61-73 行 / Lines 61-73
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const result = await window.electron.settings.saveSettings(settings as any)
|
||||
if (result.success) {
|
||||
setIsModified(false) // 清除修改标记
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态管理 / State Management:**
|
||||
|
||||
| 状态变量 | 类型 | 用途 |
|
||||
|---------|------|------|
|
||||
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
||||
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
||||
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
||||
| `message` | `object \| null` | 临时消息,3秒后自动消失 |
|
||||
|
||||
**UI 交互逻辑 / UI Interaction Logic:**
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading: 组件挂载
|
||||
Loading --> Ready: loadSettings()
|
||||
Ready --> Modified: updateSettings()
|
||||
Modified --> Modified: 继续修改
|
||||
Modified --> Ready: 保存成功
|
||||
Modified --> Error: 保存失败
|
||||
Error --> Modified: 用户继续操作
|
||||
Ready --> [*]: 组件卸载
|
||||
|
||||
note right of Modified
|
||||
保存按钮启用
|
||||
Save Button Enabled
|
||||
end note
|
||||
|
||||
note right of Ready
|
||||
保存按钮禁用
|
||||
Save Button Disabled
|
||||
end note
|
||||
```
|
||||
|
||||
### 2. 预加载脚本 / Preload Script
|
||||
|
||||
#### preload/index.ts (`src/preload/index.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
- 安全桥梁,暴露受限 API 到渲染进程
|
||||
- 类型安全的 IPC 通道定义
|
||||
|
||||
**关键代码 / Key Code:**
|
||||
|
||||
```typescript
|
||||
// 第 89-97 行 / Lines 89-97
|
||||
settings: {
|
||||
getUserType: () => ipcRenderer.invoke('settings:getUserType'),
|
||||
getSettings: () => ipcRenderer.invoke('settings:getSettings'),
|
||||
saveSettings: (settings: SettingsData) =>
|
||||
ipcRenderer.invoke('settings:saveSettings', settings),
|
||||
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
|
||||
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
|
||||
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
|
||||
}
|
||||
```
|
||||
|
||||
**安全隔离机制 / Security Isolation:**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Renderer[Renderer Process<br/>Untrusted Context]
|
||||
Preload[Preload Script<br/>Trusted Context]
|
||||
Main[Main Process<br/>Trusted Context]
|
||||
|
||||
Renderer -->|window.electron| Preload
|
||||
Preload -->|ipcRenderer.invoke| Main
|
||||
Main -->|Validation| Preload
|
||||
Preload -->|Return Promise| Renderer
|
||||
|
||||
style Renderer fill:#ffe1e1
|
||||
style Preload fill:#e1ffe1
|
||||
style Main fill:#e1e1ff
|
||||
```
|
||||
|
||||
### 3. 主进程 / Main Process
|
||||
|
||||
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
- IPC 通道注册和处理
|
||||
- 权限验证(基于用户类型)
|
||||
- 业务逻辑协调
|
||||
|
||||
**保存设置处理函数 / Save Settings Handler:**
|
||||
|
||||
```typescript
|
||||
// 第 83-102 行 / Lines 83-102
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings')
|
||||
const success = await configManager.saveAllSettings(settings)
|
||||
if (success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings')
|
||||
return { success: false, error: '保存设置失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return { success: false, error: `保存设置失败:${message}` }
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**用户类型过滤 / User Type Filtering:**
|
||||
|
||||
```typescript
|
||||
// 第 31-54 行 / Lines 31-54
|
||||
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
|
||||
if (userType === 'Admin') {
|
||||
return settings // Admin 获取完整配置
|
||||
}
|
||||
|
||||
// User 用户获取受限配置
|
||||
return {
|
||||
erp: {
|
||||
username: settings.erp.username,
|
||||
password: settings.erp.password,
|
||||
headless: settings.erp.headless,
|
||||
url: settings.erp.url,
|
||||
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
|
||||
autoCloseBrowser: settings.erp.autoCloseBrowser
|
||||
},
|
||||
paths: settings.paths,
|
||||
execution: settings.execution,
|
||||
database: settings.database,
|
||||
extraction: settings.extraction,
|
||||
validation: settings.validation,
|
||||
ui: settings.ui
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**权限控制矩阵 / Permission Control Matrix:**
|
||||
|
||||
| 功能 / Feature | Admin | User | Guest |
|
||||
|---------------|-------|------|-------|
|
||||
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
||||
| 保存设置 | ✅ | ✅ | ❌ |
|
||||
| 恢复默认值 | ✅ | ❌ | ❌ |
|
||||
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
|
||||
| 测试数据库连接 | ✅ | ✅ | ❌ |
|
||||
|
||||
### 4. 配置管理服务 / Configuration Manager Service
|
||||
|
||||
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
- .env 文件读写
|
||||
- 配置缓存管理
|
||||
- 默认值管理
|
||||
- 类型转换和验证
|
||||
|
||||
**类结构 / Class Structure:**
|
||||
|
||||
```typescript
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null // 单例模式
|
||||
private envPath: string // .env 文件路径
|
||||
private configCache: Map<string, string> // 内存缓存
|
||||
private initialized: boolean = false // 初始化标记
|
||||
|
||||
// 单例获取方法
|
||||
public static getInstance(): ConfigManager
|
||||
|
||||
// 配置读取
|
||||
public get(key: string, defaultValue?: string): string | undefined
|
||||
public getBoolean(key: string, defaultValue?: boolean): boolean
|
||||
public getNumber(key: string, defaultValue?: number): number
|
||||
|
||||
// 配置写入
|
||||
public set(key: string, value: string | number | boolean): void
|
||||
|
||||
// 持久化
|
||||
public async save(): Promise<boolean>
|
||||
|
||||
// 高级操作
|
||||
public getAllSettings(): SettingsData
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean>
|
||||
public resetToDefaults(): SettingsData
|
||||
}
|
||||
```
|
||||
|
||||
**保存详细流程 / Save Detailed Flow:**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
START[saveAllSettings] --> STEP1[更新 ERP 配置 6 字段]
|
||||
STEP1 --> STEP2[更新数据库配置 7 字段]
|
||||
STEP2 --> STEP3[更新路径配置 3 字段]
|
||||
STEP3 --> STEP4[更新提取配置 5 字段]
|
||||
STEP4 --> STEP5[更新校验配置 5 字段]
|
||||
STEP5 --> STEP6[更新 UI 配置 3 字段]
|
||||
STEP6 --> STEP7[更新执行配置 1 字段]
|
||||
STEP7 --> SAVE[调用 save 方法]
|
||||
SAVE --> BUILD[构建 .env 内容]
|
||||
BUILD --> WRITE[写入文件系统]
|
||||
WRITE --> CHECK{检查结果}
|
||||
CHECK -->|成功| SUCCESS[返回 true]
|
||||
CHECK -->|失败| FAILURE[返回 false]
|
||||
```
|
||||
|
||||
**.env 文件格式 / .env File Format:**
|
||||
|
||||
```bash
|
||||
# ===========================
|
||||
# ERP 系统配置
|
||||
# ===========================
|
||||
ERP_URL=https://68.11.34.30:8082/
|
||||
ERP_USERNAME=
|
||||
ERP_PASSWORD=
|
||||
ERP_HEADLESS=true
|
||||
ERP_IGNORE_HTTPS_ERRORS=true
|
||||
ERP_AUTO_CLOSE_BROWSER=true
|
||||
|
||||
# ===========================
|
||||
# 数据库配置 - MySQL
|
||||
# ===========================
|
||||
DB_TYPE=mysql
|
||||
DB_NAME=BLD_DB
|
||||
DB_USERNAME=remote_user
|
||||
DB_PASSWORD=
|
||||
DB_MYSQL_HOST=192.168.31.83
|
||||
DB_MYSQL_PORT=3306
|
||||
DB_MYSQL_CHARSET=utf8mb4
|
||||
|
||||
# ===========================
|
||||
# 路径配置
|
||||
# ===========================
|
||||
PATH_DATA_DIR=D:/python/playwrite/data/
|
||||
PATH_DEFAULT_OUTPUT=离散备料计划维护_合并.xlsx
|
||||
PATH_VALIDATION_OUTPUT=物料状态校验结果.xlsx
|
||||
|
||||
# ... 更多配置节
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据结构 / Data Structures
|
||||
|
||||
### SettingsData 接口 / Interface Definition
|
||||
|
||||
**类型定义位置 / Type Definition Location:**
|
||||
`src/main/types/settings.types.ts` (第 136-151 行)
|
||||
|
||||
```typescript
|
||||
export interface SettingsData {
|
||||
erp: ErpConfig
|
||||
database: DatabaseConfig
|
||||
paths: PathsConfig
|
||||
extraction: ExtractionConfig
|
||||
validation: ValidationConfig
|
||||
ui: UiConfig
|
||||
execution: ExecutionConfig
|
||||
}
|
||||
```
|
||||
|
||||
### 完整数据结构树 / Complete Data Structure Tree
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Settings[SettingsData]
|
||||
|
||||
Settings --> Erp[ErpConfig]
|
||||
Erp --> Erp1[url: string]
|
||||
Erp --> Erp2[username: string]
|
||||
Erp --> Erp3[password: string]
|
||||
Erp --> Erp4[headless: boolean]
|
||||
Erp --> Erp5[ignoreHttpsErrors: boolean]
|
||||
Erp --> Erp6[autoCloseBrowser: boolean]
|
||||
|
||||
Settings --> DB[DatabaseConfig]
|
||||
DB --> DB1[dbType: mysql or sqlserver]
|
||||
DB --> DB2[server: string]
|
||||
DB --> DB3[mysqlHost: string]
|
||||
DB --> DB4[mysqlPort: number]
|
||||
DB --> DB5[database: string]
|
||||
DB --> DB6[username: string]
|
||||
DB --> DB7[password: string]
|
||||
|
||||
Settings --> Paths[PathsConfig]
|
||||
Paths --> Paths1[dataDir: string]
|
||||
Paths --> Paths2[defaultOutput: string]
|
||||
Paths --> Paths3[validationOutput: string]
|
||||
|
||||
Settings --> Extract[ExtractionConfig]
|
||||
Extract --> Extract1[batchSize: number]
|
||||
Extract --> Extract2[verbose: boolean]
|
||||
Extract --> Extract3[autoConvert: boolean]
|
||||
Extract --> Extract4[mergeBatches: boolean]
|
||||
Extract --> Extract5[enableDbPersistence: boolean]
|
||||
|
||||
Settings --> Valid[ValidationConfig]
|
||||
Valid --> Valid1[dataSource: ValidationDataSource]
|
||||
Valid --> Valid2[batchSize: number]
|
||||
Valid --> Valid3[matchMode: MatchMode]
|
||||
Valid --> Valid4[enableCrud: boolean]
|
||||
Valid --> Valid5[defaultManager: string]
|
||||
|
||||
Settings --> UI[UiConfig]
|
||||
UI --> UI1[fontFamily: string]
|
||||
UI --> UI2[fontSize: number]
|
||||
UI --> UI3[productionIdInputWidth: number]
|
||||
|
||||
Settings --> Exec[ExecutionConfig]
|
||||
Exec --> Exec1[dryRun: boolean]
|
||||
|
||||
style Settings fill:#e1f5ff
|
||||
style Erp fill:#ffe1f5
|
||||
style DB fill:#e1ffe1
|
||||
style Paths fill:#fff4e1
|
||||
style Extract fill:#f5e1ff
|
||||
style Valid fill:#ffe1e1
|
||||
style UI fill:#e1f5ff
|
||||
style Exec fill:#f5f5f5
|
||||
```
|
||||
|
||||
### IPC 通信数据格式 / IPC Communication Data Format
|
||||
|
||||
**请求格式 / Request Format:**
|
||||
```json
|
||||
{
|
||||
"erp": {
|
||||
"url": "https://68.11.34.30:8082/",
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"headless": true,
|
||||
"ignoreHttpsErrors": true,
|
||||
"autoCloseBrowser": true
|
||||
},
|
||||
"database": { ... },
|
||||
"paths": { ... },
|
||||
"extraction": { ... },
|
||||
"validation": { ... },
|
||||
"ui": { ... },
|
||||
"execution": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式 / Response Format:**
|
||||
```json
|
||||
// 成功 / Success
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
|
||||
// 失败 / Failure
|
||||
{
|
||||
"success": false,
|
||||
"error": "保存设置失败:Access denied"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理机制 / Error Handling Mechanism
|
||||
|
||||
### 错误处理层次 / Error Handling Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "UI Layer"
|
||||
UI_TRY[try-catch in handleSaveSettings]
|
||||
UI_MSG[showMessage display]
|
||||
end
|
||||
|
||||
subgraph "IPC Layer"
|
||||
IPC_TRY[try-catch in handler]
|
||||
IPC_LOG[Structured logging]
|
||||
IPC_RETURN[Return error object]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
SVC_TRY[try-catch in save]
|
||||
SVC_LOG[Console error log]
|
||||
SVC_RETURN[Return false]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
FS_CHECK[File exists check]
|
||||
FS_WRITE[Write with error handling]
|
||||
end
|
||||
|
||||
UI_TRY -->|Catch| UI_MSG
|
||||
IPC_TRY -->|Catch| IPC_LOG --> IPC_RETURN
|
||||
SVC_TRY -->|Catch| SVC_LOG --> SVC_RETURN
|
||||
FS_WRITE -->|Error| SVC_TRY
|
||||
|
||||
style UI_TRY fill:#ffe1e1
|
||||
style IPC_TRY fill:#ffe1e1
|
||||
style SVC_TRY fill:#ffe1e1
|
||||
```
|
||||
|
||||
### 错误场景分析 / 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 异常捕获 | 返回系统错误信息 |
|
||||
|
||||
### 日志记录策略 / Logging Strategy
|
||||
|
||||
```typescript
|
||||
// Main Process 结构化日志 / Structured Logging
|
||||
log.info('Saving settings')
|
||||
log.info('Settings saved successfully')
|
||||
log.warn('Failed to save settings')
|
||||
log.error('Error saving settings', { error: message })
|
||||
```
|
||||
|
||||
**日志级别使用 / Log Level Usage:**
|
||||
- `info`: 正常操作流程
|
||||
- `warn`: 潜在问题(如保存失败但未崩溃)
|
||||
- `error`: 严重错误(如异常抛出)
|
||||
|
||||
---
|
||||
|
||||
## 安全考虑 / Security Considerations
|
||||
|
||||
### 安全机制层级 / Security Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
L1[Layer 1: Context Isolation<br/>渲染进程隔离]
|
||||
L2[Layer 2: contextBridge<br/>受限 API 暴露]
|
||||
L3[Layer 3: User Type Filtering<br/>基于角色的访问控制]
|
||||
L4[Layer 4: File System Permissions<br/>.env 文件保护]
|
||||
|
||||
L1 --> L2 --> L3 --> L4
|
||||
|
||||
style L1 fill:#e1f5ff
|
||||
style L2 fill:#fff4e1
|
||||
style L3 fill:#e1ffe1
|
||||
style L4 fill:#ffe1f5
|
||||
```
|
||||
|
||||
### 关键安全措施 / Key Security Measures
|
||||
|
||||
1. **密码明文存储风险 / Password Storage Risk**
|
||||
- ⚠️ 当前:密码以明文形式存储在 .env 文件中
|
||||
- 🔒 建议:实现加密存储机制
|
||||
|
||||
2. **用户权限隔离 / User Permission Isolation**
|
||||
- ✅ 实现:基于用户类型过滤可见配置
|
||||
- ✅ 实现:Guest 用户无法访问设置页面
|
||||
|
||||
3. **IPC 通信安全 / IPC Communication Security**
|
||||
- ✅ 实现:使用 `contextBridge` 而非直接暴露
|
||||
- ✅ 实现:类型安全的 TypeScript 接口
|
||||
|
||||
4. **文件系统访问 / File System Access**
|
||||
- ✅ 实现:.env 文件仅主进程可访问
|
||||
- ⚠️ 风险:文件权限取决于操作系统
|
||||
|
||||
### 敏感数据流向 / Sensitive Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户输入
|
||||
participant UI as UI State (内存)
|
||||
participant IPC as IPC Channel
|
||||
participant Cache as Config Cache
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: password = "secret123"
|
||||
UI->>IPC: JSON 传输 (未加密)
|
||||
IPC->>Cache: Map.set('erp.password', 'secret123')
|
||||
Cache->>File: 写入明文到磁盘
|
||||
|
||||
Note over File: ⚠️ 安全风险:<br/>密码以明文形式持久化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术实现细节 / Technical Implementation Details
|
||||
|
||||
### 文件位置索引 / 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 |
|
||||
|
||||
### 性能特性 / Performance Characteristics
|
||||
|
||||
1. **异步操作 / Async Operations**
|
||||
- 所有 IPC 调用使用 `async/await` 模式
|
||||
- 避免阻塞主进程事件循环
|
||||
|
||||
2. **内存优化 / Memory Optimization**
|
||||
- 使用 Map 缓存配置,减少文件读取
|
||||
- 按需加载配置项
|
||||
|
||||
3. **写入策略 / Write Strategy**
|
||||
- 每次保存完整重写 .env 文件
|
||||
- 原子写入(writeFileSync)
|
||||
|
||||
### 依赖关系图 / Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[SettingsPage.tsx] -->|imports| B[lucide-react]
|
||||
A -->|uses| C[window.electron.settings]
|
||||
|
||||
C -->|exposed by| D[preload/index.ts]
|
||||
D -->|imports| E[electron API]
|
||||
D -->|imports| F[SettingsData Type]
|
||||
|
||||
G[settings-handler.ts] -->|imports| H[ipcMain]
|
||||
G -->|imports| I[ConfigManager]
|
||||
G -->|imports| J[SessionManager]
|
||||
G -->|imports| K[Logger]
|
||||
|
||||
I -->|imports| L[fs/path]
|
||||
I -->|imports| M[SettingsData Type]
|
||||
I -->|imports| N[DEFAULT_SETTINGS]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style D fill:#fff4e1
|
||||
style G fill:#ffe1f5
|
||||
style I fill:#e1ffe1
|
||||
```
|
||||
|
||||
### 关键代码片段分析 / Key Code Snippet Analysis
|
||||
|
||||
**1. 状态更新逻辑 / State Update Logic**
|
||||
|
||||
```typescript
|
||||
// SettingsPage.tsx 第 50-59 行
|
||||
const updateSettings = (category: string, key: string, value: any) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...(prev as any)[category],
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
setIsModified(true) // 标记为已修改
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
- 不可变更新模式(Immutable Update Pattern)
|
||||
- 使用展开运算符保持对象引用
|
||||
- 自动启用保存按钮
|
||||
|
||||
**2. 配置保存逻辑 / Configuration Save Logic**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 437-483 行
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
||||
// 批量更新缓存 (40+ 字段)
|
||||
this.set('erp.url', settings.erp.url)
|
||||
this.set('erp.username', settings.erp.username)
|
||||
// ... 更多字段
|
||||
|
||||
// 同步写入文件
|
||||
return this.save()
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
- 先更新内存,后写入磁盘
|
||||
- 失败时缓存保持不变
|
||||
- 返回布尔值表示成功/失败
|
||||
|
||||
**3. .env 文件生成逻辑 / .env File Generation**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 179-345 行
|
||||
public async save(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
|
||||
// 构建格式化的 .env 内容
|
||||
lines.push('# ===========================')
|
||||
lines.push('# ERP 系统配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
|
||||
|
||||
const content = lines.join('\n')
|
||||
fs.writeFileSync(this.envPath, content, 'utf-8')
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
- 添加注释分隔符提高可读性
|
||||
- 使用默认值作为后备
|
||||
- 同步写入确保一致性
|
||||
|
||||
---
|
||||
|
||||
## 扩展与改进建议 / Extension and Improvement Suggestions
|
||||
|
||||
### 短期改进 / Short-term Improvements
|
||||
|
||||
1. **输入验证 / Input Validation**
|
||||
- 添加 URL 格式验证
|
||||
- 密码强度检查
|
||||
- 端口号范围验证
|
||||
|
||||
2. **用户体验 / User Experience**
|
||||
- 添加保存进度指示器
|
||||
- 实现自动保存功能
|
||||
- 添加配置导入/导出
|
||||
|
||||
3. **错误处理 / Error Handling**
|
||||
- 更详细的错误消息
|
||||
- 错误恢复建议
|
||||
- 错误日志导出
|
||||
|
||||
### 长期改进 / Long-term Improvements
|
||||
|
||||
1. **安全性增强 / Security Enhancement**
|
||||
```typescript
|
||||
// 建议实现密码加密
|
||||
interface SecureSettingsData extends SettingsData {
|
||||
erp: {
|
||||
...ErpConfig
|
||||
encryptedPassword: string // 替代明文密码
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **配置版本控制 / Configuration Versioning**
|
||||
- 实现配置历史记录
|
||||
- 支持回滚到之前版本
|
||||
- 配置变更审计日志
|
||||
|
||||
3. **实时配置重载 / Live Config Reload**
|
||||
- 监听 .env 文件变化
|
||||
- 自动重载配置
|
||||
- 通知相关服务更新
|
||||
|
||||
---
|
||||
|
||||
## 测试建议 / Testing Recommendations
|
||||
|
||||
### 单元测试 / Unit Tests
|
||||
|
||||
```typescript
|
||||
// 测试用例示例
|
||||
describe('ConfigManager', () => {
|
||||
it('should save settings successfully', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
const settings: SettingsData = { /* mock data */ }
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file write errors', async () => {
|
||||
// Mock fs.writeFileSync to throw error
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 集成测试 / Integration Tests
|
||||
|
||||
```typescript
|
||||
describe('Settings Save Flow', () => {
|
||||
it('should complete full save cycle', async () => {
|
||||
// 1. User modifies settings
|
||||
// 2. Clicks save button
|
||||
// 3. Verifies .env file updated
|
||||
// 4. Confirms UI feedback
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 / Appendix
|
||||
|
||||
### 完整配置字段列表 / 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** | |
|
||||
|
||||
### 相关文档 / Related Documentation
|
||||
|
||||
- [Electron Security Guidelines](https://www.electronjs.org/docs/latest/tutorial/security)
|
||||
- [IPC 通信最佳实践](https://www.electronjs.org/docs/latest/tutorial/ipc)
|
||||
- [环境变量管理规范](.env.example)
|
||||
|
||||
### 版本历史 / Version History
|
||||
|
||||
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
||||
|---------------|------------|--------------|
|
||||
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间 / Document Generated:** 2025-03-03
|
||||
**最后更新 / Last Updated:** 2025-03-03
|
||||
**维护者 / Maintainer:** ERPAuto Development Team
|
||||
272
docs/user-override-match-feature.md
Normal file
272
docs/user-override-match-feature.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 物料匹配算法增强 - 用户覆盖匹配功能
|
||||
|
||||
**实施日期**: 2026-03-03
|
||||
**功能版本**: 1.0
|
||||
**修改文件**: `src/main/ipc/validation-handler.ts`
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
为 **User 用户类型** 在物料清理界面增加了 **优先级3:用户覆盖匹配** 功能,确保 User 用户能够优先看到并管理与自己关键词匹配的物料。
|
||||
|
||||
---
|
||||
|
||||
## 实现的更改
|
||||
|
||||
### 1. 获取当前用户信息
|
||||
|
||||
**位置**: `validation-handler.ts:218-239`
|
||||
|
||||
```typescript
|
||||
// Get current user info
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
||||
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
||||
- 如果用户未登录,返回错误响应
|
||||
|
||||
### 2. 新增优先级3:用户覆盖匹配
|
||||
|
||||
**位置**: `validation-handler.ts:359-370`
|
||||
|
||||
```typescript
|
||||
// Priority 3: User Override Match (only for non-admin users)
|
||||
// Override with current user's typeKeyword if available
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // Force override with first match
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**匹配逻辑**:
|
||||
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
||||
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
||||
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
||||
4. **强制覆盖**: 只要匹配成功,立即覆盖原有的 `managerName` 和 `matchedTypeKeyword`
|
||||
5. **无匹配时**: 保持优先级2的匹配结果不变
|
||||
|
||||
---
|
||||
|
||||
## 匹配优先级(更新后)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([物料数据]) --> P1{优先级1<br/>MaterialsToBeDeleted<br/>精确匹配?}
|
||||
P1 -->|MaterialCode匹配| M1[✅ 已标记删除<br/>isMarkedForDeletion=true]
|
||||
P1 -->|未匹配| P2{优先级2<br/>MaterialsTypeToBeDeleted<br/>包含匹配?}
|
||||
|
||||
P2 -->|匹配到| M2[⚠️ 类型匹配<br/>managerName=其他用户]
|
||||
P2 -->|未匹配| M3[❌ 未匹配<br/>managerName='']
|
||||
|
||||
M1 --> Check{用户类型?}
|
||||
M2 --> Check
|
||||
M3 --> Check
|
||||
|
||||
Check -->|Admin| Skip[跳过覆盖]
|
||||
Check -->|User| P3{优先级3<br/>用户覆盖匹配?}
|
||||
|
||||
P3 -->|匹配成功| Override[✅ 覆为当前用户<br/>managerName=当前用户]
|
||||
P3 -->|未匹配| Keep[保持原结果]
|
||||
|
||||
Skip --> End([返回结果])
|
||||
Override --> End
|
||||
Keep --> End
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1: User 用户匹配到自己的 typeKeyword
|
||||
|
||||
**输入**:
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**预期输出**:
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2: User 用户覆盖其他用户的匹配
|
||||
|
||||
**输入**:
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**优先级2结果**: `managerName = "user2"`
|
||||
**优先级3结果**: `managerName = "user1"` ✅ 强制覆盖
|
||||
|
||||
### 场景3: User 用户无匹配关键词
|
||||
|
||||
**输入**:
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user2",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
**说明**: 保持优先级2的匹配结果
|
||||
|
||||
### 场景4: Admin 用户不执行覆盖
|
||||
|
||||
**输入**:
|
||||
- 当前用户: `admin` (isAdmin=true)
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
- `{ materialName: "螺丝", managerName: "admin" }`
|
||||
|
||||
**预期输出**:
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
||||
|
||||
### 场景5: 优先级1匹配不受影响
|
||||
|
||||
**输入**:
|
||||
- 当前用户: `user1`
|
||||
- 物料代码: `MAT001`
|
||||
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
```json
|
||||
{
|
||||
"materialCode": "MAT001",
|
||||
"managerName": "user2",
|
||||
"isMarkedForDeletion": true,
|
||||
"matchedTypeKeyword": undefined
|
||||
}
|
||||
```
|
||||
**说明**: 优先级1的精确匹配不受覆盖影响
|
||||
|
||||
---
|
||||
|
||||
## 数据库配置示例
|
||||
|
||||
### MaterialsTypeToBeDeleted 表数据
|
||||
|
||||
| MaterialName | ManagerName | 说明 |
|
||||
|--------------|-------------|------|
|
||||
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
||||
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
||||
| 垫圈 | 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跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 与前端协同
|
||||
|
||||
前端过滤器逻辑 (`CleanerPage.tsx`) 保持不变:
|
||||
|
||||
```typescript
|
||||
const filteredResults = React.useMemo(() => {
|
||||
let results = validationResults
|
||||
if (!isAdmin && currentUsername) {
|
||||
// User 只看到自己的物料 + 未分配的物料
|
||||
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
|
||||
}
|
||||
return results
|
||||
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
|
||||
```
|
||||
|
||||
**协同效果**:
|
||||
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
||||
2. 前端过滤器只显示属于当前用户或未分配的物料
|
||||
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
||||
|
||||
---
|
||||
|
||||
## 代码审查检查点
|
||||
|
||||
- ✅ User 信息获取正确使用 `SessionManager`
|
||||
- ✅ 只对 `!isAdmin` 的用户执行覆盖逻辑
|
||||
- ✅ 使用相同的包含匹配规则 `materialName.includes(typeKeyword.materialName)`
|
||||
- ✅ 优先级1(精确匹配)不受覆盖影响
|
||||
- ✅ 无匹配时保持原有结果
|
||||
- ✅ 日志记录包含用户信息 `{ user: username, isAdmin }`
|
||||
- ✅ 未登录时返回明确的错误信息
|
||||
|
||||
---
|
||||
|
||||
## 潜在改进方向
|
||||
|
||||
1. **性能优化**: 如果 `typeKeywords` 数量很大,可以预先构建 `Map<username, typeKeyword[]>` 索引
|
||||
2. **日志增强**: 添加覆盖匹配的统计信息(覆盖了多少条记录)
|
||||
3. **配置开关**: 允许 Admin 用户通过配置启用/禁用覆盖功能
|
||||
4. **UI 反馈**: 在前端显示哪些物料是通过覆盖匹配分配的
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- **实现文件**: `src/main/ipc/validation-handler.ts` (Lines 218-239, 359-370)
|
||||
- **前端页面**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **会话管理**: `src/main/services/user/session-manager.ts`
|
||||
- **类型定义**: `src/main/types/validation.types.ts`
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
@@ -78,25 +78,38 @@ export function registerSettingsHandlers(): void {
|
||||
})
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
* Save settings (updated to use partial save)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
|
||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings')
|
||||
const success = await configManager.saveAllSettings(settings)
|
||||
if (success) {
|
||||
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')
|
||||
return { success: false, error: '保存设置失败' }
|
||||
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}` }
|
||||
return {
|
||||
success: false,
|
||||
error: `保存设置失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ const sharedProductionIds = new Set<string>()
|
||||
* Set shared Production IDs
|
||||
*/
|
||||
export function setSharedProductionIds(ids: string[]): void {
|
||||
sharedProductionIds.clear()
|
||||
ids.forEach((id) => sharedProductionIds.add(id))
|
||||
}
|
||||
|
||||
@@ -214,7 +215,28 @@ export function registerValidationHandlers(): void {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
log.info('Starting validation', { mode: request.mode })
|
||||
// Get current user info
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
@@ -334,6 +356,19 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: User Override Match (only for non-admin users)
|
||||
// Override with current user's typeKeyword if available
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // Force override with first match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
materialName,
|
||||
materialCode,
|
||||
|
||||
@@ -9,20 +9,16 @@ import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import { createLogger } from '../logger'
|
||||
import type {
|
||||
SettingsData,
|
||||
ErpConfig,
|
||||
DatabaseConfig,
|
||||
PathsConfig,
|
||||
ExtractionConfig,
|
||||
ValidationConfig,
|
||||
UiConfig,
|
||||
ExecutionConfig,
|
||||
DatabaseType,
|
||||
MatchMode,
|
||||
ValidationDataSource
|
||||
} from '../../types/settings.types'
|
||||
|
||||
const log = createLogger('ConfigManager')
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
@@ -76,12 +72,83 @@ const DEFAULT_SETTINGS: SettingsData = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is a plain object
|
||||
*/
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects, only updating fields present in target
|
||||
* Preserves all fields from source that are not in target
|
||||
*/
|
||||
function deepMerge<T>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
|
||||
for (const key in target) {
|
||||
if (key in target) {
|
||||
const targetValue = target[key]
|
||||
const sourceValue = result[key]
|
||||
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMerge(
|
||||
sourceValue as T[Extract<keyof T, string>],
|
||||
targetValue as Partial<T[Extract<keyof T, string>]>
|
||||
)
|
||||
} else if (targetValue !== undefined) {
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* UI editable field whitelist
|
||||
* Fields that can be modified through the settings UI
|
||||
*/
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password'
|
||||
// Add more fields as UI expands
|
||||
]
|
||||
|
||||
/**
|
||||
* Validate that settings only contain editable fields
|
||||
*/
|
||||
function validateEditableFields(settings: Partial<SettingsData>): {
|
||||
valid: boolean
|
||||
invalidFields: string[]
|
||||
} {
|
||||
const invalidFields: string[] = []
|
||||
|
||||
for (const [section, values] of Object.entries(settings)) {
|
||||
if (values && typeof values === 'object') {
|
||||
for (const field of Object.keys(values)) {
|
||||
const fieldPath = `${section}.${field}`
|
||||
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
|
||||
invalidFields.push(fieldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: invalidFields.length === 0,
|
||||
invalidFields
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration Manager Class
|
||||
*/
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null
|
||||
private envPath: string
|
||||
private backupPath: string
|
||||
private configCache: Map<string, string> = new Map()
|
||||
private initialized: boolean = false
|
||||
|
||||
@@ -90,6 +157,7 @@ export class ConfigManager {
|
||||
return
|
||||
}
|
||||
this.envPath = path.resolve(__dirname, '../../.env')
|
||||
this.backupPath = path.resolve(__dirname, '../../.env.backup')
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
@@ -115,6 +183,9 @@ export class ConfigManager {
|
||||
*/
|
||||
private async loadEnvFile(): Promise<void> {
|
||||
try {
|
||||
// Clear cache before loading
|
||||
this.configCache.clear()
|
||||
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
const content = fs.readFileSync(this.envPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
@@ -185,21 +256,21 @@ export class ConfigManager {
|
||||
lines.push('# ===========================')
|
||||
lines.push('# ERP 系统配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
|
||||
lines.push(`ERP_URL=${this.configCache.get('ERP_URL') || DEFAULT_SETTINGS.erp.url}`)
|
||||
lines.push(
|
||||
`ERP_USERNAME=${this.configCache.get('erp.username') || DEFAULT_SETTINGS.erp.username}`
|
||||
`ERP_USERNAME=${this.configCache.get('ERP_USERNAME') || DEFAULT_SETTINGS.erp.username}`
|
||||
)
|
||||
lines.push(
|
||||
`ERP_PASSWORD=${this.configCache.get('erp.password') || DEFAULT_SETTINGS.erp.password}`
|
||||
`ERP_PASSWORD=${this.configCache.get('ERP_PASSWORD') || DEFAULT_SETTINGS.erp.password}`
|
||||
)
|
||||
lines.push(
|
||||
`ERP_HEADLESS=${this.configCache.get('erp.headless') || DEFAULT_SETTINGS.erp.headless}`
|
||||
`ERP_HEADLESS=${this.configCache.get('ERP_HEADLESS') || DEFAULT_SETTINGS.erp.headless}`
|
||||
)
|
||||
lines.push(
|
||||
`ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('erp.ignoreHttpsErrors') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}`
|
||||
`ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('ERP_IGNORE_HTTPS_ERRORS') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}`
|
||||
)
|
||||
lines.push(
|
||||
`ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('erp.autoCloseBrowser') || DEFAULT_SETTINGS.erp.autoCloseBrowser}`
|
||||
`ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('ERP_AUTO_CLOSE_BROWSER') || DEFAULT_SETTINGS.erp.autoCloseBrowser}`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
@@ -208,10 +279,10 @@ export class ConfigManager {
|
||||
lines.push('# 数据库配置 - SQL Server')
|
||||
lines.push('# ===========================')
|
||||
lines.push(`# DB_TYPE=sqlserver`)
|
||||
lines.push(`# DB_SERVER=${this.configCache.get('database.server') || ''}`)
|
||||
lines.push(`# DB_NAME=${this.configCache.get('database.database') || ''}`)
|
||||
lines.push(`# DB_USERNAME=${this.configCache.get('database.username') || ''}`)
|
||||
lines.push(`# DB_PASSWORD=${this.configCache.get('database.password') || ''}`)
|
||||
lines.push(`# DB_SERVER=${this.configCache.get('DB_SERVER') || ''}`)
|
||||
lines.push(`# DB_NAME=${this.configCache.get('DB_NAME') || ''}`)
|
||||
lines.push(`# DB_USERNAME=${this.configCache.get('DB_USERNAME') || ''}`)
|
||||
lines.push(`# DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || ''}`)
|
||||
lines.push(`DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server`)
|
||||
lines.push(`DB_TRUST_SERVER_CERTIFICATE=yes`)
|
||||
lines.push('')
|
||||
@@ -221,22 +292,22 @@ export class ConfigManager {
|
||||
lines.push('# 数据库配置 - MySQL (切换时使用)')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`DB_TYPE=${this.configCache.get('database.dbType') || DEFAULT_SETTINGS.database.dbType}`
|
||||
`DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}`
|
||||
)
|
||||
lines.push(
|
||||
`DB_NAME=${this.configCache.get('database.database') || DEFAULT_SETTINGS.database.database}`
|
||||
`DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}`
|
||||
)
|
||||
lines.push(
|
||||
`DB_USERNAME=${this.configCache.get('database.username') || DEFAULT_SETTINGS.database.username}`
|
||||
`DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}`
|
||||
)
|
||||
lines.push(
|
||||
`DB_PASSWORD=${this.configCache.get('database.password') || DEFAULT_SETTINGS.database.password}`
|
||||
`DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || DEFAULT_SETTINGS.database.password}`
|
||||
)
|
||||
lines.push(
|
||||
`DB_MYSQL_HOST=${this.configCache.get('database.mysqlHost') || DEFAULT_SETTINGS.database.mysqlHost}`
|
||||
`DB_MYSQL_HOST=${this.configCache.get('DB_MYSQL_HOST') || DEFAULT_SETTINGS.database.mysqlHost}`
|
||||
)
|
||||
lines.push(
|
||||
`DB_MYSQL_PORT=${this.configCache.get('database.mysqlPort') || DEFAULT_SETTINGS.database.mysqlPort}`
|
||||
`DB_MYSQL_PORT=${this.configCache.get('DB_MYSQL_PORT') || DEFAULT_SETTINGS.database.mysqlPort}`
|
||||
)
|
||||
lines.push(`DB_MYSQL_CHARSET=utf8mb4`)
|
||||
lines.push('')
|
||||
@@ -256,14 +327,14 @@ export class ConfigManager {
|
||||
lines.push('# 路径配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`PATH_DATA_DIR=${this.configCache.get('paths.dataDir') || DEFAULT_SETTINGS.paths.dataDir}`
|
||||
`PATH_DATA_DIR=${this.configCache.get('PATH_DATA_DIR') || DEFAULT_SETTINGS.paths.dataDir}`
|
||||
)
|
||||
lines.push(`PATH_PRODUCTION_ID_FILE=ProductionID.txt`)
|
||||
lines.push(
|
||||
`PATH_DEFAULT_OUTPUT=${this.configCache.get('paths.defaultOutput') || DEFAULT_SETTINGS.paths.defaultOutput}`
|
||||
`PATH_DEFAULT_OUTPUT=${this.configCache.get('PATH_DEFAULT_OUTPUT') || DEFAULT_SETTINGS.paths.defaultOutput}`
|
||||
)
|
||||
lines.push(
|
||||
`PATH_VALIDATION_OUTPUT=${this.configCache.get('paths.validationOutput') || DEFAULT_SETTINGS.paths.validationOutput}`
|
||||
`PATH_VALIDATION_OUTPUT=${this.configCache.get('PATH_VALIDATION_OUTPUT') || DEFAULT_SETTINGS.paths.validationOutput}`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
@@ -272,19 +343,19 @@ export class ConfigManager {
|
||||
lines.push('# 数据提取配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`EXTRACTION_BATCH_SIZE=${this.configCache.get('extraction.batchSize') || DEFAULT_SETTINGS.extraction.batchSize}`
|
||||
`EXTRACTION_BATCH_SIZE=${this.configCache.get('EXTRACTION_BATCH_SIZE') || DEFAULT_SETTINGS.extraction.batchSize}`
|
||||
)
|
||||
lines.push(
|
||||
`EXTRACTION_VERBOSE=${this.configCache.get('extraction.verbose') || DEFAULT_SETTINGS.extraction.verbose}`
|
||||
`EXTRACTION_VERBOSE=${this.configCache.get('EXTRACTION_VERBOSE') || DEFAULT_SETTINGS.extraction.verbose}`
|
||||
)
|
||||
lines.push(
|
||||
`EXTRACTION_AUTO_CONVERT=${this.configCache.get('extraction.autoConvert') || DEFAULT_SETTINGS.extraction.autoConvert}`
|
||||
`EXTRACTION_AUTO_CONVERT=${this.configCache.get('EXTRACTION_AUTO_CONVERT') || DEFAULT_SETTINGS.extraction.autoConvert}`
|
||||
)
|
||||
lines.push(
|
||||
`EXTRACTION_MERGE_BATCHES=${this.configCache.get('extraction.mergeBatches') || DEFAULT_SETTINGS.extraction.mergeBatches}`
|
||||
`EXTRACTION_MERGE_BATCHES=${this.configCache.get('EXTRACTION_MERGE_BATCHES') || DEFAULT_SETTINGS.extraction.mergeBatches}`
|
||||
)
|
||||
lines.push(
|
||||
`EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('extraction.enableDbPersistence') || DEFAULT_SETTINGS.extraction.enableDbPersistence}`
|
||||
`EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('EXTRACTION_ENABLE_DB_PERSISTENCE') || DEFAULT_SETTINGS.extraction.enableDbPersistence}`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
@@ -293,22 +364,22 @@ export class ConfigManager {
|
||||
lines.push('# 校验配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`VALIDATION_DATA_SOURCE=${this.configCache.get('validation.dataSource') || DEFAULT_SETTINGS.validation.dataSource}`
|
||||
`VALIDATION_DATA_SOURCE=${this.configCache.get('VALIDATION_DATA_SOURCE') || DEFAULT_SETTINGS.validation.dataSource}`
|
||||
)
|
||||
lines.push(
|
||||
`VALIDATION_USE_DATABASE=${this.configCache.get('validation.useDatabase') || true}`
|
||||
`VALIDATION_USE_DATABASE=${this.configCache.get('VALIDATION_USE_DATABASE') || true}`
|
||||
)
|
||||
lines.push(
|
||||
`VALIDATION_BATCH_SIZE=${this.configCache.get('validation.batchSize') || DEFAULT_SETTINGS.validation.batchSize}`
|
||||
`VALIDATION_BATCH_SIZE=${this.configCache.get('VALIDATION_BATCH_SIZE') || DEFAULT_SETTINGS.validation.batchSize}`
|
||||
)
|
||||
lines.push(
|
||||
`VALIDATION_ENABLE_CRUD=${this.configCache.get('validation.enableCrud') || DEFAULT_SETTINGS.validation.enableCrud}`
|
||||
`VALIDATION_ENABLE_CRUD=${this.configCache.get('VALIDATION_ENABLE_CRUD') || DEFAULT_SETTINGS.validation.enableCrud}`
|
||||
)
|
||||
lines.push(
|
||||
`VALIDATION_DEFAULT_MANAGER=${this.configCache.get('validation.defaultManager') || DEFAULT_SETTINGS.validation.defaultManager}`
|
||||
`VALIDATION_DEFAULT_MANAGER=${this.configCache.get('VALIDATION_DEFAULT_MANAGER') || DEFAULT_SETTINGS.validation.defaultManager}`
|
||||
)
|
||||
lines.push(
|
||||
`VALIDATION_MATCH_MODE=${this.configCache.get('validation.matchMode') || DEFAULT_SETTINGS.validation.matchMode}`
|
||||
`VALIDATION_MATCH_MODE=${this.configCache.get('VALIDATION_MATCH_MODE') || DEFAULT_SETTINGS.validation.matchMode}`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
@@ -317,13 +388,13 @@ export class ConfigManager {
|
||||
lines.push('# UI 配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`UI_FONT_FAMILY=${this.configCache.get('ui.fontFamily') || DEFAULT_SETTINGS.ui.fontFamily}`
|
||||
`UI_FONT_FAMILY=${this.configCache.get('UI_FONT_FAMILY') || DEFAULT_SETTINGS.ui.fontFamily}`
|
||||
)
|
||||
lines.push(
|
||||
`UI_FONT_SIZE=${this.configCache.get('ui.fontSize') || DEFAULT_SETTINGS.ui.fontSize}`
|
||||
`UI_FONT_SIZE=${this.configCache.get('UI_FONT_SIZE') || DEFAULT_SETTINGS.ui.fontSize}`
|
||||
)
|
||||
lines.push(
|
||||
`UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('ui.productionIdInputWidth') || DEFAULT_SETTINGS.ui.productionIdInputWidth}`
|
||||
`UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('UI_PRODUCTION_ID_INPUT_WIDTH') || DEFAULT_SETTINGS.ui.productionIdInputWidth}`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
@@ -332,7 +403,7 @@ export class ConfigManager {
|
||||
lines.push('# 执行配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(
|
||||
`EXECUTION_DRYRUN=${this.configCache.get('execution.dryRun') || DEFAULT_SETTINGS.execution.dryRun}`
|
||||
`EXECUTION_DRYRUN=${this.configCache.get('EXECUTION_DRYRUN') || DEFAULT_SETTINGS.execution.dryRun}`
|
||||
)
|
||||
|
||||
const content = lines.join('\n')
|
||||
@@ -435,53 +506,129 @@ export class ConfigManager {
|
||||
* Save settings from SettingsData object
|
||||
*/
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
||||
// ERP settings
|
||||
this.set('erp.url', settings.erp.url)
|
||||
this.set('erp.username', settings.erp.username)
|
||||
this.set('erp.password', settings.erp.password)
|
||||
this.set('erp.headless', settings.erp.headless)
|
||||
this.set('erp.ignoreHttpsErrors', settings.erp.ignoreHttpsErrors)
|
||||
this.set('erp.autoCloseBrowser', settings.erp.autoCloseBrowser)
|
||||
// ERP settings - use underscore uppercase keys to match .env file
|
||||
this.set('ERP_URL', settings.erp.url)
|
||||
this.set('ERP_USERNAME', settings.erp.username)
|
||||
this.set('ERP_PASSWORD', settings.erp.password)
|
||||
this.set('ERP_HEADLESS', settings.erp.headless)
|
||||
this.set('ERP_IGNORE_HTTPS_ERRORS', settings.erp.ignoreHttpsErrors)
|
||||
this.set('ERP_AUTO_CLOSE_BROWSER', settings.erp.autoCloseBrowser)
|
||||
|
||||
// Database settings
|
||||
this.set('database.dbType', settings.database.dbType)
|
||||
this.set('database.server', settings.database.server)
|
||||
this.set('database.mysqlHost', settings.database.mysqlHost)
|
||||
this.set('database.mysqlPort', settings.database.mysqlPort)
|
||||
this.set('database.database', settings.database.database)
|
||||
this.set('database.username', settings.database.username)
|
||||
this.set('database.password', settings.database.password)
|
||||
this.set('DB_TYPE', settings.database.dbType)
|
||||
this.set('DB_SERVER', settings.database.server)
|
||||
this.set('DB_MYSQL_HOST', settings.database.mysqlHost)
|
||||
this.set('DB_MYSQL_PORT', settings.database.mysqlPort)
|
||||
this.set('DB_NAME', settings.database.database)
|
||||
this.set('DB_USERNAME', settings.database.username)
|
||||
this.set('DB_PASSWORD', settings.database.password)
|
||||
|
||||
// Path settings
|
||||
this.set('paths.dataDir', settings.paths.dataDir)
|
||||
this.set('paths.defaultOutput', settings.paths.defaultOutput)
|
||||
this.set('paths.validationOutput', settings.paths.validationOutput)
|
||||
this.set('PATH_DATA_DIR', settings.paths.dataDir)
|
||||
this.set('PATH_DEFAULT_OUTPUT', settings.paths.defaultOutput)
|
||||
this.set('PATH_VALIDATION_OUTPUT', settings.paths.validationOutput)
|
||||
|
||||
// Extraction settings
|
||||
this.set('extraction.batchSize', settings.extraction.batchSize)
|
||||
this.set('extraction.verbose', settings.extraction.verbose)
|
||||
this.set('extraction.autoConvert', settings.extraction.autoConvert)
|
||||
this.set('extraction.mergeBatches', settings.extraction.mergeBatches)
|
||||
this.set('extraction.enableDbPersistence', settings.extraction.enableDbPersistence)
|
||||
this.set('EXTRACTION_BATCH_SIZE', settings.extraction.batchSize)
|
||||
this.set('EXTRACTION_VERBOSE', settings.extraction.verbose)
|
||||
this.set('EXTRACTION_AUTO_CONVERT', settings.extraction.autoConvert)
|
||||
this.set('EXTRACTION_MERGE_BATCHES', settings.extraction.mergeBatches)
|
||||
this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', settings.extraction.enableDbPersistence)
|
||||
|
||||
// Validation settings
|
||||
this.set('validation.dataSource', settings.validation.dataSource)
|
||||
this.set('validation.batchSize', settings.validation.batchSize)
|
||||
this.set('validation.matchMode', settings.validation.matchMode)
|
||||
this.set('validation.enableCrud', settings.validation.enableCrud)
|
||||
this.set('validation.defaultManager', settings.validation.defaultManager)
|
||||
this.set('VALIDATION_DATA_SOURCE', settings.validation.dataSource)
|
||||
this.set('VALIDATION_BATCH_SIZE', settings.validation.batchSize)
|
||||
this.set('VALIDATION_MATCH_MODE', settings.validation.matchMode)
|
||||
this.set('VALIDATION_ENABLE_CRUD', settings.validation.enableCrud)
|
||||
this.set('VALIDATION_DEFAULT_MANAGER', settings.validation.defaultManager)
|
||||
|
||||
// UI settings
|
||||
this.set('ui.fontFamily', settings.ui.fontFamily)
|
||||
this.set('ui.fontSize', settings.ui.fontSize)
|
||||
this.set('ui.productionIdInputWidth', settings.ui.productionIdInputWidth)
|
||||
this.set('UI_FONT_FAMILY', settings.ui.fontFamily)
|
||||
this.set('UI_FONT_SIZE', settings.ui.fontSize)
|
||||
this.set('UI_PRODUCTION_ID_INPUT_WIDTH', settings.ui.productionIdInputWidth)
|
||||
|
||||
// Execution settings
|
||||
this.set('execution.dryRun', settings.execution.dryRun)
|
||||
this.set('EXECUTION_DRYRUN', settings.execution.dryRun)
|
||||
|
||||
return this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* Save partial settings (only update provided fields)
|
||||
* Preserves all existing fields not included in the update
|
||||
*/
|
||||
public async savePartialSettings(
|
||||
settings: Partial<SettingsData>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Step 1: Validate field whitelist
|
||||
const validation = validateEditableFields(settings)
|
||||
if (!validation.valid) {
|
||||
log.warn('Attempted to save non-editable fields', {
|
||||
invalidFields: validation.invalidFields
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read current settings from .env file directly
|
||||
// This avoids the cache key mismatch issue (ERP_URL vs erp.url)
|
||||
await this.loadEnvFile()
|
||||
const currentSettings = this.getAllSettings()
|
||||
|
||||
log.info('Current settings before merge', {
|
||||
erpUrl: currentSettings.erp.url,
|
||||
dbType: currentSettings.database.dbType,
|
||||
dbName: currentSettings.database.database
|
||||
})
|
||||
|
||||
// Step 3: Deep merge - only update provided fields
|
||||
const mergedSettings = deepMerge(currentSettings, settings)
|
||||
|
||||
log.info('Settings after merge', {
|
||||
erpUrl: mergedSettings.erp.url,
|
||||
dbType: mergedSettings.database.dbType,
|
||||
dbName: mergedSettings.database.database
|
||||
})
|
||||
|
||||
// Step 4: Backup and save
|
||||
const backupSuccess = await this.backupEnvFile()
|
||||
if (!backupSuccess) {
|
||||
log.warn('Failed to backup .env file, proceeding with caution')
|
||||
}
|
||||
|
||||
const saveSuccess = await this.saveAllSettings(mergedSettings)
|
||||
|
||||
if (!saveSuccess) {
|
||||
// Save failed, attempt restore
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: '保存配置失败,已恢复原配置'
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Reload from disk to populate cache with correct keys (ERP_URL instead of erp.url)
|
||||
await this.loadEnvFile()
|
||||
|
||||
log.info('Settings saved successfully', {
|
||||
updatedFields: Object.keys(settings)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error in savePartialSettings', { error: message })
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: `保存配置时发生错误:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default settings
|
||||
*/
|
||||
@@ -489,43 +636,43 @@ export class ConfigManager {
|
||||
// Clear cache and reload from defaults
|
||||
this.configCache.clear()
|
||||
|
||||
// Set all defaults
|
||||
this.set('erp.url', DEFAULT_SETTINGS.erp.url)
|
||||
this.set('erp.username', DEFAULT_SETTINGS.erp.username)
|
||||
this.set('erp.password', DEFAULT_SETTINGS.erp.password)
|
||||
this.set('erp.headless', DEFAULT_SETTINGS.erp.headless)
|
||||
this.set('erp.ignoreHttpsErrors', DEFAULT_SETTINGS.erp.ignoreHttpsErrors)
|
||||
this.set('erp.autoCloseBrowser', DEFAULT_SETTINGS.erp.autoCloseBrowser)
|
||||
// Set all defaults using underscore uppercase keys
|
||||
this.set('ERP_URL', DEFAULT_SETTINGS.erp.url)
|
||||
this.set('ERP_USERNAME', DEFAULT_SETTINGS.erp.username)
|
||||
this.set('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password)
|
||||
this.set('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless)
|
||||
this.set('ERP_IGNORE_HTTPS_ERRORS', DEFAULT_SETTINGS.erp.ignoreHttpsErrors)
|
||||
this.set('ERP_AUTO_CLOSE_BROWSER', DEFAULT_SETTINGS.erp.autoCloseBrowser)
|
||||
|
||||
this.set('database.dbType', DEFAULT_SETTINGS.database.dbType)
|
||||
this.set('database.server', DEFAULT_SETTINGS.database.server)
|
||||
this.set('database.mysqlHost', DEFAULT_SETTINGS.database.mysqlHost)
|
||||
this.set('database.mysqlPort', DEFAULT_SETTINGS.database.mysqlPort)
|
||||
this.set('database.database', DEFAULT_SETTINGS.database.database)
|
||||
this.set('database.username', DEFAULT_SETTINGS.database.username)
|
||||
this.set('database.password', DEFAULT_SETTINGS.database.password)
|
||||
this.set('DB_TYPE', DEFAULT_SETTINGS.database.dbType)
|
||||
this.set('DB_SERVER', DEFAULT_SETTINGS.database.server)
|
||||
this.set('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost)
|
||||
this.set('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort)
|
||||
this.set('DB_NAME', DEFAULT_SETTINGS.database.database)
|
||||
this.set('DB_USERNAME', DEFAULT_SETTINGS.database.username)
|
||||
this.set('DB_PASSWORD', DEFAULT_SETTINGS.database.password)
|
||||
|
||||
this.set('paths.dataDir', DEFAULT_SETTINGS.paths.dataDir)
|
||||
this.set('paths.defaultOutput', DEFAULT_SETTINGS.paths.defaultOutput)
|
||||
this.set('paths.validationOutput', DEFAULT_SETTINGS.paths.validationOutput)
|
||||
this.set('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir)
|
||||
this.set('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput)
|
||||
this.set('PATH_VALIDATION_OUTPUT', DEFAULT_SETTINGS.paths.validationOutput)
|
||||
|
||||
this.set('extraction.batchSize', DEFAULT_SETTINGS.extraction.batchSize)
|
||||
this.set('extraction.verbose', DEFAULT_SETTINGS.extraction.verbose)
|
||||
this.set('extraction.autoConvert', DEFAULT_SETTINGS.extraction.autoConvert)
|
||||
this.set('extraction.mergeBatches', DEFAULT_SETTINGS.extraction.mergeBatches)
|
||||
this.set('extraction.enableDbPersistence', DEFAULT_SETTINGS.extraction.enableDbPersistence)
|
||||
this.set('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize)
|
||||
this.set('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose)
|
||||
this.set('EXTRACTION_AUTO_CONVERT', DEFAULT_SETTINGS.extraction.autoConvert)
|
||||
this.set('EXTRACTION_MERGE_BATCHES', DEFAULT_SETTINGS.extraction.mergeBatches)
|
||||
this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', DEFAULT_SETTINGS.extraction.enableDbPersistence)
|
||||
|
||||
this.set('validation.dataSource', DEFAULT_SETTINGS.validation.dataSource)
|
||||
this.set('validation.batchSize', DEFAULT_SETTINGS.validation.batchSize)
|
||||
this.set('validation.matchMode', DEFAULT_SETTINGS.validation.matchMode)
|
||||
this.set('validation.enableCrud', DEFAULT_SETTINGS.validation.enableCrud)
|
||||
this.set('validation.defaultManager', DEFAULT_SETTINGS.validation.defaultManager)
|
||||
this.set('VALIDATION_DATA_SOURCE', DEFAULT_SETTINGS.validation.dataSource)
|
||||
this.set('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize)
|
||||
this.set('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode)
|
||||
this.set('VALIDATION_ENABLE_CRUD', DEFAULT_SETTINGS.validation.enableCrud)
|
||||
this.set('VALIDATION_DEFAULT_MANAGER', DEFAULT_SETTINGS.validation.defaultManager)
|
||||
|
||||
this.set('ui.fontFamily', DEFAULT_SETTINGS.ui.fontFamily)
|
||||
this.set('ui.fontSize', DEFAULT_SETTINGS.ui.fontSize)
|
||||
this.set('ui.productionIdInputWidth', DEFAULT_SETTINGS.ui.productionIdInputWidth)
|
||||
this.set('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily)
|
||||
this.set('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize)
|
||||
this.set('UI_PRODUCTION_ID_INPUT_WIDTH', DEFAULT_SETTINGS.ui.productionIdInputWidth)
|
||||
|
||||
this.set('execution.dryRun', DEFAULT_SETTINGS.execution.dryRun)
|
||||
this.set('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun)
|
||||
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
@@ -536,4 +683,39 @@ export class ConfigManager {
|
||||
public getDefaultSettings(): SettingsData {
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup current .env file
|
||||
*/
|
||||
private async backupEnvFile(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
fs.copyFileSync(this.envPath, this.backupPath)
|
||||
log.debug('Backup created', { path: this.backupPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to backup .env file', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore .env file from backup
|
||||
*/
|
||||
private async restoreBackup(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.envPath)
|
||||
await this.loadEnvFile()
|
||||
log.debug('Restored from backup')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to restore backup', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,13 +156,17 @@ const CleanerPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleConfirmDeletion = async () => {
|
||||
if (validationResults.length === 0) return alert('没有可处理的数据')
|
||||
// For non-admin users, only process visible filtered results
|
||||
// For admin users, process all validation results
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
|
||||
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
|
||||
|
||||
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||
const materialsToDelete: string[] = []
|
||||
const missingManager: string[] = []
|
||||
|
||||
for (const result of validationResults) {
|
||||
for (const result of resultsToProcess) {
|
||||
if (!result.materialCode?.trim()) continue
|
||||
|
||||
const code = result.materialCode.trim()
|
||||
@@ -417,7 +421,17 @@ const CleanerPage: React.FC = () => {
|
||||
<CheckSquare size={14} className="text-blue-600" /> 全选
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedItems(new Set())}
|
||||
onClick={() => {
|
||||
// Only uncheck items that are visible in filteredResults
|
||||
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
for (const code of visibleCodes) {
|
||||
newSet.delete(code)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}}
|
||||
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<Square size={14} className="text-slate-400" /> 取消
|
||||
|
||||
@@ -60,7 +60,17 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const result = await window.electron.settings.saveSettings(settings as any)
|
||||
// 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', '设置保存成功')
|
||||
|
||||
217
tests/main/services/config/config-manager.test.ts
Normal file
217
tests/main/services/config/config-manager.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ConfigManager } from '@services/config/config-manager'
|
||||
import type { SettingsData } from '@types/settings.types'
|
||||
|
||||
describe('ConfigManager - deep merge utilities', () => {
|
||||
it('should deep merge objects, updating only specified fields', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
// Reload to ensure clean state from previous tests
|
||||
await manager['loadEnvFile']()
|
||||
|
||||
// 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: ''
|
||||
},
|
||||
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: ''
|
||||
},
|
||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||
execution: { dryRun: false }
|
||||
}
|
||||
|
||||
// Load initial settings
|
||||
await manager.saveAllSettings(initial)
|
||||
// Reload from disk to populate cache
|
||||
await manager['loadEnvFile']()
|
||||
|
||||
// Partial update
|
||||
const partial = {
|
||||
erp: { url: 'http://new.com' }
|
||||
}
|
||||
|
||||
const result = await manager.savePartialSettings(partial)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
// Updated field
|
||||
expect(current.erp.url).toBe('http://new.com')
|
||||
|
||||
// Preserved fields
|
||||
expect(current.erp.username).toBe('user1')
|
||||
expect(current.database.dbType).toBe('mysql')
|
||||
expect(current.paths.dataDir).toBe('/data')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigManager - backup and restore', () => {
|
||||
it('should create backup before saving', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
const backupSuccess = await manager['backupEnvFile']()
|
||||
|
||||
expect(backupSuccess).toBe(true)
|
||||
|
||||
// Check backup file exists (in same location as .env file, which is src/main/)
|
||||
const fs = await import('fs')
|
||||
const path = await import('path')
|
||||
const backupPath = path.resolve(process.cwd(), 'src/main/.env.backup')
|
||||
|
||||
expect(fs.existsSync(backupPath)).toBe(true)
|
||||
})
|
||||
|
||||
// Note: Skipping fs.writeFileSync mock test due to ESM limitations in Vitest
|
||||
// The restoreBackup functionality is tested indirectly through the savePartialSettings rollback test
|
||||
})
|
||||
|
||||
describe('ConfigManager.savePartialSettings', () => {
|
||||
it('should save only specified fields and preserve others', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
|
||||
// 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: '' },
|
||||
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: '' },
|
||||
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
||||
execution: { dryRun: true }
|
||||
})
|
||||
// Reload from disk to populate cache
|
||||
await manager['loadEnvFile']()
|
||||
|
||||
// Update only ERP URL
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: { url: 'http://new.com' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
// Verify updated field
|
||||
expect(current.erp.url).toBe('http://new.com')
|
||||
|
||||
// Verify preserved ERP fields
|
||||
expect(current.erp.username).toBe('user1')
|
||||
expect(current.erp.password).toBe('pass1')
|
||||
|
||||
// Verify preserved other categories
|
||||
expect(current.database.dbType).toBe('mysql')
|
||||
expect(current.database.mysqlHost).toBe('192.168.1.1')
|
||||
expect(current.paths.dataDir).toBe('/old/path')
|
||||
expect(current.extraction.batchSize).toBe(50)
|
||||
expect(current.ui.fontFamily).toBe('Tahoma')
|
||||
})
|
||||
|
||||
it('should reject updates to non-whitelisted fields', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
// Reset to ensure clean state
|
||||
manager.resetToDefaults()
|
||||
await manager.save()
|
||||
|
||||
const result = await manager.savePartialSettings({
|
||||
database: { dbType: 'postgres' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不允许修改')
|
||||
expect(result.error).toContain('database.dbType')
|
||||
})
|
||||
|
||||
it('should handle nested object updates correctly', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
// Reset to ensure clean state
|
||||
manager.resetToDefaults()
|
||||
await manager.save()
|
||||
|
||||
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: '' },
|
||||
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: '' },
|
||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||
execution: { dryRun: false }
|
||||
})
|
||||
// Reload from disk to populate cache
|
||||
await manager['loadEnvFile']()
|
||||
|
||||
// Update multiple ERP fields at once
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: {
|
||||
url: 'http://updated.com',
|
||||
username: 'newuser',
|
||||
password: 'newpass'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const current = manager.getAllSettings()
|
||||
|
||||
expect(current.erp.url).toBe('http://updated.com')
|
||||
expect(current.erp.username).toBe('newuser')
|
||||
expect(current.erp.password).toBe('newpass')
|
||||
expect(current.erp.headless).toBe(false) // preserved
|
||||
})
|
||||
|
||||
it('should restore backup on save failure', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
await manager.initialize()
|
||||
// Reset to ensure clean state
|
||||
manager.resetToDefaults()
|
||||
await manager.save()
|
||||
|
||||
const originalUrl = manager.getAllSettings().erp.url
|
||||
|
||||
// Mock save to fail
|
||||
vi.spyOn(manager, 'save').mockResolvedValueOnce(false)
|
||||
|
||||
const result = await manager.savePartialSettings({
|
||||
erp: { url: 'http://should-not-apply.com' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('保存配置失败')
|
||||
|
||||
// Verify rollback
|
||||
expect(manager.getAllSettings().erp.url).toBe(originalUrl)
|
||||
|
||||
manager.save.mockRestore()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user