feat: implement settings interface with user permission control

New Features:
- Add SettingsPage component with Admin/User view differentiation
- Create ConfigManager service for .env file management
- Add IPC handlers for settings CRUD operations
- Implement connection testing for ERP and database
- Add settings navigation to main app

Files Created:
- src/main/types/settings.types.ts - Type definitions
- src/main/services/config/config-manager.ts - Config service
- src/main/ipc/settings-handler.ts - IPC handlers
- src/renderer/src/pages/SettingsPage.tsx - React UI component

Files Modified:
- src/main/ipc/index.ts - Register settings handlers
- src/preload/index.ts - Expose settings API
- src/preload/index.d.ts - Add SettingsAPI type
- src/renderer/src/App.tsx - Add settings navigation

Co-Authored-By: Claude (qwen3.5-plus) <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-03-01 20:51:29 +08:00
parent 0f1e0a8908
commit 9caa38caa8
10 changed files with 2111 additions and 233 deletions

View File

@@ -10,6 +10,7 @@ import { registerDatabaseHandlers } from './database-handler'
import { registerResolverHandlers } from './resolver-handler'
import { registerAuthHandlers } from './auth-handler'
import { registerValidationHandlers } from './validation-handler'
import { registerSettingsHandlers } from './settings-handler'
/**
* Register all IPC handlers
@@ -22,4 +23,5 @@ export function registerIpcHandlers(): void {
registerResolverHandlers()
registerAuthHandlers()
registerValidationHandlers()
registerSettingsHandlers()
}

View File

@@ -0,0 +1,255 @@
/**
* Settings IPC Handler
*
* Provides IPC handlers for settings management:
* - Get/set settings
* - Reset to defaults
* - Test ERP connection
* - Test database connection
*/
import { ipcMain } from 'electron'
import { ConfigManager } from '../services/config/config-manager'
import { SessionManager } from '../services/user/session-manager'
import { ErpAuthService } from '../services/erp/erp-auth'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import type {
SettingsData,
UserType,
ConnectionTestResult,
SaveSettingsResult
} from '../types/settings.types'
/**
* Filter settings by user type
* Admin users get all settings, User users get limited settings
*/
function filterSettingsByUserType(
settings: SettingsData,
userType: UserType
): SettingsData {
if (userType === 'Admin') {
return settings // Return all settings for Admin
}
// User users get limited settings
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,
// Include minimal required fields for other sections
database: settings.database,
extraction: settings.extraction,
validation: settings.validation,
ui: settings.ui
}
}
/**
* Register IPC handlers for settings management
*/
export function registerSettingsHandlers(): void {
const configManager = ConfigManager.getInstance()
const sessionManager = SessionManager.getInstance()
/**
* Get current user type
*/
ipcMain.handle('settings:getUserType', async (): Promise<UserType> => {
return (sessionManager.getUserType() as UserType) || 'Guest'
})
/**
* Get settings (filtered by user type)
*/
ipcMain.handle('settings:getSettings', async (): Promise<SettingsData> => {
const userType = (sessionManager.getUserType() as UserType) || 'Guest'
const settings = configManager.getAllSettings()
return filterSettingsByUserType(settings, userType)
})
/**
* Save settings
*/
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
try {
const success = await configManager.saveAllSettings(settings)
if (success) {
return { success: true }
} else {
return { success: false, error: '保存设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: `保存设置失败:${message}` }
}
}
)
/**
* Reset to default settings (Admin only)
*/
ipcMain.handle(
'settings:resetDefaults',
async (): Promise<SaveSettingsResult> => {
try {
const userType = sessionManager.getUserType()
if (userType !== 'Admin') {
return { success: false, error: '只有管理员可以恢复默认设置' }
}
configManager.resetToDefaults()
const success = await configManager.save()
if (success) {
return { success: true }
} else {
return { success: false, error: '恢复默认设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: `恢复默认设置失败:${message}` }
}
}
)
/**
* Test ERP connection
*/
ipcMain.handle(
'settings:testErpConnection',
async (): Promise<ConnectionTestResult> => {
try {
const settings = configManager.getAllSettings()
const erpConfig = settings.erp
if (!erpConfig.url || !erpConfig.username || !erpConfig.password) {
return {
success: false,
message: '请先配置 ERP URL、用户名和密码'
}
}
// Create ERP auth service and try to login
const erpAuthService = new ErpAuthService(erpConfig)
try {
await erpAuthService.login()
// Login successful, close browser
await erpAuthService.close()
return {
success: true,
message: 'ERP 连接测试成功!'
}
} catch (loginError) {
const errorMessage = loginError instanceof Error ? loginError.message : '登录失败'
return {
success: false,
message: `ERP 连接测试失败:${errorMessage}`
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
message: `ERP 连接测试失败:${message}`
}
}
}
)
/**
* Test database connection
*/
ipcMain.handle(
'settings:testDbConnection',
async (): Promise<ConnectionTestResult> => {
try {
const settings = configManager.getAllSettings()
const dbConfig = settings.database
if (dbConfig.dbType === 'mysql') {
// Test MySQL connection
if (!dbConfig.mysqlHost || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 MySQL 主机、数据库名和用户名'
}
}
const mysqlService = new MySqlService({
host: dbConfig.mysqlHost,
port: dbConfig.mysqlPort,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await mysqlService.connect()
await mysqlService.disconnect()
return {
success: true,
message: 'MySQL 数据库连接测试成功!'
}
} catch (connError) {
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
return {
success: false,
message: `MySQL 数据库连接测试失败:${errorMessage}`
}
}
} else {
// Test SQL Server connection
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 SQL Server 服务器、数据库名和用户名'
}
}
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: 1433, // Default SQL Server port
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
trustServerCertificate: true
}
})
try {
await sqlServerService.connect()
await sqlServerService.disconnect()
return {
success: true,
message: 'SQL Server 数据库连接测试成功!'
}
} catch (connError) {
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
return {
success: false,
message: `SQL Server 数据库连接测试失败:${errorMessage}`
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
message: `数据库连接测试失败:${message}`
}
}
}
)
}

View File

@@ -615,4 +615,100 @@ export function registerValidationHandlers(): void {
return { productionIds: getSharedProductionIds() }
}
)
/**
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
* Filters materials by current user (admin sees all, regular users see only their own)
*/
ipcMain.handle(
'validation:getCleanerData',
async (_event): Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}> => {
let mysqlService: MySqlService | null = null
const sessionManager = (await import('../services/user/session-manager')).SessionManager.getInstance()
try {
// Get current user
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录'
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
console.log(`[CleanerData] User: ${username}, isAdmin: ${isAdmin}`)
// Connect to MySQL
mysqlService = await getValidationMySqlService()
// 1. Get order numbers from shared Production IDs
const sharedIds = getSharedProductionIds()
let orderNumbers: string[] = []
if (sharedIds.length > 0) {
console.log(`[CleanerData] Using ${sharedIds.length} shared Production IDs`)
orderNumbers = await getSourceNumbersFromInputs(sharedIds, mysqlService)
console.log(`[CleanerData] Got ${orderNumbers.length} order numbers`)
}
// 2. Get material codes from MaterialsToBeDeleted table
let materialCodes: string[] = []
if (isAdmin) {
// Admin sees all materials
const allCodesSql = `
SELECT MaterialCode
FROM dbo_MaterialsToBeDeleted
WHERE MaterialCode IS NOT NULL
`
const result = await mysqlService.query(allCodesSql)
materialCodes = result.rows
.map(row => row.MaterialCode as string)
.filter(Boolean)
console.log(`[CleanerData] Admin user: got ${materialCodes.length} materials`)
} else {
// Regular users only see their own materials
const userMaterialsSql = `
SELECT MaterialCode
FROM dbo_MaterialsToBeDeleted
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
`
const result = await mysqlService.query(userMaterialsSql, [username])
materialCodes = result.rows
.map(row => row.MaterialCode as string)
.filter(Boolean)
console.log(`[CleanerData] Regular user: got ${materialCodes.length} materials`)
}
return {
success: true,
orderNumbers,
materialCodes
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[CleanerData] Error:', error)
return {
success: false,
error: `获取清理数据失败:${message}`
}
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[CleanerData] Error disconnecting MySQL:', closeError)
}
}
}
}
)
}

View File

@@ -0,0 +1,446 @@
/**
* Configuration Manager
*
* Manages application configuration stored in .env file
* Provides methods for reading, writing, and saving configuration values
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import type {
SettingsData,
ErpConfig,
DatabaseConfig,
PathsConfig,
ExtractionConfig,
ValidationConfig,
UiConfig,
ExecutionConfig,
DatabaseType,
MatchMode,
ValidationDataSource
} from '../../types/settings.types'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Default settings values
*/
const DEFAULT_SETTINGS: SettingsData = {
erp: {
url: 'https://68.11.34.30:8082/',
username: '',
password: '',
headless: true,
ignoreHttpsErrors: true,
autoCloseBrowser: true
},
database: {
dbType: 'mysql',
server: '',
mysqlHost: '192.168.31.83',
mysqlPort: 3306,
database: 'BLD_DB',
username: 'remote_user',
password: ''
},
paths: {
dataDir: 'D:/python/playwrite/data/',
defaultOutput: '离散备料计划维护_合并.xlsx',
validationOutput: '物料状态校验结果.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: 'Microsoft YaHei UI',
fontSize: 10,
productionIdInputWidth: 20
},
execution: {
dryRun: false
}
}
/**
* Configuration Manager Class
*/
export class ConfigManager {
private static instance: ConfigManager | null = null
private envPath: string
private configCache: Map<string, string> = new Map()
private initialized: boolean = false
private constructor() {
if (this.initialized) {
return
}
this.envPath = path.resolve(__dirname, '../../.env')
this.initialized = true
}
/**
* Get the singleton instance
*/
public static getInstance(): ConfigManager {
if (ConfigManager.instance === null) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
/**
* Initialize configuration from .env file
*/
public async initialize(): Promise<void> {
await this.loadEnvFile()
}
/**
* Load .env file into cache
*/
private async loadEnvFile(): Promise<void> {
try {
if (fs.existsSync(this.envPath)) {
const content = fs.readFileSync(this.envPath, 'utf-8')
const lines = content.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue
}
const [key, ...valueParts] = trimmedLine.split('=')
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim()
this.configCache.set(key.trim(), value)
}
}
}
} catch (error) {
console.error('[ConfigManager] Failed to load .env file:', error)
}
}
/**
* Get a configuration value
* @param key - Configuration key
* @param defaultValue - Default value if key doesn't exist
*/
public get(key: string, defaultValue?: string): string | undefined {
return this.configCache.get(key) ?? defaultValue
}
/**
* Get a boolean configuration value
*/
public getBoolean(key: string, defaultValue: boolean = false): boolean {
const value = this.get(key)
if (value === undefined) return defaultValue
return value.toLowerCase() === 'true'
}
/**
* Get a number configuration value
*/
public getNumber(key: string, defaultValue: number = 0): number {
const value = this.get(key)
if (value === undefined) return defaultValue
const parsed = parseInt(value, 10)
return isNaN(parsed) ? defaultValue : parsed
}
/**
* Set a configuration value in cache
*/
public set(key: string, value: string | number | boolean): void {
this.configCache.set(key, String(value))
}
/**
* Save configuration to .env file
*/
public async save(): Promise<boolean> {
try {
// Build .env content from cache
const lines: string[] = []
// ERP Configuration
lines.push('# ===========================')
lines.push('# ERP 系统配置')
lines.push('# ===========================')
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}`)
lines.push(`ERP_PASSWORD=${this.configCache.get('erp.password') || DEFAULT_SETTINGS.erp.password}`)
lines.push(`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}`)
lines.push(`ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('erp.autoCloseBrowser') || DEFAULT_SETTINGS.erp.autoCloseBrowser}`)
lines.push('')
// Database Configuration - SQL Server
lines.push('# ===========================')
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_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server`)
lines.push(`DB_TRUST_SERVER_CERTIFICATE=yes`)
lines.push('')
// Database Configuration - MySQL
lines.push('# ===========================')
lines.push('# 数据库配置 - MySQL (切换时使用)')
lines.push('# ===========================')
lines.push(`DB_TYPE=${this.configCache.get('database.dbType') || DEFAULT_SETTINGS.database.dbType}`)
lines.push(`DB_NAME=${this.configCache.get('database.database') || DEFAULT_SETTINGS.database.database}`)
lines.push(`DB_USERNAME=${this.configCache.get('database.username') || DEFAULT_SETTINGS.database.username}`)
lines.push(`DB_PASSWORD=${this.configCache.get('database.password') || DEFAULT_SETTINGS.database.password}`)
lines.push(`DB_MYSQL_HOST=${this.configCache.get('database.mysqlHost') || DEFAULT_SETTINGS.database.mysqlHost}`)
lines.push(`DB_MYSQL_PORT=${this.configCache.get('database.mysqlPort') || DEFAULT_SETTINGS.database.mysqlPort}`)
lines.push(`DB_MYSQL_CHARSET=utf8mb4`)
lines.push('')
// Order number parsing table configuration
lines.push('# 订单号解析表配置')
lines.push('# 表名:包含 productionID 和 生产订单号 映射关系的表')
lines.push(`DB_TABLE_NAME=productionContractData_26 年压力表合同数据`)
lines.push('# 字段名:总排号 (对应 productionID)')
lines.push(`DB_FIELD_PRODUCTION_ID=总排号`)
lines.push('# 字段名:生产订单号 (对应生产订单号)')
lines.push(`DB_FIELD_ORDER_NUMBER=生产订单号`)
lines.push('')
// Path Configuration
lines.push('# ===========================')
lines.push('# 路径配置')
lines.push('# ===========================')
lines.push(`PATH_DATA_DIR=${this.configCache.get('paths.dataDir') || 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}`)
lines.push(`PATH_VALIDATION_OUTPUT=${this.configCache.get('paths.validationOutput') || DEFAULT_SETTINGS.paths.validationOutput}`)
lines.push('')
// Data Extraction Configuration
lines.push('# ===========================')
lines.push('# 数据提取配置')
lines.push('# ===========================')
lines.push(`EXTRACTION_BATCH_SIZE=${this.configCache.get('extraction.batchSize') || DEFAULT_SETTINGS.extraction.batchSize}`)
lines.push(`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}`)
lines.push(`EXTRACTION_MERGE_BATCHES=${this.configCache.get('extraction.mergeBatches') || DEFAULT_SETTINGS.extraction.mergeBatches}`)
lines.push(`EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('extraction.enableDbPersistence') || DEFAULT_SETTINGS.extraction.enableDbPersistence}`)
lines.push('')
// Validation Configuration
lines.push('# ===========================')
lines.push('# 校验配置')
lines.push('# ===========================')
lines.push(`VALIDATION_DATA_SOURCE=${this.configCache.get('validation.dataSource') || DEFAULT_SETTINGS.validation.dataSource}`)
lines.push(`VALIDATION_USE_DATABASE=${this.configCache.get('validation.useDatabase') || true}`)
lines.push(`VALIDATION_BATCH_SIZE=${this.configCache.get('validation.batchSize') || DEFAULT_SETTINGS.validation.batchSize}`)
lines.push(`VALIDATION_ENABLE_CRUD=${this.configCache.get('validation.enableCrud') || DEFAULT_SETTINGS.validation.enableCrud}`)
lines.push(`VALIDATION_DEFAULT_MANAGER=${this.configCache.get('validation.defaultManager') || DEFAULT_SETTINGS.validation.defaultManager}`)
lines.push(`VALIDATION_MATCH_MODE=${this.configCache.get('validation.matchMode') || DEFAULT_SETTINGS.validation.matchMode}`)
lines.push('')
// UI Configuration
lines.push('# ===========================')
lines.push('# UI 配置')
lines.push('# ===========================')
lines.push(`UI_FONT_FAMILY=${this.configCache.get('ui.fontFamily') || DEFAULT_SETTINGS.ui.fontFamily}`)
lines.push(`UI_FONT_SIZE=${this.configCache.get('ui.fontSize') || DEFAULT_SETTINGS.ui.fontSize}`)
lines.push(`UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('ui.productionIdInputWidth') || DEFAULT_SETTINGS.ui.productionIdInputWidth}`)
lines.push('')
// Execution Configuration
lines.push('# ===========================')
lines.push('# 执行配置')
lines.push('# ===========================')
lines.push(`EXECUTION_DRYRUN=${this.configCache.get('execution.dryRun') || DEFAULT_SETTINGS.execution.dryRun}`)
const content = lines.join('\n')
fs.writeFileSync(this.envPath, content, 'utf-8')
return true
} catch (error) {
console.error('[ConfigManager] Failed to save .env file:', error)
return false
}
}
/**
* Get all settings as SettingsData object
*/
public getAllSettings(): SettingsData {
return {
erp: {
url: this.get('ERP_URL', DEFAULT_SETTINGS.erp.url),
username: this.get('ERP_USERNAME', DEFAULT_SETTINGS.erp.username),
password: this.get('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password),
headless: this.getBoolean('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless),
ignoreHttpsErrors: this.getBoolean('ERP_IGNORE_HTTPS_ERRORS', DEFAULT_SETTINGS.erp.ignoreHttpsErrors),
autoCloseBrowser: this.getBoolean('ERP_AUTO_CLOSE_BROWSER', DEFAULT_SETTINGS.erp.autoCloseBrowser)
},
database: {
dbType: (this.get('DB_TYPE', DEFAULT_SETTINGS.database.dbType) as DatabaseType) || DEFAULT_SETTINGS.database.dbType,
server: this.get('DB_SERVER', DEFAULT_SETTINGS.database.server),
mysqlHost: this.get('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost),
mysqlPort: this.getNumber('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort),
database: this.get('DB_NAME', DEFAULT_SETTINGS.database.database),
username: this.get('DB_USERNAME', DEFAULT_SETTINGS.database.username),
password: this.get('DB_PASSWORD', DEFAULT_SETTINGS.database.password)
},
paths: {
dataDir: this.get('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir),
defaultOutput: this.get('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput),
validationOutput: this.get('PATH_VALIDATION_OUTPUT', DEFAULT_SETTINGS.paths.validationOutput)
},
extraction: {
batchSize: this.getNumber('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize),
verbose: this.getBoolean('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose),
autoConvert: this.getBoolean('EXTRACTION_AUTO_CONVERT', DEFAULT_SETTINGS.extraction.autoConvert),
mergeBatches: this.getBoolean('EXTRACTION_MERGE_BATCHES', DEFAULT_SETTINGS.extraction.mergeBatches),
enableDbPersistence: this.getBoolean('EXTRACTION_ENABLE_DB_PERSISTENCE', DEFAULT_SETTINGS.extraction.enableDbPersistence)
},
validation: {
dataSource: (this.get('VALIDATION_DATA_SOURCE', DEFAULT_SETTINGS.validation.dataSource) as ValidationDataSource) || DEFAULT_SETTINGS.validation.dataSource,
batchSize: this.getNumber('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize),
matchMode: (this.get('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode) as MatchMode) || DEFAULT_SETTINGS.validation.matchMode,
enableCrud: this.getBoolean('VALIDATION_ENABLE_CRUD', DEFAULT_SETTINGS.validation.enableCrud),
defaultManager: this.get('VALIDATION_DEFAULT_MANAGER', DEFAULT_SETTINGS.validation.defaultManager)
},
ui: {
fontFamily: this.get('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily),
fontSize: this.getNumber('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize),
productionIdInputWidth: this.getNumber('UI_PRODUCTION_ID_INPUT_WIDTH', DEFAULT_SETTINGS.ui.productionIdInputWidth)
},
execution: {
dryRun: this.getBoolean('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun)
}
}
}
/**
* 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)
// 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)
// Path settings
this.set('paths.dataDir', settings.paths.dataDir)
this.set('paths.defaultOutput', settings.paths.defaultOutput)
this.set('paths.validationOutput', 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)
// 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)
// UI settings
this.set('ui.fontFamily', settings.ui.fontFamily)
this.set('ui.fontSize', settings.ui.fontSize)
this.set('ui.productionIdInputWidth', settings.ui.productionIdInputWidth)
// Execution settings
this.set('execution.dryRun', settings.execution.dryRun)
return this.save()
}
/**
* Reset to default settings
*/
public resetToDefaults(): SettingsData {
// 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)
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('paths.dataDir', DEFAULT_SETTINGS.paths.dataDir)
this.set('paths.defaultOutput', DEFAULT_SETTINGS.paths.defaultOutput)
this.set('paths.validationOutput', 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('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('ui.fontFamily', DEFAULT_SETTINGS.ui.fontFamily)
this.set('ui.fontSize', DEFAULT_SETTINGS.ui.fontSize)
this.set('ui.productionIdInputWidth', DEFAULT_SETTINGS.ui.productionIdInputWidth)
this.set('execution.dryRun', DEFAULT_SETTINGS.execution.dryRun)
return DEFAULT_SETTINGS
}
/**
* Get default settings
*/
public getDefaultSettings(): SettingsData {
return DEFAULT_SETTINGS
}
}

View File

@@ -0,0 +1,185 @@
/**
* Settings types and interfaces
*
* Defines configuration structures for ERPAuto settings management
*/
/**
* User type for settings permission control
*/
export type UserType = 'Admin' | 'User' | 'Guest'
/**
* Database type selection
*/
export type DatabaseType = 'sqlserver' | 'mysql'
/**
* Validation match mode
*/
export type MatchMode = 'substring' | 'exact'
/**
* Validation data source options
*/
export type ValidationDataSource = 'database_full' | 'database_filtered' | 'excel_existing' | 'excel_full'
/**
* ERP configuration
*/
export interface ErpConfig {
/** ERP system URL */
url: string
/** ERP username */
username: string
/** ERP password */
password: string
/** Headless browser mode */
headless: boolean
/** Ignore HTTPS certificate errors */
ignoreHttpsErrors: boolean
/** Auto close browser after operations */
autoCloseBrowser: boolean
}
/**
* Database configuration
*/
export interface DatabaseConfig {
/** Database type */
dbType: DatabaseType
/** SQL Server server address */
server: string
/** MySQL host */
mysqlHost: string
/** MySQL port */
mysqlPort: number
/** Database name */
database: string
/** Database username */
username: string
/** Database password */
password: string
}
/**
* Path configuration
*/
export interface PathsConfig {
/** Data directory */
dataDir: string
/** Default output file */
defaultOutput: string
/** Validation output file */
validationOutput: string
}
/**
* Data extraction configuration
*/
export interface ExtractionConfig {
/** Batch size for data extraction */
batchSize: number
/** Enable verbose logging */
verbose: boolean
/** Auto convert to Excel */
autoConvert: boolean
/** Merge batches */
mergeBatches: boolean
/** Enable database persistence */
enableDbPersistence: boolean
}
/**
* Material validation configuration
*/
export interface ValidationConfig {
/** Data source type */
dataSource: ValidationDataSource
/** Batch size for validation */
batchSize: number
/** Match mode */
matchMode: MatchMode
/** Enable CRUD operations */
enableCrud: boolean
/** Default manager name */
defaultManager: string
}
/**
* UI configuration
*/
export interface UiConfig {
/** Font family */
fontFamily: string
/** Font size */
fontSize: number
/** Production ID input width */
productionIdInputWidth: number
}
/**
* Execution configuration (User-only)
*/
export interface ExecutionConfig {
/** Dry run mode */
dryRun: boolean
}
/**
* Complete settings data structure
*/
export interface SettingsData {
/** ERP configuration */
erp: ErpConfig
/** Database configuration */
database: DatabaseConfig
/** Path configuration */
paths: PathsConfig
/** Extraction configuration */
extraction: ExtractionConfig
/** Validation configuration */
validation: ValidationConfig
/** UI configuration */
ui: UiConfig
/** Execution configuration */
execution: ExecutionConfig
}
/**
* Connection test result
*/
export interface ConnectionTestResult {
/** Whether connection was successful */
success: boolean
/** Status message */
message?: string
}
/**
* Save settings result
*/
export interface SaveSettingsResult {
/** Whether save was successful */
success: boolean
/** Error message if failed */
error?: string
}
/**
* Settings API interface for preload
*/
export interface SettingsAPI {
/** Get current user type */
getUserType: () => Promise<UserType>
/** Get settings (filtered by user type) */
getSettings: () => Promise<SettingsData>
/** Save settings */
saveSettings: (settings: SettingsData) => Promise<SaveSettingsResult>
/** Reset to defaults (Admin only) */
resetDefaults: () => Promise<SaveSettingsResult>
/** Test ERP connection */
testErpConnection: () => Promise<ConnectionTestResult>
/** Test database connection */
testDbConnection: () => Promise<ConnectionTestResult>
}

View File

@@ -9,6 +9,7 @@ import type {
CurrentUserResponse
} from '../main/ipc/auth-handler'
import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types'
import type { SettingsData, UserType, ConnectionTestResult, SaveSettingsResult } from '../main/types/settings.types'
/**
* Order number resolver API
@@ -88,6 +89,16 @@ export interface ValidationAPI {
* Get shared Production IDs
*/
getSharedProductionIds: () => Promise<{ productionIds: string[] }>
/**
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
* Filters materials by current user (admin sees all, regular users see only their own)
*/
getCleanerData: () => Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}>
}
/**
@@ -131,6 +142,37 @@ export interface MaterialsAPI {
getStatistics: () => Promise<{ stats: unknown }>
}
/**
* Settings API
*/
export interface SettingsAPI {
/**
* Get current user type
*/
getUserType: () => Promise<UserType>
/**
* Get settings (filtered by user type)
*/
getSettings: () => Promise<SettingsData>
/**
* Save settings
* @param settings - Settings data to save
*/
saveSettings: (settings: SettingsData) => Promise<SaveSettingsResult>
/**
* Reset to defaults (Admin only)
*/
resetDefaults: () => Promise<SaveSettingsResult>
/**
* Test ERP connection
*/
testErpConnection: () => Promise<ConnectionTestResult>
/**
* Test database connection
*/
testDbConnection: () => Promise<ConnectionTestResult>
}
declare global {
interface Window {
electron: {
@@ -157,6 +199,7 @@ declare global {
auth: AuthAPI
validation: ValidationAPI
materials: MaterialsAPI
settings: SettingsAPI
}
api: unknown
}

View File

@@ -7,6 +7,7 @@ import type { ResolverInput } from '../main/ipc/resolver-handler'
import type { LoginRequest } from '../main/ipc/auth-handler'
import type { UserInfo } from '../main/types/user.types'
import type { ValidationRequest } from '../main/types/validation.types'
import type { SettingsData } from '../main/types/settings.types'
// Custom APIs for renderer
const api = {
@@ -70,7 +71,9 @@ const api = {
setSharedProductionIds: (productionIds: string[]) =>
ipcRenderer.invoke('validation:setSharedProductionIds', productionIds),
getSharedProductionIds: () =>
ipcRenderer.invoke('validation:getSharedProductionIds')
ipcRenderer.invoke('validation:getSharedProductionIds'),
getCleanerData: () =>
ipcRenderer.invoke('validation:getCleanerData')
},
// Materials service
@@ -84,6 +87,16 @@ const api = {
ipcRenderer.invoke('materials:getByManager', managerName),
getAll: () => ipcRenderer.invoke('materials:getAll'),
getStatistics: () => ipcRenderer.invoke('materials:getStatistics')
},
// Settings service
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')
}
} as const

View File

@@ -13,8 +13,9 @@ import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from './components/UserSelectionDialog'
import { ExtractorPage } from './pages/ExtractorPage'
import { CleanerPage } from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
type Page = 'home' | 'extractor' | 'cleaner'
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
interface CurrentUser {
username: string
@@ -354,6 +355,21 @@ function App(): React.JSX.Element {
>
</button>
<button
onClick={() => setCurrentPage('settings')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'settings' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'settings' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
</nav>
</div>
<div>
@@ -379,13 +395,14 @@ function App(): React.JSX.Element {
<div>
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}></h1>
<p style={{ color: '#666', fontSize: '14px' }}>
使
使
</p>
</div>
)}
{currentPage === 'extractor' && <ExtractorPage />}
{currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />}
</div>
</div>
)

View File

@@ -24,13 +24,7 @@ interface ValidationResult {
*/
export const CleanerPage: React.FC = () => {
// State with sessionStorage persistence
const [orderNumbers, setOrderNumbers] = useState(() => {
return sessionStorage.getItem('cleaner_orderNumbers') || ''
})
const [materialCodes, setMaterialCodes] = useState(() => {
return sessionStorage.getItem('cleaner_materialCodes') || ''
})
const [dryRun, setDryRun] = useState(() => {
const [dryRun] = useState(() => {
const saved = sessionStorage.getItem('cleaner_dryRun')
return saved ? saved === 'true' : true
})
@@ -92,13 +86,6 @@ export const CleanerPage: React.FC = () => {
results = results.filter(r => !hiddenItems.has(r.materialCode))
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
useEffect(() => {
sessionStorage.setItem('cleaner_orderNumbers', orderNumbers)
}, [orderNumbers])
useEffect(() => {
sessionStorage.setItem('cleaner_materialCodes', materialCodes)
}, [materialCodes])
useEffect(() => {
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
@@ -121,11 +108,11 @@ export const CleanerPage: React.FC = () => {
const admin = await window.electron.auth.isAdmin()
const user = await window.electron.auth.getCurrentUser()
setIsAdmin(admin)
if (user) {
setCurrentUsername(user.username)
if (user && user.userInfo) {
setCurrentUsername(user.userInfo.username)
if (!admin) {
// Non-admin users can only see their own data
setSelectedManagers(new Set([user.username]))
setSelectedManagers(new Set([user.userInfo.username]))
}
}
@@ -345,64 +332,8 @@ export const CleanerPage: React.FC = () => {
}
}, [selectedItems, validationResults, loadManagers])
// Handle export results
const handleExportResults = useCallback(async () => {
if (validationResults.length === 0) {
setError('没有数据可导出')
return
}
try {
// Prepare data for export
const data = validationResults.map(result => ({
'选择': selectedItems.has(result.materialCode) ? '是' : '否',
'材料名称': result.materialName,
'材料代码': result.materialCode,
'规格': result.specification,
'型号': result.model,
'负责人': result.managerName || ''
}))
// Create CSV content
const headers = ['选择', '材料名称', '材料代码', '规格', '型号', '负责人']
const csvContent = [
headers.join(','),
...data.map(row =>
headers.map(header => {
const value = row[header as keyof typeof row]
// Escape quotes and wrap in quotes if contains comma
const escaped = String(value).replace(/"/g, '""')
return escaped.includes(',') ? `"${escaped}"` : escaped
}).join(',')
)
].join('\n')
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `物料状态校验结果_${new Date().getTime()}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (err) {
setError(err instanceof Error ? err.message : '导出失败')
}
}, [validationResults, selectedItems])
// Handle execute deletion
// Handle execute deletion (清理)
const handleExecuteDeletion = useCallback(async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
}
if (!materialCodes.trim()) {
setError('请输入至少一个物料代码')
return
}
if (!dryRun) {
const confirmed = window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')
if (!confirmed) return
@@ -414,15 +345,35 @@ export const CleanerPage: React.FC = () => {
setError(null)
try {
const orderNumberList = orderNumbers
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
// Get cleaner data from backend (order numbers from shared Production IDs + material codes from DB)
const cleanerDataResult = await window.electron.validation.getCleanerData()
const materialCodeList = materialCodes
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
if (!cleanerDataResult.success) {
setError(cleanerDataResult.error || '获取清理数据失败')
setIsRunning(false)
return
}
const orderNumberList = cleanerDataResult.orderNumbers || []
const materialCodeList = cleanerDataResult.materialCodes || []
if (orderNumberList.length === 0) {
setError('没有订单号数据。请先到数据提取页面输入 Production ID。')
setIsRunning(false)
return
}
if (materialCodeList.length === 0) {
setError('没有物料代码数据。请确认已在物料清理界面勾选要删除的物料。')
setIsRunning(false)
return
}
console.log('[CleanerPage] Starting cleaner with:', {
orderNumbers: orderNumberList.length,
materialCodes: materialCodeList.length,
dryRun
})
const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList,
@@ -441,20 +392,7 @@ export const CleanerPage: React.FC = () => {
setIsRunning(false)
setProgress(null)
}
}, [orderNumbers, materialCodes, dryRun])
// Handle clean (alias for execute deletion)
const handleClean = useCallback(handleExecuteDeletion, [handleExecuteDeletion])
// Handle reset
const handleReset = useCallback(() => {
setOrderNumbers('')
setMaterialCodes('')
setDryRun(true)
setResult(null)
setError(null)
setProgress(null)
}, [])
}, [dryRun])
// Handle hide checked items (for user mode)
const handleHideChecked = useCallback(() => {
@@ -559,13 +497,6 @@ export const CleanerPage: React.FC = () => {
</button>
</>
)}
<button
className="btn btn-warning"
onClick={handleConfirmDeletion}
disabled={selectedItems.size === 0 || isRunning}
>
</button>
</div>
{validationStats && (
@@ -575,26 +506,6 @@ export const CleanerPage: React.FC = () => {
<span>{validationStats.markedCount}</span>
</div>
)}
{/* Additional action buttons */}
<div className="button-group" style={{ marginTop: '12px' }}>
<button
className="btn btn-secondary"
onClick={handleExportResults}
disabled={validationResults.length === 0}
>
</button>
{isAdmin && (
<button
className="btn btn-primary"
onClick={handleExecuteDeletion}
disabled={!orderNumbers.trim() || !materialCodes.trim() || isRunning}
>
{isRunning ? '清理中...' : '执行删除'}
</button>
)}
</div>
</div>
{/* Manager Filter (Admin only) */}
@@ -631,25 +542,44 @@ export const CleanerPage: React.FC = () => {
<div className="validation-results-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ margin: 0 }}></h2>
{/* User mode buttons: Hide Checked / Show All */}
{!isAdmin && (
{/* Action buttons: left group (Hide Checked, Show All, Confirm Delete) + right button (Execute Delete in red) */}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{!isAdmin && (
<>
<button
className="btn btn-secondary"
onClick={handleHideChecked}
disabled={selectedItems.size === 0}
>
</button>
<button
className="btn btn-secondary"
onClick={handleShowAll}
disabled={hiddenItems.size === 0}
>
</button>
</>
)}
<button
className="btn btn-secondary"
onClick={handleHideChecked}
disabled={selectedItems.size === 0}
className="btn btn-warning"
onClick={handleConfirmDeletion}
disabled={selectedItems.size === 0 || isRunning}
>
</button>
<button
className="btn btn-secondary"
onClick={handleShowAll}
disabled={hiddenItems.size === 0}
>
</button>
</div>
)}
<button
className="btn btn-danger"
onClick={handleExecuteDeletion}
disabled={isRunning || validationResults.length === 0}
style={{ marginLeft: '16px' }}
>
</button>
</div>
</div>
<table className="validation-results-table">
<thead>
@@ -684,57 +614,6 @@ export const CleanerPage: React.FC = () => {
</div>
)}
{/* Simple Input Section */}
<div className="input-section">
<h2></h2>
<div className="input-row">
<div className="input-group">
<label></label>
<textarea
value={orderNumbers}
onChange={(e) => setOrderNumbers(e.target.value)}
placeholder="请输入订单号,每行一个"
rows={5}
/>
</div>
<div className="input-group">
<label></label>
<textarea
value={materialCodes}
onChange={(e) => setMaterialCodes(e.target.value)}
placeholder="请输入物料代码,每行一个"
rows={5}
/>
</div>
</div>
<div className="dry-run-option">
<label>
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
disabled={isRunning}
/>
<span></span>
</label>
</div>
<div className="button-group">
<button
className="btn btn-primary"
onClick={handleClean}
disabled={isRunning || !orderNumbers.trim() || !materialCodes.trim()}
>
{isRunning ? '清理中...' : '开始清理'}
</button>
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
</button>
</div>
</div>
{/* Progress Section */}
{progress && (
<div className="progress-section">
@@ -848,12 +727,12 @@ export const CleanerPage: React.FC = () => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.validation-section, .manager-filter-section, .input-section {
.validation-section, .manager-filter-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #e8e8e8;
}
.validation-section h2, .manager-filter-section h3, .input-section h2 {
.validation-section h2, .manager-filter-section h3 {
font-size: 18px;
color: #333;
margin-bottom: 16px;
@@ -911,6 +790,13 @@ export const CleanerPage: React.FC = () => {
.btn-warning:hover:not(:disabled) {
background: #ffc069;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #ff7875;
}
.validation-stats {
display: flex;
gap: 24px;
@@ -971,46 +857,6 @@ export const CleanerPage: React.FC = () => {
.validation-results-table tr:hover {
background: #e6f7ff;
}
.input-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 16px;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.input-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
}
.input-group textarea:focus {
outline: none;
border-color: #52c41a;
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2);
}
.dry-run-option {
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
}
.dry-run-option label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #666;
}
.progress-section {
margin-top: 24px;
padding: 16px;

View File

@@ -0,0 +1,975 @@
/**
* Settings Page
*
* System settings configuration page with user type-based access control
* Admin users see all settings, User users see limited settings
*/
import React, { useState, useEffect } from 'react'
import type {
SettingsData,
UserType,
DatabaseType,
MatchMode,
ValidationDataSource
} from '../../../main/types/settings.types'
// Default settings
const DEFAULT_SETTINGS: SettingsData = {
erp: {
url: 'https://68.11.34.30:8082/',
username: '',
password: '',
headless: true,
ignoreHttpsErrors: true,
autoCloseBrowser: true
},
database: {
dbType: 'mysql',
server: '',
mysqlHost: '192.168.31.83',
mysqlPort: 3306,
database: 'BLD_DB',
username: 'remote_user',
password: ''
},
paths: {
dataDir: 'D:/python/playwrite/data/',
defaultOutput: '离散备料计划维护_合并.xlsx',
validationOutput: '物料状态校验结果.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: 'Microsoft YaHei UI',
fontSize: 10,
productionIdInputWidth: 20
},
execution: {
dryRun: false
}
}
const SettingsPage: React.FC = () => {
// User type state
const [userType, setUserType] = useState<UserType>('Guest')
// Settings state
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
// UI state
const [isModified, setIsModified] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Password visibility state for User ERP credentials
const [showPassword, setShowPassword] = useState(false)
// Load settings on mount
useEffect(() => {
loadSettings()
}, [])
/**
* Load user type and settings from main process
*/
const loadSettings = async () => {
setIsLoading(true)
try {
// Get user type
const type = await window.electron.settings.getUserType()
setUserType(type)
// Get settings (filtered by user type)
const loadedSettings = await window.electron.settings.getSettings()
setSettings(loadedSettings)
} catch (error) {
console.error('Failed to load settings:', error)
showMessage('加载设置失败', 'error')
} finally {
setIsLoading(false)
}
}
/**
* Show a message to user
*/
const showMessage = (text: string, type: 'success' | 'error') => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 3000)
}
/**
* Update settings and mark as modified
*/
const updateSettings = (
section: keyof SettingsData,
key: string,
value: unknown
) => {
setSettings((prev) => ({
...prev,
[section]: {
...prev[section],
[key]: value
}
}))
setIsModified(true)
}
/**
* Test ERP connection
*/
const handleTestErpConnection = async () => {
try {
const result = await window.electron.settings.testErpConnection()
if (result.success) {
showMessage(result.message || 'ERP 连接测试成功!', 'success')
} else {
showMessage(result.message || 'ERP 连接测试失败', 'error')
}
} catch (error) {
console.error('ERP connection test error:', error)
showMessage('ERP 连接测试失败', 'error')
}
}
/**
* Test database connection
*/
const handleTestDbConnection = async () => {
try {
const result = await window.electron.settings.testDbConnection()
if (result.success) {
showMessage(result.message || '数据库连接测试成功!', 'success')
} else {
showMessage(result.message || '数据库连接测试失败', 'error')
}
} catch (error) {
console.error('Database connection test error:', error)
showMessage('数据库连接测试失败', 'error')
}
}
/**
* Save settings
*/
const handleSaveSettings = async () => {
try {
const result = await window.electron.settings.saveSettings(settings)
if (result.success) {
showMessage('设置已保存', 'success')
setIsModified(false)
} else {
showMessage(result.error || '保存设置失败', 'error')
}
} catch (error) {
console.error('Save settings error:', error)
showMessage('保存设置失败', 'error')
}
}
/**
* Reset to default settings (Admin only)
*/
const handleResetDefaults = async () => {
if (!window.confirm('确定要恢复默认设置吗?这将覆盖 .env 文件中的所有配置。')) {
return
}
try {
const result = await window.electron.settings.resetDefaults()
if (result.success) {
showMessage('已恢复默认设置', 'success')
const loadedSettings = await window.electron.settings.getSettings()
setSettings(loadedSettings)
setIsModified(false)
} else {
showMessage(result.error || '恢复默认设置失败', 'error')
}
} catch (error) {
console.error('Reset defaults error:', error)
showMessage('恢复默认设置失败', 'error')
}
}
// Show loading state
if (isLoading) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner"></div>
<p>...</p>
</div>
<style>{`
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
const isUserOnly = userType === 'User'
return (
<div className="settings-page">
{/* Header */}
<div className="settings-header">
<h1></h1>
<span className="user-type-badge">
{userType === 'Admin' ? '管理员' : userType === 'User' ? '普通用户' : '访客'}
</span>
</div>
{/* Message toast */}
{message && (
<div className={`toast toast-${message.type}`}>
{message.text}
</div>
)}
{/* Settings content */}
<div className="settings-content">
{/* Admin: ERP System Configuration */}
{userType === 'Admin' && (
<>
<div className="settings-group">
<h2 className="settings-group-title">ERP </h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">ERP URL:</label>
<input
type="text"
className="form-input"
value={settings.erp.url}
onChange={(e) => updateSettings('erp', 'url', e.target.value)}
placeholder="https://68.11.34.30:8082/"
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.erp.username}
onChange={(e) => updateSettings('erp', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="password"
className="form-input"
value={settings.erp.password}
onChange={(e) => updateSettings('erp', 'password', e.target.value)}
/>
</div>
</div>
</div>
{/* Database Configuration */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.database.dbType}
onChange={(e) => updateSettings('database', 'dbType', e.target.value as DatabaseType)}
>
<option value="sqlserver">SQL Server</option>
<option value="mysql">MySQL</option>
</select>
</div>
{settings.database.dbType === 'mysql' ? (
<>
<div className="form-row">
<label className="form-label">MySQL :</label>
<input
type="text"
className="form-input"
value={settings.database.mysqlHost}
onChange={(e) => updateSettings('database', 'mysqlHost', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">MySQL :</label>
<input
type="number"
className="form-input"
value={settings.database.mysqlPort}
onChange={(e) => updateSettings('database', 'mysqlPort', parseInt(e.target.value) || 3306)}
/>
</div>
</>
) : (
<div className="form-row">
<label className="form-label">SQL Server :</label>
<input
type="text"
className="form-input"
value={settings.database.server}
onChange={(e) => updateSettings('database', 'server', e.target.value)}
/>
</div>
)}
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.database.database}
onChange={(e) => updateSettings('database', 'database', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.database.username}
onChange={(e) => updateSettings('database', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="password"
className="form-input"
value={settings.database.password}
onChange={(e) => updateSettings('database', 'password', e.target.value)}
/>
</div>
</div>
</div>
{/* Browser Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.headless}
onChange={(e) => updateSettings('erp', 'headless', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.ignoreHttpsErrors}
onChange={(e) => updateSettings('erp', 'ignoreHttpsErrors', e.target.checked)}
/>
<span> HTTPS </span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.autoCloseBrowser}
onChange={(e) => updateSettings('erp', 'autoCloseBrowser', e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
</div>
{/* Paths Configuration */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.dataDir}
onChange={(e) => updateSettings('paths', 'dataDir', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.defaultOutput}
onChange={(e) => updateSettings('paths', 'defaultOutput', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.validationOutput}
onChange={(e) => updateSettings('paths', 'validationOutput', e.target.value)}
/>
</div>
</div>
</div>
{/* Extraction Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.extraction.batchSize}
onChange={(e) => updateSettings('extraction', 'batchSize', parseInt(e.target.value) || 100)}
min="10"
max="500"
/>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.verbose}
onChange={(e) => updateSettings('extraction', 'verbose', e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.autoConvert}
onChange={(e) => updateSettings('extraction', 'autoConvert', e.target.checked)}
/>
<span> Excel </span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.mergeBatches}
onChange={(e) => updateSettings('extraction', 'mergeBatches', e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.enableDbPersistence}
onChange={(e) => updateSettings('extraction', 'enableDbPersistence', e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
</div>
{/* Validation Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.validation.dataSource}
onChange={(e) => updateSettings('validation', 'dataSource', e.target.value as ValidationDataSource)}
>
<option value="database_full">database_full</option>
<option value="database_filtered">database_filtered</option>
<option value="excel_existing">excel_existing</option>
<option value="excel_full">excel_full</option>
</select>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.validation.enableCrud}
onChange={(e) => updateSettings('validation', 'enableCrud', e.target.checked)}
/>
<span> CRUD </span>
</label>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.validation.batchSize}
onChange={(e) => updateSettings('validation', 'batchSize', parseInt(e.target.value) || 2000)}
min="100"
max="2000"
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.validation.matchMode}
onChange={(e) => updateSettings('validation', 'matchMode', e.target.value as MatchMode)}
>
<option value="substring"> (substring)</option>
<option value="exact"> (exact)</option>
</select>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.validation.defaultManager}
onChange={(e) => updateSettings('validation', 'defaultManager', e.target.value)}
/>
</div>
</div>
</div>
{/* UI Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.ui.fontFamily}
onChange={(e) => updateSettings('ui', 'fontFamily', e.target.value)}
>
<option value="Microsoft YaHei UI">Microsoft YaHei UI</option>
<option value="SimSun">SimSun ()</option>
<option value="KaiTi">KaiTi ()</option>
<option value="FangSong">FangSong (仿)</option>
<option value="Arial">Arial</option>
<option value="Segoe UI">Segoe UI</option>
</select>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.ui.fontSize}
onChange={(e) => updateSettings('ui', 'fontSize', parseInt(e.target.value) || 10)}
min="8"
max="24"
/>
</div>
<div className="form-row">
<label className="form-label">Production ID ():</label>
<input
type="number"
className="form-input input-small"
value={settings.ui.productionIdInputWidth}
onChange={(e) => updateSettings('ui', 'productionIdInputWidth', parseInt(e.target.value) || 20)}
min="10"
max="100"
/>
</div>
</div>
</div>
</>
)}
{/* User: ERP Credentials */}
{isUserOnly && (
<>
<div className="settings-group">
<h2 className="settings-group-title">ERP </h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">ERP :</label>
<input
type="text"
className="form-input"
value={settings.erp.username}
onChange={(e) => updateSettings('erp', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">ERP :</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type={showPassword ? 'text' : 'password'}
className="form-input"
value={settings.erp.password}
onChange={(e) => updateSettings('erp', 'password', e.target.value)}
style={{ flex: 1 }}
/>
<label className="form-checkbox-label" style={{ margin: 0, whiteSpace: 'nowrap' }}>
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
<div className="form-row">
<p className="form-hint">
使 ERP
</p>
</div>
</div>
</div>
{/* Paths Configuration (User) */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.dataDir}
onChange={(e) => updateSettings('paths', 'dataDir', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.defaultOutput}
onChange={(e) => updateSettings('paths', 'defaultOutput', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.validationOutput}
onChange={(e) => updateSettings('paths', 'validationOutput', e.target.value)}
/>
</div>
</div>
</div>
{/* Execution Settings (User) */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.execution.dryRun}
onChange={(e) => updateSettings('execution', 'dryRun', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row">
<p className="form-hint">
</p>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.headless}
onChange={(e) => updateSettings('erp', 'headless', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row">
<p className="form-hint">
</p>
</div>
</div>
</div>
</>
)}
</div>
{/* Button Group */}
<div className="settings-buttons">
<button className="btn btn-primary" onClick={handleTestErpConnection}>
ERP
</button>
<button className="btn btn-primary" onClick={handleTestDbConnection}>
</button>
<button
className="btn btn-success"
onClick={handleSaveSettings}
disabled={!isModified}
>
</button>
{userType === 'Admin' && (
<button className="btn btn-warning" onClick={handleResetDefaults}>
</button>
)}
</div>
{/* Styles */}
<style>{`
.settings-page {
padding: 24px;
background: #f5f7fa;
min-height: 100vh;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #e8e8e8;
}
.settings-header h1 {
font-size: 24px;
color: #333;
margin: 0;
}
.user-type-badge {
background: #e6f7ff;
color: #1890ff;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
z-index: 10000;
animation: slideDown 0.3s ease-out;
}
.toast-success {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
.toast-error {
background: #fff1f0;
border: 1px solid #ffa39e;
color: #ff4d4f;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.settings-content {
max-width: 900px;
margin: 0 auto;
}
.settings-group {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.settings-group-title {
font-size: 18px;
color: #333;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #e8e8e8;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-row {
display: flex;
align-items: center;
gap: 16px;
}
.form-label {
min-width: 120px;
font-size: 14px;
color: #666;
text-align: right;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
transition: all 0.3s;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-input.input-small {
width: 100px;
flex: none;
}
.form-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
background: #fff;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: #1890ff;
}
.checkbox-row {
justify-content: flex-start;
}
.form-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #333;
}
.form-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.form-hint {
color: #999;
font-size: 12px;
margin: 4px 0 0 136px;
}
.settings-buttons {
display: flex;
gap: 12px;
justify-content: center;
padding: 24px;
margin-top: 20px;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
}
.btn-success {
background: #52c41a;
color: #fff;
}
.btn-success:hover:not(:disabled) {
background: #73d13d;
}
.btn-warning {
background: #faad14;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: #ffc53d;
}
`}</style>
</div>
)
}
export default SettingsPage