feat: merge settings partial save feature
Features: - Partial settings save with deep merge - Backup and rollback mechanism - UI field whitelist validation - Defensive programming in settings page Safety: - Auto-backup before save - Automatic rollback on failure - Field validation at both client and server
This commit is contained in:
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.
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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