20 Commits

Author SHA1 Message Date
Misaka
24d9bfebaf fix(logger): use app.isPackaged for log dir detection and add logging docs
Previously getLogDir() only checked app.isReady(), which caused
development builds to write logs to the user data directory instead
of the local project logs/ folder. Now uses app.isPackaged to
correctly distinguish production from development environments.

Also adds comprehensive logging system documentation and a debug
utility for verifying Electron environment detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:22:20 +08:00
Misaka
ba436cf374 feat(extraction): read headless mode from config instead of hardcoding
Add headless field to extraction config schema (default: true).
Extractor handler now reads globalConfig.extraction.headless instead
of hardcoding true. Users can set headless: false in config.yaml
to show the browser window during extraction for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:53:21 +08:00
Misaka
6413eef5b8 fix(logger): address code review findings
- Remove misleading await from audit-logger tests (functions are sync)
- Add cleanup() to LoggerAPI type definition in index.d.ts
- Fix circular reference fallback to preserve null values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:37:59 +08:00
Misaka
6a9d144bbc fix(logger): batch fix audit-logger, circular meta, and sync callers
- Make logAudit and closeAuditLogger synchronous (were async for no reason)
- Set audit-logger silent:true initially, enable on applyAuditConfig()
- Add try-catch for circular references in consoleFormat meta JSON
- Update all callers to remove unnecessary await/.catch() on sync functions
- Add comment to shared.ts explaining acceptable sync FS usage
- Fix audit-logger test for sync closeAuditLogger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:25:06 +08:00
Misaka
a2e3681c8f feat(logger): sync renderer log level cache and support verbose IPC
- Add LOGGER_LEVEL_CHANGED IPC channel for broadcasting level changes
- setLogLevel() now notifies all BrowserWindows when level changes
- Add verbose case in IPC forwardToWinston (was falling through to info)
- Renderer logger API listens for level changes and updates cached level
- Add cleanup() method to remove level change listener

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:18:56 +08:00
Misaka
21359b31c6 fix(logger): cache isProduction and prevent error double-serialization
Cache isProduction() result at module load to avoid repeated property
lookups. Add isSerializedError() check in format functions to skip
re-serialization when error objects have already been processed by
logError/formatErrorForLogging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:16:04 +08:00
Misaka
0a1181fecd fix(logger): use will-quit instead of before-quit and remove redundant console.error
Move logger close from before-quit to will-quit to keep the logger available
for uncaughtException handlers that may fire during shutdown. Remove 4
redundant console.error calls that duplicate Winston logger output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:13:36 +08:00
Misaka
883f98065a refactor(logger): optimize logging architecture with 6 improvements
1. Extract shared module: consolidate getLogDir() and isProduction()
   into shared.ts, eliminate duplication across logger modules
2. Make retention config effective: delay file transport creation
   until config is loaded, apply appRetention/auditRetention from config.yaml
3. Add before-quit log flush: close logger and audit logger on
   app exit to prevent log loss
4. Unify logError entry point: remove duplicate logError from index.ts,
   re-export from error-utils.ts with richer error context
5. Renderer log level filtering: add client-side level check in preload
   to skip IPC for filtered-out messages
6. Child logger cache + audit cleanup: cache child loggers in IPC
   handler for performance, remove redundant timestamp format in audit logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:53:53 +08:00
Misaka_Company
020bbcdccc fix(auth): retry silent login after logout to show user selection for Admin
When Admin switches user and the switched user logs out, instead of
showing the login dialog, re-run silent login to detect if the
computer belongs to an Admin user and show user selection dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:28:26 +08:00
Misaka_Company
63a292c5f9 1.8.0 2026-04-01 15:18:31 +08:00
Misaka_Company
51f8e0a6e7 docs: add release notes for version 1.8.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:18:02 +08:00
Misaka_Company
c8ab58d390 feat(history): add Admin-only delete button and multi-user filter with chips
- Add Admin-only delete button in operation history modal
- Replace dropdown with multi-select chip filters for Admin users
- Support filtering by multiple usernames using IN clause
- Fix user state propagation by passing currentUser via props
- Change GetBatchesOptions.username to usernames (array)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:17:20 +08:00
Misaka_Company
811361a1a3 1.7.2 2026-04-01 13:56:14 +08:00
Misaka_Company
ffbda4c618 docs: add release notes for version 1.7.2 2026-04-01 13:55:50 +08:00
Misaka_Company
348b02600d fix(time): use UTC methods for operation history display
The database stores time in UTC format, and the UI should display UTC
time without timezone conversion. Use getUTCXxx() methods instead of
getHours() to avoid adding 8-hour timezone offset.

Also extract common datetime formatting logic to reduce code duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:54:26 +08:00
Misaka_Company
d004f8e9f8 feat(history): add one-click copy for production IDs and order numbers
- Add copy buttons in table headers for "总排号" and "订单号" columns
- Copy all non-empty values as newline-separated text
- Show toast notification with copied data count
- Handle clipboard errors gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:00:31 +08:00
Misaka_Company
3cbe9eef12 1.7.1 2026-04-01 08:36:50 +08:00
Misaka_Company
5b310d944b docs: add release notes for version 1.7.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:36:09 +08:00
Misaka
6e04f21b10 fix(extractor): track per-order RecordCount in operation history
Previously updateBatchStatus wrote the batch-level total recordCount to
every row, causing the detail view to show misleading identical counts.
Now mergeFiles collects per-order material counts, the handler writes
each order's count individually via updateRecordStatus, and batch
aggregation uses SUM instead of MAX for accurate totals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 20:42:22 +08:00
Misaka
17fbd7d251 fix(extractor): resolve MySQL LIMIT placeholder error in operation history query
MySQL binary protocol prepared statements (connection.execute()) do not
support ? placeholders in LIMIT/OFFSET clauses, causing "Incorrect
arguments to mysqld_stmt_execute". Embed validated integer values directly
for MySQL while keeping parameterized queries for SQL Server.

Also apply React best practices to ExtractorOperationHistoryModal:
- Hoist formatDateTime to module level
- Wrap async handlers with useCallback for stable effect dependencies
- Import shared types instead of duplicating definitions
- Use ternary for conditional rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:42:44 +08:00
39 changed files with 1558 additions and 381 deletions

View File

@@ -40,6 +40,7 @@ extraction:
autoConvert: true autoConvert: true
mergeBatches: true mergeBatches: true
enableDbPersistence: true enableDbPersistence: true
headless: true # 浏览器无头模式true=后台运行false=显示浏览器窗口(调试用)
validation: validation:
dataSource: database_full dataSource: database_full

View File

@@ -0,0 +1,752 @@
# ERPAuto 日志系统实现文档
## 概述
ERPAuto 使用 **Winston** 作为核心日志库,实现了统一的主进程 - 渲染进程日志系统。系统支持日志级别管理、文件轮转、审计日志、错误全链路追踪等功能。
---
## 架构总览
```mermaid
graph TB
subgraph Renderer Process
RC[React Components]
UH[useLogger Hook]
LA[Logger API]
end
subgraph Preload Layer
PL[Preload Bridge]
LC[Level Cache]
end
subgraph Main Process
LH[Logger Handler]
IL[IPC Router]
WL[Winston Logger]
FT[File Transports]
CT[Console Transport]
AL[Audit Logger]
end
subgraph Storage
ALF[app-YYYY-MM-DD.log]
ELF[error-YYYY-MM-DD.log]
AUF[audit-YYYY-MM-DD.jsonl]
end
RC --> UH
UH --> LA
LA --> LC
LC -->|IPC Send| PL
PL -->|logger:forward| IL
IL --> LH
LH --> WL
WL --> CT
WL --> FT
FT --> ALF
FT --> ELF
AL --> AUF
style WL fill:#f9f,stroke:#333
style LH fill:#bbf,stroke:#333
style AL fill:#bfb,stroke:#333
```
---
## 核心组件
### 1. 主进程日志服务 (`src/main/services/logger/`)
#### 1.1 核心日志器 (`index.ts`)
```typescript
// 日志器创建与配置
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
const logger = winston.createLogger({
level: 'info',
defaultMeta: { service: 'erpauto' },
transports: [new winston.transports.Console({ format: consoleFormat })]
})
```
**关键特性:**
- **双格式输出**:控制台(彩色文本)+ 文件JSON
- **每日轮转**:日志文件按日期拆分,自动压缩归档
- **错误序列化**:完整捕获 stack trace 和自定义属性
- **环境感知**:生产环境自动脱敏敏感信息
#### 1.2 日志级别与优先级
```typescript
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'
export const LOG_LEVEL_PRIORITY: Record<string, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
```
#### 1.3 错误工具类 (`error-utils.ts`)
```mermaid
flowchart LR
A[Error Occurs] --> B{Error Type?}
B -->|Error Instance| C[serializeError]
B -->|Error-like| C
B -->|Other| D[Wrap as UnknownError]
C --> E{Production?}
D --> E
E -->|Yes| F[sanitizeError]
E -->|No| G[Keep Full Details]
F --> H[Redact Sensitive Keys]
G --> I[Preserve Stack Trace]
H --> J[Log Output]
I --> J
```
**序列化流程:**
1. 捕获所有 enumerable 和 non-enumerable 属性
2. 递归处理 error cause 链
3. 生产环境脱敏 password/token/secret 等敏感字段
4. 提取堆栈中的文件/行号/列号信息
---
### 2. 审计日志服务 (`audit-logger.ts`)
**用途**:记录用户操作审计日志,满足合规要求
```typescript
interface AuditEntry {
timestamp: string // ISO 8601 时间戳
action: string // 操作类型LOGIN, EXTRACT, DELETE
userId: string // 用户 ID
username: string // 用户名
computerName: string // 计算机名
resource: string // 受影响的资源
status: 'success' | 'failure' | 'partial'
metadata: Record<string, unknown>
}
```
**格式特点:**
- **JSONL 格式**:每行一个 JSON 对象,便于流式解析
- **30 天轮转**:默认保留 30 天审计日志
- **独立文件**`audit-YYYY-MM-DD.jsonl`
---
### 3. IPC 日志处理器 (`src/main/ipc/logger-handler.ts`)
```mermaid
sequenceDiagram
participant R as Renderer
participant B as Buffer State
participant W as Winston
participant F as File
R->>B: Send Log Entry
Note over B: Circuit Breaker Check
alt Error Level
B->>B: Always Buffer
else Non-Error & Buffer < 500
B->>B: Buffer Entry
else Buffer >= 500
B->>B: Discard + Count
end
Note over B: Batch Processing
B->>B: 100ms Debounce OR 50 entries
B->>W: Flush Batch
W->>F: Write to File
```
**批处理策略:**
| 参数 | 值 | 说明 |
|------|-----|------|
| `DEBOUNCE_MS` | 100ms | 防抖等待时间 |
| `MAX_BATCH_SIZE` | 50 | 最大批次大小 |
| `CIRCUIT_BREAKER_THRESHOLD` | 500 | 熔断阈值 |
**熔断机制:**
- 当缓冲区 > 500 条时,丢弃非错误日志
- 错误日志始终绕过熔断器
- 每丢弃 100 条记录一次警告
---
### 4. 渲染进程日志 Hook (`src/renderer/src/hooks/useLogger.ts`)
```typescript
// 使用示例
function MyComponent() {
const logger = useLogger('MyComponent')
const handleClick = () => {
logger.info('User clicked button', { buttonId: 'submit' })
}
const handleError = (err: Error) => {
logger.error('Operation failed', { error: err.message })
}
}
```
**客户端级别过滤:**
```typescript
// 在发送 IPC 前检查日志级别,避免无效 IPC 调用
if (!shouldLog(level)) return
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, { ... })
```
**FPS 监控:**
- 检测因过度日志导致的 UI 卡顿
- 当 FPS < 30 时发出警告
- 5 秒冷却期避免重复警告
---
### 5. 预加载层 API (`src/preload/api/logger.ts`)
```typescript
// 级别缓存机制
let cachedLevel: LogLevel = 'info'
// 监听主进程级别变更广播
ipcRenderer.on(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, (level) => {
cachedLevel = level
})
// 客户端过滤
function shouldLog(level: LogLevel): boolean {
return priorities[level] >= priorities[cachedLevel]
}
```
---
### 6. 配置管理 (`src/main/services/config/config-manager.ts`)
```yaml
# config.yaml 配置示例
logging:
level: info # 日志级别
auditRetention: 30 # 审计日志保留天数
appRetention: 14 # 应用日志保留天数
```
**配置加载时机:**
1. 应用启动时加载 `config.yaml`
2. 调用 `applyLoggingConfig()` 配置 Winston
3. 调用 `applyAuditConfig()` 配置审计日志
---
## 日志数据流
```mermaid
flowchart TD
subgraph 渲染进程
A[Component] --> B[useLogger Hook]
B --> C{Level Check}
C -->|Pass| D[loggerApi.log]
C -->|Skip| E[Drop]
end
subgraph IPC 传输
D --> F[logger:forward]
F --> G[Context Bridge]
end
subgraph 主进程
G --> H[Logger Handler]
H --> I{Circuit Breaker}
I -->|Pass| J[Batch Buffer]
I -->|Block| K[Discard Counter]
J --> L{Debounce Timer}
L -->|100ms| M[Flush to Winston]
J -->|50 entries| M
end
subgraph Winston
M --> N[Console Transport]
M --> O[File Transport]
O --> P{Error Level?}
P -->|Yes| Q[error-DATE.log]
P -->|All| R[app-DATE.log]
end
subgraph 审计日志
S[logAudit] --> T[Audit Logger]
T --> U[audit-DATE.jsonl]
end
```
---
## 日志文件组织
### 目录结构
```
AppData/Roaming/erpauto/logs/
├── app-2024-04-01.log
├── app-2024-04-01.log.gz # 压缩归档
├── app-2024-04-02.log
├── error-2024-04-01.log # 仅错误级别
├── error-2024-04-01.log.gz
├── audit-2024-04-01.jsonl # 审计日志
└── audit-2024-04-01.jsonl.gz
```
### 文件格式
**应用日志 (JSON 格式):**
```json
{
"level": "info",
"message": "Extractor started",
"timestamp": "2024-04-01 10:30:00",
"service": "erpauto",
"context": "Extractor",
"orders": ["SO001", "SO002"]
}
```
**错误日志 (含堆栈):**
```json
{
"level": "error",
"message": "Database connection failed",
"timestamp": "2024-04-01 10:31:00",
"error": {
"name": "ConnectionError",
"message": "ECONNREFUSED",
"stack": "ConnectionError: ECONNREFUSED\n at TCP.connectWrap (...)",
"code": "ECONNREFUSED"
}
}
```
**审计日志 (JSONL 格式):**
```jsonl
{"timestamp":"2024-04-01T10:30:00Z","action":"LOGIN","userId":"1","username":"admin","computerName":"DESKTOP-001","resource":"/auth","status":"success","metadata":{}}
{"timestamp":"2024-04-01T10:35:00Z","action":"EXTRACT","userId":"1","username":"admin","computerName":"DESKTOP-001","resource":"orders","status":"success","metadata":{"orderCount":50}}
```
---
## IPC 通道定义
```typescript
// src/shared/ipc-channels.ts
export const IPC_CHANNELS = {
// 日志转发renderer → main
LOGGER_FORWARD: 'logger:forward',
// 获取当前日志级别
LOGGER_GET_LEVEL: 'logger:getLevel',
// 级别变更广播main → renderer
LOGGER_LEVEL_CHANGED: 'logger:levelChanged'
}
```
---
## 使用指南
### 在主进程中记录日志
```typescript
import { createLogger } from '@/main/services/logger'
const log = createLogger('MyService')
// 基础用法
log.info('Operation started')
log.warn('Disk space low')
log.error('Failed to connect', { error: err })
// 带上下文的日志
log.info('Processing batch', {
batchId: 'B001',
itemCount: 100,
estimatedTime: '5min'
})
// 错误日志(自动序列化堆栈)
try {
await riskyOperation()
} catch (error) {
log.error('Operation failed', { error })
}
```
### 在渲染进程中记录日志
```typescript
import { useLogger } from '@/renderer/src/hooks/useLogger'
function MyComponent() {
const logger = useLogger('MyComponent')
useEffect(() => {
logger.info('Component mounted')
return () => logger.debug('Component unmounted')
}, [])
const handleAction = async () => {
try {
await api.doSomething()
logger.info('Action succeeded')
} catch (err) {
logger.error('Action failed', { error: err.message })
}
}
}
```
### 记录审计日志
```typescript
import { logAudit } from '@/main/services/logger/audit-logger'
// 用户登录审计
logAudit('LOGIN', userId, {
username: 'admin',
computerName: 'DESKTOP-001',
resource: '/auth',
status: 'success',
metadata: { loginMethod: 'password' }
})
// 数据提取审计
logAudit('EXTRACT', userId, {
username: 'user1',
computerName: 'DESKTOP-002',
resource: 'materials',
status: 'success',
metadata: { orderCount: 50, materialCount: 1200 }
})
```
---
## 高级功能
### 1. 日志级别动态切换
```mermaid
sequenceDiagram
participant U as User (UI)
participant C as ConfigManager
participant M as Main Logger
participant R as Renderer
participant L as Level Cache
U->>C: Update logging.level
C->>M: applyLoggingConfig(newLevel)
M->>M: logger.level = newLevel
M->>R: Broadcast levelChanged
R->>L: cachedLevel = newLevel
Note over L: Future logs filtered at client
```
**代码示例:**
```typescript
// 主进程设置级别
import { setLogLevel } from '@/main/services/logger'
setLogLevel('debug')
// 渲染进程自动同步
// useLogger Hook 会自动接收级别变更广播
// 客户端过滤自动生效
```
### 2. 生产环境错误脱敏
```typescript
// 自动脱敏以下关键字段
const sensitiveKeys = [
'password', 'secret', 'token', 'apiKey',
'credentials', 'authorization', 'privateKey'
]
// 生产环境错误消息
{
"name": "AuthError",
"message": "An error occurred due to invalid credentials or configuration"
// 原始错误消息被脱敏
}
```
### 3. 错误上下文提取
```typescript
// 从堆栈跟踪提取位置信息
const errorContext = extractErrorContext(serializedError)
// 输出:
{
fileName: 'extractor.ts',
lineNumber: 142,
columnName: 15,
functionName: 'runExtraction'
}
```
---
## 最佳实践
### ✅ 推荐做法
```typescript
// 1. 使用 createLogger 创建带上下文的子日志器
const log = createLogger('DatabaseService')
// 2. 记录错误时传递完整 Error 对象
log.error('Query failed', { error })
// 3. 使用结构化元数据
log.info('Batch processed', {
batchId: 'B001',
duration: 1250,
itemCount: 100
})
// 4. 渲染进程使用 useLogger Hook
const logger = useLogger('LoginForm')
// 5. 敏感信息使用审计日志
logAudit('DELETE', userId, { ... })
```
### ❌ 避免的做法
```typescript
// 1. 避免直接 console.log
console.log('debug') // ❌ 不会被 Winston 捕获
// 2. 避免只记录错误消息
log.error(err.message) // ❌ 丢失堆栈和类型
// 3. 避免循环引用元数据
const obj: any = {}
obj.self = obj
log.info('test', { obj }) // ❌ 序列化失败
// 4. 避免过度日志
for (let i = 0; i < 1000; i++) {
logger.info(`Item ${i}`) // ❌ 触发熔断
}
```
---
## 故障排查
### 问题:日志文件不生成
**检查清单:**
1. 确认 `config.yaml` 中 logging 配置正确
2. 检查日志目录权限
3. 查看控制台输出是否有 Winston 错误
4. 验证 `applyLoggingConfig()` 是否被调用
### 问题:渲染进程日志未到达主进程
**调试步骤:**
```typescript
// 1. 检查 IPC 通道是否注册
// src/main/ipc/index.ts 应包含:
registerLoggerHandlers()
// 2. 检查 preload 暴露
// src/preload/index.ts 应暴露:
contextBridge.exposeInMainWorld('electron', api)
// 3. 检查级别过滤
console.log(window.electron.logger) // 应存在
```
### 问题:生产环境错误信息不完整
**原因**:生产环境自动脱敏
**解决方案**
- 查看 `error-DATE.log` 获取完整错误
- 开发环境禁用脱敏:设置开发模式构建
---
## 测试支持
### 单元测试示例
```typescript
import { createLogger } from '@/main/services/logger'
describe('Logger', () => {
it('should log with context', () => {
const log = createLogger('TestService')
// 测试逻辑...
expect(log).toBeDefined()
})
})
```
### 集成测试
```typescript
// tests/integration/ipc-logging.test.ts
import { loggerApi } from '@/preload/api/logger'
test('Renderer logs should reach Winston', async () => {
// Mock Winston transport
// Send log via IPC
// Assert log appears in main process
})
```
---
## 配置参考
### config.yaml 完整配置
```yaml
logging:
# 日志级别error | warn | info | debug | verbose
level: info
# 审计日志保留天数
auditRetention: 30
# 应用日志保留天数
appRetention: 14
```
### 日志级别说明
| 级别 | 使用场景 | 示例 |
| --------- | -------------- | ---------------------------- |
| `error` | 系统错误、异常 | 数据库连接失败、文件写入错误 |
| `warn` | 可恢复的警告 | 磁盘空间不足、重试操作 |
| `info` | 业务操作记录 | 用户登录、提取开始/结束 |
| `debug` | 技术调试信息 | API 请求参数、SQL 语句 |
| `verbose` | 详细跟踪 | 循环迭代、中间状态 |
---
## 相关文件索引
| 文件路径 | 职责 |
| -------------------------------------------- | ------------------ |
| `src/main/services/logger/index.ts` | Winston 日志器核心 |
| `src/main/services/logger/shared.ts` | 共享工具函数 |
| `src/main/services/logger/error-utils.ts` | 错误序列化/脱敏 |
| `src/main/services/logger/audit-logger.ts` | 审计日志服务 |
| `src/main/ipc/logger-handler.ts` | IPC 批处理与熔断 |
| `src/renderer/src/hooks/useLogger.ts` | React Hook |
| `src/preload/api/logger.ts` | Preload API |
| `src/shared/ipc-channels.ts` | IPC 通道定义 |
| `src/main/services/config/config-manager.ts` | 配置管理 |
---
## 架构图附录
### 完整日志系统架构
```mermaid
graph TB
subgraph 渲染进程 Renderer
UI[UI Components]
HL[useLogger Hook]
CF[Client Filter]
LC[Level Cache]
end
subgraph 预加载层 Preload
CB[Context Bridge]
IR[IPC Renderer]
LA[Logger API]
end
subgraph 主进程 Main
IH[IPC Handler]
BB[Batch Buffer]
CB2[Circuit Breaker]
WL[Winston Logger]
AC[Audit Logger]
CM[Config Manager]
end
subgraph 传输层 Transports
CT[Console]
AFT[App File]
EFT[Error File]
ATF[Audit File]
end
subgraph 文件系统 File System
ALF[app-DATE.log]
ELF[error-DATE.log]
AUF[audit-DATE.jsonl]
GZ[.gz Archive]
end
UI --> HL
HL --> CF
CF --> LC
LC --> LA
LA --> IR
IR --> CB
CB --> IH
IH --> CB2
CB2 --> BB
BB --> WL
WL --> CT
WL --> AFT
WL --> EFT
AC --> ATF
CM --> WL
AFT --> ALF
EFT --> ELF
ATF --> AUF
ALF --> GZ
ELF --> GZ
AUF --> GZ
style WL fill:#f9f,stroke:#333
style BB fill:#bbf,stroke:#333
style CB2 fill:#fbb,stroke:#333
style AC fill:#bfb,stroke:#333
```
---
_文档生成日期2026-04-04_
_项目版本ERPAuto v1.x_

View File

@@ -49,7 +49,7 @@ graph TD
## 文档职责一览 ## 文档职责一览
| 文档 | 主要回答的问题 | | 文档 | 主要回答的问题 |
| --- | --- | | ------------------------- | -------------------------------------------------- |
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 | | `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 | | `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
| `data-flow.md` | 核心业务数据如何在各层之间流动 | | `data-flow.md` | 核心业务数据如何在各层之间流动 |

View File

@@ -67,7 +67,7 @@ flowchart TD
## 当前文档一览 ## 当前文档一览
| 文档 | 主要内容 | | 文档 | 主要内容 |
| --- | --- | | ------------------------- | ---------------------------------------- |
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 | | `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 | | `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 | | `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |

View File

@@ -60,7 +60,7 @@ graph TD
## 模块目录一览 ## 模块目录一览
| 模块 | 文档 | 核心职责 | | 模块 | 文档 | 核心职责 |
| --- | --- | --- | | ---------- | --------------- | --------------------------------------------------------- |
| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 | | Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 |
| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 | | Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 |
| Validation | `validation.md` | 共享 Production IDs、校验查询、结果富化、Cleaner 数据准备 | | Validation | `validation.md` | 共享 Production IDs、校验查询、结果富化、Cleaner 数据准备 |

6
docs/releases/1.7.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.7.1
## 问题修复
- 修复 MySQL 数据库下操作历史查询报错问题。
- 优化历史记录数据结构,支持按订单统计记录数量。

6
docs/releases/1.7.2.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.7.2
## 问题修复
- 修复操作历史时间显示错误时区转换导致时间快8小时
- 操作历史支持一键复制总排号和订单号。

12
docs/releases/1.8.0.md Normal file
View File

@@ -0,0 +1,12 @@
# 1.8.0
## 权限控制
- 操作历史删除按钮仅对管理员可见,普通用户无法删除历史记录。
- 修复用户状态传递问题,确保权限判断正确生效。
## 界面与交互
- 管理员可使用多选标签Chip按用户筛选操作历史。
- 支持同时选择多个用户查看记录,点击标签即可切换选中状态。
- 添加"清空筛选"按钮,一键恢复显示所有用户记录。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.8.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.8.0",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",

View File

@@ -1,40 +1,45 @@
import { app } from 'electron' import { app } from 'electron'
import logger from '../services/logger/index' import logger from '../services/logger/index'
import { logAudit } from '../services/logger/audit-logger' import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
export function setupProcessGuards(): void { export function setupProcessGuards(): void {
process.on('uncaughtException', async (err) => { process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err }) logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', { logAudit('SYSTEM_CRASH', 'system', {
username: 'system', username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown', computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process', resource: 'main-process',
status: 'failure', status: 'failure',
metadata: { error: err.message, stack: err.stack } metadata: { error: err.message, stack: err.stack }
}) })
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000) setTimeout(() => process.exit(1), 1000)
}) })
process.on('unhandledRejection', async (reason) => { process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection', { reason: String(reason) }) logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', { logAudit('SYSTEM_ERROR', 'system', {
username: 'system', username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown', computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process', resource: 'main-process',
status: 'failure', status: 'failure',
metadata: { reason: String(reason) } metadata: { reason: String(reason) }
}) })
console.error('Unhandled Rejection:', reason)
}) })
app.on('render-process-gone', (_, webContents, details) => { app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id }) logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
}) })
app.on('child-process-gone', (_, details) => { app.on('child-process-gone', (_, details) => {
logger.error('Child process gone', { details }) logger.error('Child process gone', { details })
console.error('Child process gone:', details) })
// Flush and close loggers on will-quit (fires after all windows are closed,
// but before the event loop stops). Using will-quit instead of before-quit
// ensures the logger remains available for uncaughtException handlers that
// may fire between before-quit and actual process exit.
app.on('will-quit', () => {
logger.close()
closeAuditLogger()
}) })
} }

View File

@@ -90,6 +90,10 @@ export function registerExtractorHandlers(): void {
log.info('Fetching ERP configuration from database...') log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig() const erpConfig = await getErpConfig()
// Read headless setting from global config
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
log.info('ERP config retrieved', { log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY', url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY' username: erpConfig.username ? 'configured' : 'EMPTY'
@@ -188,7 +192,7 @@ export function registerExtractorHandlers(): void {
url: erpConfig.url, url: erpConfig.url,
username: erpConfig.username, username: erpConfig.username,
password: erpConfig.password, password: erpConfig.password,
headless: true headless: globalConfig.extraction.headless
}) })
sendProgress(sender, '登录 ERP 系统...', 9.99, { sendProgress(sender, '登录 ERP 系统...', 9.99, {
@@ -256,7 +260,20 @@ export function registerExtractorHandlers(): void {
: result.errors.length > 0 : result.errors.length > 0
? 'failed' ? 'failed'
: 'success' : 'success'
await historyDao.updateBatchStatus(batchId, status, result.recordCount)
// Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(
batchId,
orderNumber,
status,
undefined,
recordCount
)
}
// Update batch status without recordCount (per-order counts are set individually)
await historyDao.updateBatchStatus(batchId, status)
log.info('Operation history batch status updated', { batchId, status }) log.info('Operation history batch status updated', { batchId, status })
} }
@@ -279,7 +296,7 @@ export function registerExtractorHandlers(): void {
recordCount: result.recordCount, recordCount: result.recordCount,
errorCount: result.errors.length errorCount: result.errors.length
} }
}).catch((err) => log.warn('Failed to write audit log', { err })) })
} }
return result return result

View File

@@ -68,15 +68,21 @@ export function withErrorHandling<T>(
} }
if (isBaseError(error)) { if (isBaseError(error)) {
logError(log, `[${context}] ${error.name}`, error, { logError(log, error, {
message: `[${context}] ${error.name}`,
context: {
code, code,
cause: getErrorCauseMessage(error), cause: getErrorCauseMessage(error),
handler: context handler: context
}
}) })
} else { } else {
logError(log, `[${context}] Error`, error, { logError(log, error, {
message: `[${context}] Error`,
context: {
code, code,
handler: context handler: context
}
}) })
} }

View File

@@ -10,7 +10,9 @@
*/ */
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import winston from 'winston'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import logger from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler') const log = createLogger('LoggerHandler')
@@ -41,6 +43,7 @@ class LoggerHandlerState {
private buffer: LogEntry[] = [] private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0 private discardedCount = 0
private childLoggerCache = new Map<string, winston.Logger>()
/** /**
* Add log entry to buffer * Add log entry to buffer
@@ -131,22 +134,36 @@ class LoggerHandlerState {
} }
} }
/**
* Get or create a cached child logger for a component
* Avoids creating a new child logger for every log entry
* @param component - Component name for the child logger
*/
private getChildLogger(component: string): winston.Logger {
let child = this.childLoggerCache.get(component)
if (!child) {
child = log.child({ source: 'renderer', component })
this.childLoggerCache.set(component, child)
}
return child
}
/** /**
* Forward a single log entry to Winston logger * Forward a single log entry to Winston logger
* @param entry - Log entry to forward * @param entry - Log entry to forward
*/ */
private forwardToWinston(entry: LogEntry): void { private forwardToWinston(entry: LogEntry): void {
const context = (entry.context?.component as string) || 'renderer' const context = (entry.context?.component as string) || 'renderer'
const childLogger = log.child({ const childLogger = this.getChildLogger(context)
source: 'renderer',
component: context
})
const message = entry.context?.message const message = entry.context?.message
? `[${entry.context.message}] ${entry.message}` ? `[${entry.context.message}] ${entry.message}`
: entry.message : entry.message
switch (entry.level) { switch (entry.level) {
case 'verbose':
childLogger.verbose(message, entry.context)
break
case 'debug': case 'debug':
childLogger.debug(message, entry.context) childLogger.debug(message, entry.context)
break break
@@ -187,6 +204,7 @@ class LoggerHandlerState {
} }
this.buffer = [] this.buffer = []
this.discardedCount = 0 this.discardedCount = 0
this.childLoggerCache.clear()
} }
} }
@@ -197,6 +215,11 @@ const state = new LoggerHandlerState()
* Register IPC handlers for logger * Register IPC handlers for logger
*/ */
export function registerLoggerHandlers(): void { export function registerLoggerHandlers(): void {
// Return current log level to preload for client-side filtering
ipcMain.handle(IPC_CHANNELS.LOGGER_GET_LEVEL, () => {
return logger.level as LogLevel
})
// Use ipcMain.on with send() - fire-and-forget, non-blocking // Use ipcMain.on with send() - fire-and-forget, non-blocking
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => { ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry // Validate entry

View File

@@ -27,16 +27,13 @@ export function registerSettingsHandlers(): void {
const erpConfigService = UserErpConfigService.getInstance() const erpConfigService = UserErpConfigService.getInstance()
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => { ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling( return withErrorHandling(async () => {
async () => {
const userType = sessionManager.getUserType() const userType = sessionManager.getUserType()
if (!userType) { if (!userType) {
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT') throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
} }
return userType as UserType return userType as UserType
}, }, 'settings:getUserType')
'settings:getUserType'
)
}) })
ipcMain.handle( ipcMain.handle(
@@ -77,7 +74,7 @@ export function registerSettingsHandlers(): void {
resource: 'ERP_CONFIG', resource: 'ERP_CONFIG',
status: 'success', status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username } metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
}).catch((err) => log.warn('Failed to write audit log', { err })) })
} }
return { success: true } return { success: true }

View File

@@ -176,8 +176,6 @@ export class AuthApplicationService {
actorId: string, actorId: string,
payload: Parameters<typeof logAudit>[2] payload: Parameters<typeof logAudit>[2]
): void { ): void {
logAudit(action, actorId, payload).catch((err) => logAudit(action, actorId, payload)
log.warn('Failed to write audit log', { err })
)
} }
} }

View File

@@ -274,7 +274,7 @@ export class CleanerApplicationService {
? 'failure' ? 'failure'
: 'success' : 'success'
await logAudit('CLEAN', String(currentUser.id), { logAudit('CLEAN', String(currentUser.id), {
username: currentUser.username, username: currentUser.username,
computerName: (await import('os')).hostname(), computerName: (await import('os')).hostname(),
resource: 'MATERIAL_PLAN', resource: 'MATERIAL_PLAN',
@@ -288,7 +288,7 @@ export class CleanerApplicationService {
materialsSkipped: result.materialsSkipped, materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length errorCount: result.errors.length
} }
}).catch((err) => log.warn('Failed to write audit log', { err })) })
} }
private async generateAndUploadReport( private async generateAndUploadReport(

View File

@@ -20,7 +20,8 @@ import { dirname } from 'path'
import { app } from 'electron' import { app } from 'electron'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { z } from 'zod' import { z } from 'zod'
import { createLogger, setLogLevel } from '../logger' import { createLogger, applyLoggingConfig } from '../logger'
import { applyAuditConfig } from '../logger/audit-logger'
import { import {
fullConfigSchema, fullConfigSchema,
type FullConfig, type FullConfig,
@@ -77,7 +78,8 @@ const DEFAULT_CONFIG: FullConfig = {
verbose: true, verbose: true,
autoConvert: true, autoConvert: true,
mergeBatches: true, mergeBatches: true,
enableDbPersistence: true enableDbPersistence: true,
headless: true
}, },
validation: { validation: {
dataSource: 'database_full', dataSource: 'database_full',
@@ -170,7 +172,8 @@ export class ConfigManager {
await this.saveConfig(DEFAULT_CONFIG) await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG this.config = DEFAULT_CONFIG
// Apply logging configuration from default config // Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level) applyLoggingConfig(DEFAULT_CONFIG.logging)
applyAuditConfig(DEFAULT_CONFIG.logging.auditRetention)
return return
} }
@@ -190,7 +193,8 @@ export class ConfigManager {
this.config = validated this.config = validated
// Apply logging configuration // Apply logging configuration
setLogLevel(validated.logging.level) applyLoggingConfig(validated.logging)
applyAuditConfig(validated.logging.auditRetention)
log.info('Configuration loaded and validated successfully') log.info('Configuration loaded and validated successfully')
} catch (error) { } catch (error) {

View File

@@ -21,6 +21,17 @@ import type {
const log = createLogger('ExtractorOperationHistoryDAO') const log = createLogger('ExtractorOperationHistoryDAO')
/**
* Format datetime value from database to ISO string
* mssql driver returns Date objects in UTC format
*/
function formatDateTime(value: unknown): string {
if (value instanceof Date) {
return value.toISOString()
}
return value ? String(value) : new Date().toISOString()
}
/** /**
* Configuration for ExtractorOperationHistory table * Configuration for ExtractorOperationHistory table
*/ */
@@ -160,43 +171,24 @@ export class ExtractorOperationHistoryDAO {
* Update the status of all records in a batch * Update the status of all records in a batch
* @param batchId - Batch identifier * @param batchId - Batch identifier
* @param status - New status (success, failed, partial) * @param status - New status (success, failed, partial)
* @param recordCount - Total record count for the batch
* @returns Update result * @returns Update result
*/ */
async updateBatchStatus( async updateBatchStatus(batchId: string, status: string): Promise<UpdateBatchStatusResult> {
batchId: string,
status: string,
recordCount: number | null
): Promise<UpdateBatchStatusResult> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver' const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?' const sqlString = `
let sqlString: string
let params: (string | number | null)[]
if (recordCount !== null) {
sqlString = `
UPDATE ${tableName} UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}, SET Status = ${isSqlServer ? '@p0' : '?'}
RecordCount = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
`
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${placeholder}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'} WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
` `
params = isSqlServer ? [status, batchId] : [status, batchId] const params = [status, batchId]
}
await dbService.query(sqlString, params) await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status, recordCount }) log.info('Batch status updated', { batchId, status })
return { success: true, updatedCount: 1 } return { success: true, updatedCount: 1 }
} catch (error) { } catch (error) {
log.error('Update batch status error', { log.error('Update batch status error', {
@@ -208,33 +200,51 @@ export class ExtractorOperationHistoryDAO {
} }
/** /**
* Update a single record's status and error message * Update a single record's status, error message, and optional record count
* @param batchId - Batch identifier * @param batchId - Batch identifier
* @param orderNumber - Order number * @param orderNumber - Order number
* @param status - New status * @param status - New status
* @param errorMessage - Optional error message * @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful * @returns True if successful
*/ */
async updateRecordStatus( async updateRecordStatus(
batchId: string, batchId: string,
orderNumber: string, orderNumber: string,
status: string, status: string,
errorMessage?: string errorMessage?: string,
recordCount?: number
): Promise<boolean> { ): Promise<boolean> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver' const isSqlServer = dbService.type === 'sqlserver'
const sqlString = ` let sqlString: string
let params: (string | number | null)[]
if (recordCount !== undefined) {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
RecordCount = ${isSqlServer ? '@p2' : '?'}
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
`
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
} else {
sqlString = `
UPDATE ${tableName} UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}, SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'} ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'} WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'} AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
` `
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber]) await dbService.query(sqlString, params)
return true return true
} catch (error) { } catch (error) {
@@ -252,7 +262,7 @@ export class ExtractorOperationHistoryDAO {
/** /**
* Get batch statistics with optional user filtering * Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering (Admin gets all, User gets own) * @param userId - Optional user ID for filtering (Admin gets all, User gets own)
* @param options - Query options (limit, offset) * @param options - Query options (limit, offset, usernames)
* @returns Array of batch statistics * @returns Array of batch statistics
*/ */
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> { async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
@@ -280,6 +290,11 @@ export class ExtractorOperationHistoryDAO {
if (userId !== undefined) { if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId) params.push(userId)
} else if (options?.usernames && options.usernames.length > 0) {
// Admin user filtering by multiple usernames using IN clause
const placeholders = this.buildPlaceholders(options.usernames.length, isSqlServer)
sqlString += ` WHERE Username IN (${placeholders}) `
params.push(...options.usernames)
} }
sqlString += ` sqlString += `
@@ -288,31 +303,30 @@ export class ExtractorOperationHistoryDAO {
` `
if (options?.limit) { if (options?.limit) {
// Add pagination - track current param count before adding new params const safeLimit = Math.floor(options.limit)
const offsetIndex = params.length const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
const limitIndex = params.length + 1
if (options.offset !== undefined) {
params.push(options.offset)
}
params.push(options.limit)
if (isSqlServer) { if (isSqlServer) {
if (options.offset !== undefined) { // SQL Server: use parameterized OFFSET/FETCH
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY` const offsetIndex = params.length
if (safeOffset !== undefined) {
params.push(safeOffset)
}
params.push(safeLimit)
if (safeOffset !== undefined) {
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
} else { } else {
// When no offset, use 0 for offset and next index for limit
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY` sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
} }
} else { } else {
if (options.offset !== undefined) { // MySQL: embed validated integer values directly.
sqlString += ` LIMIT ?` // connection.execute() uses binary protocol prepared statements,
// For MySQL with offset, we need to modify the query // which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
// Replace LIMIT with OFFSET LIMIT if (safeOffset !== undefined) {
const parts = sqlString.split(' LIMIT ?') sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
} else { } else {
sqlString += ` LIMIT ?` sqlString += ` LIMIT ${safeLimit}`
} }
} }
} }
@@ -323,9 +337,7 @@ export class ExtractorOperationHistoryDAO {
batchId: row.BatchId as string, batchId: row.BatchId as string,
userId: row.UserId as number, userId: row.UserId as number,
username: row.Username as string, username: row.Username as string,
operationTime: row.OperationTime operationTime: formatDateTime(row.OperationTime),
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string, status: row.Status as string,
totalOrders: row.TotalOrders as number, totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0, totalRecords: (row.TotalRecords as number) || 0,
@@ -431,9 +443,7 @@ export class ExtractorOperationHistoryDAO {
batchId: row.BatchId as string, batchId: row.BatchId as string,
userId: row.UserId as number, userId: row.UserId as number,
username: row.Username as string, username: row.Username as string,
operationTime: row.OperationTime operationTime: formatDateTime(row.OperationTime),
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string, status: row.Status as string,
totalOrders: row.TotalOrders as number, totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0, totalRecords: (row.TotalRecords as number) || 0,
@@ -565,9 +575,10 @@ export class ExtractorOperationHistoryDAO {
/** /**
* Count total batches with optional user filtering * Count total batches with optional user filtering
* @param userId - Optional user ID for filtering * @param userId - Optional user ID for filtering
* @param usernames - Optional usernames filter for Admin users
* @returns Total number of batches * @returns Total number of batches
*/ */
async countBatches(userId?: number): Promise<number> { async countBatches(userId?: number, usernames?: string[]): Promise<number> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
@@ -578,11 +589,16 @@ export class ExtractorOperationHistoryDAO {
FROM ${tableName} FROM ${tableName}
` `
const params: number[] = [] const params: (number | string)[] = []
if (userId !== undefined) { if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId) params.push(userId)
} else if (usernames && usernames.length > 0) {
// Admin user filtering by multiple usernames using IN clause
const placeholders = this.buildPlaceholders(usernames.length, isSqlServer)
sqlString += ` WHERE Username IN (${placeholders}) `
params.push(...usernames)
} }
const result = await dbService.query(sqlString, params) const result = await dbService.query(sqlString, params)

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [], downloadedFiles: [],
mergedFile: null, mergedFile: null,
recordCount: 0, recordCount: 0,
errors: [] errors: [],
orderRecordCounts: []
} }
try { try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles) const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any // Add merge error to result if any
if (mergeResult.error) { if (mergeResult.error) {
@@ -121,11 +123,14 @@ export class ExtractorService {
* @param filePaths - Array of downloaded Excel file paths * @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message * @returns Merged file path, total record count, and optional error message
*/ */
private async mergeFiles( private async mergeFiles(filePaths: string[]): Promise<{
filePaths: string[] mergedFile: string | null
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> { recordCount: number
error?: string
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}> {
if (filePaths.length === 0) { if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 } return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
} }
log.info('Starting merge', { fileCount: filePaths.length }) log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +159,21 @@ export class ExtractorService {
// Calculate total record count (total material rows) // Calculate total record count (total material rows)
let recordCount = 0 let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
for (const order of allOrders) { for (const order of allOrders) {
recordCount += order.materials.length const count = order.materials.length
recordCount += count
orderRecordCounts.push({
orderNumber: order.orderInfo.productionOrder || '',
recordCount: count
})
} }
log.info('Merge summary', { orderCount: allOrders.length, recordCount }) log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) { if (recordCount === 0) {
log.warn('No records found in any downloaded files') log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 } return { mergedFile: null, recordCount: 0, orderRecordCounts }
} }
// Generate output filename with timestamp // Generate output filename with timestamp
@@ -178,13 +189,18 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath }) log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath) await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount }) log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount } return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error) const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : '' const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack }) log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails // Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` } return {
mergedFile: null,
recordCount,
orderRecordCounts,
error: `保存合并文件失败:${errorMsg}`
}
} }
} }

View File

@@ -6,8 +6,7 @@
import winston from 'winston' import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { app } from 'electron' import { getLogDir } from './shared'
import fs from 'fs'
/** /**
* Audit log entry structure * Audit log entry structure
@@ -32,22 +31,6 @@ export interface AuditEntry {
metadata: Record<string, unknown> metadata: Record<string, unknown>
} }
/**
* Get the log directory for audit logs
* Uses app.getPath('logs') in production, local logs dir in development
*/
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/** /**
* JSONL formatter - outputs one JSON object per line * JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter * This is the key difference from the standard JSON formatter
@@ -59,26 +42,43 @@ const jsonlFormat = winston.format.printf(({ message }) => {
/** /**
* Create the audit logger instance with daily rotation * Create the audit logger instance with daily rotation
* Configured for 30-day retention as per requirements * Initially silent (no transports). Call applyAuditConfig() after config is loaded.
*/ */
const auditLogger = winston.createLogger({ const auditLogger = winston.createLogger({
level: 'info', level: 'info',
silent: false, silent: true,
transports: [ transports: []
})
/**
* Apply audit log retention configuration
* Creates the DailyRotateFile transport with the configured retention period
*
* @param retentionDays - Number of days to retain audit logs
*/
export function applyAuditConfig(retentionDays: number): void {
// Enable logging now that config is loaded
auditLogger.silent = false
// Remove existing DailyRotateFile transports
const existingTransports = auditLogger.transports.filter((t) => t instanceof DailyRotateFile)
for (const transport of existingTransports) {
auditLogger.remove(transport)
}
// Add audit transport with configured retention
auditLogger.add(
new DailyRotateFile({ new DailyRotateFile({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'), filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '30d', // 30-day retention maxFiles: `${retentionDays}d`,
level: 'info', level: 'info',
format: winston.format.combine( format: jsonlFormat
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), })
jsonlFormat
) )
}) }
]
})
/** /**
* Log an audit event * Log an audit event
@@ -86,9 +86,8 @@ const auditLogger = winston.createLogger({
* @param action - The action that was performed * @param action - The action that was performed
* @param userId - User ID who performed the action * @param userId - User ID who performed the action
* @param details - Additional details including username, computerName, resource, status, and optional metadata * @param details - Additional details including username, computerName, resource, status, and optional metadata
* @returns Promise that resolves when the log is written (non-blocking)
*/ */
export async function logAudit( export function logAudit(
action: string, action: string,
userId: string, userId: string,
details: { details: {
@@ -98,7 +97,7 @@ export async function logAudit(
status: 'success' | 'failure' | 'partial' status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
): Promise<void> { ): void {
const entry: AuditEntry = { const entry: AuditEntry = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
action, action,
@@ -118,8 +117,7 @@ export async function logAudit(
/** /**
* Flush and close the audit logger (call on app shutdown) * Flush and close the audit logger (call on app shutdown)
*/ */
export async function closeAuditLogger(): Promise<void> { export function closeAuditLogger(): void {
// Winston logger.close() is synchronous
auditLogger.close() auditLogger.close()
} }

View File

@@ -6,6 +6,7 @@
*/ */
import type { ErrorLike, SerializedError } from '../../types/errors' import type { ErrorLike, SerializedError } from '../../types/errors'
import { isProduction } from './shared'
/** /**
* Check if value is an Error or Error-like object * Check if value is an Error or Error-like object
@@ -84,7 +85,7 @@ export function sanitizeError(error: SerializedError): SerializedError {
const sanitized: SerializedError = { ...error } const sanitized: SerializedError = { ...error }
// Sanitize message in production // Sanitize message in production
if (process.env.NODE_ENV === 'production') { if (isProduction()) {
// Keep error name and structure, but sanitize message // Keep error name and structure, but sanitize message
if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) { if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) {
sanitized.message = 'An error occurred due to invalid credentials or configuration' sanitized.message = 'An error occurred due to invalid credentials or configuration'
@@ -165,7 +166,7 @@ export function formatErrorForLogging(
metadata: Record<string, unknown> metadata: Record<string, unknown>
} { } {
const serialized = serializeError(error) const serialized = serializeError(error)
const isProd = process.env.NODE_ENV === 'production' const isProd = isProduction()
const errorToLog = isProd ? sanitizeError(serialized) : serialized const errorToLog = isProd ? sanitizeError(serialized) : serialized
const errorContext = extractErrorContext(errorToLog) const errorContext = extractErrorContext(errorToLog)

View File

@@ -11,25 +11,27 @@
import winston from 'winston' import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { app } from 'electron' import { BrowserWindow } from 'electron'
import fs from 'fs'
import { serializeError, sanitizeError } from './error-utils' import { serializeError, sanitizeError } from './error-utils'
import { getLogDir, isProduction } from './shared'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
// Get log directory - use app.getPath('logs') in production, or local logs dir in development // Cache isProduction() at module load — app.isPackaged never changes at runtime
function getLogDir(): string { const IS_PROD = isProduction()
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
// Check if running in production /**
const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production' * Check if an error has already been serialized (plain object with name/message but not an Error instance).
* Prevents double-serialization when logError() output passes through the format pipeline.
*/
function isSerializedError(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof Error) &&
'name' in value &&
'message' in value
)
}
// Custom format for console output - includes full error details // Custom format for console output - includes full error details
const consoleFormat = winston.format.combine( const consoleFormat = winston.format.combine(
@@ -41,7 +43,12 @@ const consoleFormat = winston.format.combine(
// Format error with full stack trace // Format error with full stack trace
let errorStr = '' let errorStr = ''
if (error) { if (error) {
const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error) // Skip re-serialization if already a serialized error object
const serialized: { stack?: string; message: string } = isSerializedError(error)
? (error as { stack?: string; message: string })
: IS_PROD
? sanitizeError(serializeError(error))
: serializeError(error)
if (serialized.stack) { if (serialized.stack) {
errorStr = `\n${serialized.stack}` errorStr = `\n${serialized.stack}`
} else { } else {
@@ -49,7 +56,24 @@ const consoleFormat = winston.format.combine(
} }
} }
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '' let metaStr = ''
if (Object.keys(meta).length > 0) {
try {
metaStr = ` ${JSON.stringify(meta, null, 2)}`
} catch {
// Fallback for circular references: stringify primitives, replace complex objects with placeholder
metaStr = ` ${JSON.stringify(
Object.fromEntries(
Object.entries(meta).map(([k, v]) => [
k,
v !== null && typeof v === 'object' ? `[Object]` : v
])
),
null,
2
)}`
}
}
return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}` return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
}) })
) )
@@ -58,19 +82,19 @@ const consoleFormat = winston.format.combine(
const fileFormat = winston.format.combine( const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format((info) => { winston.format((info) => {
// Serialize errors in metadata // Serialize errors in metadata (skip if already serialized)
if (info.error) { if (info.error) {
info.error = isProduction if (!isSerializedError(info.error)) {
info.error = IS_PROD
? sanitizeError(serializeError(info.error)) ? sanitizeError(serializeError(info.error))
: serializeError(info.error) : serializeError(info.error)
} }
}
// Serialize any error in meta fields // Serialize any error in meta fields (skip if already serialized)
for (const key of Object.keys(info)) { for (const key of Object.keys(info)) {
if (key !== 'error' && info[key] instanceof Error) { if (key !== 'error' && info[key] instanceof Error) {
info[key] = isProduction info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key])
? sanitizeError(serializeError(info[key]))
: serializeError(info[key])
} }
} }
@@ -80,19 +104,20 @@ const fileFormat = winston.format.combine(
) )
// Daily rotate file transport configuration // Daily rotate file transport configuration
const createFileTransport = (level?: string): DailyRotateFile => { const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => {
return new DailyRotateFile({ return new DailyRotateFile({
filename: path.join(getLogDir(), 'app-%DATE%.log'), filename: path.join(getLogDir(), 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '14d', maxFiles: maxFiles || '14d',
level, level,
format: fileFormat format: fileFormat
}) })
} }
// Create the logger instance with default level // Create the logger instance with default level - Console only initially
// File transports are added after config is loaded via applyLoggingConfig()
const logger = winston.createLogger({ const logger = winston.createLogger({
level: 'info', // Default level, can be updated via setLogLevel() level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' }, defaultMeta: { service: 'erpauto' },
@@ -100,34 +125,60 @@ const logger = winston.createLogger({
// Console transport - always enabled // Console transport - always enabled
new winston.transports.Console({ new winston.transports.Console({
format: consoleFormat format: consoleFormat
}), })
// File transport for all levels
createFileTransport()
] ]
}) })
/** /**
* Update the logger level dynamically * Update the logger level dynamically and notify renderer processes
* @param level - The new log level * @param level - The new log level
*/ */
export function setLogLevel(level: string): void { export function setLogLevel(level: string): void {
logger.level = level logger.level = level
// Broadcast level change to all renderer windows so they update their cached level
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, level)
}
}
} }
/**
* Apply logging configuration from config file
* Removes existing DailyRotateFile transports and recreates them with config values
*
* @param config - Logging configuration from config.yaml
*/
export function applyLoggingConfig(config: { level: string; appRetention: number }): void {
// Update log level
setLogLevel(config.level)
// Remove existing DailyRotateFile transports
const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile)
for (const transport of existingFileTransports) {
logger.remove(transport)
}
// Add app log transport with configured retention
const retentionStr = `${config.appRetention}d`
logger.add(createFileTransport(undefined, retentionStr))
// Add error-specific file transport in production // Add error-specific file transport in production
if (app?.isPackaged) { if (IS_PROD) {
logger.add( logger.add(
new DailyRotateFile({ new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'), filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '14d', maxFiles: retentionStr,
level: 'error', level: 'error',
format: fileFormat format: fileFormat
}) })
) )
} }
}
/** /**
* Create a child logger with a specific context * Create a child logger with a specific context
@@ -138,23 +189,8 @@ export function createLogger(context: string): winston.Logger {
return logger.child({ context }) return logger.child({ context })
} }
/** // Re-export error utilities for convenience
* Log an error with full context and stack trace export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
* This is the recommended way to log errors in the application
*
* @param log - Logger instance
* @param message - Error message
* @param error - The error object (Error, BaseError, or any)
* @param meta - Additional metadata to include
*/
export function logError(
log: winston.Logger,
message: string,
error: unknown,
meta?: Record<string, unknown>
): void {
log.error(message, { error, ...meta })
}
// Export the main logger for direct use // Export the main logger for direct use
export default logger export default logger

View File

@@ -0,0 +1,60 @@
/**
* Shared Logger Utilities
* Common functions used across logger modules
*/
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
/**
* Get log directory path
* Uses app.getPath('logs') in production, local logs dir in development
* Production = app.isPackaged === true
*/
export function getLogDir(): string {
// Check if running in production (packed app)
// This must be checked BEFORE app.getPath('logs') because Electron
// always returns the user data logs path regardless of environment
if (app && app.isReady() && app.isPackaged) {
return app.getPath('logs')
}
// Development environment: use logs directory in project root
// Note: synchronous FS calls are acceptable here because this branch
// executes in dev environments or before app is ready.
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/**
* Check if running in production environment
* Uses app.isPackaged as the single source of truth
*/
export function isProduction(): boolean {
return app?.isPackaged ?? false
}
/**
* Log level priority mapping (higher number = more severe)
*/
export const LOG_LEVEL_PRIORITY: Record<string, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
/**
* Check if a log level should be logged given a threshold
* @param level - The log level of the message
* @param threshold - The minimum log level threshold
* @returns true if the message should be logged
*/
export function isLoggable(level: string, threshold: string): boolean {
return (LOG_LEVEL_PRIORITY[level] ?? 0) >= (LOG_LEVEL_PRIORITY[threshold] ?? 2)
}

View File

@@ -163,10 +163,10 @@ export class BIPUsersDAO {
return null return null
} }
} catch (error) { } catch (error) {
logError(log, 'Authenticate failed', error, { logError(log, error, {
message: 'Authenticate failed',
operation: 'authenticate', operation: 'authenticate',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -222,10 +222,10 @@ export class BIPUsersDAO {
return null return null
} }
} catch (error) { } catch (error) {
logError(log, 'Silent login failed', error, { logError(log, error, {
message: 'Silent login failed',
operation: 'authenticateByComputerName', operation: 'authenticateByComputerName',
computerName, context: { computerName, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -258,9 +258,10 @@ export class BIPUsersDAO {
createTime: row.CreateTime as Date | undefined createTime: row.CreateTime as Date | undefined
})) }))
} catch (error) { } catch (error) {
logError(log, 'Get all users failed', error, { logError(log, error, {
message: 'Get all users failed',
operation: 'getAllUsers', operation: 'getAllUsers',
dbType: this.dbType context: { dbType: this.dbType }
}) })
return [] return []
} }
@@ -345,11 +346,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Create user failed', error, { logError(log, error, {
message: 'Create user failed',
operation: 'createUser', operation: 'createUser',
username, context: { username, userType, dbType: this.dbType }
userType,
dbType: this.dbType
}) })
return false return false
} }
@@ -389,11 +389,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update user type failed', error, { logError(log, error, {
message: 'Update user type failed',
operation: 'updateUserType', operation: 'updateUserType',
username, context: { username, userType, dbType: this.dbType }
userType,
dbType: this.dbType
}) })
return false return false
} }
@@ -433,10 +432,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update password failed', error, { logError(log, error, {
message: 'Update password failed',
operation: 'updatePassword', operation: 'updatePassword',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -472,10 +471,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Delete user failed', error, { logError(log, error, {
message: 'Delete user failed',
operation: 'deleteUser', operation: 'deleteUser',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -513,10 +512,10 @@ export class BIPUsersDAO {
return result.rows.length > 0 && (result.rows[0].count as number) > 0 return result.rows.length > 0 && (result.rows[0].count as number) > 0
} }
} catch (error) { } catch (error) {
logError(log, 'Check user exists failed', error, { logError(log, error, {
message: 'Check user exists failed',
operation: 'userExists', operation: 'userExists',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -574,10 +573,10 @@ export class BIPUsersDAO {
return null return null
} }
} catch (error) { } catch (error) {
logError(log, 'Get user ERP credentials failed', error, { logError(log, error, {
message: 'Get user ERP credentials failed',
operation: 'getUserErpCredentials', operation: 'getUserErpCredentials',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -626,10 +625,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update user ERP credentials failed', error, { logError(log, error, {
message: 'Update user ERP credentials failed',
operation: 'updateUserErpCredentials', operation: 'updateUserErpCredentials',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -668,9 +667,10 @@ export class BIPUsersDAO {
erpUsername: (row[cols.ERP_USERNAME] as string) || '' erpUsername: (row[cols.ERP_USERNAME] as string) || ''
})) }))
} catch (error) { } catch (error) {
logError(log, 'Get all users ERP config failed', error, { logError(log, error, {
message: 'Get all users ERP config failed',
operation: 'getAllUsersErpConfig', operation: 'getAllUsersErpConfig',
dbType: this.dbType context: { dbType: this.dbType }
}) })
return [] return []
} }

View File

@@ -0,0 +1,27 @@
/**
* Debug script to verify Electron environment detection
*/
import { app } from 'electron'
console.log('=== Electron Environment Debug ===\n')
console.log('1. app.isPackaged:', app.isPackaged)
console.log('2. app.getPath("userData"):', app.getPath('userData'))
console.log('3. app.getPath("logs"):', app.getPath('logs'))
console.log('4. NODE_ENV:', process.env.NODE_ENV)
console.log('5. process.cwd():', process.cwd())
console.log('6. __dirname:', __dirname)
// Predict log dir
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
const devLogDir = `${process.cwd()}\\logs`
return devLogDir
}
console.log('\n7. Predicted log dir:', getLogDir())
console.log('\n=== END DEBUG ===')
app.quit()

View File

@@ -83,7 +83,8 @@ export const extractionConfigSchema = z.object({
verbose: z.boolean().default(true), verbose: z.boolean().default(true),
autoConvert: z.boolean().default(true), autoConvert: z.boolean().default(true),
mergeBatches: z.boolean().default(true), mergeBatches: z.boolean().default(true),
enableDbPersistence: z.boolean().default(true) enableDbPersistence: z.boolean().default(true),
headless: z.boolean().default(true)
}) })
/** /**

View File

@@ -43,6 +43,8 @@ export interface ExtractorResult {
errors: string[] errors: string[]
/** Database import result (only populated if mergedFile was created) */ /** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
} }
export interface OrderInfo { export interface OrderInfo {

View File

@@ -83,4 +83,6 @@ export interface GetBatchesOptions {
limit?: number limit?: number
/** Number of batches to skip (for pagination) */ /** Number of batches to skip (for pagination) */
offset?: number offset?: number
/** Optional username filter for Admin users (supports multiple) */
usernames?: string[]
} }

View File

@@ -2,13 +2,56 @@ import type { LogLevel } from '../../shared/ipc-channels'
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ipcRenderer } from '../lib/ipc' import { ipcRenderer } from '../lib/ipc'
// Cached log level for client-side filtering (avoids IPC for filtered-out messages)
let cachedLevel: LogLevel = 'info'
/**
* Check if a message at the given level should be logged
* Based on level priority: error > warn > info > debug > verbose
*/
function shouldLog(level: LogLevel): boolean {
const priorities: Record<LogLevel, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
return (priorities[level] ?? 0) >= (priorities[cachedLevel] ?? 2)
}
// Listener for level change broadcasts from main process
function onLevelChanged(_event: Electron.IpcRendererEvent, level: LogLevel): void {
cachedLevel = level
}
export const loggerApi = { export const loggerApi = {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => { log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
// Drop messages below the configured log level
if (!shouldLog(level)) return
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, { ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level, level,
message, message,
context, context,
timestamp: Date.now() timestamp: Date.now()
}) })
},
/**
* Fetch the current log level from main process and cache it.
* Also registers a listener for future level changes.
* Should be called early in renderer initialization.
*/
fetchLevel: async (): Promise<void> => {
cachedLevel = (await ipcRenderer.invoke(IPC_CHANNELS.LOGGER_GET_LEVEL)) as LogLevel
ipcRenderer.on(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, onLevelChanged)
},
/**
* Remove the level change listener (call on cleanup/unmount)
*/
cleanup: (): void => {
ipcRenderer.removeListener(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, onLevelChanged)
} }
} as const } as const

View File

@@ -129,6 +129,8 @@ export interface ConfigAPI {
export interface LoggerAPI { export interface LoggerAPI {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
fetchLevel: () => Promise<void>
cleanup: () => void
} }
export interface UpdateAPI { export interface UpdateAPI {

View File

@@ -5,7 +5,7 @@
* Admin users see all users' records, regular users see only their own. * Admin users see all users' records, regular users see only their own.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { import {
RefreshCw, RefreshCw,
@@ -14,35 +14,15 @@ import {
ChevronRight, ChevronRight,
CheckCircle, CheckCircle,
XCircle, XCircle,
Clock Clock,
Copy
} from 'lucide-react' } from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog' import type { UserInfo } from './UserSelectionDialog'
import type {
// Local type definitions matching the backend types BatchStats,
interface BatchStats { OperationHistoryRecord
batchId: string } from '../../../main/types/operation-history.types'
userId: number import { showSuccess, showError, showWarning } from '../stores/useAppStore'
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
interface OperationHistoryRecord {
id?: number
batchId: string
userId: number
username: string
productionId: string | null
orderNumber: string
operationTime: Date
status: string
recordCount: number | null
errorMessage: string | null
}
interface ExtractorOperationHistoryModalProps { interface ExtractorOperationHistoryModalProps {
isOpen: boolean isOpen: boolean
@@ -71,6 +51,24 @@ const statusIcons: Record<string, React.ReactNode> = {
pending: <Clock size={16} className="text-gray-500" /> pending: <Clock size={16} className="text-gray-500" />
} }
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
// Check if the date is valid
if (isNaN(date.getTime())) {
return dateStr // Return original if invalid
}
// Use UTC methods to display the time as stored in database (without timezone conversion)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
isOpen, isOpen,
onClose, onClose,
@@ -82,21 +80,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set()) const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map()) const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set()) const [deleting, setDeleting] = useState<Set<string>>(new Set())
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const isAdmin = user?.userType === 'Admin' const isAdmin = user?.userType === 'Admin'
// Fetch batches when modal opens const fetchBatches = useCallback(async () => {
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen])
const fetchBatches = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const result = await window.electron.operationHistory.getBatches({ limit: 100 }) // Admin user can pass usernames filter
const options =
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
const result = await window.electron.operationHistory.getBatches(options)
if (result.success && result.data) { if (result.success && result.data) {
setBatches(result.data) setBatches(result.data)
} else { } else {
@@ -107,9 +106,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [isAdmin, selectedUsers])
const fetchBatchDetails = async (batchId: string) => { const fetchAllUsers = useCallback(async () => {
try {
const result = await window.electron.auth.getAllUsers()
if (result.success && result.data) {
const usernames = result.data.map((u: UserInfo) => u.username)
setAllUsers(usernames)
}
} catch (err) {
console.error('Failed to fetch users:', err)
}
}, [])
const fetchBatchDetails = useCallback(
async (batchId: string) => {
// If already loaded, don't fetch again // If already loaded, don't fetch again
if (batchDetails.has(batchId)) { if (batchDetails.has(batchId)) {
return return
@@ -123,7 +135,19 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} catch (err) { } catch (err) {
console.error('Failed to fetch batch details:', err) console.error('Failed to fetch batch details:', err)
} }
},
[batchDetails]
)
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
if (isAdmin) {
void fetchAllUsers()
} }
}
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const toggleBatchExpansion = (batchId: string) => { const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => { setExpandedBatches((prev) => {
@@ -175,15 +199,34 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} }
} }
const formatDateTime = (dateStr: string) => { const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
const date = new Date(dateStr) const details = batchDetails.get(batchId) || []
return date.toLocaleString('zh-CN', { const values = details
year: 'numeric', .map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
month: '2-digit', .filter(Boolean) // 移除空值
day: '2-digit', .join('\n') // 使用换行符分隔
hour: '2-digit',
minute: '2-digit' if (!values) {
}) showWarning('没有可复制的数据')
return
}
try {
await navigator.clipboard.writeText(values)
showSuccess(`已复制 ${values.split('\n').length} 条数据`)
} catch {
showError('复制失败,请手动复制')
}
}
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
}
const clearUserFilters = () => {
setSelectedUsers([])
} }
if (!isOpen) return null if (!isOpen) return null
@@ -192,11 +235,48 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl"> <Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]"> <div className="flex flex-col h-[70vh]">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200"> <div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1">
{isAdmin && allUsers.length > 0 && (
<div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{allUsers.map((username) => {
const isSelected = selectedUsers.includes(username)
return (
<button
key={username}
onClick={() => toggleUserFilter(username)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
isSelected
? 'bg-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{username}
</button>
)
})}
{selectedUsers.length > 0 && (
<button
onClick={clearUserFilters}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
>
</button>
)}
</div>
</div>
)}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{isAdmin ? ( {isAdmin ? (
<span className="text-amber-600 font-medium"></span> <span className="text-amber-600 font-medium">
{selectedUsers.length > 0
? `已选择 ${selectedUsers.length} 个用户`
: '显示所有用户记录'}
</span>
) : ( ) : (
<span></span> <span></span>
)} )}
@@ -205,8 +285,9 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<span className="text-sm text-gray-500"> {batches.length} </span> <span className="text-sm text-gray-500"> {batches.length} </span>
)} )}
</div> </div>
</div>
<button <button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50" className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
onClick={() => void fetchBatches()} onClick={() => void fetchBatches()}
disabled={loading} disabled={loading}
title="刷新" title="刷新"
@@ -299,6 +380,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
</div> </div>
</div> </div>
{isAdmin && (
<button <button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50" className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => { onClick={(e) => {
@@ -310,6 +392,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
> >
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} /> <Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button> </button>
)}
</div> </div>
{/* Batch details */} {/* Batch details */}
@@ -320,10 +403,38 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('productionId', batch.batchId)
}
title="复制所有总排号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th> </th>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('orderNumber', batch.batchId)
}
title="复制所有订单号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th> </th>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">

View File

@@ -185,7 +185,7 @@ export function AuthenticatedAppShell({
</div> </div>
</div> </div>
)} )}
{currentPage === 'extractor' && <ExtractorPage />} {currentPage === 'extractor' && <ExtractorPage currentUser={currentUser} />}
{currentPage === 'cleaner' && <CleanerPage />} {currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />} {currentPage === 'settings' && <SettingsPage />}
</main> </main>

View File

@@ -47,7 +47,10 @@ export const ComparisonTooltip = React.memo(
{user || '未分配'}: {user || '未分配'}:
</span> </span>
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
{firstMetric === 'executionTimeSecs' ? Number(userEntry.value).toFixed(1) : userEntry.value} {firstMetric === 'executionTimeSecs' ? '秒' : ''} {firstMetric === 'executionTimeSecs'
? Number(userEntry.value).toFixed(1)
: userEntry.value}{' '}
{firstMetric === 'executionTimeSecs' ? '秒' : ''}
</span> </span>
</div> </div>
) )

View File

@@ -40,7 +40,10 @@ export const CustomTooltip = React.memo(
{entry.name}: {entry.name}:
</span> </span>
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
{entry.dataKey === 'executionTimeSecs' ? Number(entry.value).toFixed(1) : entry.value} {entry.dataKey === 'executionTimeSecs' ? '秒' : ''} {entry.dataKey === 'executionTimeSecs'
? Number(entry.value).toFixed(1)
: entry.value}{' '}
{entry.dataKey === 'executionTimeSecs' ? '秒' : ''}
</span> </span>
</div> </div>
))} ))}

View File

@@ -68,6 +68,10 @@ export function useAppBootstrap() {
const initializeAuth = useCallback(async () => { const initializeAuth = useCallback(async () => {
logger.info('=== Starting initializeAuth ===') logger.info('=== Starting initializeAuth ===')
// Fetch log level early so client-side filtering takes effect
await window.electron.logger.fetchLevel()
try { try {
logger.debug('Getting computer name...') logger.debug('Getting computer name...')
const computerNameResult = await window.electron.auth.getComputerName() const computerNameResult = await window.electron.auth.getComputerName()
@@ -246,12 +250,19 @@ export function useAppBootstrap() {
}, []) }, [])
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
// 退出登录,清空后端状态
await window.electron.auth.logout() await window.electron.auth.logout()
// 清空前端状态
setIsAuthenticated(false) setIsAuthenticated(false)
setCurrentUser(null) setCurrentUser(null)
setIsSwitchedByAdmin(false) setIsSwitchedByAdmin(false)
setShowLoginDialog(true) setShowUserSelection(false)
}, []) setShowLoginDialog(false)
// 重新进行静默登录,如果是 Admin 会自动弹出用户选择界面
await initializeAuth()
}, [initializeAuth])
const openUpdateDialog = useCallback(async () => { const openUpdateDialog = useCallback(async () => {
await Promise.all([refreshUpdateCatalog(), refreshUpdateState()]) await Promise.all([refreshUpdateCatalog(), refreshUpdateState()])

View File

@@ -7,12 +7,28 @@ import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel' import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar' import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal' import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore' import type { CurrentUser } from '../hooks/useAppBootstrap'
const ExtractorPage: React.FC = () => { interface ExtractorPageProps {
currentUser: CurrentUser | null
}
const ExtractorPage: React.FC<ExtractorPageProps> = ({ currentUser }) => {
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers') const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
const [showHistoryModal, setShowHistoryModal] = React.useState(false) const [showHistoryModal, setShowHistoryModal] = React.useState(false)
const user = useUserStore((state) => state.user)
// Convert currentUser to UserInfo format for the modal
const user = React.useMemo(
() =>
currentUser
? {
id: 0, // ID is not needed for modal display logic
username: currentUser.username,
userType: currentUser.userType
}
: null,
[currentUser]
)
const { const {
isRunning, isRunning,
@@ -90,13 +106,13 @@ const ExtractorPage: React.FC = () => {
</div> </div>
</div> </div>
{showHistoryModal && ( {showHistoryModal ? (
<ExtractorOperationHistoryModal <ExtractorOperationHistoryModal
isOpen={showHistoryModal} isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)} onClose={() => setShowHistoryModal(false)}
user={user} user={user}
/> />
)} ) : null}
{!isRunning && isComplete && ( {!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md"> <div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">

View File

@@ -92,6 +92,8 @@ export const IPC_CHANNELS = {
// Logger // Logger
LOGGER_FORWARD: 'logger:forward', LOGGER_FORWARD: 'logger:forward',
LOGGER_GET_LEVEL: 'logger:getLevel',
LOGGER_LEVEL_CHANGED: 'logger:levelChanged',
// Report // Report
REPORT_LIST_ALL: 'report:listAll', REPORT_LIST_ALL: 'report:listAll',
@@ -122,4 +124,4 @@ export const IPC_CHANNELS = {
/** /**
* Log level for logger service * Log level for logger service
*/ */
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'

View File

@@ -92,7 +92,7 @@ describe('Audit Logger - Real File Integration', () => {
const entry = createTestEntry() const entry = createTestEntry()
await logAudit(entry.action as string, entry.userId as string, { logAudit(entry.action as string, entry.userId as string, {
username: entry.username as string, username: entry.username as string,
computerName: entry.computerName as string, computerName: entry.computerName as string,
resource: entry.resource as string, resource: entry.resource as string,
@@ -101,7 +101,7 @@ describe('Audit Logger - Real File Integration', () => {
}) })
// Close logger to flush writes // Close logger to flush writes
await closeAuditLogger() closeAuditLogger()
// Find the audit log file (should be today's file) // Find the audit log file (should be today's file)
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
@@ -122,7 +122,7 @@ describe('Audit Logger - Real File Integration', () => {
await import('../../src/main/services/logger/audit-logger') await import('../../src/main/services/logger/audit-logger')
// Test success status // Test success status
await logAudit('EXTRACT', 'user1', { logAudit('EXTRACT', 'user1', {
username: 'extractor', username: 'extractor',
computerName: 'PC-001', computerName: 'PC-001',
resource: 'materials', resource: 'materials',
@@ -130,7 +130,7 @@ describe('Audit Logger - Real File Integration', () => {
}) })
// Test failure status // Test failure status
await logAudit('DELETE', 'user2', { logAudit('DELETE', 'user2', {
username: 'cleaner', username: 'cleaner',
computerName: 'PC-002', computerName: 'PC-002',
resource: 'temp_files', resource: 'temp_files',
@@ -139,7 +139,7 @@ describe('Audit Logger - Real File Integration', () => {
}) })
// Test partial status // Test partial status
await logAudit('UPDATE', 'user3', { logAudit('UPDATE', 'user3', {
username: 'updater', username: 'updater',
computerName: 'PC-003', computerName: 'PC-003',
resource: 'config', resource: 'config',
@@ -147,7 +147,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { updated: 5, failed: 2 } metadata: { updated: 5, failed: 2 }
}) })
await closeAuditLogger() closeAuditLogger()
// Verify all entries were processed // Verify all entries were processed
expect(true).toBe(true) // Logger accepted all status types without error expect(true).toBe(true) // Logger accepted all status types without error
@@ -158,7 +158,7 @@ describe('Audit Logger - Real File Integration', () => {
await import('../../src/main/services/logger/audit-logger') await import('../../src/main/services/logger/audit-logger')
// Without metadata // Without metadata
await logAudit('LOGIN', 'user-no-meta', { logAudit('LOGIN', 'user-no-meta', {
username: 'no.meta', username: 'no.meta',
computerName: 'PC-001', computerName: 'PC-001',
resource: 'ERP', resource: 'ERP',
@@ -166,7 +166,7 @@ describe('Audit Logger - Real File Integration', () => {
}) })
// With metadata // With metadata
await logAudit('LOGOUT', 'user-with-meta', { logAudit('LOGOUT', 'user-with-meta', {
username: 'with.meta', username: 'with.meta',
computerName: 'PC-002', computerName: 'PC-002',
resource: 'ERP', resource: 'ERP',
@@ -174,7 +174,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { sessionDuration: 3600, actionsPerformed: 15 } metadata: { sessionDuration: 3600, actionsPerformed: 15 }
}) })
await closeAuditLogger() closeAuditLogger()
// Both entries should be processed successfully // Both entries should be processed successfully
expect(true).toBe(true) expect(true).toBe(true)
@@ -186,14 +186,14 @@ describe('Audit Logger - Real File Integration', () => {
const beforeLog = Date.now() const beforeLog = Date.now()
await logAudit('TEST', 'timestamp-user', { logAudit('TEST', 'timestamp-user', {
username: 'timestamp.test', username: 'timestamp.test',
computerName: 'PC-TS', computerName: 'PC-TS',
resource: 'test_resource', resource: 'test_resource',
status: 'success' status: 'success'
}) })
await closeAuditLogger() closeAuditLogger()
const afterLog = Date.now() const afterLog = Date.now()
@@ -204,15 +204,15 @@ describe('Audit Logger - Real File Integration', () => {
it('should close audit logger without errors', async () => { it('should close audit logger without errors', async () => {
const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger') const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger')
// Should resolve without throwing // Should complete without throwing
await expect(closeAuditLogger()).resolves.toBeUndefined() expect(() => closeAuditLogger()).not.toThrow()
}) })
it('should handle special characters in fields', async () => { it('should handle special characters in fields', async () => {
const { logAudit, closeAuditLogger } = const { logAudit, closeAuditLogger } =
await import('../../src/main/services/logger/audit-logger') await import('../../src/main/services/logger/audit-logger')
await logAudit('LOGIN_ATTEMPT', 'user-special', { logAudit('LOGIN_ATTEMPT', 'user-special', {
username: 'user.name+test@example.com', username: 'user.name+test@example.com',
computerName: 'DESKTOP-特殊字符-001', computerName: 'DESKTOP-特殊字符-001',
resource: 'ERP/子系统', resource: 'ERP/子系统',
@@ -220,7 +220,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { reason: '密码错误', attempt: 3 } metadata: { reason: '密码错误', attempt: 3 }
}) })
await closeAuditLogger() closeAuditLogger()
// Should handle without errors // Should handle without errors
expect(true).toBe(true) expect(true).toBe(true)
@@ -230,7 +230,7 @@ describe('Audit Logger - Real File Integration', () => {
const { logAudit, closeAuditLogger } = const { logAudit, closeAuditLogger } =
await import('../../src/main/services/logger/audit-logger') await import('../../src/main/services/logger/audit-logger')
await logAudit('PING', 'ping-user', { logAudit('PING', 'ping-user', {
username: 'pinger', username: 'pinger',
computerName: 'PC-PING', computerName: 'PC-PING',
resource: 'health_check', resource: 'health_check',
@@ -238,7 +238,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: {} metadata: {}
}) })
await closeAuditLogger() closeAuditLogger()
// Should handle empty metadata // Should handle empty metadata
expect(true).toBe(true) expect(true).toBe(true)