23 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
Misaka_Company
c6eb60ada7 1.7.0 2026-03-31 15:28:26 +08:00
Misaka_Company
571ec2325f docs: add release notes for version 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:28:08 +08:00
Misaka_Company
557ed174c3 feat(extractor): add operation history tracking
Add a new operation history feature for the extractor module that tracks
all extraction operations with persistent database storage.

Features:
- Records extraction operations with batch tracking (UUID-based)
- Preserves production ID to order number mapping
- Shows batch statistics (orders, records, success/failure counts)
- Expandable details for each batch showing individual order records
- User-based permission: Admin sees all records, User sees own records only
- Delete functionality with permission validation

Database:
- New ExtractorOperationHistory table schema
- Supports both SQL Server and MySQL
- Indexed on BatchId, UserId, and OperationTime

Files:
- Add DAO class for history operations
- Add IPC handler with permission checks
- Add preload API wrapper
- Add React modal component with expandable batch details
- Integrate history recording into extractor handler
- Add operation history button to ExtractorPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:22:53 +08:00
43 changed files with 2767 additions and 236 deletions

View File

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

@@ -48,13 +48,13 @@ graph TD
## 文档职责一览
| 文档 | 主要回答的问题 |
| --- | --- |
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
| `data-flow.md` | 核心业务数据如何在各层之间流动 |
| `file-map.md` | 关键文件在哪里、应该先看哪些入口 |
| `decision-log.md` | 最近几轮重要重构和架构决策是什么 |
| 文档 | 主要回答的问题 |
| ------------------------- | -------------------------------------------------- |
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
| `data-flow.md` | 核心业务数据如何在各层之间流动 |
| `file-map.md` | 关键文件在哪里、应该先看哪些入口 |
| `decision-log.md` | 最近几轮重要重构和架构决策是什么 |
## 按问题选择阅读路径

View File

@@ -66,13 +66,13 @@ flowchart TD
## 当前文档一览
| 文档 | 主要内容 |
| --- | --- |
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
| 文档 | 主要内容 |
| ------------------------- | ---------------------------------------- |
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |
| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 |
| `release-process.md` | 构建、发布、更新产物与上传流程 |
| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 |
| `release-process.md` | 构建、发布、更新产物与上传流程 |
## 与其他文档目录的关系

View File

@@ -59,14 +59,14 @@ graph TD
## 模块目录一览
| 模块 | 文档 | 核心职责 |
| --- | --- | --- |
| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 |
| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 |
| 模块 | 文档 | 核心职责 |
| ---------- | --------------- | --------------------------------------------------------- |
| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 |
| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 |
| Validation | `validation.md` | 共享 Production IDs、校验查询、结果富化、Cleaner 数据准备 |
| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 |
| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 |
| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 |
| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 |
| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 |
| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 |
## 模块入口地图

18
docs/releases/1.7.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.7.0
## 核心功能
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
- 批次记录可展开查看,显示总排号与订单号的对应关系。
## 界面与交互
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
## 数据存储
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
- 需执行数据库脚本创建表结构(详见项目文档)。

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",
"version": "1.6.2",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.6.2",
"version": "1.8.0",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

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

View File

@@ -1,40 +1,45 @@
import { app } from 'electron'
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 {
process.on('uncaughtException', async (err) => {
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', {
logAudit('SYSTEM_CRASH', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { error: err.message, stack: err.stack }
})
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', async (reason) => {
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', {
logAudit('SYSTEM_ERROR', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { reason: String(reason) }
})
console.error('Unhandled Rejection:', reason)
})
app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
})
app.on('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

@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager'
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { randomUUID } from 'crypto'
const log = createLogger('ExtractorHandler')
@@ -88,6 +90,10 @@ export function registerExtractorHandlers(): void {
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
// Read headless setting from global config
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
@@ -141,6 +147,29 @@ export function registerExtractorHandlers(): void {
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Initialize operation history recording
const currentUser = SessionManager.getInstance().getUserInfo()
const historyDao = new ExtractorOperationHistoryDAO()
const batchId = randomUUID()
// Save order records to history (preserve productionId -> orderNumber mapping)
if (currentUser) {
const orderRecords = mappings.map((m) => ({
productionId: m.productionId || null,
orderNumber: m.orderNumber || m.input
}))
await historyDao.insertBatchRecords(
batchId,
currentUser.id,
currentUser.username,
orderRecords
)
log.info('Operation history batch created', {
batchId,
recordCount: orderRecords.length
})
}
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
@@ -163,7 +192,7 @@ export function registerExtractorHandlers(): void {
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: true
headless: globalConfig.extraction.headless
})
sendProgress(sender, '登录 ERP 系统...', 9.99, {
@@ -223,11 +252,35 @@ export function registerExtractorHandlers(): void {
})
}
// Update operation history batch status
if (currentUser) {
const status: 'success' | 'failed' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
? 'failed'
: 'success'
// 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 })
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
const auditStatus: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
@@ -237,13 +290,13 @@ export function registerExtractorHandlers(): void {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
status: auditStatus,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
})
}
return result

View File

@@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler'
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
import { registerOperationHistoryHandlers } from './operation-history-handler'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -67,15 +68,21 @@ export function withErrorHandling<T>(
}
if (isBaseError(error)) {
logError(log, `[${context}] ${error.name}`, error, {
code,
cause: getErrorCauseMessage(error),
handler: context
logError(log, error, {
message: `[${context}] ${error.name}`,
context: {
code,
cause: getErrorCauseMessage(error),
handler: context
}
})
} else {
logError(log, `[${context}] Error`, error, {
code,
handler: context
logError(log, error, {
message: `[${context}] Error`,
context: {
code,
handler: context
}
})
}
@@ -107,5 +114,6 @@ export function registerIpcHandlers(): void {
registerReportHandlers()
registerUpdateHandlers()
registerPlaywrightBrowserHandlers()
registerOperationHistoryHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -10,7 +10,9 @@
*/
import { ipcMain } from 'electron'
import winston from 'winston'
import { createLogger } from '../services/logger'
import logger from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler')
@@ -41,6 +43,7 @@ class LoggerHandlerState {
private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0
private childLoggerCache = new Map<string, winston.Logger>()
/**
* 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
* @param entry - Log entry to forward
*/
private forwardToWinston(entry: LogEntry): void {
const context = (entry.context?.component as string) || 'renderer'
const childLogger = log.child({
source: 'renderer',
component: context
})
const childLogger = this.getChildLogger(context)
const message = entry.context?.message
? `[${entry.context.message}] ${entry.message}`
: entry.message
switch (entry.level) {
case 'verbose':
childLogger.verbose(message, entry.context)
break
case 'debug':
childLogger.debug(message, entry.context)
break
@@ -187,6 +204,7 @@ class LoggerHandlerState {
}
this.buffer = []
this.discardedCount = 0
this.childLoggerCache.clear()
}
}
@@ -197,6 +215,11 @@ const state = new LoggerHandlerState()
* Register IPC handlers for logger
*/
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
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry

View File

@@ -0,0 +1,124 @@
/**
* IPC Handler for Extractor Operation History
*
* Handles IPC requests for operation history management:
* - Get batch list (filtered by user for non-admin users)
* - Get batch details
* - Delete batches
*/
import { ipcMain } from 'electron'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { createLogger } from '../services/logger'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../types/operation-history.types'
const log = createLogger('OperationHistoryHandler')
/**
* Register IPC handlers for operation history
*/
export function registerOperationHistoryHandlers(): void {
const dao = new ExtractorOperationHistoryDAO()
/**
* Get batches list
* Admin users get all batches, regular users get only their own
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
// Admin gets all batches, User gets only their own
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
log.info('Getting operation history batches', {
userId: currentUser.id,
userType: currentUser.userType,
filtered: userId !== undefined
})
const batches = await dao.getBatches(userId, options)
return batches
}, 'operationHistory:getBatches')
}
)
/**
* Get batch details
* Users can only view their own batch details, admins can view all
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
log.info('Getting batch details', { batchId, userId: currentUser.id })
const details = await dao.getBatchDetails(batchId)
// For non-admin users, verify they own this batch
if (currentUser.userType !== 'Admin' && details.length > 0) {
const batchOwnerId = details[0].userId
if (batchOwnerId !== currentUser.id) {
throw new Error('没有权限查看此批次详情')
}
}
return details
}, 'operationHistory:getBatchDetails')
}
)
/**
* Delete a batch
* Users can only delete their own batches, admins can delete any
*/
ipcMain.handle(
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
return withErrorHandling(async () => {
const currentUser = SessionManager.getInstance().getUserInfo()
if (!currentUser) {
throw new Error('用户未登录')
}
const isAdmin = currentUser.userType === 'Admin'
log.info('Deleting batch', {
batchId,
userId: currentUser.id,
isAdmin
})
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
if (!result.success) {
throw new Error(result.error || '删除批次失败')
}
return { deleted: true }
}, 'operationHistory:deleteBatch')
}
)
log.info('Operation history IPC handlers registered')
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,623 @@
/**
* Data Access Object for ExtractorOperationHistory table
*
* Handles database operations for tracking extraction operation history:
* - Batch record insertion
* - Batch status updates
* - Querying batches (with user filtering for non-admin users)
* - Getting batch details
* - Deleting batches
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
import type {
OperationHistoryRecord,
BatchStats,
InsertBatchRecordInput,
UpdateBatchStatusResult,
GetBatchesOptions
} from '../../types/operation-history.types'
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
*/
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
COLUMNS: {
ID: 'ID',
BATCH_ID: 'BatchId',
USER_ID: 'UserId',
USERNAME: 'Username',
PRODUCTION_ID: 'ProductionId',
ORDER_NUMBER: 'OrderNumber',
OPERATION_TIME: 'OperationTime',
STATUS: 'Status',
RECORD_COUNT: 'RecordCount',
ERROR_MESSAGE: 'ErrorMessage'
}
} as const
/**
* ExtractorOperationHistory DAO Class
*/
export class ExtractorOperationHistoryDAO {
private dbService: IDatabaseService | null = null
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
const isSqlServer = this.dbService?.type === 'sqlserver'
return isSqlServer
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== INSERT ====================
/**
* Insert batch records for a single extraction operation
* @param batchId - Unique batch identifier
* @param userId - User ID performing the operation
* @param username - Username performing the operation
* @param records - Array of order records to insert
* @returns True if successful
*/
async insertBatchRecords(
batchId: string,
userId: number,
username: string,
records: InsertBatchRecordInput[]
): Promise<boolean> {
if (!records || records.length === 0) {
log.warn('No records to insert')
return false
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (const record of records) {
try {
if (isSqlServer) {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
} else {
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(?, ?, ?, ?, ?, NOW(), 'pending')
`
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
])
}
} catch (error) {
log.error('Error inserting individual record', {
batchId,
orderNumber: record.orderNumber,
error: error instanceof Error ? error.message : String(error)
})
}
}
log.info('Batch records inserted', { batchId, count: records.length })
return true
} catch (error) {
log.error('Insert batch records error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== UPDATE ====================
/**
* Update the status of all records in a batch
* @param batchId - Batch identifier
* @param status - New status (success, failed, partial)
* @returns Update result
*/
async updateBatchStatus(batchId: string, status: string): Promise<UpdateBatchStatusResult> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
const params = [status, batchId]
await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status })
return { success: true, updatedCount: 1 }
} catch (error) {
log.error('Update batch status error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return { success: false, updatedCount: 0 }
}
}
/**
* Update a single record's status, error message, and optional record count
* @param batchId - Batch identifier
* @param orderNumber - Order number
* @param status - New status
* @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful
*/
async updateRecordStatus(
batchId: string,
orderNumber: string,
status: string,
errorMessage?: string,
recordCount?: number
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
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}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
`
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, params)
return true
} catch (error) {
log.error('Update record status error', {
batchId,
orderNumber,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
// ==================== READ ====================
/**
* Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
* @param options - Query options (limit, offset, usernames)
* @returns Array of batch statistics
*/
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
`
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE 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 += `
GROUP BY BatchId, UserId, Username
ORDER BY OperationTime DESC
`
if (options?.limit) {
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
if (isSqlServer) {
// SQL Server: use parameterized OFFSET/FETCH
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 {
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
}
} else {
// MySQL: embed validated integer values directly.
// connection.execute() uses binary protocol prepared statements,
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
if (safeOffset !== undefined) {
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
} else {
sqlString += ` LIMIT ${safeLimit}`
}
}
}
const result = await dbService.query(sqlString, params)
return result.rows.map((row) => ({
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: formatDateTime(row.OperationTime),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}))
} catch (error) {
log.error('Get batches error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get detailed records for a specific batch
* @param batchId - Batch identifier
* @returns Array of operation records
*/
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
ID,
BatchId,
UserId,
Username,
ProductionId,
OrderNumber,
OperationTime,
Status,
RecordCount,
ErrorMessage
FROM ${tableName}
WHERE BatchId = ${placeholder}
ORDER BY ID
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.map((row) => ({
id: row.ID as number,
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
productionId: row.ProductionId as string | null,
orderNumber: row.OrderNumber as string,
operationTime: new Date(row.OperationTime as string),
status: row.Status as string,
recordCount: row.RecordCount as number | null,
errorMessage: row.ErrorMessage as string | null
}))
} catch (error) {
log.error('Get batch details error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get a single batch's statistics
* @param batchId - Batch identifier
* @returns Batch statistics or null
*/
async getBatchStats(batchId: string): Promise<BatchStats | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT
BatchId,
UserId,
Username,
MIN(OperationTime) as OperationTime,
MAX(Status) as Status,
COUNT(*) as TotalOrders,
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
FROM ${tableName}
WHERE BatchId = ${placeholder}
GROUP BY BatchId, UserId, Username
`
const result = await dbService.query(sqlString, [batchId])
if (result.rows.length === 0) {
return null
}
const row = result.rows[0]
return {
batchId: row.BatchId as string,
userId: row.UserId as number,
username: row.Username as string,
operationTime: formatDateTime(row.OperationTime),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
successCount: (row.SuccessCount as number) || 0,
failedCount: (row.FailedCount as number) || 0
}
} catch (error) {
log.error('Get batch stats error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ==================== DELETE ====================
/**
* Delete a batch with permission checking
* @param batchId - Batch identifier
* @param requestingUserId - User ID requesting the deletion
* @param isAdmin - Whether the requesting user is an admin
* @returns True if successful
*/
async deleteBatch(
batchId: string,
requestingUserId: number,
isAdmin: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
// First check if the batch exists and if the user has permission
const batchStats = await this.getBatchStats(batchId)
if (!batchStats) {
return { success: false, error: '批次不存在' }
}
// Non-admin users can only delete their own batches
if (!isAdmin && batchStats.userId !== requestingUserId) {
return { success: false, error: '没有权限删除此批次' }
}
// Delete the batch
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
return { success: true }
} catch (error) {
log.error('Delete batch error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* Delete all batches for a specific user
* @param userId - User ID
* @returns Number of batches deleted
*/
async deleteByUser(userId: number): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE UserId = ${placeholder}
`
const result = await dbService.query(sqlString, [userId])
return result.rowCount
} catch (error) {
log.error('Delete by user error', {
userId,
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
// ==================== UTILITIES ====================
/**
* Check if a batch exists
* @param batchId - Batch identifier
* @returns True if batch exists
*/
async batchExists(batchId: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE BatchId = ${placeholder}
`
const result = await dbService.query(sqlString, [batchId])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
log.error('Batch exists error', {
batchId,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Count total batches with optional user filtering
* @param userId - Optional user ID for filtering
* @param usernames - Optional usernames filter for Admin users
* @returns Total number of batches
*/
async countBatches(userId?: number, usernames?: string[]): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString = `
SELECT COUNT(DISTINCT BatchId) as count
FROM ${tableName}
`
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE 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)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count batches error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
errors: [],
orderRecordCounts: []
}
try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any
if (mergeResult.error) {
@@ -121,11 +123,14 @@ export class ExtractorService {
* @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message
*/
private async mergeFiles(
filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
private async mergeFiles(filePaths: string[]): Promise<{
mergedFile: string | null
recordCount: number
error?: string
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}> {
if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
}
log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +159,21 @@ export class ExtractorService {
// Calculate total record count (total material rows)
let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
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 })
if (recordCount === 0) {
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
@@ -178,13 +189,18 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// 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 DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { getLogDir } from './shared'
/**
* Audit log entry structure
@@ -32,22 +31,6 @@ export interface AuditEntry {
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
* 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
* Configured for 30-day retention as per requirements
* Initially silent (no transports). Call applyAuditConfig() after config is loaded.
*/
const auditLogger = winston.createLogger({
level: 'info',
silent: false,
transports: [
silent: true,
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({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d', // 30-day retention
maxFiles: `${retentionDays}d`,
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
jsonlFormat
)
format: jsonlFormat
})
]
})
)
}
/**
* Log an audit event
@@ -86,9 +86,8 @@ const auditLogger = winston.createLogger({
* @param action - The action that was performed
* @param userId - User ID who performed the action
* @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,
userId: string,
details: {
@@ -98,7 +97,7 @@ export async function logAudit(
status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown>
}
): Promise<void> {
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
@@ -118,8 +117,7 @@ export async function logAudit(
/**
* Flush and close the audit logger (call on app shutdown)
*/
export async function closeAuditLogger(): Promise<void> {
// Winston logger.close() is synchronous
export function closeAuditLogger(): void {
auditLogger.close()
}

View File

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

View File

@@ -11,26 +11,28 @@
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { BrowserWindow } from 'electron'
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
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
// Cache isProduction() at module load — app.isPackaged never changes at runtime
const IS_PROD = isProduction()
/**
* 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
)
}
// Check if running in production
const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production'
// Custom format for console output - includes full error details
const consoleFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
@@ -41,7 +43,12 @@ const consoleFormat = winston.format.combine(
// Format error with full stack trace
let errorStr = ''
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) {
errorStr = `\n${serialized.stack}`
} 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}`
})
)
@@ -58,19 +82,19 @@ const consoleFormat = winston.format.combine(
const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format((info) => {
// Serialize errors in metadata
// Serialize errors in metadata (skip if already serialized)
if (info.error) {
info.error = isProduction
? sanitizeError(serializeError(info.error))
: serializeError(info.error)
if (!isSerializedError(info.error)) {
info.error = IS_PROD
? sanitizeError(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)) {
if (key !== 'error' && info[key] instanceof Error) {
info[key] = isProduction
? sanitizeError(serializeError(info[key]))
: serializeError(info[key])
info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key])
}
}
@@ -80,19 +104,20 @@ const fileFormat = winston.format.combine(
)
// Daily rotate file transport configuration
const createFileTransport = (level?: string): DailyRotateFile => {
const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => {
return new DailyRotateFile({
filename: path.join(getLogDir(), 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
maxFiles: maxFiles || '14d',
level,
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({
level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' },
@@ -100,33 +125,59 @@ const logger = winston.createLogger({
// Console transport - always enabled
new winston.transports.Console({
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
*/
export function setLogLevel(level: string): void {
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)
}
}
}
// Add error-specific file transport in production
if (app?.isPackaged) {
logger.add(
new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level: 'error',
format: fileFormat
})
)
/**
* 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
if (IS_PROD) {
logger.add(
new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: retentionStr,
level: 'error',
format: fileFormat
})
)
}
}
/**
@@ -138,23 +189,8 @@ export function createLogger(context: string): winston.Logger {
return logger.child({ context })
}
/**
* Log an error with full context and stack trace
* 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 })
}
// Re-export error utilities for convenience
export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
// Export the main logger for direct use
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
}
} catch (error) {
logError(log, 'Authenticate failed', error, {
logError(log, error, {
message: 'Authenticate failed',
operation: 'authenticate',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return null
}
@@ -222,10 +222,10 @@ export class BIPUsersDAO {
return null
}
} catch (error) {
logError(log, 'Silent login failed', error, {
logError(log, error, {
message: 'Silent login failed',
operation: 'authenticateByComputerName',
computerName,
dbType: this.dbType
context: { computerName, dbType: this.dbType }
})
return null
}
@@ -258,9 +258,10 @@ export class BIPUsersDAO {
createTime: row.CreateTime as Date | undefined
}))
} catch (error) {
logError(log, 'Get all users failed', error, {
logError(log, error, {
message: 'Get all users failed',
operation: 'getAllUsers',
dbType: this.dbType
context: { dbType: this.dbType }
})
return []
}
@@ -345,11 +346,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Create user failed', error, {
logError(log, error, {
message: 'Create user failed',
operation: 'createUser',
username,
userType,
dbType: this.dbType
context: { username, userType, dbType: this.dbType }
})
return false
}
@@ -389,11 +389,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update user type failed', error, {
logError(log, error, {
message: 'Update user type failed',
operation: 'updateUserType',
username,
userType,
dbType: this.dbType
context: { username, userType, dbType: this.dbType }
})
return false
}
@@ -433,10 +432,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update password failed', error, {
logError(log, error, {
message: 'Update password failed',
operation: 'updatePassword',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -472,10 +471,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Delete user failed', error, {
logError(log, error, {
message: 'Delete user failed',
operation: 'deleteUser',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -513,10 +512,10 @@ export class BIPUsersDAO {
return result.rows.length > 0 && (result.rows[0].count as number) > 0
}
} catch (error) {
logError(log, 'Check user exists failed', error, {
logError(log, error, {
message: 'Check user exists failed',
operation: 'userExists',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -574,10 +573,10 @@ export class BIPUsersDAO {
return null
}
} catch (error) {
logError(log, 'Get user ERP credentials failed', error, {
logError(log, error, {
message: 'Get user ERP credentials failed',
operation: 'getUserErpCredentials',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return null
}
@@ -626,10 +625,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update user ERP credentials failed', error, {
logError(log, error, {
message: 'Update user ERP credentials failed',
operation: 'updateUserErpCredentials',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -668,9 +667,10 @@ export class BIPUsersDAO {
erpUsername: (row[cols.ERP_USERNAME] as string) || ''
}))
} catch (error) {
logError(log, 'Get all users ERP config failed', error, {
logError(log, error, {
message: 'Get all users ERP config failed',
operation: 'getAllUsersErpConfig',
dbType: this.dbType
context: { dbType: this.dbType }
})
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),
autoConvert: 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[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}
export interface OrderInfo {

View File

@@ -0,0 +1,88 @@
/**
* Operation History Type Definitions
*
* Type definitions for the Extractor Operation History feature.
* Tracks extraction operations with batch and individual order record details.
*/
/**
* Individual operation history record
*/
export interface OperationHistoryRecord {
/** Auto-increment ID */
id?: number
/** Batch ID - shared among all orders in a single extraction operation */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
/** When the operation was performed */
operationTime: Date
/** Operation status: pending, success, failed, partial */
status: string
/** Number of records extracted for this order */
recordCount: number | null
/** Error message if operation failed */
errorMessage: string | null
}
/**
* Batch statistics - aggregated view of a batch operation
*/
export interface BatchStats {
/** Unique batch identifier */
batchId: string
/** User ID who performed the operation */
userId: number
/** Username who performed the operation */
username: string
/** When the operation started */
operationTime: string
/** Overall batch status: pending, success, failed, partial */
status: string
/** Total number of orders in the batch */
totalOrders: number
/** Total records extracted across all orders */
totalRecords: number
/** Number of orders that succeeded */
successCount: number
/** Number of orders that failed */
failedCount: number
}
/**
* Input for inserting batch records
*/
export interface InsertBatchRecordInput {
/** Original input production ID (e.g., "22A1") */
productionId: string | null
/** Resolved order number (e.g., "SC70202602120085") */
orderNumber: string
}
/**
* Result for batch status update
*/
export interface UpdateBatchStatusResult {
/** Whether the update was successful */
success: boolean
/** Number of records updated */
updatedCount: number
}
/**
* Options for querying batches
*/
export interface GetBatchesOptions {
/** Maximum number of batches to return */
limit?: number
/** Number of batches to skip (for pagination) */
offset?: number
/** Optional username filter for Admin users (supports multiple) */
usernames?: string[]
}

View File

@@ -17,6 +17,7 @@ import {
} from './materials'
import { loggerApi } from './logger'
import { playwrightBrowserApi } from './browser-download'
import { operationHistoryApi } from './operation-history'
export const api = {
process: processApi,
@@ -35,7 +36,8 @@ export const api = {
logger: loggerApi,
report: reportApi,
update: updateApi,
playwrightBrowser: playwrightBrowserApi
playwrightBrowser: playwrightBrowserApi,
operationHistory: operationHistoryApi
} as const
export type ElectronApi = typeof api

View File

@@ -2,13 +2,56 @@ import type { LogLevel } from '../../shared/ipc-channels'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
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 = {
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, {
level,
message,
context,
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

View File

@@ -0,0 +1,30 @@
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { invokeIpc } from '../lib/ipc'
import type {
BatchStats,
OperationHistoryRecord,
GetBatchesOptions
} from '../../main/types/operation-history.types'
import type { IpcResult } from '../../main/types/ipc.types'
export const operationHistoryApi = {
/**
* Get list of operation batches
* Admin users receive all batches, regular users only their own
*/
getBatches: (options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options),
/**
* Get detailed records for a specific batch
*/
getBatchDetails: (batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId),
/**
* Delete a batch
* Admin users can delete any batch, regular users only their own
*/
deleteBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId)
} as const

View File

@@ -129,6 +129,8 @@ export interface ConfigAPI {
export interface LoggerAPI {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
fetchLevel: () => Promise<void>
cleanup: () => void
}
export interface UpdateAPI {
@@ -157,6 +159,37 @@ export interface PlaywrightBrowserAPI {
onProgress: (callback: (data: DownloadProgress) => void) => () => void
}
export interface OperationHistoryAPI {
getBatches: (options?: { limit?: number; offset?: number }) => Promise<IpcResult<BatchStats[]>>
getBatchDetails: (batchId: string) => Promise<IpcResult<OperationHistoryRecord[]>>
deleteBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
}
export interface BatchStats {
batchId: string
userId: number
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
export 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
}
export interface ProcessAPI {
versions: {
electron: string
@@ -185,6 +218,7 @@ declare global {
report: ReportAPI
update: UpdateAPI
playwrightBrowser: PlaywrightBrowserAPI
operationHistory: OperationHistoryAPI
}
api: unknown
}

View File

@@ -0,0 +1,503 @@
/**
* Extractor Operation History Modal
*
* Displays extraction operation history with batch statistics and details.
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import {
RefreshCw,
Trash2,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
Copy
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface ExtractorOperationHistoryModalProps {
isOpen: boolean
onClose: () => void
user?: UserInfo | null
}
const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
}
const statusLabels: Record<string, string> = {
success: '成功',
partial: '部分成功',
failed: '失败',
pending: '进行中'
}
const statusIcons: Record<string, React.ReactNode> = {
success: <CheckCircle size={16} className="text-green-600" />,
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
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> = ({
isOpen,
onClose,
user
}) => {
const [batches, setBatches] = useState<BatchStats[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set())
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const isAdmin = user?.userType === 'Admin'
const fetchBatches = useCallback(async () => {
setLoading(true)
setError(null)
try {
// 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) {
setBatches(result.data)
} else {
setError(result.error || '获取历史记录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取历史记录失败')
} finally {
setLoading(false)
}
}, [isAdmin, selectedUsers])
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 (batchDetails.has(batchId)) {
return
}
try {
const result = await window.electron.operationHistory.getBatchDetails(batchId)
if (result.success && result.data) {
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
}
} catch (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) => {
setExpandedBatches((prev) => {
const newSet = new Set(prev)
if (newSet.has(batchId)) {
newSet.delete(batchId)
} else {
newSet.add(batchId)
void fetchBatchDetails(batchId)
}
return newSet
})
}
const handleDeleteBatch = async (batchId: string) => {
if (deleting.has(batchId)) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId))
try {
const result = await window.electron.operationHistory.deleteBatch(batchId)
if (result.success) {
// Remove from local state
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
setBatchDetails((prev) => {
const newMap = new Map(prev)
newMap.delete(batchId)
return newMap
})
setExpandedBatches((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
} else {
alert(result.error || '删除失败')
}
} catch (err) {
alert(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
}
}
const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
const details = batchDetails.get(batchId) || []
const values = details
.map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
.filter(Boolean) // 移除空值
.join('\n') // 使用换行符分隔
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
return (
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<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">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium">
{selectedUsers.length > 0
? `已选择 ${selectedUsers.length} 个用户`
: '显示所有用户记录'}
</span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
onClick={() => void fetchBatches()}
disabled={loading}
title="刷新"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => {
const isExpanded = expandedBatches.has(batch.batchId)
const details = batchDetails.get(batch.batchId) || []
const isDeleting = deleting.has(batch.batchId)
return (
<div
key={batch.batchId}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Batch summary */}
<div
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => toggleBatchExpansion(batch.batchId)}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="flex-1 grid grid-cols-6 gap-4 text-sm">
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">
{formatDateTime(batch.operationTime)}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="flex items-center gap-1">
{statusIcons[batch.status] || statusIcons.pending}
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[batch.status] || statusStyles.pending
}`}
>
{statusLabels[batch.status] || batch.status}
</span>
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalRecords}</div>
</div>
<div>
<div className="text-gray-500 text-xs">/</div>
<div className="font-medium text-gray-900">
<span className="text-green-600">{batch.successCount}</span>
{batch.failedCount > 0 && (
<>
{' / '}
<span className="text-red-600">{batch.failedCount}</span>
</>
)}
</div>
</div>
</div>
</div>
{isAdmin && (
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
)}
</div>
{/* Batch details */}
{isExpanded && details.length > 0 && (
<div className="border-t border-gray-200 bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<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 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 className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{details.map((detail) => (
<tr key={detail.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-900">
{detail.productionId || '-'}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{detail.orderNumber}
</td>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[detail.status] || statusStyles.pending
}`}
>
{statusIcons[detail.status]}
{statusLabels[detail.status] || detail.status}
</span>
</td>
<td className="px-4 py-2 text-gray-900">
{detail.recordCount ?? '-'}
</td>
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
{detail.errorMessage || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
</div>
</div>
</Modal>
)
}
export default ExtractorOperationHistoryModal

View File

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

View File

@@ -47,7 +47,10 @@ export const ComparisonTooltip = React.memo(
{user || '未分配'}:
</span>
<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>
</div>
)

View File

@@ -40,7 +40,10 @@ export const CustomTooltip = React.memo(
{entry.name}:
</span>
<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>
</div>
))}

View File

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

View File

@@ -1,14 +1,34 @@
import React from 'react'
import { Download, Play, CheckCircle } from 'lucide-react'
import { Download, Play, CheckCircle, History } from 'lucide-react'
import OrderNumberInput from '../components/OrderNumberInput'
import { useExtractor } from '../hooks/useExtractor'
import { usePersistentTextState } from '../hooks/usePersistentTextState'
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
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 [showHistoryModal, setShowHistoryModal] = React.useState(false)
// 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 {
isRunning,
@@ -67,6 +87,14 @@ const ExtractorPage: React.FC = () => {
</div>
<div className="flex items-center gap-4">
<button
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2.5 rounded-lg flex items-center gap-2 font-medium transition-colors"
onClick={() => setShowHistoryModal(true)}
disabled={isRunning}
>
<History size={18} />
</button>
<button
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
onClick={handleExtract}
@@ -78,6 +106,14 @@ const ExtractorPage: React.FC = () => {
</div>
</div>
{showHistoryModal ? (
<ExtractorOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={user}
/>
) : null}
{!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
<CheckCircle className="text-green-600" size={35} />

View File

@@ -92,6 +92,8 @@ export const IPC_CHANNELS = {
// Logger
LOGGER_FORWARD: 'logger:forward',
LOGGER_GET_LEVEL: 'logger:getLevel',
LOGGER_LEVEL_CHANGED: 'logger:levelChanged',
// Report
REPORT_LIST_ALL: 'report:listAll',
@@ -111,10 +113,15 @@ export const IPC_CHANNELS = {
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check',
// Operation History
OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches',
OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails',
OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch'
} as const
/**
* 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()
await logAudit(entry.action as string, entry.userId as string, {
logAudit(entry.action as string, entry.userId as string, {
username: entry.username as string,
computerName: entry.computerName as string,
resource: entry.resource as string,
@@ -101,7 +101,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Close logger to flush writes
await closeAuditLogger()
closeAuditLogger()
// Find the audit log file (should be today's file)
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')
// Test success status
await logAudit('EXTRACT', 'user1', {
logAudit('EXTRACT', 'user1', {
username: 'extractor',
computerName: 'PC-001',
resource: 'materials',
@@ -130,7 +130,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Test failure status
await logAudit('DELETE', 'user2', {
logAudit('DELETE', 'user2', {
username: 'cleaner',
computerName: 'PC-002',
resource: 'temp_files',
@@ -139,7 +139,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Test partial status
await logAudit('UPDATE', 'user3', {
logAudit('UPDATE', 'user3', {
username: 'updater',
computerName: 'PC-003',
resource: 'config',
@@ -147,7 +147,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { updated: 5, failed: 2 }
})
await closeAuditLogger()
closeAuditLogger()
// Verify all entries were processed
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')
// Without metadata
await logAudit('LOGIN', 'user-no-meta', {
logAudit('LOGIN', 'user-no-meta', {
username: 'no.meta',
computerName: 'PC-001',
resource: 'ERP',
@@ -166,7 +166,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// With metadata
await logAudit('LOGOUT', 'user-with-meta', {
logAudit('LOGOUT', 'user-with-meta', {
username: 'with.meta',
computerName: 'PC-002',
resource: 'ERP',
@@ -174,7 +174,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { sessionDuration: 3600, actionsPerformed: 15 }
})
await closeAuditLogger()
closeAuditLogger()
// Both entries should be processed successfully
expect(true).toBe(true)
@@ -186,14 +186,14 @@ describe('Audit Logger - Real File Integration', () => {
const beforeLog = Date.now()
await logAudit('TEST', 'timestamp-user', {
logAudit('TEST', 'timestamp-user', {
username: 'timestamp.test',
computerName: 'PC-TS',
resource: 'test_resource',
status: 'success'
})
await closeAuditLogger()
closeAuditLogger()
const afterLog = Date.now()
@@ -204,15 +204,15 @@ describe('Audit Logger - Real File Integration', () => {
it('should close audit logger without errors', async () => {
const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger')
// Should resolve without throwing
await expect(closeAuditLogger()).resolves.toBeUndefined()
// Should complete without throwing
expect(() => closeAuditLogger()).not.toThrow()
})
it('should handle special characters in fields', async () => {
const { logAudit, closeAuditLogger } =
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',
computerName: 'DESKTOP-特殊字符-001',
resource: 'ERP/子系统',
@@ -220,7 +220,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { reason: '密码错误', attempt: 3 }
})
await closeAuditLogger()
closeAuditLogger()
// Should handle without errors
expect(true).toBe(true)
@@ -230,7 +230,7 @@ describe('Audit Logger - Real File Integration', () => {
const { logAudit, closeAuditLogger } =
await import('../../src/main/services/logger/audit-logger')
await logAudit('PING', 'ping-user', {
logAudit('PING', 'ping-user', {
username: 'pinger',
computerName: 'PC-PING',
resource: 'health_check',
@@ -238,7 +238,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: {}
})
await closeAuditLogger()
closeAuditLogger()
// Should handle empty metadata
expect(true).toBe(true)