Compare commits
23 Commits
v1.6.2
...
24d9bfebaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d9bfebaf | ||
|
|
ba436cf374 | ||
|
|
6413eef5b8 | ||
|
|
6a9d144bbc | ||
|
|
a2e3681c8f | ||
|
|
21359b31c6 | ||
|
|
0a1181fecd | ||
|
|
883f98065a | ||
|
|
020bbcdccc | ||
|
|
63a292c5f9 | ||
|
|
51f8e0a6e7 | ||
|
|
c8ab58d390 | ||
|
|
811361a1a3 | ||
|
|
ffbda4c618 | ||
|
|
348b02600d | ||
|
|
d004f8e9f8 | ||
|
|
3cbe9eef12 | ||
|
|
5b310d944b | ||
|
|
6e04f21b10 | ||
|
|
17fbd7d251 | ||
|
|
c6eb60ada7 | ||
|
|
571ec2325f | ||
|
|
557ed174c3 |
@@ -40,6 +40,7 @@ extraction:
|
||||
autoConvert: true
|
||||
mergeBatches: true
|
||||
enableDbPersistence: true
|
||||
headless: true # 浏览器无头模式,true=后台运行,false=显示浏览器窗口(调试用)
|
||||
|
||||
validation:
|
||||
dataSource: database_full
|
||||
|
||||
752
docs/LOGGING_IMPLEMENTATION.md
Normal file
752
docs/LOGGING_IMPLEMENTATION.md
Normal 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_
|
||||
@@ -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` | 最近几轮重要重构和架构决策是什么 |
|
||||
|
||||
## 按问题选择阅读路径
|
||||
|
||||
|
||||
@@ -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` | 构建、发布、更新产物与上传流程 |
|
||||
|
||||
## 与其他文档目录的关系
|
||||
|
||||
|
||||
@@ -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
18
docs/releases/1.7.0.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 1.7.0
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
|
||||
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
|
||||
- 批次记录可展开查看,显示总排号与订单号的对应关系。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
|
||||
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
|
||||
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
|
||||
|
||||
## 数据存储
|
||||
|
||||
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
|
||||
- 需执行数据库脚本创建表结构(详见项目文档)。
|
||||
6
docs/releases/1.7.1.md
Normal file
6
docs/releases/1.7.1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 1.7.1
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复 MySQL 数据库下操作历史查询报错问题。
|
||||
- 优化历史记录数据结构,支持按订单统计记录数量。
|
||||
6
docs/releases/1.7.2.md
Normal file
6
docs/releases/1.7.2.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 1.7.2
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复操作历史时间显示错误(时区转换导致时间快8小时)。
|
||||
- 操作历史支持一键复制总排号和订单号。
|
||||
12
docs/releases/1.8.0.md
Normal file
12
docs/releases/1.8.0.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 1.8.0
|
||||
|
||||
## 权限控制
|
||||
|
||||
- 操作历史删除按钮仅对管理员可见,普通用户无法删除历史记录。
|
||||
- 修复用户状态传递问题,确保权限判断正确生效。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 管理员可使用多选标签(Chip)按用户筛选操作历史。
|
||||
- 支持同时选择多个用户查看记录,点击标签即可切换选中状态。
|
||||
- 添加"清空筛选"按钮,一键恢复显示所有用户记录。
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
124
src/main/ipc/operation-history-handler.ts
Normal file
124
src/main/ipc/operation-history-handler.ts
Normal 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')
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
623
src/main/services/database/extractor-operation-history-dao.ts
Normal file
623
src/main/services/database/extractor-operation-history-dao.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
60
src/main/services/logger/shared.ts
Normal file
60
src/main/services/logger/shared.ts
Normal 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)
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
27
src/main/tools/debug-env.ts
Normal file
27
src/main/tools/debug-env.ts
Normal 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()
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
88
src/main/types/operation-history.types.ts
Normal file
88
src/main/types/operation-history.types.ts
Normal 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[]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
30
src/preload/api/operation-history.ts
Normal file
30
src/preload/api/operation-history.ts
Normal 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
|
||||
34
src/preload/index.d.ts
vendored
34
src/preload/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
503
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
503
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal 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
|
||||
@@ -185,7 +185,7 @@ export function AuthenticatedAppShell({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentPage === 'extractor' && <ExtractorPage />}
|
||||
{currentPage === 'extractor' && <ExtractorPage currentUser={currentUser} />}
|
||||
{currentPage === 'cleaner' && <CleanerPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user