Compare commits
201 Commits
a05c8a9037
...
v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2a3b088f | ||
|
|
82a6e24132 | ||
|
|
4a1a78ee14 | ||
|
|
00c75fb0b8 | ||
|
|
a56d37a2e9 | ||
|
|
43e6d1f4b4 | ||
|
|
5ff5e5d18b | ||
|
|
44483120a5 | ||
|
|
18a81ae030 | ||
|
|
5178a2425a | ||
|
|
5cce470850 | ||
|
|
ffc3cbb4a9 | ||
|
|
7a78948a8c | ||
|
|
97bf918ed1 | ||
|
|
1d5268a4da | ||
|
|
2adfc77a58 | ||
|
|
2343fb2188 | ||
|
|
fb46586a13 | ||
|
|
6f21785c64 | ||
|
|
fbd62fe390 | ||
|
|
a711781f21 | ||
|
|
6ad916998c | ||
|
|
32df3cea67 | ||
|
|
37eade6360 | ||
|
|
5cd1e98bbf | ||
|
|
8713f8c0e2 | ||
|
|
bdde4fefd0 | ||
|
|
180f4aab0a | ||
|
|
31a32db899 | ||
|
|
14e7abe11e | ||
|
|
57ca1d5650 | ||
|
|
f40512449a | ||
|
|
c09e0eb4e7 | ||
|
|
86614efa22 | ||
|
|
16ac892c93 | ||
|
|
1fc2eab816 | ||
|
|
2baf55bd2e | ||
|
|
957cfbda46 | ||
|
|
bede488230 | ||
|
|
ed2a42ad6b | ||
|
|
13db99d51d | ||
|
|
84c6b81959 | ||
|
|
9a1f5a483e | ||
|
|
ed65312fff | ||
|
|
26c05f3726 | ||
|
|
546d005c19 | ||
|
|
325e6fcc89 | ||
|
|
2e01542cd8 | ||
|
|
0e77479955 | ||
|
|
2b4a09dabe | ||
|
|
2fba07fd8f | ||
|
|
08bd2cb7d5 | ||
|
|
7b57545127 | ||
|
|
b979b73ba1 | ||
|
|
68e6c9483f | ||
|
|
63ff32817e | ||
|
|
78544af8de | ||
|
|
7d73592d41 | ||
|
|
bacdd2d82f | ||
|
|
4add295e44 | ||
|
|
88b2e8d355 | ||
|
|
fe5ad13f88 | ||
|
|
c8ce67dc75 | ||
|
|
9add23f6ed | ||
|
|
6d4b5efc95 | ||
|
|
2216720e24 | ||
|
|
36304b88e1 | ||
|
|
6ad9463e73 | ||
|
|
7644b8d4ea | ||
|
|
5e50a8fbcf | ||
|
|
9ee1ea566c | ||
|
|
29f29f6a9e | ||
|
|
baa7622954 | ||
|
|
851c2ce634 | ||
|
|
64349125ba | ||
|
|
a020ee537d | ||
|
|
351e9a92bc | ||
|
|
f5514dd721 | ||
|
|
b94640ca81 | ||
|
|
b8163d7d4f | ||
|
|
569c8e8ecc | ||
|
|
21bb8ef79c | ||
|
|
fbcc656b99 | ||
|
|
3854c0f048 | ||
|
|
7bc6daf1b7 | ||
|
|
db44618ee5 | ||
|
|
2d17a6b792 | ||
|
|
e8aa7d21a8 | ||
|
|
defeb01808 | ||
|
|
b289fb9624 | ||
|
|
103effcfca | ||
|
|
975bee6ce8 | ||
|
|
6a2fba0e57 | ||
|
|
9248be6310 | ||
|
|
ad8b3deb00 | ||
|
|
cccc4e4c8c | ||
|
|
9e577a2226 | ||
|
|
715dfb4d71 | ||
|
|
a25ffd75c5 | ||
|
|
8eaba79c26 | ||
|
|
05a44bb464 | ||
|
|
77fabf2018 | ||
|
|
13187ddce1 | ||
|
|
33b17e278d | ||
|
|
bcfe0eecca | ||
|
|
9a640b96e6 | ||
|
|
5b4d7fab49 | ||
|
|
72d8d981b1 | ||
|
|
d1f2f40123 | ||
|
|
545048045d | ||
|
|
62e1647eaf | ||
|
|
fba2c73782 | ||
|
|
c4fff84848 | ||
|
|
57c3452d82 | ||
|
|
f412e0e72c | ||
|
|
6df16898da | ||
|
|
5e6898fb40 | ||
|
|
f45d3df385 | ||
|
|
8f3e5273e2 | ||
|
|
616e2b31a5 | ||
|
|
b3abad7fae | ||
|
|
9d15f7aca9 | ||
|
|
310d5e462f | ||
|
|
c4bacdaf2a | ||
|
|
54a82200b6 | ||
|
|
2d6838c0e7 | ||
|
|
73b8f2409a | ||
|
|
c7c192a703 | ||
|
|
42bd6dcb8a | ||
|
|
e4a92e9be4 | ||
|
|
6f19890a84 | ||
|
|
1ee33672dd | ||
|
|
8e0c37bf74 | ||
|
|
c13be9e19a | ||
|
|
48f1f51d76 | ||
|
|
5977254180 | ||
|
|
3d2127b660 | ||
|
|
7db7f513be | ||
|
|
a906f34ac2 | ||
|
|
224a42e44b | ||
|
|
7d2cf934f8 | ||
|
|
49ac29e70c | ||
|
|
dc01896d8b | ||
|
|
ba64c27457 | ||
|
|
921ca15be6 | ||
|
|
121d49bfe8 | ||
|
|
0783bc037c | ||
|
|
d855b3f84d | ||
|
|
e0d7559cf7 | ||
|
|
302018f631 | ||
|
|
e49191d531 | ||
|
|
2dea1f9556 | ||
|
|
4494351e52 | ||
|
|
9e1b5530ea | ||
|
|
743d830b0d | ||
|
|
dcd3c6a571 | ||
|
|
9939201ca1 | ||
|
|
fed76b4fe0 | ||
|
|
bfa2445c76 | ||
|
|
b62ae10650 | ||
|
|
974eaac6dd | ||
|
|
2f5dc7607d | ||
|
|
a06276127d | ||
|
|
29cb79abfd | ||
|
|
dc3d577f6f | ||
|
|
50041d2a7b | ||
|
|
9833552652 | ||
|
|
8dbdf6f394 | ||
|
|
c61776a1ff | ||
|
|
88c8c256e2 | ||
|
|
5497e86b58 | ||
|
|
63ea81e0d6 | ||
|
|
abe51d17fa | ||
|
|
240e3838ba | ||
|
|
c384513273 | ||
|
|
e23cf71f78 | ||
|
|
7aa1abbc22 | ||
|
|
879dccaa09 | ||
|
|
e13bb15969 | ||
|
|
00b120c762 | ||
|
|
0040ca7521 | ||
|
|
6698d82d6b | ||
|
|
b98cd46237 | ||
|
|
baaa031dac | ||
|
|
816060444c | ||
|
|
73a49f9de3 | ||
|
|
4d8df61187 | ||
|
|
a967050058 | ||
|
|
f3be0ad01d | ||
|
|
7e37a9b1fc | ||
|
|
b5ef38f486 | ||
|
|
c06880e946 | ||
|
|
765fb95644 | ||
|
|
429357ae8c | ||
|
|
79934a58d0 | ||
|
|
b942b2fb15 | ||
|
|
6ecf03ce11 | ||
|
|
77311edce0 | ||
|
|
74ddef2d7b | ||
|
|
edf696ab39 | ||
|
|
6ec422f357 |
35
.env.example
35
.env.example
@@ -1,35 +0,0 @@
|
||||
# ERP System Configuration
|
||||
ERP_URL=https://your-erp-system.com
|
||||
ERP_USERNAME=your_username
|
||||
ERP_PASSWORD=your_password
|
||||
|
||||
# Additional ERP Settings
|
||||
ERP_HEADLESS=true
|
||||
ERP_IGNORE_HTTPS_ERRORS=true
|
||||
ERP_AUTO_CLOSE_BROWSER=true
|
||||
|
||||
# Database Configuration - SQL Server
|
||||
SQL_SERVER_HOST=localhost
|
||||
SQL_SERVER_PORT=1433
|
||||
SQL_SERVER_DATABASE=erp_db
|
||||
SQL_SERVER_USERNAME=sa
|
||||
SQL_SERVER_PASSWORD=your_password
|
||||
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=erp_db
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PASSWORD=your_password
|
||||
|
||||
# Application Settings
|
||||
LOG_LEVEL=info
|
||||
DOWNLOAD_DIR=./downloads
|
||||
TEMP_DIR=./temp
|
||||
|
||||
# Legacy configurations (if needed)
|
||||
DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server
|
||||
DB_TRUST_SERVER_CERTIFICATE=yes
|
||||
DB_TYPE=mysql
|
||||
DB_MYSQL_HOST=192.168.31.83
|
||||
DB_MYSQL_PORT=3306
|
||||
DB_MYSQL_CHARSET=utf8mb4
|
||||
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
* text=auto
|
||||
|
||||
# Force Unix line endings for source files
|
||||
*.md text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
|
||||
# Force Windows line endings for build artifacts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -4,9 +4,11 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.npmrc
|
||||
|
||||
# AI Agent
|
||||
.claude
|
||||
.agents
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -14,6 +16,8 @@ out
|
||||
# Test outputs
|
||||
coverage/
|
||||
downloads/
|
||||
release-output/
|
||||
build/bin/
|
||||
*.xlsx
|
||||
*.parsed.json
|
||||
|
||||
@@ -22,4 +26,22 @@ tests/debug/
|
||||
tests/manual/
|
||||
test-*.mjs
|
||||
test-*.js
|
||||
compare_*.js
|
||||
compare_*.js
|
||||
|
||||
# logs
|
||||
logs
|
||||
|
||||
# oh my opencode
|
||||
.sisyphus
|
||||
|
||||
# Runtime config files
|
||||
*.yaml
|
||||
*.yaml.backup
|
||||
# But keep config.template.yaml
|
||||
!config.template.yaml
|
||||
|
||||
# nul
|
||||
nul
|
||||
|
||||
# TypeScript incremental compilation cache
|
||||
*.tsbuildinfo
|
||||
|
||||
212
CLAUDE.md
212
CLAUDE.md
@@ -1,133 +1,165 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
本文件用于给 AI 编程代理提供本项目的最小必要指导。
|
||||
目标不是替代 `README` 或 `docs/`,而是帮助代理快速理解项目结构、工作方式和关键约束。
|
||||
|
||||
## Development Commands
|
||||
## 项目概览
|
||||
|
||||
### Running the Application
|
||||
ERPAuto 是一个基于 Electron 的桌面应用,用于自动化处理 ERP 系统中的数据提取、清理、校验和配置管理。
|
||||
|
||||
技术栈:
|
||||
|
||||
- Electron 39
|
||||
- React 19
|
||||
- TypeScript 5.9
|
||||
- electron-vite / Vite 7
|
||||
- Playwright 1.58
|
||||
- Vitest
|
||||
|
||||
## 常用命令
|
||||
|
||||
开发与构建:
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server with hot reload
|
||||
npm run build # Full build with type checking
|
||||
npm run build:win # Build Windows executable
|
||||
npm run build:mac # Build macOS DMG
|
||||
npm run build:linux # Build Linux AppImage
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
质量检查:
|
||||
|
||||
```bash
|
||||
npm run lint # ESLint check
|
||||
npm run format # Prettier format
|
||||
npm run typecheck # TypeScript check (both main and renderer)
|
||||
npm run typecheck:node # TypeScript check for main process only
|
||||
npm run typecheck:web # TypeScript check for renderer only
|
||||
npm run typecheck
|
||||
npm run typecheck:node
|
||||
npm run typecheck:web
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Testing
|
||||
测试:
|
||||
|
||||
```bash
|
||||
npm run test # Run unit tests (Vitest)
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run test:e2e # Run E2E tests (Playwright)
|
||||
npm run test:e2e:ui # Run E2E tests with UI
|
||||
npm run test:e2e:report # Show E2E test report
|
||||
npm run test
|
||||
npm run test:coverage
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
发布:
|
||||
|
||||
ERPAuto is an **Electron desktop application** for automating ERP system data processing. The application follows the classic Electron architecture with three distinct processes:
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
### Process Structure
|
||||
详细发布流程见:
|
||||
[docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
|
||||
1. **Main Process** (`src/main/`)
|
||||
- Node.js environment managing application lifecycle
|
||||
- Entry point: `src/main/index.ts`
|
||||
- Registers all IPC handlers via `registerIpcHandlers()`
|
||||
- Loads environment variables from `.env` at startup
|
||||
## 代码结构
|
||||
|
||||
2. **Preload Script** (`src/preload/`)
|
||||
- Security bridge between main and renderer processes
|
||||
- Exposes type-safe APIs via `contextBridge` as `window.electron` and `window.api`
|
||||
- Central API surface organized by domain (auth, extractor, cleaner, database, etc.)
|
||||
### 主进程
|
||||
|
||||
3. **Renderer Process** (`src/renderer/`)
|
||||
- React 19 + TypeScript UI
|
||||
- Uses exposed preload APIs for all main process communication
|
||||
- Authentication-based routing with role-based access control
|
||||
- 路径:`src/main/`
|
||||
- 入口:`src/main/index.ts`
|
||||
- 职责:
|
||||
- 应用生命周期管理
|
||||
- 配置加载
|
||||
- IPC 注册
|
||||
- 更新服务初始化
|
||||
|
||||
### Service Architecture
|
||||
### 预加载层
|
||||
|
||||
The main process is organized around domain-specific services in `src/main/services/`:
|
||||
- 路径:`src/preload/`
|
||||
- 职责:
|
||||
- 暴露 `window.electron` API
|
||||
- 作为 renderer 和 main 之间的安全桥
|
||||
|
||||
- **ERP Services** (`services/erp/`): Browser automation using Playwright
|
||||
- `ExtractorService` - Downloads material plan data
|
||||
- `CleanerService` - Deletes specified materials with dry-run support
|
||||
- `ErpAuthService` - Handles ERP authentication
|
||||
- `OrderResolverService` - Validates and resolves order numbers
|
||||
- `locators.ts` - ERP element selectors
|
||||
### 渲染进程
|
||||
|
||||
- **Database Services** (`services/database/`): Dual database support
|
||||
- `MySqlService` / `mysql.ts` - MySQL operations
|
||||
- `SqlServerService` / `sql-server.ts` - SQL Server operations
|
||||
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts`
|
||||
- 路径:`src/renderer/`
|
||||
- 技术:React + TypeScript
|
||||
- 特点:
|
||||
- 通过 preload 暴露的 API 调用主进程
|
||||
- 以登录状态和角色控制主要功能入口
|
||||
|
||||
- **User Services** (`services/user/`): Authentication and session management
|
||||
- `BipUsersDao` - User data access
|
||||
- `SessionManager` - Active session tracking
|
||||
### 服务层
|
||||
|
||||
- **Other Services**:
|
||||
- `config/` - Configuration management
|
||||
- `excel/` - Excel file parsing
|
||||
主进程服务集中在 `src/main/services/`,按领域拆分:
|
||||
|
||||
### IPC Handler Pattern
|
||||
- `erp/`:ERP 浏览器自动化
|
||||
- `database/`:MySQL / SQL Server
|
||||
- `user/`:登录、会话、用户切换
|
||||
- `config/`:YAML 配置管理
|
||||
- `update/`:便携版更新
|
||||
- `excel/`:Excel 处理
|
||||
|
||||
All IPC communication follows a consistent pattern:
|
||||
## 关键事实
|
||||
|
||||
- Handlers are in `src/main/ipc/`, organized by domain (8 modules)
|
||||
- Each handler module exports a `register*Handlers()` function
|
||||
- All handlers are registered in `src/main/ipc/index.ts`
|
||||
- Channel naming follows `domain:action` convention (e.g., `extractor:run`, `auth:login`)
|
||||
### 配置系统
|
||||
|
||||
### Authentication Flow
|
||||
- 使用 YAML 配置
|
||||
- 模板文件:`config.template.yaml`
|
||||
- 开发环境通常使用项目根目录下的 `config.yaml`
|
||||
- 生产环境会将配置放到用户目录
|
||||
|
||||
The application implements a multi-stage authentication system:
|
||||
### IPC 组织方式
|
||||
|
||||
1. **Silent Login**: On startup, attempts automatic login using computer name
|
||||
2. **Fallback**: Shows login dialog if silent login fails
|
||||
3. **Admin User Selection**: Admin users can switch to other user accounts
|
||||
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest)
|
||||
- 所有 IPC handler 在 `src/main/ipc/`
|
||||
- 每个领域一个 handler 模块
|
||||
- 统一在 `src/main/ipc/index.ts` 注册
|
||||
- channel 命名遵循 `domain:action`
|
||||
|
||||
Admin users see logout buttons and can access user switching. Non-admin users have restricted access based on the user who initiated their session.
|
||||
### 认证与角色
|
||||
|
||||
### Type System
|
||||
当前主要角色是:
|
||||
|
||||
- Separate TypeScript configs: `tsconfig.node.json` (main/preload) and `tsconfig.web.json` (renderer)
|
||||
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions
|
||||
- The preload script exposes a typed API surface that's available in renderer
|
||||
- `Admin`
|
||||
- `User`
|
||||
- `Guest`
|
||||
|
||||
### Path Aliases
|
||||
登录流程支持:
|
||||
|
||||
- `@renderer` → `src/renderer/src` (renderer process)
|
||||
- `@main` → `src/main` (main process, tests only)
|
||||
- `@services` → `src/main/services` (main process, tests only)
|
||||
- `@types` → `src/main/types` (main process, tests only)
|
||||
- 静默登录
|
||||
- 普通登录
|
||||
- 管理员切换用户
|
||||
|
||||
## Environment Configuration
|
||||
### 便携版自动更新
|
||||
|
||||
The application requires a `.env` file in the project root. Reference `.env.example` for the full structure. Key configurations:
|
||||
项目已实现 Windows 便携版更新,关键点:
|
||||
|
||||
- **ERP Settings**: URL, credentials, headless mode, HTTPS error handling
|
||||
- **Database**: MySQL and SQL Server connection configs (dual support)
|
||||
- **App Settings**: Log level, download/temp directories
|
||||
- 更新检查基于登录用户角色
|
||||
- 支持 `stable` / `preview` 双通道
|
||||
- `User` 只看 `stable`
|
||||
- `Admin` 同时看 `stable` 和 `preview`
|
||||
- 使用原生 `portable-updater.exe` 完成替换,不依赖 PowerShell
|
||||
|
||||
## Key Technologies
|
||||
相关文档:
|
||||
|
||||
- **Electron 39** - Desktop framework
|
||||
- **React 19** - UI framework
|
||||
- **TypeScript 5.9** - Type safety
|
||||
- **Playwright 1.58** - Browser automation for ERP interaction
|
||||
- **electron-vite + Vite 7** - Build tooling
|
||||
- **Zod** - Runtime validation
|
||||
- **Vitest** - Unit tests
|
||||
- **Playwright Test** - E2E tests
|
||||
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
|
||||
## 代理工作约束
|
||||
|
||||
1. 优先修改现有文件,不要随意新建同类文件。
|
||||
2. 变更前先理解对应模块的现有模式,尽量保持风格一致。
|
||||
3. renderer 不要直接访问 Node/Electron 能力,统一走 preload。
|
||||
4. 配置、IPC、类型定义通常需要同步更新,避免只改一层。
|
||||
5. 涉及发布、更新、构建链路时,优先复用现有脚本,不要重复实现。
|
||||
6. 涉及浏览器部署或更新流程时,先看 `docs/` 里的专题文档。
|
||||
|
||||
## 代理优先查看的文档
|
||||
|
||||
- [README.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/README.md)
|
||||
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||
- [docs/browser/PLAYWRIGHT_DEPLOYMENT.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/browser/PLAYWRIGHT_DEPLOYMENT.md)
|
||||
|
||||
## 不放在这里的内容
|
||||
|
||||
以下内容不应继续堆在本文件中:
|
||||
|
||||
- 详细用户使用说明
|
||||
- 大段业务流程说明
|
||||
- 重复的架构长文
|
||||
- 版本发布记录
|
||||
|
||||
这些内容应继续放在 `README` 或 `docs/` 下的专题文档中。
|
||||
|
||||
65
README.md
65
README.md
@@ -30,29 +30,43 @@ npm install
|
||||
|
||||
### 配置
|
||||
|
||||
在项目根目录创建 `.env` 文件:
|
||||
在项目根目录创建 `config.yaml` 文件(可参考 `config.template.yaml`):
|
||||
|
||||
```bash
|
||||
# ERP 配置
|
||||
ERP_URL=https://your-erp-server.com
|
||||
ERP_USERNAME=your_username
|
||||
ERP_PASSWORD=your_password
|
||||
```yaml
|
||||
# ERP 配置(固定基础设施)
|
||||
erp:
|
||||
url: https://your-erp-server.com
|
||||
|
||||
# MySQL 配置(可选)
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=password
|
||||
MYSQL_DATABASE=erpauto
|
||||
# 数据库配置
|
||||
database:
|
||||
activeType: mysql # 或 sqlserver
|
||||
|
||||
# SQL Server 配置(可选)
|
||||
SQL_SERVER_HOST=localhost
|
||||
SQL_SERVER_PORT=1433
|
||||
SQL_SERVER_USER=sa
|
||||
SQL_SERVER_PASSWORD=password
|
||||
SQL_SERVER_DATABASE=erpauto
|
||||
mysql:
|
||||
host: localhost
|
||||
port: 3306
|
||||
database: erpauto
|
||||
username: root
|
||||
password: your_password
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: localhost
|
||||
port: 1433
|
||||
database: erpauto
|
||||
username: sa
|
||||
password: your_password
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
# 路径配置
|
||||
paths:
|
||||
dataDir: './data/'
|
||||
defaultOutput: 'output.xlsx'
|
||||
validationOutput: 'validation-result.xlsx'
|
||||
```
|
||||
|
||||
**注意**:ERP 用户名和密码在应用的设置界面中配置,存储在数据库中(按用户管理)。
|
||||
|
||||
### 运行开发环境
|
||||
|
||||
```bash
|
||||
@@ -62,16 +76,12 @@ npm run dev
|
||||
### 构建应用
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
# Windows 安装版 + 便携版
|
||||
npm run build:win
|
||||
|
||||
# macOS
|
||||
npm run build:mac
|
||||
|
||||
# Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
当前项目的正式构建与发布链路仅维护 Windows 目标。
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 数据提取
|
||||
@@ -136,9 +146,10 @@ ERPAuto/
|
||||
|
||||
### 无法连接 ERP 系统
|
||||
|
||||
1. 检查 `.env` 文件中的 ERP_URL 是否正确
|
||||
1. 检查 `config.yaml` 中的 ERP URL 是否正确
|
||||
2. 确认网络连接正常
|
||||
3. 检查 ERP 系统是否可访问
|
||||
4. 在设置界面中确认 ERP 用户名和密码已配置
|
||||
|
||||
### 提取失败
|
||||
|
||||
@@ -149,7 +160,7 @@ ERPAuto/
|
||||
### 数据库连接失败
|
||||
|
||||
1. 确认数据库服务已启动
|
||||
2. 检查 `.env` 中的数据库配置
|
||||
2. 检查 `config.yaml` 中的数据库配置
|
||||
3. 确认防火墙允许数据库端口访问
|
||||
|
||||
## 开发
|
||||
|
||||
247
build/PortableUpdater.cs
Normal file
247
build/PortableUpdater.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
internal static class PortableUpdater
|
||||
{
|
||||
private static string _logPath = string.Empty;
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = ParseArgs(args);
|
||||
|
||||
var targetExe = Require(options, "--targetExe");
|
||||
var downloadedExe = Require(options, "--downloadedExe");
|
||||
var parentPid = int.Parse(Require(options, "--parentPid"));
|
||||
_logPath = Require(options, "--logPath");
|
||||
var argsBase64 = options.ContainsKey("--argsBase64") ? options["--argsBase64"] : string.Empty;
|
||||
|
||||
WriteLog("Portable updater started");
|
||||
WriteLog("Target exe: " + targetExe);
|
||||
WriteLog("Downloaded exe: " + downloadedExe);
|
||||
WriteLog("Parent pid: " + parentPid);
|
||||
|
||||
var appArgs = DecodeArgs(argsBase64);
|
||||
|
||||
WaitForProcessExit(parentPid, 120);
|
||||
WaitForFileAvailable(targetExe, 120);
|
||||
|
||||
var backupExe = targetExe + ".bak";
|
||||
if (File.Exists(backupExe))
|
||||
{
|
||||
WriteLog("Removing stale backup: " + backupExe);
|
||||
File.Delete(backupExe);
|
||||
}
|
||||
|
||||
WriteLog("Backing up current executable");
|
||||
File.Move(targetExe, backupExe);
|
||||
|
||||
try
|
||||
{
|
||||
WriteLog("Replacing executable");
|
||||
File.Move(downloadedExe, targetExe);
|
||||
}
|
||||
catch (Exception replaceError)
|
||||
{
|
||||
WriteLog("Replace failed: " + replaceError.Message);
|
||||
if (File.Exists(backupExe) && !File.Exists(targetExe))
|
||||
{
|
||||
File.Move(backupExe, targetExe);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = targetExe,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(targetExe) ?? Environment.CurrentDirectory,
|
||||
Arguments = BuildArgumentString(appArgs)
|
||||
};
|
||||
|
||||
WriteLog("Launching updated executable");
|
||||
Process.Start(startInfo);
|
||||
|
||||
if (File.Exists(backupExe))
|
||||
{
|
||||
WriteLog("Removing backup file");
|
||||
File.Delete(backupExe);
|
||||
}
|
||||
|
||||
WriteLog("Portable update completed successfully");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteLog("Portable update failed: " + ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var key = args[i];
|
||||
if (!key.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = i + 1 < args.Length ? args[i + 1] : string.Empty;
|
||||
if (value.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
result[key] = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Require(Dictionary<string, string> options, string key)
|
||||
{
|
||||
if (!options.ContainsKey(key) || string.IsNullOrWhiteSpace(options[key]))
|
||||
{
|
||||
throw new InvalidOperationException("Missing required argument: " + key);
|
||||
}
|
||||
|
||||
return options[key];
|
||||
}
|
||||
|
||||
private static string[] DecodeArgs(string argsBase64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argsBase64))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(argsBase64));
|
||||
return raw.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static void WaitForProcessExit(int pid, int timeoutSeconds)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var process = Process.GetProcessById(pid))
|
||||
{
|
||||
if (process.HasExited)
|
||||
{
|
||||
WriteLog("Parent process exited");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
WriteLog("Parent process already exited");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for process exit: " + pid);
|
||||
}
|
||||
|
||||
private static void WaitForFileAvailable(string filePath, int timeoutSeconds)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
WriteLog("Target executable is no longer locked");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for target executable to become writable: " + filePath);
|
||||
}
|
||||
|
||||
private static void WriteLog(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_logPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_logPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.AppendAllText(
|
||||
_logPath,
|
||||
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + message + Environment.NewLine,
|
||||
Encoding.UTF8
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort logging only.
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildArgumentString(IEnumerable<string> args)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string arg)
|
||||
{
|
||||
if (string.IsNullOrEmpty(arg))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0)
|
||||
{
|
||||
return arg;
|
||||
}
|
||||
|
||||
return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
85
config.template.yaml
Normal file
85
config.template.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
# ================================
|
||||
# ERPAuto 配置模板
|
||||
# ================================
|
||||
# 部署说明:
|
||||
# 1. 复制此文件为 config.yaml
|
||||
# 2. 根据实际环境修改配置值
|
||||
# 3. 设置 database.activeType 为 mysql 或 sqlserver
|
||||
# ================================
|
||||
# 注意:ERP 认证信息存储在数据库 (dbo_BIPUsers) 中,按用户管理
|
||||
# ================================
|
||||
|
||||
database:
|
||||
activeType: mysql
|
||||
|
||||
mysql:
|
||||
host: <MYSQL_HOST>
|
||||
port: 3306
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: <SQL_SERVER_HOST>
|
||||
port: 1433
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
paths:
|
||||
dataDir: './data/'
|
||||
defaultOutput: 'output.xlsx'
|
||||
validationOutput: 'validation-result.xlsx'
|
||||
|
||||
extraction:
|
||||
batchSize: 100
|
||||
verbose: true
|
||||
autoConvert: true
|
||||
mergeBatches: true
|
||||
enableDbPersistence: true
|
||||
|
||||
validation:
|
||||
dataSource: database_full
|
||||
batchSize: 2000
|
||||
matchMode: substring
|
||||
enableCrud: false
|
||||
defaultManager: ''
|
||||
|
||||
orderResolution:
|
||||
tableName: ''
|
||||
productionIdField: ''
|
||||
orderNumberField: ''
|
||||
|
||||
cleaner:
|
||||
queryBatchSize: 100
|
||||
processConcurrency: 1
|
||||
|
||||
logging:
|
||||
level: info
|
||||
auditRetention: 30
|
||||
appRetention: 14
|
||||
|
||||
# RustFS 对象存储配置(用于持久化报告)
|
||||
rustfs:
|
||||
enabled: false # 设置为 true 启用 RustFS 上传
|
||||
endpoint: 'http://192.168.110.114:9000' # RustFS 服务器地址
|
||||
accessKey: '<YOUR_ACCESS_KEY>' # 访问密钥
|
||||
secretKey: '<YOUR_SECRET_KEY>' # 密钥
|
||||
bucket: 'erpauto' # 存储桶名称
|
||||
region: 'us-east-1' # 区域(S3 兼容,默认即可)
|
||||
|
||||
# 便携版自动更新配置
|
||||
update:
|
||||
enabled: false
|
||||
allowDevMode: false
|
||||
endpoint: 'http://192.168.110.114:9000'
|
||||
accessKey: '<YOUR_ACCESS_KEY>'
|
||||
secretKey: '<YOUR_SECRET_KEY>'
|
||||
bucket: 'erpauto'
|
||||
region: 'us-east-1'
|
||||
basePrefix: 'updates/win-portable'
|
||||
checkIntervalMinutes: 30
|
||||
maxAdminHistoryPerChannel: 10
|
||||
300
docs/CONFIG_FILE_LOCATION.md
Normal file
300
docs/CONFIG_FILE_LOCATION.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# ERPAuto 配置文件位置说明
|
||||
|
||||
## 概述
|
||||
|
||||
ERPAuto 根据运行环境自动选择配置文件的存储位置:
|
||||
|
||||
- **开发环境**:项目根目录(方便编辑和版本控制)
|
||||
- **生产环境**:用户数据目录(AppData,安全且升级时保留)
|
||||
|
||||
---
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
### 1. 开发环境
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 开发和调试
|
||||
- 配置需要版本控制
|
||||
- 团队协作
|
||||
|
||||
**配置文件位置**:
|
||||
|
||||
```
|
||||
<项目根目录>\config.yaml
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
D:\Projects\ERPAuto\
|
||||
├── src\
|
||||
├── package.json
|
||||
├── config.yaml # 开发配置
|
||||
├── config.yaml.backup # 自动备份
|
||||
└── config.template.yaml # 配置模板
|
||||
```
|
||||
|
||||
**检测方式**:
|
||||
|
||||
```typescript
|
||||
process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 生产环境(安装版和便携版)
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 正式发布的应用
|
||||
- 配置需要在应用升级时保留
|
||||
- 多用户环境,每个用户独立配置
|
||||
|
||||
**配置文件位置**:
|
||||
|
||||
```
|
||||
Windows: C:\Users\<用户名>\AppData\Roaming\erpauto\config.yaml
|
||||
macOS: ~/Library/Application Support/erpauto/config.yaml
|
||||
Linux: ~/.config/erpauto/config.yaml
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
C:\Users\zhangsan\AppData\Roaming\erpauto\
|
||||
├── config.yaml # 用户配置
|
||||
└── config.yaml.backup # 自动备份
|
||||
```
|
||||
|
||||
**检测方式**:
|
||||
|
||||
```typescript
|
||||
app.isPackaged === true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 为什么生产环境使用用户数据目录?
|
||||
|
||||
| 方案 | 配置位置 | 优点 | 缺点 |
|
||||
| ------------------ | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **用户数据目录** ✓ | AppData\Roaming | • 应用升级时配置保留<br>• 符合 Windows 规范<br>• 多用户隔离<br>• 配置不暴露 | • 路径较深,不易访问 |
|
||||
| **应用同目录** ✗ | .exe 同目录 | • 易于访问和编辑 | • 应用升级时配置可能丢失<br>• 需要写权限<br>• 配置暴露在应用目录<br>• 多用户共享配置 |
|
||||
|
||||
**我们的选择**:生产环境统一使用用户数据目录,确保:
|
||||
|
||||
1. ✅ 应用升级时用户配置不会丢失
|
||||
2. ✅ 符合 Windows 应用规范
|
||||
3. ✅ 配置不暴露在应用目录,更安全
|
||||
4. ✅ 多用户环境下,每个用户有独立配置
|
||||
|
||||
---
|
||||
|
||||
## 构建配置
|
||||
|
||||
### electron-builder.yml
|
||||
|
||||
```yaml
|
||||
win:
|
||||
target:
|
||||
- nsis # 安装版
|
||||
- portable # 便携版
|
||||
|
||||
portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
# 便携版也使用用户数据目录 (AppData)
|
||||
# 不是 exe 同目录,确保配置在升级时保留
|
||||
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
```
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# 构建 Windows 安装版和便携版
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 输出文件
|
||||
|
||||
```
|
||||
dist/
|
||||
├── erpauto-1.0.0-setup.exe # 安装版
|
||||
└── erpauto-1.0.0-portable.exe # 便携版
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件结构
|
||||
|
||||
```yaml
|
||||
# ================================
|
||||
# ERPAuto 配置文件
|
||||
# ================================
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
activeType: mysql # 切换字段:mysql 或 sqlserver
|
||||
|
||||
mysql:
|
||||
host: 192.168.31.83
|
||||
port: 3306
|
||||
database: BLD_DB
|
||||
username: remote_user
|
||||
password: ''
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: localhost
|
||||
port: 1433
|
||||
database: BLD_DB
|
||||
username: sa
|
||||
password: ''
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
# 路径配置
|
||||
paths:
|
||||
dataDir: 'D:/python/playwrite/data/'
|
||||
defaultOutput: '离散备料计划维护_合并.xlsx'
|
||||
validationOutput: '物料状态校验结果.xlsx'
|
||||
|
||||
# 数据提取配置
|
||||
extraction:
|
||||
batchSize: 100
|
||||
verbose: true
|
||||
autoConvert: true
|
||||
mergeBatches: true
|
||||
enableDbPersistence: true
|
||||
|
||||
# 校验配置
|
||||
validation:
|
||||
dataSource: database_full
|
||||
batchSize: 2000
|
||||
matchMode: substring
|
||||
enableCrud: false
|
||||
defaultManager: ''
|
||||
|
||||
# 订单号解析配置
|
||||
orderResolution:
|
||||
tableName: 'productionContractData_26 年压力表合同数据'
|
||||
productionIdField: '总排号'
|
||||
orderNumberField: '生产订单号'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件管理
|
||||
|
||||
### 查看当前配置路径
|
||||
|
||||
运行调试工具:
|
||||
|
||||
```bash
|
||||
npx tsx src\main\tools\config-path-debug.ts
|
||||
```
|
||||
|
||||
### 快速访问配置(Windows)
|
||||
|
||||
```bash
|
||||
# 打开配置所在目录
|
||||
%APPDATA%\erpauto
|
||||
```
|
||||
|
||||
### 备份配置
|
||||
|
||||
```bash
|
||||
# 备份整个配置目录
|
||||
xcopy %APPDATA%\erpauto D:\Backup\erpauto-config /E /I
|
||||
```
|
||||
|
||||
### 迁移配置
|
||||
|
||||
从旧版本迁移:
|
||||
|
||||
```bash
|
||||
# 使用迁移脚本
|
||||
npx tsx scripts\migrate-env-to-yaml.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 便携版应用的配置为什么不放在 exe 同目录?
|
||||
|
||||
**A**:
|
||||
|
||||
- 放在 exe 同目录会导致应用升级时配置丢失
|
||||
- 便携版每次运行会解压到临时目录,无法持久保存配置
|
||||
- 使用用户数据目录(AppData)确保配置持久化
|
||||
|
||||
### Q: 如何快速访问配置文件?
|
||||
|
||||
**A**:
|
||||
|
||||
- Windows: 按 `Win + R`,输入 `%APPDATA%\erpauto`,回车
|
||||
- 或在文件管理器地址栏输入 `%APPDATA%\erpauto`
|
||||
|
||||
### Q: 多台电脑如何同步配置?
|
||||
|
||||
**A**:
|
||||
|
||||
1. 导出配置:`xcopy %APPDATA%\erpauto\config.yaml \\server\share\`
|
||||
2. 导入配置:`xcopy \\server\share\config.yaml %APPDATA%\erpauto\`
|
||||
|
||||
或使用同步工具(OneDrive、坚果云等)同步配置目录。
|
||||
|
||||
### Q: 配置文件损坏了怎么办?
|
||||
|
||||
**A**:
|
||||
|
||||
1. 删除 `config.yaml`
|
||||
2. 应用会自动创建新的默认配置
|
||||
3. 从 `config.yaml.backup` 恢复(如果存在)
|
||||
|
||||
### Q: 开发环境下如何切换配置?
|
||||
|
||||
**A**:
|
||||
|
||||
- 直接编辑项目根目录的 `config.yaml`
|
||||
- 建议保留 `config.template.yaml` 作为模板
|
||||
- 将 `config.yaml` 加入 `.gitignore`,避免提交敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 技术实现
|
||||
|
||||
### ConfigManager 路径选择逻辑
|
||||
|
||||
```typescript
|
||||
// 检测是否为开发环境
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:项目根目录
|
||||
this.configPath = path.resolve(__dirname, '../../config.yaml')
|
||||
} else {
|
||||
// 生产环境(安装版和便携版):用户数据目录
|
||||
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 配置策略 | 说明 |
|
||||
| ---- | ------------------------------- | -------------------- |
|
||||
| 1.0+ | 开发:项目目录<br>生产:AppData | 确保配置在升级时保留 |
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Electron app.getPath() 文档](https://www.electronjs.org/docs/api/app#appgetpathname)
|
||||
- [electron-builder 配置](https://www.electron.build/configuration.html)
|
||||
- [Windows 应用数据存储规范](https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid)
|
||||
731
docs/Configuration-Architecture-Analysis.md
Normal file
731
docs/Configuration-Architecture-Analysis.md
Normal file
@@ -0,0 +1,731 @@
|
||||
# ERPAuto 配置系统架构分析
|
||||
|
||||
## 1. 概述
|
||||
|
||||
ERPAuto 是一个基于 Electron 的桌面应用程序,采用多层次配置管理系统来支持 ERP 系统自动化数据处理。配置系统采用 `.env` 文件作为持久化存储,通过 `ConfigManager` 统一管理,支持运行时动态修改和持久化保存。
|
||||
|
||||
## 2. 配置系统整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置数据源"
|
||||
ENV[.env 文件]
|
||||
ENV_EXAMPLE[.env.example 模板]
|
||||
CACHE[内存缓存 ConfigCache]
|
||||
end
|
||||
|
||||
subgraph "配置管理层 ConfigManager"
|
||||
CM_LOAD[loadEnvFile]
|
||||
CM_GET[get/getBoolean/getNumber]
|
||||
CM_SET[set]
|
||||
CM_SAVE[save/saveAllSettings]
|
||||
CM_PARTIAL[savePartialSettings]
|
||||
CM_MERGE[deepMerge 深度合并]
|
||||
end
|
||||
|
||||
subgraph "IPC 通信层"
|
||||
SETTINGS_HANDLER[settings-handler.ts]
|
||||
IPC_GET[settings:getSettings]
|
||||
IPC_SAVE[settings:saveSettings]
|
||||
IPC_TEST[settings:testErpConnection/testDbConnection]
|
||||
end
|
||||
|
||||
subgraph "业务服务层"
|
||||
ERP_SVC[ERP 服务]
|
||||
DB_SVC[数据库服务]
|
||||
USER_SVC[用户服务]
|
||||
EXTRACTOR[ExtractorService]
|
||||
CLEANER[CleanerService]
|
||||
end
|
||||
|
||||
subgraph "UI 呈现层"
|
||||
SETTINGS_UI[设置界面]
|
||||
LOGIN_UI[登录界面]
|
||||
MAIN_UI[主界面]
|
||||
end
|
||||
|
||||
ENV -->|读取 | CM_LOAD
|
||||
ENV_EXAMPLE -.->|模板参考 | ENV
|
||||
CM_LOAD -->|填充 | CACHE
|
||||
CACHE --> CM_GET
|
||||
CM_SET --> CACHE
|
||||
CM_PARTIAL --> CM_MERGE --> CM_SAVE --> ENV
|
||||
|
||||
CM_GET --> SETTINGS_HANDLER
|
||||
SETTINGS_HANDLER --> IPC_GET
|
||||
SETTINGS_HANDLER --> IPC_SAVE
|
||||
SETTINGS_HANDLER --> IPC_TEST
|
||||
|
||||
IPC_GET --> SETTINGS_UI
|
||||
IPC_SAVE --> SETTINGS_UI
|
||||
IPC_TEST --> SETTINGS_UI
|
||||
|
||||
CACHE --> ERP_SVC
|
||||
CACHE --> DB_SVC
|
||||
CACHE --> USER_SVC
|
||||
CACHE --> EXTRACTOR
|
||||
CACHE --> CLEANER
|
||||
|
||||
SETTINGS_UI --> MAIN_UI
|
||||
LOGIN_UI --> USER_SVC
|
||||
```
|
||||
|
||||
## 3. 配置文件结构
|
||||
|
||||
### 3.1 .env 文件组织
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "ERP 系统配置"
|
||||
ERP_URL[ERP_URL]
|
||||
ERP_USER[ERP_USERNAME]
|
||||
ERP_PASS[ERP_PASSWORD]
|
||||
ERP_HEADLESS[ERP_HEADLESS]
|
||||
ERP_HTTPS[ERP_IGNORE_HTTPS_ERRORS]
|
||||
ERP_CLOSE[ERP_AUTO_CLOSE_BROWSER]
|
||||
end
|
||||
|
||||
subgraph "数据库配置 - SQL Server"
|
||||
SQL_DRIVER[DB_SQLSERVER_DRIVER]
|
||||
SQL_TRUST[DB_TRUST_SERVER_CERTIFICATE]
|
||||
end
|
||||
|
||||
subgraph "数据库配置 - MySQL"
|
||||
DB_TYPE[DB_TYPE]
|
||||
DB_NAME[DB_NAME]
|
||||
DB_USER[DB_USERNAME]
|
||||
DB_PASS[DB_PASSWORD]
|
||||
MYSQL_HOST[DB_MYSQL_HOST]
|
||||
MYSQL_PORT[DB_MYSQL_PORT]
|
||||
MYSQL_CHARSET[DB_MYSQL_CHARSET]
|
||||
end
|
||||
|
||||
subgraph "订单号解析表配置"
|
||||
TABLE_NAME[DB_TABLE_NAME]
|
||||
FIELD_PROD_ID[DB_FIELD_PRODUCTION_ID]
|
||||
FIELD_ORDER[DB_FIELD_ORDER_NUMBER]
|
||||
end
|
||||
|
||||
subgraph "路径配置"
|
||||
DATA_DIR[PATH_DATA_DIR]
|
||||
PROD_ID_FILE[PATH_PRODUCTION_ID_FILE]
|
||||
DEFAULT_OUT[PATH_DEFAULT_OUTPUT]
|
||||
VALID_OUT[PATH_VALIDATION_OUTPUT]
|
||||
end
|
||||
|
||||
subgraph "数据提取配置"
|
||||
BATCH_SIZE[EXTRACTION_BATCH_SIZE]
|
||||
VERBOSE[EXTRACTION_VERBOSE]
|
||||
AUTO_CONVERT[EXTRACTION_AUTO_CONVERT]
|
||||
MERGE_BATCHES[EXTRACTION_MERGE_BATCHES]
|
||||
DB_PERSIST[EXTRACTION_ENABLE_DB_PERSISTENCE]
|
||||
end
|
||||
|
||||
subgraph "校验配置"
|
||||
DATA_SOURCE[VALIDATION_DATA_SOURCE]
|
||||
USE_DB[VALIDATION_USE_DATABASE]
|
||||
VAL_BATCH[VALIDATION_BATCH_SIZE]
|
||||
ENABLE_CRUD[VALIDATION_ENABLE_CRUD]
|
||||
DEFAULT_MGR[VALIDATION_DEFAULT_MANAGER]
|
||||
MATCH_MODE[VALIDATION_MATCH_MODE]
|
||||
end
|
||||
|
||||
subgraph "UI 配置"
|
||||
FONT_FAMILY[UI_FONT_FAMILY]
|
||||
FONT_SIZE[UI_FONT_SIZE]
|
||||
INPUT_WIDTH[UI_PRODUCTION_ID_INPUT_WIDTH]
|
||||
end
|
||||
|
||||
subgraph "执行配置"
|
||||
DRY_RUN[EXECUTION_DRYRUN]
|
||||
end
|
||||
```
|
||||
|
||||
### 3.2 默认配置值
|
||||
|
||||
| 配置类别 | 配置项 | 默认值 | 说明 |
|
||||
| ---------- | ----------------- | --------------------------- | -------------------- |
|
||||
| ERP | url | `https://68.11.34.30:8082/` | ERP 系统地址 |
|
||||
| ERP | headless | `true` | 无头浏览器模式 |
|
||||
| ERP | ignoreHttpsErrors | `true` | 忽略 HTTPS 证书错误 |
|
||||
| ERP | autoCloseBrowser | `true` | 操作后自动关闭浏览器 |
|
||||
| Database | dbType | `mysql` | 数据库类型 |
|
||||
| Database | mysqlHost | `192.168.31.83` | MySQL 主机地址 |
|
||||
| Database | mysqlPort | `3306` | MySQL 端口 |
|
||||
| Database | database | `BLD_DB` | 数据库名 |
|
||||
| Database | username | `remote_user` | 数据库用户名 |
|
||||
| Paths | dataDir | `D:/python/playwrite/data/` | 数据目录 |
|
||||
| Extraction | batchSize | `100` | 批次大小 |
|
||||
| Extraction | verbose | `true` | 详细日志 |
|
||||
| Validation | dataSource | `database_full` | 校验数据源 |
|
||||
| Validation | batchSize | `2000` | 校验批次大小 |
|
||||
| Validation | matchMode | `substring` | 匹配模式 |
|
||||
| UI | fontFamily | `Microsoft YaHei UI` | 字体 |
|
||||
| UI | fontSize | `10` | 字体大小 |
|
||||
| Execution | dryRun | `false` | 干运行模式 |
|
||||
|
||||
## 4. ConfigManager 核心类设计
|
||||
|
||||
### 4.1 类结构与单例模式
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ConfigManager {
|
||||
-static instance: ConfigManager | null
|
||||
-envPath: string
|
||||
-backupPath: string
|
||||
-configCache: Map<string, string>
|
||||
-initialized: boolean
|
||||
+static getInstance(): ConfigManager
|
||||
+initialize(): Promise<void>
|
||||
+get(key: string): string | undefined
|
||||
+getBoolean(key: string, default: boolean): boolean
|
||||
+getNumber(key: string, default: number): number
|
||||
+set(key: string, value: string|number|boolean): void
|
||||
+save(): Promise<boolean>
|
||||
+getAllSettings(): SettingsData
|
||||
+saveAllSettings(settings: SettingsData): Promise<boolean>
|
||||
+savePartialSettings(settings: Partial<SettingsData>): Promise<Object>
|
||||
+resetToDefaults(): SettingsData
|
||||
+getDefaultSettings(): SettingsData
|
||||
-loadEnvFile(): Promise<void>
|
||||
-backupEnvFile(): Promise<boolean>
|
||||
-restoreBackup(): Promise<boolean>
|
||||
}
|
||||
|
||||
class SettingsData {
|
||||
+erp: ErpConfig
|
||||
+database: DatabaseConfig
|
||||
+paths: PathsConfig
|
||||
+extraction: ExtractionConfig
|
||||
+validation: ValidationConfig
|
||||
+ui: UiConfig
|
||||
+execution: ExecutionConfig
|
||||
}
|
||||
|
||||
ConfigManager --> SettingsData: 返回/接收
|
||||
```
|
||||
|
||||
### 4.2 核心方法流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 客户端/IPC
|
||||
participant CM as ConfigManager
|
||||
participant Cache as ConfigCache
|
||||
participant FS as 文件系统
|
||||
participant Backup as Backup 文件
|
||||
|
||||
Client->>CM: savePartialSettings(settings)
|
||||
activate CM
|
||||
|
||||
CM->>CM: validateEditableFields()
|
||||
alt 包含非白名单字段
|
||||
CM-->>Client: 返回错误 (不允许修改)
|
||||
else 验证通过
|
||||
CM->>FS: loadEnvFile()
|
||||
FS-->>Cache: 填充缓存
|
||||
CM->>CM: getAllSettings()
|
||||
CM->>Cache: 读取当前配置
|
||||
CM->>CM: deepMerge(current, settings)
|
||||
|
||||
CM->>FS: backupEnvFile()
|
||||
FS-->>Backup: 创建备份
|
||||
|
||||
CM->>FS: saveAllSettings(merged)
|
||||
alt 保存成功
|
||||
FS-->>Cache: 重新加载
|
||||
CM-->>Client: 返回成功
|
||||
else 保存失败
|
||||
CM->>FS: restoreBackup()
|
||||
FS-->>Cache: 恢复配置
|
||||
CM-->>Client: 返回错误
|
||||
end
|
||||
end
|
||||
deactivate CM
|
||||
```
|
||||
|
||||
### 4.3 深度合并算法
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[deepMerge 函数] --> B{遍历 target 键值对}
|
||||
B --> C{targetValue 是对象?}
|
||||
C -->|是 | D{sourceValue 也是对象?}
|
||||
D -->|是 | E[递归调用 deepMerge]
|
||||
D -->|否 | F[直接使用 targetValue]
|
||||
C -->|否 | G{targetValue !== undefined?}
|
||||
G -->|是 | H[更新该键值]
|
||||
G -->|否 | I[跳过该键]
|
||||
E --> J[合并结果存入 result]
|
||||
F --> J
|
||||
H --> J
|
||||
B --> K[遍历完成]
|
||||
K --> L[返回合并后的对象]
|
||||
```
|
||||
|
||||
## 5. 配置读取与使用模式
|
||||
|
||||
### 5.1 环境变量直接读取模式
|
||||
|
||||
各业务服务通过 `process.env` 直接读取配置:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "环境变量读取点"
|
||||
MAIN[main/index.ts<br/>dotenv.config]
|
||||
end
|
||||
|
||||
subgraph "服务模块"
|
||||
DB_INDEX[database/index.ts]
|
||||
DB_MYSQL[database/mysql.ts]
|
||||
DB_SQL[database/sql-server.ts]
|
||||
BIP_DAO[bip-users-dao.ts]
|
||||
ORDER_RES[order-resolver.ts]
|
||||
EXTRACTOR[extractor-handler.ts]
|
||||
CLEANER[cleaner-handler.ts]
|
||||
VALIDATION[validation-handler.ts]
|
||||
end
|
||||
|
||||
MAIN -->|初始化加载 | ENV[process.env]
|
||||
|
||||
ENV --> DB_INDEX
|
||||
ENV --> DB_MYSQL
|
||||
ENV --> DB_SQL
|
||||
ENV --> BIP_DAO
|
||||
ENV --> ORDER_RES
|
||||
ENV --> EXTRACTOR
|
||||
ENV --> CLEANER
|
||||
ENV --> VALIDATION
|
||||
```
|
||||
|
||||
### 5.2 ConfigManager 获取模式
|
||||
|
||||
通过 IPC 层统一获取:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 设置界面
|
||||
participant Preload as Preload 脚本
|
||||
participant IPC as IPC Handler
|
||||
participant CM as ConfigManager
|
||||
|
||||
UI->>Preload: window.api.settings.getSettings()
|
||||
Preload->>IPC: ipcRenderer.invoke('settings:getSettings')
|
||||
IPC->>IPC: SessionManager.getUserType()
|
||||
IPC->>CM: getAllSettings()
|
||||
CM->>IPC: SettingsData
|
||||
IPC->>IPC: filterSettingsByUserType()
|
||||
IPC-->>Preload: 过滤后的 SettingsData
|
||||
Preload-->>UI: SettingsData
|
||||
```
|
||||
|
||||
### 5.3 数据库配置工厂模式
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置创建"
|
||||
GET_TYPE[getDatabaseType] -->|DB_TYPE env| TYPE_CHECK{数据库类型}
|
||||
TYPE_CHECK -->|mysql| CREATE_MYSQL[createMySqlConfig]
|
||||
TYPE_CHECK -->|sqlserver| CREATE_SQL[createSqlServerConfig]
|
||||
end
|
||||
|
||||
subgraph "服务创建"
|
||||
CREATE_MYSQL --> MYSQL_SVC[MySqlService]
|
||||
CREATE_SQL --> SQL_SVC[SqlServerService]
|
||||
end
|
||||
|
||||
subgraph "单例缓存"
|
||||
MYSQL_SVC --> CACHE[instances Map]
|
||||
SQL_SVC --> CACHE
|
||||
CACHE -->|返回已连接实例 | CLIENT[调用方]
|
||||
end
|
||||
|
||||
CREATE_MYSQL --> CONNECT_MYSQL[service.connect]
|
||||
CREATE_SQL --> CONNECT_SQL[service.connect]
|
||||
|
||||
CONNECT_MYSQL --> CACHE
|
||||
CONNECT_SQL --> CACHE
|
||||
```
|
||||
|
||||
## 6. 用户权限与配置访问控制
|
||||
|
||||
### 6.1 用户类型与权限
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "用户类型 UserType"
|
||||
ADMIN[Admin<br/>管理员]
|
||||
USER[User<br/>普通用户]
|
||||
GUEST[Guest<br/>访客]
|
||||
end
|
||||
|
||||
subgraph "配置访问权限"
|
||||
ADMIN_SETTINGS[全部配置可访问<br/>可修改 ERP 配置<br/>可恢复默认设置]
|
||||
USER_SETTINGS[有限配置访问<br/>可修改 ERP 配置<br/>可查看执行配置]
|
||||
GUEST_SETTINGS[只读访问]
|
||||
end
|
||||
|
||||
ADMIN --> ADMIN_SETTINGS
|
||||
USER --> USER_SETTINGS
|
||||
GUEST --> GUEST_SETTINGS
|
||||
|
||||
subgraph "SessionManager 会话管理"
|
||||
SM_LOGIN[login]
|
||||
SM_SILENT[loginByComputerName]
|
||||
SM_SWITCH[switchUser - Admin only]
|
||||
SM_GET[getUserType/getUserInfo]
|
||||
end
|
||||
|
||||
SM_LOGIN --> USER
|
||||
SM_SILENT --> USER
|
||||
SM_SWITCH --> USER
|
||||
```
|
||||
|
||||
### 6.2 配置过滤机制
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[getSettings 请求] --> B[获取当前用户类型]
|
||||
B --> C{用户类型判断}
|
||||
|
||||
C -->|Admin| D[返回全部配置]
|
||||
|
||||
C -->|User| E[过滤配置]
|
||||
E --> F[返回 ERP 配置<br/>username/password/headless/url/...<br/>paths 配置<br/>execution 配置<br/>最小化其他配置]
|
||||
|
||||
C -->|Guest| G[返回空配置或只读配置]
|
||||
|
||||
D --> H[返回给 UI]
|
||||
E --> H
|
||||
G --> H
|
||||
```
|
||||
|
||||
## 7. 配置修改白名单机制
|
||||
|
||||
### 7.1 可编辑字段白名单
|
||||
|
||||
```javascript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password'
|
||||
// 可根据需要扩展
|
||||
]
|
||||
```
|
||||
|
||||
### 7.2 白名单验证流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[savePartialSettings 调用] --> B[遍历 settings 中的字段]
|
||||
B --> C[构建字段路径 section.field]
|
||||
C --> D{字段在白名单中?}
|
||||
D -->|否 | E[添加到 invalidFields]
|
||||
D -->|是 | F[继续检查下一字段]
|
||||
E --> B
|
||||
F --> B
|
||||
B --> G{所有字段检查完成}
|
||||
G --> H{invalidFields 为空?}
|
||||
H -->|否 | I[返回错误<br/>包含不允许修改的字段]
|
||||
H -->|是 | J[继续保存流程]
|
||||
```
|
||||
|
||||
## 8. 数据库配置详解
|
||||
|
||||
### 8.1 双数据库支持架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "数据库抽象层"
|
||||
IDB[IDatabaseService 接口<br/>connect/disconnect<br/>query/transaction<br/>isConnected]
|
||||
end
|
||||
|
||||
subgraph "MySQL 实现"
|
||||
MYSQL[MySqlService<br/>mysql2/promise<br/>createConnection<br/>execute/transaction]
|
||||
end
|
||||
|
||||
subgraph "SQL Server 实现"
|
||||
MSSQL[SqlServerService<br/>mssql<br/>ConnectionPool<br/>request.query<br/>Transaction]
|
||||
end
|
||||
|
||||
IDB -.->|实现 | MYSQL
|
||||
IDB -.->|实现 | MSSQL
|
||||
|
||||
MYSQL --> ENV_MYSQL[DB_MYSQL_HOST<br/>DB_MYSQL_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD]
|
||||
MSSQL --> ENV_MSSQL[DB_SERVER<br/>DB_SQLSERVER_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD<br/>DB_TRUST_SERVER_CERTIFICATE]
|
||||
```
|
||||
|
||||
### 8.2 数据库配置参数映射
|
||||
|
||||
| 环境变量 | MySQL 用途 | SQL Server 用途 |
|
||||
| --------------------------- | ----------- | ----------------- |
|
||||
| DB_TYPE | mysql | sqlserver/mssql |
|
||||
| DB_NAME | 数据库名 | 数据库名 |
|
||||
| DB_USERNAME | 用户名 | 用户名 |
|
||||
| DB_PASSWORD | 密码 | 密码 |
|
||||
| DB_MYSQL_HOST | 主机地址 | - |
|
||||
| DB_MYSQL_PORT | 端口 (3306) | - |
|
||||
| DB_SERVER | - | 服务器地址 |
|
||||
| DB_SQLSERVER_PORT | - | 端口 (1433) |
|
||||
| DB_TRUST_SERVER_CERTIFICATE | - | 信任证书 (yes/no) |
|
||||
|
||||
## 9. ERP 配置与浏览器自动化
|
||||
|
||||
### 9.1 ERP 认证配置流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 设置界面
|
||||
participant IPC as settings-handler
|
||||
participant CM as ConfigManager
|
||||
participant ERP_AUTH as ErpAuthService
|
||||
participant PW as Playwright
|
||||
|
||||
UI->>IPC: testErpConnection
|
||||
IPC->>CM: getAllSettings
|
||||
CM-->>IPC: SettingsData(含 erp 配置)
|
||||
|
||||
IPC->>ERP_AUTH: new ErpAuthService(erpConfig)
|
||||
ERP_AUTH->>PW: chromium.launch
|
||||
Note over PW: headless=erpConfig.headless<br/>args=[--ignore-certificate-errors]
|
||||
|
||||
PW-->>ERP_AUTH: Browser Context
|
||||
|
||||
ERP_AUTH->>PW: page.goto(loginUrl)
|
||||
PW-->>ERP_AUTH: 加载登录页面
|
||||
|
||||
ERP_AUTH->>PW: fill username/password
|
||||
ERP_AUTH->>PW: click login button
|
||||
|
||||
PW-->>ERP_AUTH: 登录成功
|
||||
|
||||
ERP_AUTH-->>IPC: ErpSession
|
||||
IPC-->>UI: {success: true}
|
||||
|
||||
ERP_AUTH->>PW: close
|
||||
```
|
||||
|
||||
### 9.2 ERP 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| ----------------- | ------- | ------ | -------------- |
|
||||
| url | string | - | ERP 系统 URL |
|
||||
| username | string | - | ERP 用户名 |
|
||||
| password | string | - | ERP 密码 |
|
||||
| headless | boolean | true | 无头模式 |
|
||||
| ignoreHttpsErrors | boolean | true | 忽略 SSL 错误 |
|
||||
| autoCloseBrowser | boolean | true | 自动关闭浏览器 |
|
||||
|
||||
## 10. 订单号解析表配置
|
||||
|
||||
### 10.1 配置结构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "订单号解析配置"
|
||||
TABLE[DB_TABLE_NAME<br/>表名]
|
||||
FIELD_ID[DB_FIELD_PRODUCTION_ID<br/>总排号字段]
|
||||
FIELD_ORDER[DB_FIELD_ORDER_NUMBER<br/>生产订单号字段]
|
||||
end
|
||||
|
||||
TABLE --> ORDER_RESOLVER[OrderResolverService]
|
||||
FIELD_ID --> ORDER_RESOLVER
|
||||
FIELD_ORDER --> ORDER_RESOLVER
|
||||
|
||||
ORDER_RESOLVER --> DB_QUERY[查询映射关系]
|
||||
DB_QUERY --> PRODUCTION_ID[productionID]
|
||||
DB_QUERY --> ORDER_NUMBER[生产订单号]
|
||||
```
|
||||
|
||||
### 10.2 默认配置示例
|
||||
|
||||
```env
|
||||
DB_TABLE_NAME=productionContractData_26 年压力表合同数据
|
||||
DB_FIELD_PRODUCTION_ID=总排号
|
||||
DB_FIELD_ORDER_NUMBER=生产订单号
|
||||
```
|
||||
|
||||
## 11. 配置持久化与备份机制
|
||||
|
||||
### 11.1 保存流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[saveAllSettings] --> B[设置写入 configCache]
|
||||
B --> C[构建.env 文件内容]
|
||||
C --> D[按分类组织配置<br/>ERP/数据库/路径/提取/校验/UI/执行]
|
||||
D --> E[写入.env 文件]
|
||||
E --> F{写入成功?}
|
||||
F -->|是 | G[返回 true]
|
||||
F -->|否 | H[返回 false]
|
||||
```
|
||||
|
||||
### 11.2 备份与恢复流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as 调用方
|
||||
participant CM as ConfigManager
|
||||
participant ENV as .env
|
||||
participant BAK as .env.backup
|
||||
|
||||
Caller->>CM: savePartialSettings
|
||||
CM->>CM: validateEditableFields
|
||||
|
||||
CM->>ENV: loadEnvFile
|
||||
CM->>CM: deepMerge 合并配置
|
||||
|
||||
CM->>ENV: backupEnvFile
|
||||
ENV->>BAK: copyFileSync
|
||||
|
||||
CM->>ENV: writeFileSync 新配置
|
||||
ENV-->>CM: 保存结果
|
||||
|
||||
alt 保存成功
|
||||
CM->>ENV: loadEnvFile 重新加载
|
||||
CM-->>Caller: success: true
|
||||
else 保存失败
|
||||
CM->>BAK: restoreBackup
|
||||
BAK->>ENV: copyFileSync 恢复
|
||||
CM->>ENV: loadEnvFile
|
||||
CM-->>Caller: success: false + error
|
||||
end
|
||||
```
|
||||
|
||||
## 12. 配置系统初始化时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Electron App
|
||||
participant Main as main/index.ts
|
||||
participant Dotenv as dotenv
|
||||
participant CM as ConfigManager
|
||||
participant IPC as registerIpcHandlers
|
||||
participant SM as SessionManager
|
||||
|
||||
App->>Main: 应用启动
|
||||
Main->>Dotenv: config .env
|
||||
Dotenv-->>Main: process.env 已加载
|
||||
|
||||
Main->>IPC: registerIpcHandlers
|
||||
Note over IPC: 注册所有 IPC 处理器<br/>settings/extractor/cleaner/auth...
|
||||
|
||||
App->>Main: app.whenReady
|
||||
Main->>SM: silent login 尝试
|
||||
SM->>SM: loginByComputerName
|
||||
|
||||
alt 静默登录成功
|
||||
SM-->>Main: 用户已认证
|
||||
else 静默登录失败
|
||||
Main->>Main: 显示登录对话框
|
||||
end
|
||||
|
||||
Main->>CM: initialize 按需加载
|
||||
```
|
||||
|
||||
## 13. 关键代码模式
|
||||
|
||||
### 13.1 环境变量读取模式
|
||||
|
||||
```typescript
|
||||
// 直接读取 process.env
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const mysqlHost = process.env.DB_MYSQL_HOST || 'localhost'
|
||||
const mysqlPort = parseInt(process.env.DB_MYSQL_PORT || '3306', 10)
|
||||
```
|
||||
|
||||
### 13.2 ConfigManager 读取模式
|
||||
|
||||
```typescript
|
||||
// 通过 ConfigManager 获取结构化配置
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const settings = configManager.getAllSettings()
|
||||
const erpUrl = settings.erp.url
|
||||
const batchSize = settings.extraction.batchSize
|
||||
```
|
||||
|
||||
### 13.3 部分保存模式
|
||||
|
||||
```typescript
|
||||
// 只更新允许修改的字段
|
||||
const result = await configManager.savePartialSettings({
|
||||
erp: {
|
||||
url: 'http://new-url.com',
|
||||
username: 'newuser',
|
||||
password: 'newpass'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 14. 配置类别与业务模块映射
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置类别"
|
||||
ERP_CONF[ERP 配置]
|
||||
DB_CONF[数据库配置]
|
||||
PATH_CONF[路径配置]
|
||||
EXTRACT_CONF[提取配置]
|
||||
VALID_CONF[校验配置]
|
||||
UI_CONF[UI 配置]
|
||||
EXEC_CONF[执行配置]
|
||||
end
|
||||
|
||||
subgraph "业务模块"
|
||||
ERP_AUTH[ErpAuthService]
|
||||
ERP_EXTRACT[ExtractorService]
|
||||
ERP_CLEAN[CleanerService]
|
||||
ERP_ORDER[OrderResolverService]
|
||||
DB_MYSQL[MySqlService]
|
||||
DB_SQL[SqlServerService]
|
||||
DB_DAO[各种 DAO 类]
|
||||
EXCEL[Excel Parser/Exporter]
|
||||
UI[React 界面]
|
||||
end
|
||||
|
||||
ERP_CONF --> ERP_AUTH
|
||||
ERP_CONF --> ERP_EXTRACT
|
||||
ERP_CONF --> ERP_CLEAN
|
||||
|
||||
DB_CONF --> DB_MYSQL
|
||||
DB_CONF --> DB_SQL
|
||||
DB_CONF --> DB_DAO
|
||||
|
||||
PATH_CONF --> EXCEL
|
||||
PATH_CONF --> UI
|
||||
|
||||
EXTRACT_CONF --> ERP_EXTRACT
|
||||
EXTRACT_CONF --> DB_DAO
|
||||
|
||||
VALID_CONF --> ERP_EXTRACT
|
||||
VALID_CONF --> DB_DAO
|
||||
|
||||
UI_CONF --> UI
|
||||
|
||||
EXEC_CONF --> ERP_CLEAN
|
||||
```
|
||||
|
||||
## 15. 配置系统特点总结
|
||||
|
||||
### 15.1 优点
|
||||
|
||||
1. **集中化管理**: ConfigManager 单例模式统一管理所有配置
|
||||
2. **类型安全**: TypeScript 类型定义确保配置结构正确
|
||||
3. **权限控制**: 基于用户类型的配置访问和修改权限控制
|
||||
4. **备份恢复**: 自动备份机制防止配置丢失
|
||||
5. **双数据库支持**: MySQL 和 SQL Server 灵活切换
|
||||
6. **部分更新**: deepMerge 支持配置部分字段更新
|
||||
|
||||
### 15.2 可扩展性
|
||||
|
||||
1. **新增配置项**: 在 `.env.example` 添加 → `DEFAULT_SETTINGS` 定义 → `SettingsData` 类型 → `save` 方法输出
|
||||
2. **新增用户权限**: 扩展 `UserType` → 更新 `filterSettingsByUserType` 逻辑
|
||||
3. **新增白名单字段**: 在 `UI_EDITABLE_FIELDS` 数组添加路径
|
||||
|
||||
### 15.3 注意事项
|
||||
|
||||
1. 修改配置后需要重新加载 `.env` 文件使 `process.env` 生效
|
||||
2. 非白名单字段只能通过 `saveAllSettings` 或 `resetToDefaults` 修改
|
||||
3. 数据库服务使用单例缓存,配置变更需重启应用或手动重连
|
||||
4. ERP 配置变更需重启浏览器才能生效
|
||||
171
docs/MIGRATION_GUIDE.md
Normal file
171
docs/MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# BIPUsers 表 ERP 参数迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
本次迁移将 ERP 配置参数(`ERP_URL`, `ERP_USERNAME`, `ERP_PASSWORD`)从 `.env` 文件迁移到 `dbo_BIPUsers` 数据库表中,实现每个用户独立的 ERP 配置。
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### 步骤 1:连接到 MySQL 数据库
|
||||
|
||||
使用你喜欢的 MySQL 客户端工具连接:
|
||||
|
||||
**方式 A: MySQL 命令行**
|
||||
|
||||
```bash
|
||||
mysql -h 192.168.31.83 -P 3306 -u remote_user -p'3.1415926Beeke' BLD_DB
|
||||
```
|
||||
|
||||
**方式 B: MySQL Workbench / Navicat / DBeaver**
|
||||
|
||||
- Host: `192.168.31.83`
|
||||
- Port: `3306`
|
||||
- Username: `remote_user`
|
||||
- Password: `3.1415926Beeke`
|
||||
- Database: `BLD_DB`
|
||||
|
||||
### 步骤 2:执行迁移 SQL
|
||||
|
||||
运行以下 SQL 脚本添加新字段:
|
||||
|
||||
```sql
|
||||
-- ============================================
|
||||
-- BIPUsers 表迁移:添加 ERP 参数字段
|
||||
-- ============================================
|
||||
|
||||
USE BLD_DB;
|
||||
|
||||
-- 1. 添加 ERP_URL 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
|
||||
|
||||
-- 2. 添加 ERP_Username 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
|
||||
|
||||
-- 3. 添加 ERP_Password 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
|
||||
|
||||
-- 4. 验证字段已添加
|
||||
DESCRIBE dbo_BIPUsers;
|
||||
```
|
||||
|
||||
**注意:** 如果你的 MySQL 版本不支持 `ADD COLUMN IF NOT EXISTS`,请使用:
|
||||
|
||||
```sql
|
||||
USE BLD_DB;
|
||||
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
|
||||
```
|
||||
|
||||
### 步骤 3:初始化 ERP 配置
|
||||
|
||||
将所有现有用户的 ERP 配置设置为当前 `.env` 中的值:
|
||||
|
||||
```sql
|
||||
-- 更新所有用户的 ERP 配置
|
||||
UPDATE dbo_BIPUsers
|
||||
SET
|
||||
ERP_URL = 'https://68.11.34.30:8082/',
|
||||
ERP_Username = '在这里填写你的 ERP 用户名',
|
||||
ERP_Password = '在这里填写你的 ERP 密码'
|
||||
WHERE ERP_URL IS NULL OR ERP_URL = '';
|
||||
```
|
||||
|
||||
**请将上面的占位符替换为实际的 ERP 凭证!**
|
||||
|
||||
### 步骤 4:验证迁移结果
|
||||
|
||||
```sql
|
||||
-- 检查所有用户的 ERP 配置
|
||||
SELECT
|
||||
UserName,
|
||||
UserType,
|
||||
ERP_URL,
|
||||
ERP_Username,
|
||||
CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
ORDER BY UserName;
|
||||
```
|
||||
|
||||
## 迁移后配置
|
||||
|
||||
### 更新 .env 文件(可选)
|
||||
|
||||
迁移完成后,`.env` 文件中的 ERP 配置将不再使用,但为了向后兼容可以保留:
|
||||
|
||||
```bash
|
||||
# ERP 配置(已废弃,仅用于向后兼容)
|
||||
# ERP_URL=https://68.11.34.30:8082/
|
||||
# ERP_USERNAME=your_username
|
||||
# ERP_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### 在应用中配置用户 ERP 参数
|
||||
|
||||
迁移完成后,每个用户可以通过应用界面配置自己的 ERP 参数:
|
||||
|
||||
1. 登录应用
|
||||
2. 进入设置页面
|
||||
3. 配置个人 ERP 连接信息
|
||||
4. 测试连接
|
||||
5. 保存
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题 1:字段已存在错误
|
||||
|
||||
```
|
||||
Error: Duplicate column name 'ERP_URL'
|
||||
```
|
||||
|
||||
**解决方案:** 字段已经存在,跳过添加步骤,直接执行步骤 3 初始化数据。
|
||||
|
||||
### 问题 2:连接被拒绝
|
||||
|
||||
```
|
||||
Error: Access denied for user 'remote_user'@'%'
|
||||
```
|
||||
|
||||
**解决方案:** 检查数据库用户权限,确保 `remote_user` 有 `ALTER` 和 `UPDATE` 权限。
|
||||
|
||||
### 问题 3:连接超时
|
||||
|
||||
```
|
||||
Error: connect ETIMEDOUT
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查网络连接
|
||||
- 确认 MySQL 服务器正在运行
|
||||
- 检查防火墙设置
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果需要回滚,可以删除新增的字段:
|
||||
|
||||
```sql
|
||||
-- ⚠️ 警告:这将永久删除 ERP 配置数据
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_URL;
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Username;
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Password;
|
||||
```
|
||||
|
||||
## 完成确认
|
||||
|
||||
迁移完成后,请确认以下事项:
|
||||
|
||||
- [ ] 三个新字段已成功添加到 `dbo_BIPUsers` 表
|
||||
- [ ] 所有现有用户的 ERP 配置已初始化
|
||||
- [ ] 应用程序可以正常启动
|
||||
- [ ] 数据提取和物料清理功能正常工作
|
||||
|
||||
---
|
||||
|
||||
**迁移脚本文件:**
|
||||
|
||||
- `src/main/services/user/migration/add-erp-params-to-bipusers-mysql.sql` - 完整 SQL 脚本
|
||||
- `src/main/services/user/migration/run-migration.ts` - TypeScript 自动迁移脚本(需要网络访问)
|
||||
|
||||
**创建时间:** 2026-03-05
|
||||
139
docs/browser/BROWSER_VERSIONS.md
Normal file
139
docs/browser/BROWSER_VERSIONS.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Playwright 浏览器版本信息
|
||||
|
||||
本文档记录 ERPAuto 当前使用的 Playwright 版本,以及代码中采用的浏览器目录约定。
|
||||
|
||||
## 当前版本
|
||||
|
||||
根据 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json),项目当前依赖为:
|
||||
|
||||
| 组件 | 当前版本 |
|
||||
| ------------------ | --------- |
|
||||
| `playwright` | `^1.58.2` |
|
||||
| `playwright-core` | `^1.58.2` |
|
||||
| `@playwright/test` | `^1.58.2` |
|
||||
|
||||
## 当前代码中的目录约定
|
||||
|
||||
根据 [src/main/index.ts](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/index.ts),应用启动时会把:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_BROWSERS_PATH = %APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
随后按以下顺序检查 Chromium:
|
||||
|
||||
1. 优先检查:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
2. 兼容旧结构:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe
|
||||
```
|
||||
|
||||
3. 如果仍然找不到,则继续扫描任意 `chromium-*` 目录,并尝试:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
这意味着:
|
||||
|
||||
- `chromium-1208` 是当前代码优先查找的默认 revision
|
||||
- 但应用并不只接受 `1208`
|
||||
- 当前主目录结构是 `chrome-win64`
|
||||
|
||||
## 推荐目录结构
|
||||
|
||||
### Windows 开发环境
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 目标部署环境
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
如果不是 `1208`,只要目录名满足 `chromium-*` 且内部存在 `chrome-win64\chrome.exe`,当前代码也能识别。
|
||||
|
||||
## 使用建议
|
||||
|
||||
### 开发环境准备
|
||||
|
||||
1. 安装项目依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 复制浏览器文件
|
||||
|
||||
1. 在开发机上找到 Playwright 浏览器缓存目录:
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
```
|
||||
|
||||
2. 复制完整的 `chromium-*` 目录到目标路径:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
3. 确保内部存在:
|
||||
|
||||
```text
|
||||
chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ERPAuto 当前只依赖 Chromium,不需要 Firefox 和 WebKit
|
||||
- 浏览器文件体积较大,建议按整个 revision 目录复制
|
||||
- 当前代码会优先尝试 `chromium-1208`
|
||||
- 但从实现角度看,“revision 严格等于 1208”不是唯一成功条件
|
||||
- 真正关键的是目录结构与可执行文件路径满足当前查找规则
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 浏览器无法启动
|
||||
|
||||
优先检查:
|
||||
|
||||
1. `%APPDATA%\erpauto\ms-playwright\` 是否存在
|
||||
2. 是否存在 `chromium-1208\chrome-win64\chrome.exe`
|
||||
3. 是否存在其他 `chromium-*` 目录,且包含 `chrome-win64\chrome.exe`
|
||||
4. 是否误用了过时的 `chrome-win\chrome.exe` 路径
|
||||
|
||||
### 版本不匹配或路径不匹配
|
||||
|
||||
建议同时确认:
|
||||
|
||||
- `package.json` 中的 Playwright 版本
|
||||
- 目标机器上实际部署的 Chromium 目录
|
||||
- 当前目录结构是否为 `chrome-win64\chrome.exe`
|
||||
- 应用启动时是否能在 `%APPDATA%\erpauto\ms-playwright\` 下扫描到有效 revision
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [PLAYWRIGHT_DEPLOYMENT.md](./PLAYWRIGHT_DEPLOYMENT.md)
|
||||
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Playwright 部署说明
|
||||
|
||||
本文档聚焦“如何让 ERPAuto 在目标机器上拥有可用的 Playwright Chromium 浏览器”,适合作为实际部署操作说明。
|
||||
|
||||
如果你想看版本信息,请同时参考:
|
||||
|
||||
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md)
|
||||
|
||||
## 背景
|
||||
|
||||
ERPAuto 启动时会把 `PLAYWRIGHT_BROWSERS_PATH` 设置到:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
随后在该目录下查找 Chromium 浏览器文件。当前代码支持:
|
||||
|
||||
- `chromium-1208\chrome-win64\chrome.exe`
|
||||
- `chromium-win32\chrome.exe`
|
||||
- 任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||
|
||||
因此,部署的本质就是把一个完整可用的 Chromium revision 目录放到这个位置。
|
||||
|
||||
当前查找顺序是:
|
||||
|
||||
1. 先查 `%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe`
|
||||
2. 再查 `%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe`
|
||||
3. 最后扫描任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||
|
||||
所以从部署角度看,真正重要的不是目录名一定等于 `1208`,而是目录结构满足当前实现的查找规则。
|
||||
|
||||
## 推荐部署方式
|
||||
|
||||
### 方式 1:使用 Playwright CLI
|
||||
|
||||
如果目标机器能联网,最简单的方式是在项目目录执行:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
执行后,需要把下载得到的 `chromium-*` 目录放到:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Playwright 默认下载目录通常是 `%LOCALAPPDATA%\ms-playwright\`
|
||||
- ERPAuto 运行时查找的是 `%APPDATA%\erpauto\ms-playwright\`
|
||||
- 所以“下载成功”不等于“应用一定能找到”,最终还是要确保文件落在 ERPAuto 使用的目录下
|
||||
|
||||
### 方式 2:手动复制
|
||||
|
||||
这是离线环境或最稳定的部署方式。
|
||||
|
||||
1. 在一台已完成 `npx playwright install chromium` 的机器上找到:
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
```
|
||||
|
||||
2. 复制完整的 `chromium-*` 目录,例如:
|
||||
|
||||
```text
|
||||
chromium-1208
|
||||
```
|
||||
|
||||
3. 将该目录复制到目标机器:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
4. 确认内部存在:
|
||||
|
||||
```text
|
||||
chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
## 推荐目录示例
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
├── chrome.dll
|
||||
├── locales/
|
||||
├── resources/
|
||||
└── ...
|
||||
```
|
||||
|
||||
如果你使用的是旧结构,也可兼容:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-win32/
|
||||
└── chrome.exe
|
||||
```
|
||||
|
||||
## 验证方式
|
||||
|
||||
### 验证 1:检查文件
|
||||
|
||||
执行:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
如果使用默认 revision,再执行:
|
||||
|
||||
```powershell
|
||||
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe"
|
||||
```
|
||||
|
||||
如果你部署的是其他 revision,请按实际目录替换。
|
||||
|
||||
### 验证 2:启动应用
|
||||
|
||||
启动 ERPAuto,观察是否仍弹出“浏览器文件未找到”错误框。
|
||||
|
||||
如果没有弹窗,通常说明启动时的浏览器路径检查已经通过。
|
||||
|
||||
### 验证 3:执行真实业务
|
||||
|
||||
进入依赖 Playwright 的功能页面,执行一次真实流程,确认浏览器可以正常启动。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:文件明明存在,但应用还是报找不到
|
||||
|
||||
常见原因:
|
||||
|
||||
1. 文件放在 `%LOCALAPPDATA%\ms-playwright\`,而不是 `%APPDATA%\erpauto\ms-playwright\`
|
||||
2. 目录结构是旧文档里的 `chrome-win\chrome.exe`
|
||||
3. revision 目录名不符合 `chromium-*`
|
||||
4. 缺少 `chrome-win64\chrome.exe`
|
||||
|
||||
### 问题 2:想多个用户共用同一份浏览器文件
|
||||
|
||||
当前代码在应用启动时会直接把 `PLAYWRIGHT_BROWSERS_PATH` 设为:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
所以默认行为是“每个用户使用自己的用户目录”。
|
||||
如果要改成共享目录,需要同时改代码,而不是只改系统环境变量。
|
||||
|
||||
### 问题 3:构建时是否会自动打包 Chromium
|
||||
|
||||
不会。
|
||||
|
||||
当前 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 的 `build:win` 明确设置了:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
```
|
||||
|
||||
也就是说:
|
||||
|
||||
- 构建过程不会自动下载浏览器
|
||||
- 打包产物也不自带 Chromium
|
||||
- 浏览器仍需要单独部署到目标机器
|
||||
|
||||
## 结论
|
||||
|
||||
当前最稳妥的部署方式是:
|
||||
|
||||
1. 在联网机器下载 Chromium
|
||||
2. 复制完整 `chromium-*` 目录
|
||||
3. 放到 `%APPDATA%\erpauto\ms-playwright\`
|
||||
4. 确保内部有 `chrome-win64\chrome.exe`
|
||||
|
||||
只要满足这几个条件,ERPAuto 当前实现就能正确识别并使用浏览器。
|
||||
199
docs/build-and-release-guide.md
Normal file
199
docs/build-and-release-guide.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# 构建与发布流程
|
||||
|
||||
本文档说明 ERPAuto Windows 便携版的当前构建与发布方式,包括推荐的一键发布命令、分步命令,以及发布产物在对象存储中的结构。
|
||||
|
||||
## 概览
|
||||
|
||||
当前发布链路分为 3 个阶段:
|
||||
|
||||
1. 构建 Windows 包
|
||||
2. 生成本地发布物料和索引
|
||||
3. 上传到对象存储并校验远端索引
|
||||
|
||||
现在已经提供一键总控脚本:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
## 发布前准备
|
||||
|
||||
发布前需要确认:
|
||||
|
||||
1. [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 和 [package-lock.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package-lock.json) 的版本号已经改到目标版本
|
||||
2. [config.yaml](/d:/FileLib/Projects/CodeMigration/ERPAuto/config.yaml) 中 `update` 配置正确,且对象存储可访问
|
||||
3. 对应版本的 changelog 已存在于 `docs/releases/`
|
||||
|
||||
changelog 自动查找规则如下:
|
||||
|
||||
1. 优先查找 `docs/releases/<version>-rebuild.md`
|
||||
2. 如果不存在,再查找 `docs/releases/<version>.md`
|
||||
|
||||
例如当前版本是 `1.3.6`,脚本会按顺序尝试:
|
||||
|
||||
```text
|
||||
docs/releases/1.3.6-rebuild.md
|
||||
docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
## 推荐流程:一键发布
|
||||
|
||||
### 发布 Stable
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
```
|
||||
|
||||
### 发布 Preview
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
这个命令会自动完成:
|
||||
|
||||
1. 读取当前 `package.json` 版本号
|
||||
2. 校验 `package.json` / `package-lock.json` 版本一致
|
||||
3. 校验 changelog 文件存在
|
||||
4. 设置 `APP_CHANNEL`
|
||||
5. 执行 `build:win`
|
||||
6. 执行 `release:prepare`
|
||||
7. 执行 `release:upload --verify`
|
||||
8. 输出本次发布摘要
|
||||
|
||||
## 分步流程
|
||||
|
||||
如果需要调试,也可以手工分步执行。
|
||||
|
||||
### 1. 构建
|
||||
|
||||
Stable:
|
||||
|
||||
```bash
|
||||
$env:APP_CHANNEL="stable"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
Preview:
|
||||
|
||||
```bash
|
||||
$env:APP_CHANNEL="preview"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 2. 生成发布物料
|
||||
|
||||
```bash
|
||||
npm run release:prepare -- --channel stable --changelog docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:prepare -- --channel preview --changelog docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
这一步会生成:
|
||||
|
||||
- `release-output/updates/win-portable/<channel>/artifacts/...`
|
||||
- `release-output/updates/win-portable/<channel>/changelogs/...`
|
||||
- `release-output/updates/win-portable/<channel>/index.json`
|
||||
|
||||
### 3. 上传并校验
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel stable --verify
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel preview --verify
|
||||
```
|
||||
|
||||
## 上传策略
|
||||
|
||||
当前上传脚本默认采用“增量上传”:
|
||||
|
||||
- 上传当前版本对应的 `artifact`
|
||||
- 上传当前版本对应的 `changelog`
|
||||
- 上传最新的 `index.json`
|
||||
|
||||
也就是说,它不会再把旧版本的 exe 和旧 changelog 全部重复上传。
|
||||
|
||||
如果确实需要整条通道做一次全量同步,可以显式使用:
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel stable --full-sync
|
||||
```
|
||||
|
||||
## 对象存储目录结构
|
||||
|
||||
发布到对象存储后的目录结构如下:
|
||||
|
||||
```text
|
||||
updates/win-portable/
|
||||
├── stable/
|
||||
│ ├── artifacts/
|
||||
│ │ └── erpauto-<version>-stable-portable.exe
|
||||
│ ├── changelogs/
|
||||
│ │ └── <version>.md
|
||||
│ └── index.json
|
||||
└── preview/
|
||||
├── artifacts/
|
||||
│ └── erpauto-<version>-preview-portable.exe
|
||||
├── changelogs/
|
||||
│ └── <version>.md
|
||||
└── index.json
|
||||
```
|
||||
|
||||
## 相关脚本
|
||||
|
||||
- [publish-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/publish-release.js)
|
||||
一键总控脚本,负责串起 build、prepare、upload。
|
||||
- [prepare-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
负责整理本地发布物料和生成 `index.json`。
|
||||
- [upload-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||
负责上传当前版本物料和 `index.json`,并可回读远端索引。
|
||||
- [compile-updater.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||
负责构建原生 `portable-updater.exe`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 缺少 `--channel`
|
||||
|
||||
会直接失败,因为发布必须显式指定 `stable` 或 `preview`。
|
||||
|
||||
### 2. 找不到 changelog
|
||||
|
||||
说明 `docs/releases/` 下没有当前版本对应的 Markdown 文件。
|
||||
先补 changelog,再执行发布。
|
||||
|
||||
### 3. 版本不是当前通道最新
|
||||
|
||||
总控脚本在 `prepare` 后会检查本地生成的 `index.json`。
|
||||
如果当前版本不是该通道索引的第一项,脚本会停止,避免把旧版本误当成当前发布版本。
|
||||
|
||||
### 4. 只想调试某一步
|
||||
|
||||
可以直接使用分步命令:
|
||||
|
||||
- `npm run build:win`
|
||||
- `npm run release:prepare -- ...`
|
||||
- `npm run release:upload -- ...`
|
||||
|
||||
## 建议
|
||||
|
||||
日常发布优先使用:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel <stable|preview>
|
||||
```
|
||||
|
||||
只有在排查问题或需要特殊处理时,再退回分步命令。
|
||||
86
docs/cleaner-execution-report-template.md
Normal file
86
docs/cleaner-execution-report-template.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ERP 物料清理执行报告
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| 项目 | 值 |
|
||||
| -------------- | --------------------------------- |
|
||||
| **执行时间** | `YYYY-MM-DD HH:mm:ss` |
|
||||
| **执行模式** | `正式执行` / `模拟运行 (Dry Run)` |
|
||||
| **操作用户** | `username` |
|
||||
| **处理订单数** | `X` |
|
||||
| **删除物料数** | `X` |
|
||||
| **跳过物料数** | `X` |
|
||||
| **错误数量** | `X` |
|
||||
| **执行耗时** | `X 分 Y 秒` |
|
||||
|
||||
---
|
||||
|
||||
## 执行状态
|
||||
|
||||
| 状态 | 数量 | 百分比 |
|
||||
| ----------- | ---- | ------ |
|
||||
| ✅ 成功订单 | X | XX% |
|
||||
| ❌ 失败订单 | X | XX% |
|
||||
|
||||
---
|
||||
|
||||
## 订单处理详情
|
||||
|
||||
| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |
|
||||
| --- | -------- | ------ | ------ | ------- | ------------------------ |
|
||||
| 1 | `PO-001` | 5 | 2 | ✅ 成功 | - |
|
||||
| 2 | `PO-002` | 0 | 0 | ❌ 失败 | `Order PO-002: 超时错误` |
|
||||
| 3 | `PO-003` | 3 | 1 | ✅ 成功 | - |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 跳过的物料原因说明
|
||||
|
||||
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
|
||||
| -------- | -------- | -------- | ---- | --------------------------------- |
|
||||
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 2000-7999 范围内(受保护) |
|
||||
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
|
||||
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 错误详情
|
||||
|
||||
**错误总数**: `X`
|
||||
|
||||
### 错误订单列表
|
||||
|
||||
- `PO-002`
|
||||
- `PO-005`
|
||||
- `PO-008`
|
||||
- ...
|
||||
|
||||
### 错误详细信息
|
||||
|
||||
#### `PO-002`
|
||||
|
||||
```
|
||||
订单号: PO-002
|
||||
错误: 订单不存在或已被锁定,无法访问备料计划
|
||||
```
|
||||
|
||||
#### `PO-005`
|
||||
|
||||
```
|
||||
订单号: PO-005
|
||||
错误: ERP 连接超时:请求在 30000ms 内未得到响应
|
||||
```
|
||||
|
||||
#### `PO-008`
|
||||
|
||||
```
|
||||
订单号: PO-008
|
||||
错误: 备料状态异常:当前状态为"待审批",无法执行删除操作
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**: `YYYY-MM-DD HH:mm:ss`
|
||||
**报表版本**: `v1.0`
|
||||
985
docs/cleaner-order-error-collection.md
Normal file
985
docs/cleaner-order-error-collection.md
Normal file
@@ -0,0 +1,985 @@
|
||||
# 物料清理模块 - 订单错误收集逻辑分析
|
||||
|
||||
本文档详细分析了 ERPAuto 应用中物料清理功能在处理订单过程中的错误收集机制。
|
||||
|
||||
## 一、系统架构概览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["渲染进程 (Frontend)"]
|
||||
CleanerPage["CleanerPage.tsx<br/>UI 界面"]
|
||||
UseCleaner["useCleaner.ts<br/>状态管理 Hook"]
|
||||
ExecReport["ExecutionReportDialog.tsx<br/>错误报告展示"]
|
||||
end
|
||||
|
||||
subgraph Preload["Preload 脚本"]
|
||||
ContextBridge["window.electron.cleaner<br/>IPC API 桥接"]
|
||||
end
|
||||
|
||||
subgraph Main["主进程 (Main)"]
|
||||
CleanerHandler["cleaner-handler.ts<br/>IPC 处理器"]
|
||||
CleanerService["cleaner.ts<br/>CleanerService"]
|
||||
OrderResolver["order-resolver.ts<br/>订单号解析"]
|
||||
ReportGen["cleaner-report-generator.ts<br/>报告生成"]
|
||||
end
|
||||
|
||||
subgraph Storage["数据存储"]
|
||||
ConfigYAML["config.yaml<br/>ERP URL 配置"]
|
||||
DB[(数据库<br/>dbo_MaterialsToBeDeleted)]
|
||||
end
|
||||
|
||||
CleanerPage --> UseCleaner
|
||||
UseCleaner --> ContextBridge
|
||||
ContextBridge --> CleanerHandler
|
||||
CleanerHandler --> OrderResolver
|
||||
CleanerHandler --> CleanerService
|
||||
CleanerService --> ReportGen
|
||||
CleanerHandler --> ConfigYAML
|
||||
CleanerHandler --> DB
|
||||
|
||||
style CleanerService fill:#e1f5ff
|
||||
style CleanerHandler fill:#fff4e1
|
||||
style ExecReport fill:#f0e1ff
|
||||
```
|
||||
|
||||
## 二、错误收集流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant UI as CleanerPage
|
||||
participant Hook as useCleaner
|
||||
participant IPC as cleaner-handler
|
||||
participant Resolver as OrderNumberResolver
|
||||
participant Service as CleanerService
|
||||
participant ERP as ERP 系统
|
||||
participant Dialog as ExecutionReportDialog
|
||||
|
||||
User->>UI: 点击"正式执行 ERP 清理"
|
||||
UI->>Hook: handleExecuteDeletion()
|
||||
|
||||
Hook->>Hook: 获取 CleanerData<br/>(订单号 + 物料代码)
|
||||
Hook->>IPC: electron.cleaner.runCleaner()
|
||||
|
||||
IPC->>IPC: 验证 ERP 配置
|
||||
IPC->>Resolver: resolve(orderNumbers)
|
||||
|
||||
Note over Resolver: 订单号解析验证
|
||||
Resolver-->>IPC: 返回 mappings + warnings
|
||||
|
||||
alt 存在解析警告
|
||||
IPC->>IPC: 收集 warnings 到错误列表
|
||||
end
|
||||
|
||||
IPC->>Service: new CleanerService()
|
||||
IPC->>Service: clean(input)
|
||||
|
||||
Note over Service: 批量处理订单
|
||||
loop 每个订单批次
|
||||
Service->>ERP: 查询订单列表
|
||||
Service->>ERP: 打开订单详情页
|
||||
|
||||
alt 订单处理成功
|
||||
Service->>Service: 记录删除/跳过统计
|
||||
else 订单处理失败
|
||||
Service->>Service: createErrorDetail()
|
||||
Service->>Service: errors.push(error)
|
||||
end
|
||||
|
||||
alt 订单未出现在查询结果中
|
||||
Service->>Service: 添加"订单未找到"错误
|
||||
end
|
||||
end
|
||||
|
||||
Note over Service: 失败订单重试机制
|
||||
Service->>Service: retryFailedOrders()
|
||||
loop 每个失败订单 (最多 2 次重试)
|
||||
Service->>ERP: 重新查询并处理
|
||||
alt 重试成功
|
||||
Service->>Service: retrySuccess = true
|
||||
Service->>Service: 从错误列表移除
|
||||
else 重试失败
|
||||
Service->>Service: 记录 retryAttempts
|
||||
end
|
||||
end
|
||||
|
||||
Service-->>IPC: 返回 CleanerResult
|
||||
IPC->>IPC: 合并 warnings + errors
|
||||
|
||||
IPC-->>Hook: IpcResult<CleanerResult>
|
||||
Hook->>Hook: 设置 reportData
|
||||
Hook->>Dialog: 打开错误报告对话框
|
||||
|
||||
Dialog->>User: 显示执行结果<br/>+ 错误详情列表
|
||||
```
|
||||
|
||||
## 三、错误类型详解
|
||||
|
||||
### 3.1 错误来源分类(完整版)
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((订单错误))
|
||||
前置验证错误
|
||||
ERP 配置不完整
|
||||
数据库连接失败
|
||||
ERP 登录失败
|
||||
未登录先调用会话
|
||||
解析阶段错误
|
||||
订单号格式无效
|
||||
格式不识别 (非订单号/总排号)
|
||||
ProductionID 无对应订单
|
||||
数据库查询异常
|
||||
执行阶段错误
|
||||
导航失败
|
||||
弹出窗口等待超时
|
||||
forwardFrame 访问失败
|
||||
mainiframe 访问失败
|
||||
热键区域加载超时
|
||||
查询界面设置失败
|
||||
订单号查询模式切换失败
|
||||
下拉框选择失败
|
||||
订单查询失败
|
||||
查询结果加载超时
|
||||
查询无结果
|
||||
详情页打开失败
|
||||
行元素等待超时 (15s)
|
||||
更多按钮定位失败
|
||||
popup 事件等待超时
|
||||
备料计划菜单定位失败
|
||||
详情页处理失败
|
||||
forwardFrame 访问失败
|
||||
mainiframe 访问失败 (30s)
|
||||
页面标题等待超时 (30s)
|
||||
修改按钮点击失败
|
||||
保存按钮等待超时 (30s/60s)
|
||||
展开按钮点击失败
|
||||
删行按钮点击失败
|
||||
删行后行变化等待失败
|
||||
下一行按钮点击失败
|
||||
收起按钮点击失败
|
||||
重试阶段错误
|
||||
重试查询无结果
|
||||
重试打开详情页失败
|
||||
重试处理异常
|
||||
达到最大重试次数 (2 次)
|
||||
业务规则错误
|
||||
物料不在删除清单
|
||||
行号在保护范围 (2000-7999)
|
||||
累计待发数量不为空
|
||||
收尾错误
|
||||
浏览器关闭失败
|
||||
数据库断开失败
|
||||
报告生成失败
|
||||
```
|
||||
|
||||
### 3.2 错误数据结构
|
||||
|
||||
```typescript
|
||||
// 主结果结构
|
||||
interface CleanerResult {
|
||||
ordersProcessed: number // 成功处理的订单数
|
||||
materialsDeleted: number // 删除的物料数
|
||||
materialsSkipped: number // 跳过的物料数
|
||||
errors: string[] // 错误消息列表
|
||||
details: OrderCleanDetail[] // 每个订单的详细信息
|
||||
retriedOrders: number // 重试的订单数
|
||||
successfulRetries: number // 成功的重试数
|
||||
}
|
||||
|
||||
// 单个订单详情
|
||||
interface OrderCleanDetail {
|
||||
orderNumber: string // 订单号
|
||||
materialsDeleted: number // 该订单删除的物料数
|
||||
materialsSkipped: number // 该订单跳过的物料数
|
||||
errors: string[] // 该订单的错误列表
|
||||
skippedMaterials: SkippedMaterial[] // 跳过的物料详情
|
||||
retryCount: number // 重试次数
|
||||
retryAttempts?: RetryAttempt[] // 每次重试的错误详情
|
||||
retriedAt?: number // 重试时间戳
|
||||
retrySuccess?: boolean // 重试是否成功
|
||||
}
|
||||
|
||||
// 重试尝试记录
|
||||
interface RetryAttempt {
|
||||
attempt: number // 第几次尝试
|
||||
error: string // 错误消息
|
||||
timestamp: number // 时间戳
|
||||
}
|
||||
|
||||
// 跳过物料详情
|
||||
interface SkippedMaterial {
|
||||
materialCode: string // 物料代码
|
||||
materialName: string // 物料名称
|
||||
rowNumber: number // 行号
|
||||
reason: string // 跳过原因
|
||||
}
|
||||
```
|
||||
|
||||
## 四、核心错误收集点(完整版)
|
||||
|
||||
### 4.1 IPC 处理层 (cleaner-handler.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 前置验证错误 ==========
|
||||
|
||||
// 1. ERP 配置验证失败
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 数据库连接失败
|
||||
try {
|
||||
dbService = await getDatabaseService()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 订单号解析后无有效订单
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
// 4. ERP 登录失败
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 执行结果合并 ==========
|
||||
|
||||
// 5. 解析警告合并到错误列表
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
// 6. 导出验证错误
|
||||
if (!items || items.length === 0) {
|
||||
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 订单号解析层 (order-resolver.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 解析错误 ==========
|
||||
|
||||
// 1. ProductionID 数据库查询失败
|
||||
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await this.dbService.query(sql, params)
|
||||
// ...
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionID to order number', {
|
||||
productionId,
|
||||
error: message
|
||||
})
|
||||
throw error // 向上抛出
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 批量映射查询失败
|
||||
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await this.dbService.query(sql, params)
|
||||
// ...
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionIds to order numbers', { error: message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 单个订单解析失败 - 在 resolve() 中记录
|
||||
for (const input of inputs) {
|
||||
const mapping: OrderMapping = { input, resolved: false }
|
||||
|
||||
if (this.isOrderNumber(input)) {
|
||||
mapping.orderNumber = input
|
||||
mapping.resolved = true
|
||||
} else if (this.isProductionId(input)) {
|
||||
mapping.productionId = input
|
||||
const orderNumber = mappings.get(input)
|
||||
if (orderNumber) {
|
||||
mapping.orderNumber = orderNumber
|
||||
mapping.resolved = true
|
||||
} else {
|
||||
// 错误:ProductionID 在数据库中找不到
|
||||
mapping.error = '未在数据库中找到对应的订单号'
|
||||
}
|
||||
} else {
|
||||
// 错误:格式不识别
|
||||
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
||||
}
|
||||
|
||||
results.push(mapping)
|
||||
}
|
||||
|
||||
// 4. 警告收集
|
||||
getWarnings(mappings: OrderMapping[]): string[] {
|
||||
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ERP 认证层 (erp-auth.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 登录阶段错误 ==========
|
||||
|
||||
async login(): Promise<ErpSession> {
|
||||
// 1. 浏览器启动失败(隐式抛出)
|
||||
const browser = await chromium.launch({ ... })
|
||||
|
||||
// 2. 上下文创建失败(隐式抛出)
|
||||
const context = await browser.newContext({ ... })
|
||||
|
||||
// 3. 页面创建失败(隐式抛出)
|
||||
const page = await context.newPage()
|
||||
|
||||
// 4. 导航失败(隐式抛出)
|
||||
await page.goto(loginUrl)
|
||||
|
||||
// 5. 页面加载超时
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
|
||||
|
||||
// 6. iframe 选择器等待超时
|
||||
await page.waitForSelector('#forwardFrame', {
|
||||
state: 'attached',
|
||||
timeout: LOGIN_RESULT_TIMEOUT
|
||||
})
|
||||
|
||||
// 7. forwardFrame content frame 访问失败
|
||||
const contentFrame = await frameLocator.contentFrame()
|
||||
if (!contentFrame) {
|
||||
throw new Error('Failed to access forwardFrame content frame')
|
||||
}
|
||||
|
||||
// 8. 用户名输入框定位失败
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find username input: ${e}`)
|
||||
}
|
||||
|
||||
// 9. 密码输入框定位失败
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find password input: ${e}`)
|
||||
}
|
||||
|
||||
// 10. 登录按钮点击失败
|
||||
try {
|
||||
await contentFrame.getByRole('button', { name: '登录' }).click()
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
// 11. 登录结果等待 - 多种失败场景
|
||||
await this.waitForLoginResult(mainFrame)
|
||||
}
|
||||
|
||||
// waitForLoginResult 内部错误
|
||||
private async waitForLoginResult(mainFrame: Frame): Promise<void> {
|
||||
// 12. 登录成功图标等待超时
|
||||
// 13. 错误消息等待超时
|
||||
// 14. 强制登录对话框等待超时
|
||||
// 15. 强制登录确认按钮点击失败
|
||||
// 16. 名称或密码错误检测
|
||||
const hasError = await errorLocator.isVisible()
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 服务层 (cleaner.ts) - 主处理循环
|
||||
|
||||
```typescript
|
||||
// ========== 导航阶段错误 ==========
|
||||
|
||||
async navigateToCleanerPage(session: ErpSession): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||
// 1. 菜单图标点击失败
|
||||
await mainFrame.locator('i').first().click()
|
||||
|
||||
// 2. 弹出窗口等待超时
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
|
||||
// 3. 标题定位点击失败
|
||||
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
||||
const popupPage = await popupPromise
|
||||
|
||||
// 4. forwardFrame 定位失败
|
||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||
const fFrame = await forwardFrameLocator.contentFrame()
|
||||
|
||||
// 5. mainiframe 等待超时 (30s)
|
||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const workFrame = await innerFrameLocator.contentFrame()
|
||||
|
||||
// 6. 热键区域加载超时 (30s)
|
||||
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
||||
}
|
||||
|
||||
// ========== 查询界面设置错误 ==========
|
||||
|
||||
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
|
||||
// 7. 查询模式切换按钮点击失败
|
||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||
|
||||
// 8. 订单号查询选项点击失败
|
||||
await innerFrame.getByText('订单号查询').click()
|
||||
|
||||
// 9. 全部 Tab 点击失败
|
||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||
|
||||
// 10. 下拉框填充失败
|
||||
const inputEl = innerFrame.locator('#rc_select_0')
|
||||
await inputEl.fill('5000')
|
||||
await inputEl.press('Enter')
|
||||
}
|
||||
|
||||
// ========== 订单查询错误 ==========
|
||||
|
||||
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
|
||||
// 11. 文本框填充失败
|
||||
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
||||
await textbox.fill(orderNumbers.join(','))
|
||||
|
||||
// 12. 查询按钮点击失败
|
||||
await workFrame.locator('.search-component-searchBtn').click()
|
||||
}
|
||||
|
||||
// ========== 订单详情打开错误 ==========
|
||||
|
||||
private async openDetailPageFromRow(workFrame: FrameLocator, popupPage: Page, rowIndex: number): Promise<Page> {
|
||||
// 13. 行元素等待超时 (15s)
|
||||
const row = workFrame.locator('tbody tr').nth(rowIndex)
|
||||
await row.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
// 14. 更多按钮定位失败
|
||||
const moreButton = row.locator('a.row-more').first()
|
||||
await moreButton.scrollIntoViewIfNeeded()
|
||||
|
||||
// 15. popup 事件等待超时
|
||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||
|
||||
// 16. 更多按钮点击失败
|
||||
await moreButton.click()
|
||||
|
||||
// 17. 备料计划菜单点击失败(备料计划菜单可能有多套定位策略)
|
||||
await this.clickMaterialPlanMenu(workFrame)
|
||||
|
||||
return await detailPagePromise
|
||||
}
|
||||
|
||||
// 18. 备料计划菜单定位失败 - 遍历 4 套定位器全部失败
|
||||
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
|
||||
const candidates = [/* 4 套定位器 */]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout: 2000 })
|
||||
await target.click()
|
||||
return
|
||||
} catch { /* 尝试下一个 */ }
|
||||
}
|
||||
throw new Error('无法定位"备料计划"菜单项(可能菜单结构已变化)')
|
||||
}
|
||||
|
||||
// ========== 详情页处理错误 ==========
|
||||
|
||||
private async processDetailPage(params: {...}): Promise<OrderCleanDetail> {
|
||||
try {
|
||||
// 19. forwardFrame 定位失败
|
||||
const detailMainFrame = detailPage.locator('#forwardFrame')
|
||||
const dFrame = await detailMainFrame.contentFrame()
|
||||
if (!dFrame) {
|
||||
throw new Error('Failed to access detail page forward frame')
|
||||
}
|
||||
|
||||
// 20. mainiframe 定位失败
|
||||
const detailInnerLocator = dFrame.locator('#mainiframe')
|
||||
|
||||
// 21. mainiframe 等待超时 (30s)
|
||||
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const detailInnerFrame = await detailInnerLocator.contentFrame()
|
||||
if (!detailInnerFrame) {
|
||||
throw new Error('Failed to access detail inner frame')
|
||||
}
|
||||
|
||||
// 22. 页面标题等待超时 (30s)
|
||||
await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
// 23. 源订单号提取失败(静默处理,返回空字符串)
|
||||
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
|
||||
|
||||
// 24. 详细信息计数提取失败(静默处理,返回 0)
|
||||
const detailCountText = await detailInnerFrame.getByText(/^详细信息(\d+)$/).innerText()
|
||||
|
||||
// 25. 备料状态文本提取失败(静默处理,返回空字符串)
|
||||
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
|
||||
|
||||
if (detailStatus === '审批通过' && detailCount > 0) {
|
||||
// 26. 修改按钮点击失败
|
||||
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
|
||||
|
||||
// 27. 保存按钮等待超时 (30s)
|
||||
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
|
||||
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
// 28. 展开按钮点击失败
|
||||
await detailInnerFrame.getByText('展开').first().click()
|
||||
|
||||
// 29. 行号输入值获取失败(静默处理)
|
||||
const currentRow = await this.getInputValue(childForm, /^行号$/)
|
||||
|
||||
// 30. 材料编码输入值获取失败(静默处理)
|
||||
const materialCode = await this.getInputValue(childForm, /^材料编码/)
|
||||
|
||||
// 31. 材料名称输入值获取失败(静默处理)
|
||||
const materialName = await this.getInputValue(childForm, /^材料名称/)
|
||||
|
||||
// 32. 累计待发数量输入值获取失败(静默处理)
|
||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||
|
||||
// 33. 删行按钮点击失败
|
||||
await deleteRowBtn.click()
|
||||
|
||||
// 34. 删行后行变化等待超时 (10s)
|
||||
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
||||
|
||||
// 35. 下一行按钮点击失败
|
||||
await nextBtn.click()
|
||||
|
||||
// 36. 收起按钮点击失败
|
||||
await collapseBtn.click()
|
||||
|
||||
// 37. 保存按钮点击失败
|
||||
await saveButtonLocator.click()
|
||||
|
||||
// 38. 保存完成等待超时 (60s)
|
||||
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||
}
|
||||
} finally {
|
||||
// 39. 详情页关闭失败(静默处理)
|
||||
await detailPage.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 重试机制 (cleaner.ts)
|
||||
|
||||
```typescript
|
||||
private async retryFailedOrders(params: {...}): Promise<RetryResult> {
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
for (const failedDetail of failedDetails) {
|
||||
const orderNumber = failedDetail.orderNumber
|
||||
const retryAttempts: RetryAttempt[] = []
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// 1. 重试查询订单
|
||||
await this.queryOrders(workFrame, [orderNumber])
|
||||
|
||||
// 2. 重试加载等待
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
// 3. 重试查询结果验证
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
if (rowCount === 0) {
|
||||
throw new Error('订单重试查询无结果')
|
||||
}
|
||||
|
||||
// 4. 重试打开详情页(从第一行)
|
||||
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
|
||||
|
||||
// 5. 重试处理详情页
|
||||
const retryDetail = await this.processDetailPage({...})
|
||||
|
||||
// 重试成功
|
||||
result.successfulRetries += 1
|
||||
result.updatedDetails.push({
|
||||
...retryDetail,
|
||||
retryCount: attempt,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: true,
|
||||
retryAttempts
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
|
||||
|
||||
// 记录重试失败详情
|
||||
retryAttempts.push({
|
||||
attempt,
|
||||
error: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// 达到最大重试次数
|
||||
if (attempt === MAX_RETRIES) {
|
||||
result.updatedDetails.push({
|
||||
...failedDetail,
|
||||
retryCount: MAX_RETRIES,
|
||||
retryAttempts,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: false
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理成功的重试错误
|
||||
const successfulRetryOrders = new Set(
|
||||
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
|
||||
)
|
||||
result.errors = result.errors.filter(
|
||||
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 全局异常捕获 (cleaner.ts - clean 方法)
|
||||
|
||||
```typescript
|
||||
async clean(input: CleanerInput): Promise<CleanerResult> {
|
||||
const result: CleanerResult = { /* ... */ }
|
||||
|
||||
try {
|
||||
// 主处理逻辑
|
||||
// ...
|
||||
} catch (error) {
|
||||
// 全局异常捕获 - 任何未处理的错误都会在这里被捕获
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Cleaner failed', { error: message })
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
} finally {
|
||||
// 资源清理 - 错误静默处理
|
||||
if (popupPage) {
|
||||
try {
|
||||
await popupPage.close()
|
||||
} catch { /* Ignore close errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## 五、前端错误展示流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph State["React 状态"]
|
||||
ReportData["reportData state"]
|
||||
IsExecuting["isExecuting state"]
|
||||
Progress["progress state"]
|
||||
end
|
||||
|
||||
subgraph Dialog["ExecutionReportDialog"]
|
||||
ProgressView["进度视图"]
|
||||
ResultView["结果视图"]
|
||||
ErrorList["错误列表渲染"]
|
||||
end
|
||||
|
||||
subgraph Display["UI 展示"]
|
||||
StatsCards["统计卡片"]
|
||||
ErrorItems["错误项"]
|
||||
RetryStats["重试统计"]
|
||||
end
|
||||
|
||||
ReportData --> ResultView
|
||||
IsExecuting --> ProgressView
|
||||
Progress --> ProgressView
|
||||
|
||||
ResultView --> StatsCards
|
||||
ResultView --> ErrorList
|
||||
ResultView --> RetryStats
|
||||
|
||||
ErrorList --> ErrorItems
|
||||
|
||||
style ErrorList fill:#ffe1e1
|
||||
style ErrorItems fill:#ffc0c0
|
||||
```
|
||||
|
||||
### 5.1 错误展示组件 (ExecutionReportDialog.tsx)
|
||||
|
||||
```tsx
|
||||
// 错误列表渲染
|
||||
{
|
||||
hasErrors && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm font-semibold text-red-600 mb-2">错误详情</div>
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||
{errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 p-2 bg-red-50 rounded border border-red-200"
|
||||
>
|
||||
<XCircle size={14} className="text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-gray-900 break-words">{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 重试统计展示
|
||||
{
|
||||
hasRetries && (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-50">
|
||||
<RefreshIcon className="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">重试订单</div>
|
||||
<div className="text-xl font-semibold">{retriedOrders}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-emerald-50">
|
||||
<CheckCircle className="text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">成功重试</div>
|
||||
<div className="text-xl font-semibold">{successfulRetries}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 六、完整数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Input["输入数据"]
|
||||
ProductionIDs["Production IDs<br/>(共享状态)"]
|
||||
MaterialCodes["物料代码<br/>(dbo_MaterialsToBeDeleted)"]
|
||||
end
|
||||
|
||||
subgraph Resolve["解析阶段"]
|
||||
DBQuery["数据库查询<br/>生产订单号"]
|
||||
Validation["格式验证"]
|
||||
Warnings["警告收集"]
|
||||
end
|
||||
|
||||
subgraph Execute["执行阶段"]
|
||||
BatchQuery["批量查询订单"]
|
||||
ProcessDetail["处理订单详情"]
|
||||
SkipLogic["跳过判断逻辑"]
|
||||
end
|
||||
|
||||
subgraph Retry["重试阶段"]
|
||||
FailedList["失败订单列表"]
|
||||
RetryLoop["最多 2 次重试"]
|
||||
UpdateErrors["更新错误列表"]
|
||||
end
|
||||
|
||||
subgraph Output["输出结果"]
|
||||
Stats["统计数据"]
|
||||
Errors["错误列表"]
|
||||
Details["订单详情"]
|
||||
Report["生成报告"]
|
||||
end
|
||||
|
||||
ProductionIDs --> DBQuery
|
||||
MaterialCodes --> Execute
|
||||
DBQuery --> Validation
|
||||
Validation --> Warnings
|
||||
Warnings --> Errors
|
||||
|
||||
Validation --> BatchQuery
|
||||
BatchQuery --> ProcessDetail
|
||||
ProcessDetail --> SkipLogic
|
||||
SkipLogic --> Stats
|
||||
|
||||
ProcessDetail --> FailedList
|
||||
FailedList --> RetryLoop
|
||||
RetryLoop --> UpdateErrors
|
||||
UpdateErrors --> Errors
|
||||
|
||||
Stats --> Output
|
||||
Errors --> Output
|
||||
Details --> Output
|
||||
Output --> Report
|
||||
|
||||
style Warnings fill:#fff4e1
|
||||
style Errors fill:#ffe1e1
|
||||
style UpdateErrors fill:#e1ffe1
|
||||
```
|
||||
|
||||
## 七、关键配置参数
|
||||
|
||||
| 参数 | 默认值 | 范围 | 说明 |
|
||||
| -------------------- | ------ | ----- | ------------------------ |
|
||||
| `queryBatchSize` | 100 | 1-100 | 每批查询的订单数量 |
|
||||
| `processConcurrency` | 1 | 1-20 | 并行处理的订单详情页数量 |
|
||||
| `dryRun` | false | - | 预览模式,不实际删除 |
|
||||
| `headless` | true | - | 后台模式,不显示浏览器 |
|
||||
| `MAX_RETRIES` | 2 | - | 失败订单最大重试次数 |
|
||||
|
||||
## 八、错误处理最佳实践
|
||||
|
||||
### 8.1 已实现的模式
|
||||
|
||||
1. **分层错误收集**: IPC 层、服务层、重试层分别收集
|
||||
2. **错误聚合**: 所有错误最终汇总到 `CleanerResult.errors`
|
||||
3. **重试恢复**: 自动重试失败订单,成功后从错误列表移除
|
||||
4. **详细记录**: 每个订单的 `OrderCleanDetail` 包含独立错误列表
|
||||
5. **审计追踪**: `RetryAttempt[]` 记录每次重试的详细信息
|
||||
|
||||
### 8.2 错误格式规范
|
||||
|
||||
```typescript
|
||||
// 订单级别错误格式
|
||||
;`Order ${orderNumber}: ${errorMessage}`
|
||||
|
||||
// 解析警告直接添加
|
||||
warnings.push(warningMessage)
|
||||
|
||||
// 重试失败记录
|
||||
retryAttempts.push({
|
||||
attempt: 1,
|
||||
error: '具体错误消息',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
```
|
||||
|
||||
## 九、完整错误覆盖清单
|
||||
|
||||
### 错误覆盖完整性审计
|
||||
|
||||
| 层级 | 错误点 | 错误类型 | 是否收集 | 是否可重试 |
|
||||
| --------------- | -------------------------- | ------------------ | -------- | ---------- |
|
||||
| **前置验证** |
|
||||
| cleaner-handler | ERP 配置不完整 | ValidationError | ✅ | ❌ |
|
||||
| cleaner-handler | 数据库连接失败 | DatabaseQueryError | ✅ | ❌ |
|
||||
| cleaner-handler | 无有效订单号 | ValidationError | ✅ | ❌ |
|
||||
| cleaner-handler | ERP 登录失败 | ErpConnectionError | ✅ | ❌ |
|
||||
| **订单解析** |
|
||||
| order-resolver | ProductionID 无对应订单 | 解析警告 | ✅ | ❌ |
|
||||
| order-resolver | 格式不识别 | 解析警告 | ✅ | ❌ |
|
||||
| order-resolver | 数据库查询异常 | 抛出错误 | ✅ | ❌ |
|
||||
| **ERP 认证** |
|
||||
| erp-auth | forwardFrame 访问失败 | Error | ✅ | ❌ |
|
||||
| erp-auth | 用户名输入框找不到 | Error | ✅ | ❌ |
|
||||
| erp-auth | 密码输入框找不到 | Error | ✅ | ❌ |
|
||||
| erp-auth | 登录按钮点击失败 | Error | ✅ | ❌ |
|
||||
| erp-auth | 登录超时 | 隐式超时 | ✅ | ❌ |
|
||||
| erp-auth | 名称或密码错误 | Error | ✅ | ❌ |
|
||||
| **导航阶段** |
|
||||
| cleaner | 弹出窗口等待超时 | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | forwardFrame 访问失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | mainiframe 等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 热键区域加载超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| **查询设置** |
|
||||
| cleaner | 查询模式切换失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 下拉框填充失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 查询按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| **订单打开** |
|
||||
| cleaner | 行元素等待超时 (15s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 更多按钮定位失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | popup 事件等待超时 | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 备料计划菜单定位失败 | Error | ✅ | ✅ |
|
||||
| **详情处理** |
|
||||
| cleaner | forwardFrame 访问失败 | Error | ✅ | ✅ |
|
||||
| cleaner | mainiframe 访问失败 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 页面标题等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 修改按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存按钮等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 展开按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 删行按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 删行后行变化等待失败 (10s) | 逻辑超时 | ✅ | ✅ |
|
||||
| cleaner | 下一行按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 收起按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存完成等待超时 (60s) | Playwright Timeout | ✅ | ✅ |
|
||||
| **重试阶段** |
|
||||
| cleaner | 重试查询无结果 | Error | ✅ | N/A |
|
||||
| cleaner | 重试打开详情页失败 | Playwright Error | ✅ | N/A |
|
||||
| cleaner | 重试处理异常 | Error | ✅ | N/A |
|
||||
| cleaner | 达到最大重试次数 | 逻辑错误 | ✅ | N/A |
|
||||
| **业务规则** |
|
||||
| cleaner | 物料不在删除清单 | 跳过原因 | ✅ | ❌ |
|
||||
| cleaner | 行号在保护范围 | 跳过原因 | ✅ | ❌ |
|
||||
| cleaner | 累计待发数量不为空 | 跳过原因 | ✅ | ❌ |
|
||||
| **收尾阶段** |
|
||||
| cleaner | 浏览器关闭失败 | 静默忽略 | ⚠️ | N/A |
|
||||
| cleaner | 数据库断开失败 | 静默忽略 | ⚠️ | N/A |
|
||||
| cleaner | 报告生成失败 | 静默记录 | ⚠️ | N/A |
|
||||
|
||||
**图例说明**:
|
||||
|
||||
- ✅ = 已收集到 errors 数组
|
||||
- ⚠️ = 仅记录日志,不加入错误列表
|
||||
- ❌ = 不收集(终止性错误或业务跳过)
|
||||
- N/A = 不适用
|
||||
|
||||
### 覆盖率分析
|
||||
|
||||
**总计错误点**: 52 个
|
||||
|
||||
**覆盖情况**:
|
||||
|
||||
- 完全收集 (✅): 43 个 (82.7%)
|
||||
- 静默处理 (⚠️): 3 个 (5.8%) - 资源清理类错误,不影响业务
|
||||
- 不收集 (❌): 9 个 (17.3%) - 终止性错误或业务规则跳过
|
||||
|
||||
**结论**: 错误收集覆盖全面,所有影响业务结果的错误均被正确收集。资源清理类错误采用静默处理是合理的设计决策,不影响用户对执行结果的认知。
|
||||
|
||||
## 十、总结
|
||||
|
||||
物料清理模块的错误收集机制具有以下特点:
|
||||
|
||||
1. **多层防护**: 从解析、执行到重试,每个阶段都有错误捕获
|
||||
2. **自动恢复**: 失败订单自动重试,成功后从错误列表移除
|
||||
3. **详细追踪**: 每个订单、每次重试都有详细记录
|
||||
4. **用户友好**: 前端清晰展示错误类型和统计信息
|
||||
5. **审计完整**: 所有操作记录到数据库和报告文件
|
||||
|
||||
错误处理流程遵循"收集 → 尝试恢复 → 记录 → 报告"的模式,确保用户能够清楚了解每个订单的处理状态和失败原因。
|
||||
|
||||
## 十一、相关源文件
|
||||
|
||||
| 文件路径 | 职责 | 错误收集点数 |
|
||||
| ------------------------------------------------------- | ------------------- | ------------ |
|
||||
| `src/renderer/src/pages/CleanerPage.tsx` | UI 界面 | - |
|
||||
| `src/renderer/src/hooks/useCleaner.ts` | 状态管理与 IPC 调用 | - |
|
||||
| `src/renderer/src/components/ExecutionReportDialog.tsx` | 错误报告展示 | - |
|
||||
| `src/main/ipc/cleaner-handler.ts` | IPC 处理器 | 6 |
|
||||
| `src/main/services/erp/cleaner.ts` | 核心清理服务 | 32 |
|
||||
| `src/main/services/erp/order-resolver.ts` | 订单号解析 | 4 |
|
||||
| `src/main/services/erp/erp-auth.ts` | ERP 认证 | 6 |
|
||||
| `src/main/services/report/cleaner-report-generator.ts` | 报告生成 | - |
|
||||
| `src/main/types/cleaner.types.ts` | 类型定义 | - |
|
||||
| `src/main/types/errors.ts` | 错误类型定义 | - |
|
||||
| `src/main/ipc/validation-handler.ts` | CleanerData 获取 | - |
|
||||
291
docs/cleaner-user-scope-fix.md
Normal file
291
docs/cleaner-user-scope-fix.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# CleanerPage User Scope Fix
|
||||
|
||||
**Issue**: User users were affecting other users' data when using "取消" and "确认删除" buttons
|
||||
|
||||
**Date**: 2026-03-03
|
||||
**Branch**: `fix/cleaner-user-scope`
|
||||
|
||||
---
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Bug Description
|
||||
|
||||
For **User type (non-Admin)** users:
|
||||
|
||||
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
|
||||
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
|
||||
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
|
||||
4. This caused User A to delete User B's materials that User A never saw!
|
||||
|
||||
### Root Causes
|
||||
|
||||
#### 1. "取消" Button (Line 420)
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Clears ALL selected items
|
||||
onClick={() => setSelectedItems(new Set())}
|
||||
```
|
||||
|
||||
#### 2. `handleConfirmDeletion` Function (Line 165)
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Iterates ALL validation results
|
||||
for (const result of validationResults) {
|
||||
// Processes items user can't even see!
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Backend"
|
||||
A[validationResults<br/>1000 items] --> B[User Filter<br/>currentUsername]
|
||||
end
|
||||
|
||||
subgraph "Frontend Display"
|
||||
B --> C[filteredResults<br/>100 items visible]
|
||||
C --> D[Table Display]
|
||||
end
|
||||
|
||||
subgraph "Bug Behavior (BEFORE FIX)"
|
||||
E[取消 Button] --> F[Clears selectedItems<br/>for ALL 1000 items ❌]
|
||||
G[确认删除 Button] --> H[Processes ALL 1000 items ❌]
|
||||
H --> I[Deletes User B's data ❌]
|
||||
end
|
||||
|
||||
subgraph "Fixed Behavior (AFTER FIX)"
|
||||
E2[取消 Button] --> F2[Clears only visible<br/>100 items ✅]
|
||||
G2[确认删除 Button] --> H2[Processes only<br/>100 items ✅]
|
||||
H2 --> I2[Only affects User A ✅]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: "取消" Button - Only Uncheck Visible Items
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:419-432`
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={() => {
|
||||
// Only uncheck items that are visible in filteredResults
|
||||
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
for (const code of visibleCodes) {
|
||||
newSet.delete(code)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}}
|
||||
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<Square size={14} className="text-slate-400" /> 取消
|
||||
</button>
|
||||
```
|
||||
|
||||
**What Changed**:
|
||||
|
||||
- Before: `setSelectedItems(new Set())` - clears everything
|
||||
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
||||
- Preserves selections for items not currently visible (e.g., other users' data)
|
||||
|
||||
### Fix 2: `handleConfirmDeletion` - Only Process Visible Items (Non-Admin)
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:158-222`
|
||||
|
||||
```typescript
|
||||
const handleConfirmDeletion = async () => {
|
||||
// For non-admin users, only process visible filtered results
|
||||
// For admin users, process all validation results
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
|
||||
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
|
||||
|
||||
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||
const materialsToDelete: string[] = []
|
||||
const missingManager: string[] = []
|
||||
|
||||
for (const result of resultsToProcess) {
|
||||
// ... rest of processing logic
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**What Changed**:
|
||||
|
||||
- Before: `for (const result of validationResults)` - processes all 1000 items
|
||||
- After: `for (const result of resultsToProcess)` where:
|
||||
- `Admin` → processes `validationResults` (all items)
|
||||
- `User` → processes only `filteredResults` (visible items)
|
||||
|
||||
---
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: User Unchecks Own Data Only
|
||||
|
||||
**Setup**:
|
||||
|
||||
- User A logs in (non-Admin)
|
||||
- 100 materials visible (assigned to User A)
|
||||
- 900 materials invisible (assigned to other users)
|
||||
- All 1000 materials are initially checked
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. User A clicks "取消"
|
||||
2. Table shows all checkboxes unchecked
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ User A's 100 materials are unchecked
|
||||
- ✅ Other users' 900 materials **remain checked** (not affected)
|
||||
|
||||
**Verification**:
|
||||
|
||||
```typescript
|
||||
// Before fix: selectedItems.size === 0
|
||||
// After fix: selectedItems.size === 900 (other users' items still checked)
|
||||
```
|
||||
|
||||
### Scenario 2: User Confirms Deletion
|
||||
|
||||
**Setup**:
|
||||
|
||||
- User A logs in (non-Admin)
|
||||
- User A unchecks 50 of their 100 materials
|
||||
- 50 items checked (User A's)
|
||||
- 900 items checked (other users')
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. User A clicks "确认删除"
|
||||
2. Confirm dialog shows: "写入/更新 50 条记录"
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ Only User A's 50 materials are upserted to database
|
||||
- ✅ Other users' 900 materials are **NOT touched**
|
||||
- ✅ No materials are deleted (since other users' items aren't processed)
|
||||
|
||||
### Scenario 3: Admin Behavior Unchanged
|
||||
|
||||
**Setup**:
|
||||
|
||||
- Admin logs in
|
||||
- All 1000 materials visible
|
||||
- All filtered by selected managers
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. Admin clicks "取消" → all visible items unchecked
|
||||
2. Admin clicks "确认删除" → processes all filtered items
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ Admin behavior unchanged (can manage all data)
|
||||
- ✅ Admin can still filter by managers and process filtered results
|
||||
|
||||
---
|
||||
|
||||
## Security & Scope Implications
|
||||
|
||||
### Before Fix (Vulnerability)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes ALL 1000 items ❌]
|
||||
Deletes --> Impact[User B loses data ❌]
|
||||
```
|
||||
|
||||
### After Fix (Secure)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes 100 items ✅]
|
||||
Sees2 --> Independent[User B's data independent ✅]
|
||||
Deletes --> Safe[User scope isolation ✅]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Summary
|
||||
|
||||
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
|
||||
| Line | Change | Description |
|
||||
| ------- | -------------------------------- | ----------------------------------------- |
|
||||
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
||||
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
||||
|
||||
### Variables Used
|
||||
|
||||
- `validationResults`: All materials from backend (1000 items)
|
||||
- `filteredResults`: Materials after user/manager filtering (100 items for User A)
|
||||
- `selectedItems`: Set of checked material codes
|
||||
- `isAdmin`: Boolean, true for Admin users
|
||||
- `currentUsername`: Current logged-in username
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. **Test as User A**:
|
||||
|
||||
```bash
|
||||
# Login as user1
|
||||
npm run dev
|
||||
# Navigate to CleanerPage
|
||||
# Verify only user1's materials are visible
|
||||
# Click "取消" → only visible items unchecked
|
||||
# Check selectedItems size = other users' checked items
|
||||
```
|
||||
|
||||
2. **Test as User B**:
|
||||
|
||||
```bash
|
||||
# Login as user2
|
||||
# Verify user1's changes didn't affect user2's data
|
||||
# All user2's materials should still be intact
|
||||
```
|
||||
|
||||
3. **Test as Admin**:
|
||||
```bash
|
||||
# Login as admin
|
||||
# Verify can still see and manage all materials
|
||||
# "取消" and "确认删除" work on all filtered results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Implementation**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **Related**: `src/main/ipc/validation-handler.ts` (backend matching logic)
|
||||
- **Related**: `docs/user-override-match-feature.md` (user override matching)
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Add Confirmation Dialog for Scope**: Show user how many items will be affected
|
||||
2. **Add Audit Logging**: Log which user modified which materials
|
||||
3. **Add Warning for Large Operations**: Warn if user is about to delete many items
|
||||
4. **Backend Validation**: Add backend check to prevent cross-user data modification
|
||||
|
||||
---
|
||||
|
||||
**Document End**
|
||||
@@ -287,7 +287,7 @@ WHERE rn = 1
|
||||
|
||||
### 4. 物料匹配算法
|
||||
|
||||
**位置**: `validation-handler.ts:325-361`
|
||||
**位置**: `validation-handler.ts:343-382`
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -299,14 +299,24 @@ flowchart TB
|
||||
Priority1 -->|materialCode<br/>在markedCodesDict中| SetMarked[设置managerName<br/>isMarkedForDeletion=true]
|
||||
Priority1 -->|未匹配| Priority2{优先级2:<br/>MaterialsTypeToBeDeleted<br/>名称包含匹配?}
|
||||
|
||||
SetMarked --> PushResult[添加到results]
|
||||
Priority2 -->|遍历typeKeywords| CheckContains{typeKeyword.materialName<br/>包含 materialName?}
|
||||
SetMarked --> CheckUser{当前用户类型?}
|
||||
Priority2 -->|遍历typeKeywords| CheckContains{typeKeyword.materialName<br/>匹配?}
|
||||
|
||||
CheckContains -->|是| SetMatched[设置managerName<br/>matchedTypeKeyword<br/>isMarkedForDeletion=false]
|
||||
CheckContains -->|否| SetNull[managerName=null<br/>isMarkedForDeletion=false]
|
||||
CheckContains -->|是| SetMatched[设置managerName<br/>matchedTypeKeyword]
|
||||
CheckContains -->|否| SetNull[managerName=null]
|
||||
|
||||
SetMatched --> PushResult
|
||||
SetNull --> PushResult
|
||||
SetMatched --> CheckUser
|
||||
SetNull --> CheckUser
|
||||
|
||||
CheckUser -->|Admin| Skip[跳过覆盖]
|
||||
CheckUser -->|User| Priority3{优先级3:<br/>用户覆盖匹配?}
|
||||
|
||||
Priority3 -->|匹配成功| Override[覆盖为当前用户<br/>managerName=当前用户]
|
||||
Priority3 -->|未匹配| Keep[保持原结果]
|
||||
|
||||
Skip --> PushResult[添加到results]
|
||||
Override --> PushResult
|
||||
Keep --> PushResult
|
||||
|
||||
PushResult --> Next{还有物料?}
|
||||
Next -->|是| Loop
|
||||
@@ -321,11 +331,14 @@ flowchart TB
|
||||
- 结果: `isMarkedForDeletion = true`, `managerName` 从表中获取
|
||||
|
||||
2. **优先级2 (次高)**: `MaterialsTypeToBeDeleted` 表包含匹配
|
||||
- 匹配条件: `MaterialName` 包含关系 (`typeKeyword.materialName.includes(materialName)`)
|
||||
- 匹配条件: `MaterialName` 包含关系 (`materialName.includes(typeKeyword.materialName)`)
|
||||
- 结果: `isMarkedForDeletion = false`, `managerName` 从表中获取, `matchedTypeKeyword` 记录匹配项
|
||||
|
||||
3. **未匹配**: 无任何匹配
|
||||
- 结果: `isMarkedForDeletion = false`, `managerName = ''`, `matchedTypeKeyword = undefined`
|
||||
3. **优先级3 (User 覆盖)**: 当前用户 typeKeyword 覆盖匹配
|
||||
- 适用范围: 仅对 `User` 类型用户生效,`Admin` 用户跳过此步骤
|
||||
- 匹配条件: 筛选 `managerName === 当前用户名` 的 typeKeywords,使用相同的包含匹配逻辑
|
||||
- 结果: 强制覆盖 `managerName` 和 `matchedTypeKeyword` 为当前用户的值
|
||||
- 无匹配时: 保持优先级2的匹配结果不变
|
||||
|
||||
**核心代码**:
|
||||
|
||||
@@ -344,7 +357,7 @@ for (const record of materialRecords) {
|
||||
// 优先级2: 匹配 MaterialsTypeToBeDeleted (MaterialName 包含匹配)
|
||||
if (!managerName) {
|
||||
for (const typeKeyword of typeKeywords) {
|
||||
if (typeKeyword.materialName && typeKeyword.materialName.includes(materialName)) {
|
||||
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
|
||||
matchedTypeKeyword = typeKeyword.materialName
|
||||
managerName = typeKeyword.managerName
|
||||
break
|
||||
@@ -352,6 +365,18 @@ for (const record of materialRecords) {
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级3: 用户覆盖匹配 (仅限 User 用户)
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // 强制覆盖,只使用第一个匹配
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
materialName,
|
||||
materialCode,
|
||||
@@ -1223,7 +1248,9 @@ flowchart TB
|
||||
| 文件路径 | 说明 | 关键行号 |
|
||||
| ----------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
||||
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 209-392 (validation:validate)<br>399-422 (materials:upsertBatch)<br>427-449 (materials:delete) |
|
||||
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)<br>407-420 (materials:upsertBatch)<br>425-447 (materials:delete) |
|
||||
| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
|
||||
| `src/main/ipc/validation-handler.ts` | 物料匹配算法 | 343-382 (优先级1-3匹配逻辑) |
|
||||
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 191-227 (queryAllDistinctByMaterialCode) |
|
||||
| `src/main/services/database/discrete-material-plan-dao.ts` | 物料计划DAO | 294-377 (queryBySourceNumbersDistinct) |
|
||||
| `src/main/services/database/materials-to-be-deleted-dao.ts` | 待删除物料DAO | 180-240 (upsertBatch)<br>248-268 (getAllMaterialCodes)<br>539-586 (deleteByMaterialCodes) |
|
||||
|
||||
137
docs/developer/README.md
Normal file
137
docs/developer/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Developer Docs
|
||||
|
||||
这组文档面向项目开发者,目标是帮助团队快速理解项目结构、运行时分层、核心业务模块和常见开发路径。
|
||||
|
||||
它不是一次性说明,而是一套会持续维护的开发文档入口。
|
||||
|
||||
## 文档目标
|
||||
|
||||
- 帮助新开发者快速建立项目地图
|
||||
- 帮助现有开发者定位功能入口、关键文件和调用链
|
||||
- 为重构、排障、扩展功能提供统一参考
|
||||
- 逐步沉淀重要设计决策,而不是只留在提交记录和口头沟通中
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
如果你是第一次接触这个项目,建议按下面顺序阅读:
|
||||
|
||||
1. 项目总览
|
||||
2. 运行时架构
|
||||
3. 核心模块文档
|
||||
4. 开发与调试指南
|
||||
|
||||
## 开发者文档总导航
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Developer[docs/developer]
|
||||
Architecture[architecture/]
|
||||
Modules[modules/]
|
||||
Guides[guides/]
|
||||
|
||||
Overview[系统地图]
|
||||
Runtime[运行时分层]
|
||||
DataFlow[数据流与关键文件]
|
||||
Business[业务模块]
|
||||
TaskGuides[开发任务指南]
|
||||
|
||||
Developer --> Architecture
|
||||
Developer --> Modules
|
||||
Developer --> Guides
|
||||
|
||||
Architecture --> Overview
|
||||
Architecture --> Runtime
|
||||
Architecture --> DataFlow
|
||||
Modules --> Business
|
||||
Guides --> TaskGuides
|
||||
```
|
||||
|
||||
也可以把这三层理解成:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[architecture]
|
||||
B[modules]
|
||||
C[guides]
|
||||
|
||||
A -->|先理解系统| B
|
||||
B -->|再理解业务边界| C
|
||||
C -->|最后落到开发动作| Done[开始修改与维护]
|
||||
```
|
||||
|
||||
## 计划中的目录结构
|
||||
|
||||
```text
|
||||
docs/developer/
|
||||
README.md
|
||||
architecture/
|
||||
README.md
|
||||
overview.md
|
||||
runtime-architecture.md
|
||||
data-flow.md
|
||||
file-map.md
|
||||
decision-log.md
|
||||
modules/
|
||||
README.md
|
||||
extractor.md
|
||||
cleaner.md
|
||||
validation.md
|
||||
auth.md
|
||||
update.md
|
||||
settings.md
|
||||
guides/
|
||||
README.md
|
||||
local-development.md
|
||||
debugging.md
|
||||
ipc-development.md
|
||||
renderer-development.md
|
||||
release-process.md
|
||||
```
|
||||
|
||||
## 内容组织原则
|
||||
|
||||
这套文档会按“读者任务”来组织,而不是简单照抄源码目录。
|
||||
|
||||
文档主要分为三类:
|
||||
|
||||
- `architecture/`
|
||||
说明系统整体结构、运行时分层、关键数据流和核心设计决策。
|
||||
- `modules/`
|
||||
说明每个业务模块的职责、入口文件、调用链、状态流和常见改动点。
|
||||
- `guides/`
|
||||
说明开发者在实际工作中最常见的任务,例如本地启动、调试、扩展 IPC、修改前端页面、发布版本等。
|
||||
|
||||
## 维护约定
|
||||
|
||||
为了保证这套文档长期可用,后续维护建议遵循这些约定:
|
||||
|
||||
- 新增核心模块时,同步补一篇对应的模块文档
|
||||
- 发生重要重构时,更新相关架构文档和决策记录
|
||||
- 文档优先解释“职责、边界、调用关系”,而不是堆砌实现细节
|
||||
- 文档应当多用、善用 `mermaid` 做图形化表达
|
||||
- 遇到结构、分层、调用链、时序、流程时,优先考虑先画图再解释
|
||||
- 图负责帮助读者快速建立整体认知,文字负责解释细节和边界
|
||||
- 文档尽量附上关键文件路径,并保持图和正文一一对应
|
||||
- 文档中的路径、模块名、调用链描述应与当前代码保持一致
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前 `developer` 文档目录刚刚建立,后续会优先补齐这些内容:
|
||||
|
||||
- 项目总览
|
||||
- 运行时架构
|
||||
- `extractor` 模块
|
||||
- `cleaner` 模块
|
||||
- `validation` 模块
|
||||
- `update` 模块
|
||||
|
||||
## 相关文档
|
||||
|
||||
当前仓库里已经有一些与架构、重构和流程相关的文档,后续会逐步整理并决定是否纳入这套开发者文档体系:
|
||||
|
||||
- `docs/validation-handler-refactor-overview.md`
|
||||
- `docs/use-cleaner-refactor-overview.md`
|
||||
- `docs/plans/2026-03-21-electron-best-practices-optimization-plan.md`
|
||||
- `docs/plans/2026-03-21-vercel-react-best-practices-optimization-plan.md`
|
||||
|
||||
后续这份 README 会作为整个 `docs/developer/` 的总索引持续维护。
|
||||
107
docs/developer/architecture/README.md
Normal file
107
docs/developer/architecture/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 架构文档索引
|
||||
|
||||
本目录收录项目的架构层文档,主要用于帮助开发者建立系统级认知。
|
||||
|
||||
如果 `modules/` 关注“某个业务模块怎么工作”,`guides/` 关注“具体开发时怎么做”,那么 `architecture/` 关注的是:
|
||||
|
||||
- 项目整体长什么样
|
||||
- 运行时是怎么分层的
|
||||
- 关键数据流怎么走
|
||||
- 核心文件分布在哪里
|
||||
- 过去为什么做出某些架构决策
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
建议按下面顺序阅读:
|
||||
|
||||
1. `overview.md`
|
||||
2. `runtime-architecture.md`
|
||||
3. `data-flow.md`
|
||||
4. `file-map.md`
|
||||
5. `decision-log.md`
|
||||
|
||||
这个顺序基本对应:
|
||||
|
||||
- 先建立地图
|
||||
- 再理解运行时分层
|
||||
- 再看核心数据如何流动
|
||||
- 再定位关键文件
|
||||
- 最后理解历史决策
|
||||
|
||||
## 架构层导航图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Architecture[architecture/]
|
||||
Overview[overview.md]
|
||||
Runtime[runtime-architecture.md]
|
||||
DataFlow[data-flow.md]
|
||||
FileMap[file-map.md]
|
||||
Decisions[decision-log.md]
|
||||
|
||||
Architecture --> Overview
|
||||
Architecture --> Runtime
|
||||
Architecture --> DataFlow
|
||||
Architecture --> FileMap
|
||||
Architecture --> Decisions
|
||||
```
|
||||
|
||||
## 文档职责一览
|
||||
|
||||
| 文档 | 主要回答的问题 |
|
||||
| --- | --- |
|
||||
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
|
||||
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
|
||||
| `data-flow.md` | 核心业务数据如何在各层之间流动 |
|
||||
| `file-map.md` | 关键文件在哪里、应该先看哪些入口 |
|
||||
| `decision-log.md` | 最近几轮重要重构和架构决策是什么 |
|
||||
|
||||
## 按问题选择阅读路径
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Question[当前问题]
|
||||
Whole[我想先理解整个项目]
|
||||
RuntimeQ[我想知道进程分层和调用边界]
|
||||
FlowQ[我想知道数据怎么流]
|
||||
FileQ[我想快速定位该看哪些文件]
|
||||
HistoryQ[我想知道为什么现在是这个结构]
|
||||
|
||||
Question --> Whole
|
||||
Question --> RuntimeQ
|
||||
Question --> FlowQ
|
||||
Question --> FileQ
|
||||
Question --> HistoryQ
|
||||
|
||||
Whole --> OverviewDoc[overview.md]
|
||||
RuntimeQ --> RuntimeDoc[runtime-architecture.md]
|
||||
FlowQ --> FlowDoc[data-flow.md]
|
||||
FileQ --> FileDoc[file-map.md]
|
||||
HistoryQ --> DecisionDoc[decision-log.md]
|
||||
```
|
||||
|
||||
## 与其他目录的关系
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Architecture[architecture/]
|
||||
Modules[modules/]
|
||||
Guides[guides/]
|
||||
|
||||
Architecture --> Modules
|
||||
Modules --> Guides
|
||||
```
|
||||
|
||||
理解方式:
|
||||
|
||||
- 先通过 `architecture/` 建立系统级认知
|
||||
- 再进入 `modules/` 深入业务模块
|
||||
- 最后通过 `guides/` 落到开发动作
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 如果准备改动较大的功能,先看架构层文档再下手
|
||||
- 如果遇到“代码都看到了,但不知道该从哪里改”,优先看 `file-map.md`
|
||||
- 如果遇到“现在为什么这样设计”,优先看 `decision-log.md`
|
||||
|
||||
后续如果新增新的架构层文档,也建议同步更新这份索引页。
|
||||
273
docs/developer/architecture/data-flow.md
Normal file
273
docs/developer/architecture/data-flow.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 数据流
|
||||
|
||||
本文档聚焦项目中的核心数据流,帮助开发者理解关键业务数据如何在 `renderer`、`preload`、`main` 和外部系统之间流动。
|
||||
|
||||
## 1. 数据流总览
|
||||
|
||||
项目中的数据大致分成五类:
|
||||
|
||||
- 用户输入数据
|
||||
- 页面状态数据
|
||||
- IPC 请求与响应数据
|
||||
- 主进程领域数据
|
||||
- 外部系统数据
|
||||
|
||||
整体关系如下:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[用户输入]
|
||||
Renderer[Renderer State]
|
||||
Preload[Preload API]
|
||||
IPC[IPC Handlers]
|
||||
Services[Main Services]
|
||||
External[DB / ERP / Files / Update Source]
|
||||
|
||||
User --> Renderer
|
||||
Renderer --> Preload
|
||||
Preload --> IPC
|
||||
IPC --> Services
|
||||
Services --> External
|
||||
External --> Services
|
||||
Services --> IPC
|
||||
IPC --> Preload
|
||||
Preload --> Renderer
|
||||
```
|
||||
|
||||
## 2. 提取到清理的主数据流
|
||||
|
||||
项目里最核心的一条数据流是:
|
||||
|
||||
1. 用户输入订单号
|
||||
2. Extractor 执行提取
|
||||
3. 共享 Production IDs
|
||||
4. Cleaner 基于共享数据做校验
|
||||
5. 保存删除计划
|
||||
6. 执行 ERP 清理
|
||||
7. 生成报告与导出
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Input[订单号输入]
|
||||
Extractor[Extractor 提取]
|
||||
SharedIds[共享 Production IDs]
|
||||
Validation[物料校验]
|
||||
Plan[删除计划]
|
||||
Cleaner[ERP 清理执行]
|
||||
Report[报告 / 导出]
|
||||
|
||||
Input --> Extractor
|
||||
Input --> SharedIds
|
||||
Extractor --> SharedIds
|
||||
SharedIds --> Validation
|
||||
Validation --> Plan
|
||||
Plan --> Cleaner
|
||||
Cleaner --> Report
|
||||
```
|
||||
|
||||
## 3. Renderer 内部数据流
|
||||
|
||||
在 renderer 中,数据通常按下面路径流动:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[页面 / 组件]
|
||||
Hook[Hook]
|
||||
Store[Store / Local State]
|
||||
Bridge[window.electron facade]
|
||||
|
||||
UI --> Hook
|
||||
Hook --> Store
|
||||
Hook --> Bridge
|
||||
Bridge --> Hook
|
||||
Hook --> UI
|
||||
```
|
||||
|
||||
具体表现为:
|
||||
|
||||
- 页面组件负责接收用户输入和渲染状态
|
||||
- hook 负责请求编排、局部状态和副作用管理
|
||||
- store 负责消息提示、日志或跨组件状态
|
||||
- preload facade 负责把 bridge 调用标准化
|
||||
|
||||
## 4. Authentication 数据流
|
||||
|
||||
认证流程是应用启动时最先发生的一条数据流。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as App / useAppBootstrap
|
||||
participant Preload as preload.auth
|
||||
participant Handler as auth-handler
|
||||
participant AppSvc as auth-application-service
|
||||
participant Session as session-manager
|
||||
|
||||
App->>Preload: getComputerName()
|
||||
App->>Preload: silentLogin()
|
||||
Preload->>Handler: invoke auth channel
|
||||
Handler->>AppSvc: silentLogin()
|
||||
AppSvc->>Session: resolve session / user
|
||||
Session-->>AppSvc: user info
|
||||
AppSvc-->>Handler: login result
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>App: auth state
|
||||
```
|
||||
|
||||
这条链路最终驱动:
|
||||
|
||||
- `UnauthenticatedApp`
|
||||
- `AuthenticatedAppShell`
|
||||
- 管理员代切用户流程
|
||||
|
||||
## 5. Extractor 数据流
|
||||
|
||||
Extractor 模块的数据流重点在“订单号输入”和“提取执行结果”。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Page as ExtractorPage
|
||||
participant Persist as usePersistentTextState
|
||||
participant Shared as useSharedProductionIds
|
||||
participant Hook as useExtractor
|
||||
participant Preload as preload.extractor / validation
|
||||
participant Main as extractor-handler + services
|
||||
|
||||
Page->>Persist: 保存订单号输入
|
||||
Page->>Shared: debounce 同步共享 Production IDs
|
||||
Page->>Hook: startExtraction(orderNumbers)
|
||||
Hook->>Preload: setSharedProductionIds()
|
||||
Hook->>Preload: runExtractor()
|
||||
Preload->>Main: invoke
|
||||
Main-->>Preload: extraction result
|
||||
Preload-->>Hook: result
|
||||
Hook-->>Page: progress / logs / complete
|
||||
```
|
||||
|
||||
这里当前有两类数据:
|
||||
|
||||
- 持久化输入数据
|
||||
通过 `sessionStorage`
|
||||
- 跨模块共享数据
|
||||
通过 `validation` 模块中的 shared production IDs
|
||||
|
||||
## 6. Validation / Cleaner 数据流
|
||||
|
||||
Cleaner 页面的数据流相对更复杂,包含筛选、校验、选择、保存和执行几个阶段。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ValidationInput[校验模式 / 共享订单号]
|
||||
Validate[请求校验]
|
||||
Results[validationResults]
|
||||
Filter[filteredResults]
|
||||
Selection[selectedItems]
|
||||
Plan[保存删除计划]
|
||||
Execute[执行 ERP 清理]
|
||||
Progress[progress]
|
||||
Report[执行报告]
|
||||
|
||||
ValidationInput --> Validate
|
||||
Validate --> Results
|
||||
Results --> Filter
|
||||
Results --> Selection
|
||||
Filter --> Selection
|
||||
Selection --> Plan
|
||||
Plan --> Execute
|
||||
Execute --> Progress
|
||||
Execute --> Report
|
||||
```
|
||||
|
||||
这一块当前的关键状态都集中在:
|
||||
|
||||
- `useCleaner`
|
||||
- `src/renderer/src/hooks/cleaner/api.ts`
|
||||
- `src/renderer/src/hooks/cleaner/helpers.ts`
|
||||
|
||||
## 7. Update 数据流
|
||||
|
||||
更新模块的数据流分成两部分:
|
||||
|
||||
- 被动状态流
|
||||
main 进程通过事件推送状态变化
|
||||
- 主动拉取流
|
||||
renderer 在打开对话框或刷新时拉取 catalog / status / changelog
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Hook as useAppBootstrap
|
||||
participant Dialog as useUpdateDialogState
|
||||
participant Preload as preload.update
|
||||
participant Main as update-handler / update services
|
||||
|
||||
Main->>Preload: onStatusChanged
|
||||
Preload->>Hook: update status event
|
||||
Hook->>Preload: getStatus()
|
||||
Hook->>Preload: getCatalog()
|
||||
Dialog->>Preload: getChangelog(release)
|
||||
Preload->>Main: invoke
|
||||
Main-->>Preload: status / catalog / changelog
|
||||
Preload-->>Hook: normalized result
|
||||
Preload-->>Dialog: changelog content
|
||||
```
|
||||
|
||||
## 8. 事件推送型数据流
|
||||
|
||||
项目中有一部分状态不是通过“请求一次拿一次”获取,而是主进程主动推送。
|
||||
|
||||
当前主要推送通道包括:
|
||||
|
||||
- cleaner progress
|
||||
- extractor progress
|
||||
- extractor log
|
||||
- update status changed
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
MainService[Main Service]
|
||||
EventChannel[IPC Event Channel]
|
||||
PreloadListener[Preload Listener]
|
||||
RendererHook[Renderer Hook]
|
||||
UI[UI]
|
||||
|
||||
MainService --> EventChannel
|
||||
EventChannel --> PreloadListener
|
||||
PreloadListener --> RendererHook
|
||||
RendererHook --> UI
|
||||
```
|
||||
|
||||
## 9. 配置与持久化数据流
|
||||
|
||||
项目中的持久化既包含主进程配置,也包含 renderer 局部偏好。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UI[Renderer UI]
|
||||
Hook[Hook / Helper]
|
||||
Session[sessionStorage]
|
||||
ConfigIPC[config API]
|
||||
ConfigSvc[ConfigManager]
|
||||
ConfigFile[config.yaml]
|
||||
|
||||
UI --> Hook
|
||||
Hook --> Session
|
||||
Hook --> ConfigIPC
|
||||
ConfigIPC --> ConfigSvc
|
||||
ConfigSvc --> ConfigFile
|
||||
```
|
||||
|
||||
当前典型例子:
|
||||
|
||||
- `cleaner_dryRun`
|
||||
- `cleaner_headless`
|
||||
- `cleaner_validationMode`
|
||||
- `extractor_orderNumbers`
|
||||
|
||||
## 10. 开发建议
|
||||
|
||||
在处理数据流时,建议优先遵守这些原则:
|
||||
|
||||
- 页面输入态不要直接驱动高频 bridge 副作用
|
||||
- 共享数据流要明确谁负责写入、谁负责消费
|
||||
- preload 只做 facade,不在 bridge 层堆业务分支
|
||||
- handler 只做转发和错误包装
|
||||
- 复杂状态流尽量配套时序图或单测
|
||||
326
docs/developer/architecture/decision-log.md
Normal file
326
docs/developer/architecture/decision-log.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# 设计决策记录
|
||||
|
||||
本文档记录项目中值得被长期记住的关键架构决策。它不追求覆盖所有历史细节,而是保留那些会影响后续开发判断的决定。
|
||||
|
||||
## 1. 记录原则
|
||||
|
||||
这里主要记录三类决策:
|
||||
|
||||
- 影响整体结构的重构决策
|
||||
- 影响跨层边界的接口决策
|
||||
- 影响后续维护方式的工程化决策
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Problem[问题]
|
||||
Decision[决策]
|
||||
Impact[影响]
|
||||
FollowUp[后续维护]
|
||||
|
||||
Problem --> Decision --> Impact --> FollowUp
|
||||
```
|
||||
|
||||
## 2. 决策一览
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title 近期关键架构决策
|
||||
2026-03-21 : 主进程 bootstrap 拆分
|
||||
: IPC handler 薄壳化
|
||||
: preload 按 domain 重组
|
||||
: update 模块职责拆分
|
||||
: React App 入口收敛
|
||||
: Cleaner 页面拆分
|
||||
: UpdateDialog 状态收敛
|
||||
: renderer 重型弹窗按需加载
|
||||
```
|
||||
|
||||
## 3. 主进程入口拆分
|
||||
|
||||
### 背景
|
||||
|
||||
此前主进程入口承载了过多职责,启动、窗口、运行时检查、IPC 注册和进程守卫都集中在单文件中。
|
||||
|
||||
### 决策
|
||||
|
||||
将主进程启动相关逻辑拆分到:
|
||||
|
||||
- `bootstrap/main-window.ts`
|
||||
- `bootstrap/runtime.ts`
|
||||
- `bootstrap/process-guards.ts`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Before[单一 index.ts]
|
||||
After[index.ts + bootstrap/*]
|
||||
|
||||
Before --> After
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- `index.ts` 更容易读
|
||||
- 启动链路更容易定位问题
|
||||
- 后续添加启动逻辑不必继续堆到一个入口文件里
|
||||
|
||||
## 4. IPC handler 薄壳化
|
||||
|
||||
### 背景
|
||||
|
||||
部分 handler 曾经承担大量业务编排逻辑,尤其是认证、校验和清理流程。
|
||||
|
||||
### 决策
|
||||
|
||||
把核心编排下沉到 application service,handler 保持为薄壳。
|
||||
|
||||
当前典型结构:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Handler[IPC Handler]
|
||||
AppService[Application Service]
|
||||
Domain[Domain Service]
|
||||
|
||||
Handler --> AppService --> Domain
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- handler 更容易测试
|
||||
- 业务逻辑更容易复用
|
||||
- 主进程边界更清晰
|
||||
|
||||
## 5. Validation 模块拆分
|
||||
|
||||
### 背景
|
||||
|
||||
`validation-handler` 曾同时承担 IPC、共享状态、数据库分支、SQL 拼接和数据富化。
|
||||
|
||||
### 决策
|
||||
|
||||
将职责拆分到独立模块:
|
||||
|
||||
- `shared-production-ids-store.ts`
|
||||
- `validation-database.ts`
|
||||
- `production-input-service.ts`
|
||||
- `validation-application-service.ts`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Handler[validation-handler]
|
||||
Store[shared-production-ids-store]
|
||||
DB[validation-database]
|
||||
Input[production-input-service]
|
||||
AppSvc[validation-application-service]
|
||||
|
||||
Handler --> Store
|
||||
Handler --> AppSvc
|
||||
AppSvc --> DB
|
||||
AppSvc --> Input
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- 共享订单号状态不再埋在 handler 中
|
||||
- 数据库与输入识别边界更清晰
|
||||
- 后续校验链路文档化和测试化更容易
|
||||
|
||||
## 6. Preload 按领域重组
|
||||
|
||||
### 背景
|
||||
|
||||
preload 曾接近一个“大接口总表”,内部职责不够清晰。
|
||||
|
||||
### 决策
|
||||
|
||||
将 preload 改为按 domain 组织:
|
||||
|
||||
- `api/auth.ts`
|
||||
- `api/cleaner.ts`
|
||||
- `api/extractor.ts`
|
||||
- `api/validation.ts`
|
||||
- `api/materials.ts`
|
||||
- `api/process.ts`
|
||||
- `api/logger.ts`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Before[单体 preload]
|
||||
After[domain preload APIs]
|
||||
|
||||
Before --> After
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- renderer 使用的 bridge 更有语义
|
||||
- preload 更适合继续维护
|
||||
- 类型边界更稳定
|
||||
|
||||
## 7. Update 模块拆分
|
||||
|
||||
### 背景
|
||||
|
||||
更新服务长期承担目录拉取、状态广播、下载、安装和版本决策等多类职责。
|
||||
|
||||
### 决策
|
||||
|
||||
将 update 模块拆成多个协作者:
|
||||
|
||||
- `update-service.ts`
|
||||
- `update-catalog-service.ts`
|
||||
- `update-installer.ts`
|
||||
- `update-storage-client.ts`
|
||||
- `update-status-publisher.ts`
|
||||
- `update-support.ts`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UpdateService[UpdateService]
|
||||
Catalog[UpdateCatalogService]
|
||||
Installer[UpdateInstaller]
|
||||
Storage[UpdateStorageClient]
|
||||
Publisher[UpdateStatusPublisher]
|
||||
|
||||
UpdateService --> Catalog
|
||||
UpdateService --> Installer
|
||||
UpdateService --> Storage
|
||||
UpdateService --> Publisher
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- 更新职责边界更清晰
|
||||
- 测试粒度更细
|
||||
- 维护内部更新逻辑的成本下降
|
||||
|
||||
## 8. React 入口收敛
|
||||
|
||||
### 背景
|
||||
|
||||
`App.tsx` 曾同时承担认证启动、更新状态刷新、导航、未认证态和已认证态 UI。
|
||||
|
||||
### 决策
|
||||
|
||||
拆出:
|
||||
|
||||
- `useAppBootstrap.ts`
|
||||
- `AuthenticatedAppShell.tsx`
|
||||
- `UnauthenticatedApp.tsx`
|
||||
|
||||
### 影响
|
||||
|
||||
- 入口组件回归组装层
|
||||
- 认证与更新状态更容易追踪
|
||||
- 后续页面和对话框拆分更容易
|
||||
|
||||
## 9. Cleaner 页面拆分
|
||||
|
||||
### 背景
|
||||
|
||||
`CleanerPage` 曾是典型的大页面,包含筛选区、工具栏、表格、执行区和多个弹窗。
|
||||
|
||||
### 决策
|
||||
|
||||
拆分出:
|
||||
|
||||
- `CleanerSidebar.tsx`
|
||||
- `CleanerToolbar.tsx`
|
||||
- `CleanerResultsTable.tsx`
|
||||
- `CleanerExecutionBar.tsx`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
CleanerPage[CleanerPage]
|
||||
Sidebar[Sidebar]
|
||||
Toolbar[Toolbar]
|
||||
Table[ResultsTable]
|
||||
Bar[ExecutionBar]
|
||||
|
||||
CleanerPage --> Sidebar
|
||||
CleanerPage --> Toolbar
|
||||
CleanerPage --> Table
|
||||
CleanerPage --> Bar
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- 页面阅读成本下降
|
||||
- UI 结构更清楚
|
||||
- 后续继续拆 `useCleaner` 更安全
|
||||
|
||||
## 10. UpdateDialog 状态收敛
|
||||
|
||||
### 背景
|
||||
|
||||
更新弹窗里选中版本和 changelog 请求状态容易产生旧请求覆盖新状态的问题。
|
||||
|
||||
### 决策
|
||||
|
||||
新增:
|
||||
|
||||
- `useUpdateDialogState.ts`
|
||||
|
||||
并把 changelog 请求保护和选中版本逻辑集中到 hook 中。
|
||||
|
||||
### 影响
|
||||
|
||||
- 异步状态更稳定
|
||||
- 版本切换逻辑更容易测试
|
||||
|
||||
## 11. Renderer 重型弹窗按需加载
|
||||
|
||||
### 背景
|
||||
|
||||
多个重型弹窗并不是首屏关键路径,但此前会参与静态导入。
|
||||
|
||||
### 决策
|
||||
|
||||
对这些组件使用 `React.lazy + Suspense`:
|
||||
|
||||
- `UpdateDialog`
|
||||
- `MaterialTypeManagementDialog`
|
||||
- `ExecutionReportDialog`
|
||||
- `ReportViewerDialog`
|
||||
|
||||
### 结果
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Static[静态导入]
|
||||
Lazy[按需加载]
|
||||
|
||||
Static --> Lazy
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
- renderer 初始负担下降
|
||||
- 常用主流程更轻
|
||||
|
||||
## 12. 后续记录方式
|
||||
|
||||
后续新增重大决策时,建议按这个格式补充:
|
||||
|
||||
1. 背景
|
||||
2. 决策
|
||||
3. 结果图
|
||||
4. 影响
|
||||
5. 相关文件
|
||||
|
||||
建议记录的场景包括:
|
||||
|
||||
- 新增跨层通信机制
|
||||
- 重构核心模块边界
|
||||
- 修改更新、认证、校验、清理主链路
|
||||
- 引入新的状态管理或测试策略
|
||||
288
docs/developer/architecture/file-map.md
Normal file
288
docs/developer/architecture/file-map.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 文件地图
|
||||
|
||||
本文档提供一个“高频核心文件地图”,帮助开发者快速定位项目里最值得先看的文件,而不是在目录树里盲找。
|
||||
|
||||
## 1. 快速定位图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Root[项目入口]
|
||||
Main[src/main/index.ts]
|
||||
Preload[src/preload/index.ts]
|
||||
Renderer[src/renderer/src/App.tsx]
|
||||
Pages[src/renderer/src/pages]
|
||||
IPC[src/main/ipc]
|
||||
Services[src/main/services]
|
||||
|
||||
Root --> Main
|
||||
Root --> Preload
|
||||
Root --> Renderer
|
||||
Renderer --> Pages
|
||||
Main --> IPC
|
||||
Main --> Services
|
||||
```
|
||||
|
||||
## 2. 最先阅读的文件
|
||||
|
||||
如果你刚进入仓库,建议优先看这些文件:
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ----------------------------------------------------------- | -------------------------- |
|
||||
| `src/main/index.ts` | 主进程启动入口 |
|
||||
| `src/main/bootstrap/runtime.ts` | 运行时初始化与 IPC 注册 |
|
||||
| `src/main/ipc/index.ts` | 所有 IPC handler 注册中心 |
|
||||
| `src/preload/index.ts` | preload 入口 |
|
||||
| `src/preload/api/index.ts` | renderer 可用 API 聚合入口 |
|
||||
| `src/renderer/src/App.tsx` | React 应用入口 |
|
||||
| `src/renderer/src/components/app/AuthenticatedAppShell.tsx` | 已认证态主壳层 |
|
||||
|
||||
## 3. Main 进程文件地图
|
||||
|
||||
### 3.1 启动与窗口
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Index[index.ts]
|
||||
Runtime[bootstrap/runtime.ts]
|
||||
Guards[bootstrap/process-guards.ts]
|
||||
Window[bootstrap/main-window.ts]
|
||||
|
||||
Index --> Runtime
|
||||
Index --> Guards
|
||||
Index --> Window
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/main/index.ts`
|
||||
- `src/main/bootstrap/runtime.ts`
|
||||
- `src/main/bootstrap/process-guards.ts`
|
||||
- `src/main/bootstrap/main-window.ts`
|
||||
|
||||
### 3.2 IPC 注册层
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
IPCIndex[ipc/index.ts]
|
||||
Auth[auth-handler.ts]
|
||||
Cleaner[cleaner-handler.ts]
|
||||
Extractor[extractor-handler.ts]
|
||||
Validation[validation-handler.ts]
|
||||
Update[update-handler.ts]
|
||||
Settings[settings-handler.ts]
|
||||
Report[report-handler.ts]
|
||||
|
||||
IPCIndex --> Auth
|
||||
IPCIndex --> Cleaner
|
||||
IPCIndex --> Extractor
|
||||
IPCIndex --> Validation
|
||||
IPCIndex --> Update
|
||||
IPCIndex --> Settings
|
||||
IPCIndex --> Report
|
||||
```
|
||||
|
||||
建议优先关注:
|
||||
|
||||
- `src/main/ipc/index.ts`
|
||||
- `src/main/ipc/auth-handler.ts`
|
||||
- `src/main/ipc/cleaner-handler.ts`
|
||||
- `src/main/ipc/extractor-handler.ts`
|
||||
- `src/main/ipc/validation-handler.ts`
|
||||
- `src/main/ipc/update-handler.ts`
|
||||
|
||||
### 3.3 核心服务层
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Services[services/]
|
||||
Auth[auth/]
|
||||
Cleaner[cleaner/]
|
||||
Validation[validation/]
|
||||
Update[update/]
|
||||
Config[config/]
|
||||
ERP[erp/]
|
||||
Report[report/]
|
||||
|
||||
Services --> Auth
|
||||
Services --> Cleaner
|
||||
Services --> Validation
|
||||
Services --> Update
|
||||
Services --> Config
|
||||
Services --> ERP
|
||||
Services --> Report
|
||||
```
|
||||
|
||||
高频核心文件:
|
||||
|
||||
- `src/main/services/auth/auth-application-service.ts`
|
||||
- `src/main/services/cleaner/cleaner-application-service.ts`
|
||||
- `src/main/services/validation/validation-application-service.ts`
|
||||
- `src/main/services/validation/shared-production-ids-store.ts`
|
||||
- `src/main/services/update/update-service.ts`
|
||||
- `src/main/services/update/update-catalog-service.ts`
|
||||
- `src/main/services/config/config-manager.ts`
|
||||
|
||||
## 4. Preload 文件地图
|
||||
|
||||
preload 现在已经按领域组织。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Preload[index.ts]
|
||||
API[api/index.ts]
|
||||
IPC[lib/ipc.ts]
|
||||
Auth[api/auth.ts]
|
||||
Cleaner[api/cleaner.ts]
|
||||
Extractor[api/extractor.ts]
|
||||
Validation[api/validation.ts]
|
||||
Materials[api/materials.ts]
|
||||
Process[api/process.ts]
|
||||
Resolver[api/resolver.ts]
|
||||
|
||||
Preload --> API
|
||||
API --> Auth
|
||||
API --> Cleaner
|
||||
API --> Extractor
|
||||
API --> Validation
|
||||
API --> Materials
|
||||
API --> Process
|
||||
API --> Resolver
|
||||
API --> IPC
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/preload/index.ts`
|
||||
- `src/preload/index.d.ts`
|
||||
- `src/preload/api/index.ts`
|
||||
- `src/preload/lib/ipc.ts`
|
||||
|
||||
## 5. Renderer 文件地图
|
||||
|
||||
### 5.1 应用壳层
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/hooks/useAppBootstrap.ts`
|
||||
- `src/renderer/src/components/app/AuthenticatedAppShell.tsx`
|
||||
- `src/renderer/src/components/app/UnauthenticatedApp.tsx`
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx]
|
||||
Bootstrap[useAppBootstrap.ts]
|
||||
Authenticated[AuthenticatedAppShell.tsx]
|
||||
Unauthenticated[UnauthenticatedApp.tsx]
|
||||
|
||||
App --> Bootstrap
|
||||
App --> Authenticated
|
||||
App --> Unauthenticated
|
||||
```
|
||||
|
||||
### 5.2 页面入口
|
||||
|
||||
关键页面:
|
||||
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/pages/SettingsPage.tsx`
|
||||
|
||||
### 5.3 Cleaner 相关
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Page[CleanerPage.tsx]
|
||||
Hook[useCleaner.ts]
|
||||
Sidebar[CleanerSidebar.tsx]
|
||||
Toolbar[CleanerToolbar.tsx]
|
||||
Table[CleanerResultsTable.tsx]
|
||||
Bar[CleanerExecutionBar.tsx]
|
||||
Helpers[hooks/cleaner/helpers.ts]
|
||||
API[hooks/cleaner/api.ts]
|
||||
|
||||
Page --> Hook
|
||||
Page --> Sidebar
|
||||
Page --> Toolbar
|
||||
Page --> Table
|
||||
Page --> Bar
|
||||
Hook --> Helpers
|
||||
Hook --> API
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
- `src/renderer/src/hooks/cleaner/api.ts`
|
||||
- `src/renderer/src/hooks/cleaner/helpers.ts`
|
||||
|
||||
### 5.4 Extractor 相关
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
- `src/renderer/src/hooks/useExtractor.ts`
|
||||
- `src/renderer/src/hooks/useSharedProductionIds.ts`
|
||||
- `src/renderer/src/hooks/usePersistentTextState.ts`
|
||||
- `src/renderer/src/components/OrderNumberInput.tsx`
|
||||
|
||||
### 5.5 更新相关
|
||||
|
||||
关键文件:
|
||||
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
- `src/renderer/src/hooks/useUpdateDialogState.ts`
|
||||
- `src/renderer/src/hooks/useAppBootstrap.ts`
|
||||
|
||||
## 6. 测试文件地图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Tests[tests/]
|
||||
Unit[unit/]
|
||||
Integration[integration/]
|
||||
E2E[e2e/]
|
||||
Manual[manual/]
|
||||
|
||||
Tests --> Unit
|
||||
Tests --> Integration
|
||||
Tests --> E2E
|
||||
Tests --> Manual
|
||||
```
|
||||
|
||||
和当前重构关系较强的测试包括:
|
||||
|
||||
- `tests/unit/preload-surface.test.ts`
|
||||
- `tests/unit/auth-handler.test.ts`
|
||||
- `tests/unit/cleaner-handler.test.ts`
|
||||
- `tests/unit/bootstrap-runtime.test.ts`
|
||||
- `tests/unit/update-catalog-service.test.ts`
|
||||
- `tests/unit/use-shared-production-ids.test.ts`
|
||||
- `tests/unit/use-update-dialog-state.test.ts`
|
||||
|
||||
## 7. 阅读建议
|
||||
|
||||
不同任务建议优先看不同文件:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Task[开发任务]
|
||||
Startup[启动问题]
|
||||
Cleaner[Cleaner 功能]
|
||||
Extractor[Extractor 功能]
|
||||
Update[更新功能]
|
||||
Auth[认证功能]
|
||||
|
||||
Task --> Startup
|
||||
Task --> Cleaner
|
||||
Task --> Extractor
|
||||
Task --> Update
|
||||
Task --> Auth
|
||||
|
||||
Startup --> A[src/main/index.ts / bootstrap]
|
||||
Cleaner --> B[CleanerPage / useCleaner / cleaner-handler / cleaner service]
|
||||
Extractor --> C[ExtractorPage / useExtractor / extractor-handler]
|
||||
Update --> D[UpdateDialog / useAppBootstrap / update service]
|
||||
Auth --> E[useAppBootstrap / auth-handler / auth service]
|
||||
```
|
||||
273
docs/developer/architecture/overview.md
Normal file
273
docs/developer/architecture/overview.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 项目总览
|
||||
|
||||
本文档用于帮助开发者快速建立对项目的整体认知,包括系统目标、核心能力、目录结构和主要运行路径。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
`ERPAuto` 是一个基于 Electron + React + TypeScript 构建的内部桌面工具,主要用于辅助 ERP 相关的数据提取、校验、清理、配置和更新管理。
|
||||
|
||||
当前项目的核心业务能力主要包括:
|
||||
|
||||
- 批量提取 ERP 数据并导入本地数据库
|
||||
- 基于数据库和共享订单号进行物料校验
|
||||
- 执行 ERP 物料清理与结果导出
|
||||
- 用户认证、管理员代切用户
|
||||
- 桌面端应用更新
|
||||
- 本地配置、日志、报告与文件处理
|
||||
|
||||
项目可以先粗略理解成下面这张图:
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((ERPAuto))
|
||||
数据提取
|
||||
订单号输入
|
||||
批量导出
|
||||
导入数据库
|
||||
物料校验与清理
|
||||
共享 Production IDs
|
||||
校验结果
|
||||
删除计划
|
||||
ERP 执行
|
||||
执行报告
|
||||
用户与权限
|
||||
silent login
|
||||
管理员代切用户
|
||||
系统能力
|
||||
配置
|
||||
日志
|
||||
更新
|
||||
报告
|
||||
```
|
||||
|
||||
## 2. 技术栈概览
|
||||
|
||||
- 桌面容器:Electron
|
||||
- 前端渲染:React 19
|
||||
- 构建工具:electron-vite / Vite
|
||||
- 语言:TypeScript
|
||||
- 样式:Tailwind CSS
|
||||
- 测试:Vitest / Playwright
|
||||
- 数据库:MySQL / SQL Server
|
||||
- 自动化:Playwright
|
||||
|
||||
## 3. 顶层结构
|
||||
|
||||
项目核心代码主要分布在这几个目录:
|
||||
|
||||
- `src/main/`
|
||||
Electron 主进程,负责窗口、IPC、服务编排、配置、日志、更新、ERP 相关主流程。
|
||||
- `src/preload/`
|
||||
preload bridge,向 renderer 暴露按领域组织的安全 API facade。
|
||||
- `src/renderer/src/`
|
||||
React 渲染层,负责页面、组件、hooks、状态管理和交互流程。
|
||||
- `tests/`
|
||||
单元测试、集成测试、e2e 和手工测试。
|
||||
- `docs/`
|
||||
项目说明、执行计划、架构文档和后续维护文档。
|
||||
|
||||
也可以从目录责任关系上理解:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Root[项目根目录]
|
||||
Main[src/main]
|
||||
Preload[src/preload]
|
||||
Renderer[src/renderer/src]
|
||||
Tests[tests]
|
||||
Docs[docs]
|
||||
|
||||
Root --> Main
|
||||
Root --> Preload
|
||||
Root --> Renderer
|
||||
Root --> Tests
|
||||
Root --> Docs
|
||||
|
||||
Main --> MainDesc[主进程与服务执行]
|
||||
Preload --> PreloadDesc[桥接 API 与 IPC 封装]
|
||||
Renderer --> RendererDesc[页面 组件 Hooks 状态]
|
||||
Tests --> TestsDesc[单测 集成 E2E]
|
||||
Docs --> DocsDesc[说明 计划 开发文档]
|
||||
```
|
||||
|
||||
## 4. 运行时分层
|
||||
|
||||
项目运行时可简单理解为三层:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Renderer[Renderer / React]
|
||||
Preload[Preload API Facade]
|
||||
Main[Main Process Services]
|
||||
|
||||
Renderer --> Preload
|
||||
Preload --> Main
|
||||
```
|
||||
|
||||
职责划分如下:
|
||||
|
||||
- `renderer`
|
||||
负责页面展示、用户交互、状态管理和流程触发。
|
||||
- `preload`
|
||||
负责把 IPC 能力整理成前端可用的 API facade。
|
||||
- `main`
|
||||
负责真正的业务执行、数据库访问、ERP 自动化、文件和更新处理。
|
||||
|
||||
从用户操作到系统执行的主路径如下:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[用户操作]
|
||||
Page[React 页面]
|
||||
Hook[页面 Hook]
|
||||
Preload[Preload API]
|
||||
Handler[IPC Handler]
|
||||
Service[Main Service]
|
||||
External[数据库 / ERP / 文件 / 更新源]
|
||||
|
||||
User --> Page
|
||||
Page --> Hook
|
||||
Hook --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> Service
|
||||
Service --> External
|
||||
```
|
||||
|
||||
## 5. 当前核心页面
|
||||
|
||||
当前渲染层主要有三个业务页面:
|
||||
|
||||
- `ExtractorPage`
|
||||
负责订单号输入、批量提取和提取日志展示。
|
||||
- `CleanerPage`
|
||||
负责物料校验、负责人分配、删除计划保存、ERP 清理执行与结果查看。
|
||||
- `SettingsPage`
|
||||
负责系统设置与配置维护。
|
||||
|
||||
应用入口在:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/components/app/AuthenticatedAppShell.tsx`
|
||||
- `src/renderer/src/components/app/UnauthenticatedApp.tsx`
|
||||
|
||||
页面级结构可以简化为:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx]
|
||||
Unauth[UnauthenticatedApp]
|
||||
Shell[AuthenticatedAppShell]
|
||||
Extractor[ExtractorPage]
|
||||
Cleaner[CleanerPage]
|
||||
Settings[SettingsPage]
|
||||
|
||||
App --> Unauth
|
||||
App --> Shell
|
||||
Shell --> Extractor
|
||||
Shell --> Cleaner
|
||||
Shell --> Settings
|
||||
```
|
||||
|
||||
## 6. 当前主进程结构
|
||||
|
||||
主进程侧目前已经按职责拆成几类目录:
|
||||
|
||||
- `bootstrap/`
|
||||
应用启动、窗口创建、进程守卫、运行时初始化。
|
||||
- `ipc/`
|
||||
IPC handler 注册与调用入口。
|
||||
- `services/`
|
||||
具体业务服务实现,按领域组织。
|
||||
- `types/`
|
||||
主进程与 preload/renderer 共享的类型定义。
|
||||
|
||||
`services/` 当前主要领域包括:
|
||||
|
||||
- `auth`
|
||||
- `cleaner`
|
||||
- `config`
|
||||
- `database`
|
||||
- `erp`
|
||||
- `excel`
|
||||
- `logger`
|
||||
- `report`
|
||||
- `rustfs`
|
||||
- `update`
|
||||
- `user`
|
||||
- `validation`
|
||||
|
||||
主进程结构关系如下:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MainIndex[index.ts]
|
||||
Bootstrap[bootstrap/]
|
||||
IPC[ipc/]
|
||||
Services[services/]
|
||||
Types[types/]
|
||||
|
||||
MainIndex --> Bootstrap
|
||||
MainIndex --> IPC
|
||||
IPC --> Services
|
||||
Services --> Types
|
||||
IPC --> Types
|
||||
```
|
||||
|
||||
## 7. 关键业务链路
|
||||
|
||||
项目最重要的几条业务链路可以概括为:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[登录与认证]
|
||||
B[订单号提取]
|
||||
C[共享 Production IDs]
|
||||
D[物料校验]
|
||||
E[删除计划保存]
|
||||
F[ERP 清理执行]
|
||||
G[报告与导出]
|
||||
H[应用更新]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> G
|
||||
A --> H
|
||||
```
|
||||
|
||||
## 8. 目录阅读建议
|
||||
|
||||
如果你是第一次进入代码库,建议按下面顺序读:
|
||||
|
||||
1. `src/main/index.ts`
|
||||
2. `src/main/bootstrap/`
|
||||
3. `src/preload/index.ts`
|
||||
4. `src/renderer/src/App.tsx`
|
||||
5. `src/renderer/src/pages/`
|
||||
6. 对应业务模块的 `src/main/ipc/` 和 `src/main/services/`
|
||||
|
||||
阅读路径也可以理解成:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[src/main/index.ts]
|
||||
B[src/main/bootstrap]
|
||||
C[src/preload/index.ts]
|
||||
D[src/renderer/src/App.tsx]
|
||||
E[src/renderer/src/pages]
|
||||
F[src/main/ipc]
|
||||
G[src/main/services]
|
||||
|
||||
A --> B --> C --> D --> E --> F --> G
|
||||
```
|
||||
|
||||
## 9. 相关文档
|
||||
|
||||
继续阅读建议:
|
||||
|
||||
- `runtime-architecture.md`
|
||||
了解 `main / preload / renderer` 的分层与调用关系。
|
||||
- 后续 `modules/` 目录中的模块文档
|
||||
深入理解各业务模块。
|
||||
381
docs/developer/architecture/runtime-architecture.md
Normal file
381
docs/developer/architecture/runtime-architecture.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# 运行时架构
|
||||
|
||||
本文档说明项目在运行时的主要分层、进程边界和核心调用路径,帮助开发者理解请求是如何从 React 页面一路进入主进程服务的。
|
||||
|
||||
## 1. 运行时结构
|
||||
|
||||
项目运行时由三部分组成:
|
||||
|
||||
- Electron `main` 进程
|
||||
- Electron `preload`
|
||||
- Electron `renderer` 渲染进程
|
||||
|
||||
它们之间的关系如下:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Renderer[Renderer\nReact Pages / Hooks / Components]
|
||||
Preload[Preload\nDomain APIs + IPC Wrapper]
|
||||
IPC[IPC Handlers]
|
||||
Services[Main Services]
|
||||
External[DB / ERP / Files / Update Source]
|
||||
|
||||
Renderer --> Preload
|
||||
Preload --> IPC
|
||||
IPC --> Services
|
||||
Services --> External
|
||||
```
|
||||
|
||||
如果从 Electron 的进程边界来理解,可以进一步看成:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph RendererProcess[Renderer Process]
|
||||
UI[Pages / Components]
|
||||
Hooks[Hooks / Stores]
|
||||
end
|
||||
|
||||
subgraph PreloadLayer[Preload Layer]
|
||||
Facade[Domain APIs]
|
||||
IPCClient[ipc wrapper]
|
||||
end
|
||||
|
||||
subgraph MainProcess[Main Process]
|
||||
Bootstrap[Bootstrap]
|
||||
Handlers[IPC Handlers]
|
||||
DomainServices[Domain Services]
|
||||
end
|
||||
|
||||
UI --> Hooks
|
||||
Hooks --> Facade
|
||||
Facade --> IPCClient
|
||||
IPCClient --> Handlers
|
||||
Handlers --> DomainServices
|
||||
Bootstrap --> Handlers
|
||||
```
|
||||
|
||||
## 2. Main 进程
|
||||
|
||||
主进程是应用的执行中心,负责:
|
||||
|
||||
- 应用启动和窗口创建
|
||||
- 进程守卫和异常处理
|
||||
- IPC 注册
|
||||
- 配置、日志、数据库、文件、更新等系统能力
|
||||
- ERP 自动化与业务流程执行
|
||||
|
||||
关键入口文件:
|
||||
|
||||
- `src/main/index.ts`
|
||||
- `src/main/bootstrap/main-window.ts`
|
||||
- `src/main/bootstrap/runtime.ts`
|
||||
- `src/main/bootstrap/process-guards.ts`
|
||||
|
||||
### 2.1 Bootstrap 层
|
||||
|
||||
`bootstrap/` 负责把主进程入口收敛成薄启动文件。
|
||||
|
||||
当前主要模块:
|
||||
|
||||
- `main-window.ts`
|
||||
创建 `BrowserWindow`,配置窗口行为与生命周期。
|
||||
- `runtime.ts`
|
||||
负责运行时初始化、服务初始化和 IPC 注册。
|
||||
- `process-guards.ts`
|
||||
负责全局异常、未处理拒绝和进程级兜底。
|
||||
|
||||
bootstrap 层内部关系如下:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Index[index.ts]
|
||||
Guards[process-guards.ts]
|
||||
Runtime[runtime.ts]
|
||||
Window[main-window.ts]
|
||||
AppReady[app.whenReady]
|
||||
|
||||
Index --> Guards
|
||||
Index --> AppReady
|
||||
AppReady --> Runtime
|
||||
AppReady --> Window
|
||||
```
|
||||
|
||||
### 2.2 IPC 层
|
||||
|
||||
`src/main/ipc/` 中的 handler 负责注册 IPC 通道,并把请求转发到应用服务或领域服务。
|
||||
|
||||
当前主要 handler 包括:
|
||||
|
||||
- `auth-handler.ts`
|
||||
- `cleaner-handler.ts`
|
||||
- `extractor-handler.ts`
|
||||
- `validation-handler.ts`
|
||||
- `update-handler.ts`
|
||||
- `settings-handler.ts`
|
||||
- `report-handler.ts`
|
||||
|
||||
当前设计目标是:handler 尽量保持“薄壳”,只做参数接收、错误包装和 service 转发。
|
||||
|
||||
这层的目标结构是:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Request[IPC Request]
|
||||
Handler[Handler]
|
||||
AppService[Application Service]
|
||||
DomainService[Domain Service / Repository]
|
||||
Response[IpcResult Response]
|
||||
|
||||
Request --> Handler
|
||||
Handler --> AppService
|
||||
AppService --> DomainService
|
||||
DomainService --> AppService
|
||||
AppService --> Handler
|
||||
Handler --> Response
|
||||
```
|
||||
|
||||
### 2.3 Services 层
|
||||
|
||||
`src/main/services/` 是主进程的核心实现层。
|
||||
|
||||
主要领域:
|
||||
|
||||
- `auth/`
|
||||
用户登录、silent login、用户切换。
|
||||
- `cleaner/`
|
||||
ERP 清理执行编排。
|
||||
- `update/`
|
||||
更新目录拉取、状态广播、下载和安装。
|
||||
- `validation/`
|
||||
物料校验、共享订单号、数据库查询封装。
|
||||
- `config/`
|
||||
配置加载与保存。
|
||||
- `logger/`
|
||||
日志服务。
|
||||
- `report/`
|
||||
报告查询与下载。
|
||||
|
||||
当前主进程服务从领域上大致可视化为:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Services[services/]
|
||||
Auth[auth]
|
||||
Validation[validation]
|
||||
Cleaner[cleaner]
|
||||
Update[update]
|
||||
Config[config]
|
||||
ERP[erp]
|
||||
Report[report]
|
||||
Logger[logger]
|
||||
|
||||
Services --> Auth
|
||||
Services --> Validation
|
||||
Services --> Cleaner
|
||||
Services --> Update
|
||||
Services --> Config
|
||||
Services --> ERP
|
||||
Services --> Report
|
||||
Services --> Logger
|
||||
```
|
||||
|
||||
## 3. Preload 层
|
||||
|
||||
preload 是 renderer 与 main 之间的桥接层,负责把 IPC 能力封装成按领域组织的 API。
|
||||
|
||||
关键入口文件:
|
||||
|
||||
- `src/preload/index.ts`
|
||||
- `src/preload/index.d.ts`
|
||||
|
||||
当前 preload 内部结构:
|
||||
|
||||
- `src/preload/api/`
|
||||
按领域拆分的 API facade
|
||||
- `src/preload/lib/ipc.ts`
|
||||
统一的 IPC 调用封装
|
||||
|
||||
当前已经拆分出的 API 模块包括:
|
||||
|
||||
- `auth.ts`
|
||||
- `cleaner.ts`
|
||||
- `database.ts`
|
||||
- `extractor.ts`
|
||||
- `file.ts`
|
||||
- `logger.ts`
|
||||
- `materials.ts`
|
||||
- `process.ts`
|
||||
- `resolver.ts`
|
||||
- `validation.ts`
|
||||
|
||||
preload 组织方式如下:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Preload[index.ts]
|
||||
API[api/index.ts]
|
||||
IPC[lib/ipc.ts]
|
||||
Auth[api/auth.ts]
|
||||
Cleaner[api/cleaner.ts]
|
||||
Extractor[api/extractor.ts]
|
||||
Validation[api/validation.ts]
|
||||
UpdateLike[api/process.ts / logger.ts / file.ts]
|
||||
|
||||
Preload --> API
|
||||
API --> Auth
|
||||
API --> Cleaner
|
||||
API --> Extractor
|
||||
API --> Validation
|
||||
API --> UpdateLike
|
||||
API --> IPC
|
||||
```
|
||||
|
||||
preload 的职责不是承载业务,而是:
|
||||
|
||||
- 隐藏 IPC 细节
|
||||
- 为 renderer 提供稳定的调用接口
|
||||
- 维持类型边界
|
||||
|
||||
## 4. Renderer 层
|
||||
|
||||
renderer 是 React 应用本体,负责页面展示、交互和前端状态管理。
|
||||
|
||||
关键入口文件:
|
||||
|
||||
- `src/renderer/src/main.tsx`
|
||||
- `src/renderer/src/App.tsx`
|
||||
|
||||
当前主要目录:
|
||||
|
||||
- `pages/`
|
||||
页面级容器,例如 `ExtractorPage`、`CleanerPage`、`SettingsPage`
|
||||
- `components/`
|
||||
通用 UI、业务组件、对话框
|
||||
- `hooks/`
|
||||
页面逻辑、状态收敛、bridge 调用编排
|
||||
- `stores/`
|
||||
状态存储与消息提示
|
||||
- `lib/`
|
||||
前端侧辅助工具和持久化 helper
|
||||
|
||||
renderer 层当前结构可以简化为:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx]
|
||||
Pages[pages/]
|
||||
Components[components/]
|
||||
Hooks[hooks/]
|
||||
Stores[stores/]
|
||||
Lib[lib/]
|
||||
|
||||
App --> Pages
|
||||
Pages --> Components
|
||||
Pages --> Hooks
|
||||
Hooks --> Stores
|
||||
Hooks --> Lib
|
||||
```
|
||||
|
||||
## 5. 一次典型调用链
|
||||
|
||||
以 Cleaner 校验流程为例,一次从页面到主进程的调用链大致如下:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as CleanerPage / useCleaner
|
||||
participant Preload as preload.validation
|
||||
participant IPC as validation-handler
|
||||
participant AppSvc as validation-application-service
|
||||
participant DB as database / repository
|
||||
|
||||
UI->>Preload: validate(request)
|
||||
Preload->>IPC: ipcRenderer.invoke(...)
|
||||
IPC->>AppSvc: service.validate(...)
|
||||
AppSvc->>DB: query / enrich / aggregate
|
||||
DB-->>AppSvc: validation results
|
||||
AppSvc-->>IPC: payload
|
||||
IPC-->>Preload: IpcResult
|
||||
Preload-->>UI: normalized response
|
||||
```
|
||||
|
||||
## 6. 页面与模块关系
|
||||
|
||||
当前主要页面与主进程模块的对应关系大致如下:
|
||||
|
||||
- `ExtractorPage`
|
||||
对应 `extractor`、`validation`
|
||||
- `CleanerPage`
|
||||
对应 `validation`、`cleaner`、`materials`、`report`
|
||||
- `SettingsPage`
|
||||
对应 `settings`、`config`
|
||||
- `UpdateDialog`
|
||||
对应 `update`
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
ExtractorPage --> ExtractorSvc[extractor / validation]
|
||||
CleanerPage --> CleanerSvc[validation / cleaner / report]
|
||||
SettingsPage --> SettingsSvc[settings / config]
|
||||
UpdateDialog --> UpdateSvc[update]
|
||||
```
|
||||
|
||||
## 7. 事件与状态流
|
||||
|
||||
项目里常见的状态流主要有三类:
|
||||
|
||||
- 页面内局部状态
|
||||
例如表单输入、当前选中项、弹窗开关。
|
||||
- preload bridge 调用结果
|
||||
例如查询结果、校验结果、更新目录。
|
||||
- 主进程主动推送事件
|
||||
例如 cleaner 执行进度、update 状态变化。
|
||||
|
||||
当前典型事件订阅点包括:
|
||||
|
||||
- `window.electron.cleaner.onProgress(...)`
|
||||
- `window.electron.update.onStatusChanged(...)`
|
||||
- `window.electron.extractor.onProgress(...)`
|
||||
- `window.electron.extractor.onLog(...)`
|
||||
|
||||
事件流可以概括成:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as Main Service
|
||||
participant IPC as IPC / Preload
|
||||
participant Hook as Renderer Hook
|
||||
participant UI as React UI
|
||||
|
||||
Main->>IPC: push progress/status
|
||||
IPC->>Hook: onProgress / onStatusChanged
|
||||
Hook->>UI: update state
|
||||
UI->>UI: rerender
|
||||
```
|
||||
|
||||
## 8. 当前架构特点
|
||||
|
||||
当前项目运行时架构有几个比较明显的特点:
|
||||
|
||||
- 主进程侧已经完成一轮职责收敛,bootstrap、handler、service 边界更清晰
|
||||
- preload 已从大文件重组为按领域组织的 facade
|
||||
- renderer 正在从“大页面 + 大 hook”逐步收敛为更小的页面边界
|
||||
- 更新模块、validation 模块、Cleaner 页面都已经完成阶段性重构
|
||||
|
||||
## 9. 开发建议
|
||||
|
||||
在这个运行时架构下,建议按下面原则进行开发:
|
||||
|
||||
- 新业务优先落在 main service,而不是直接堆到 handler
|
||||
- renderer 不直接感知 IPC channel,统一走 preload facade
|
||||
- 页面容器尽量只负责组装,复杂流程下沉到 hook
|
||||
- 共享类型优先放在稳定的 `types/` 目录
|
||||
- 重要状态流尽量画出调用链或补单测
|
||||
|
||||
## 10. 后续阅读
|
||||
|
||||
如果你已经理解了运行时分层,下一步建议继续读:
|
||||
|
||||
- 后续的 `modules/cleaner.md`
|
||||
- 后续的 `modules/extractor.md`
|
||||
- 后续的 `modules/validation.md`
|
||||
- 后续的 `modules/update.md`
|
||||
114
docs/developer/guides/README.md
Normal file
114
docs/developer/guides/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 开发指南索引
|
||||
|
||||
本目录收录“开发者实际做事时会用到的指南文档”。
|
||||
|
||||
如果 `architecture/` 负责解释系统是什么,`modules/` 负责解释模块怎么工作,那么 `guides/` 负责回答:
|
||||
|
||||
- 本地怎么启动
|
||||
- 出问题怎么调试
|
||||
- 怎么新增或修改 IPC
|
||||
- 怎么开发 renderer
|
||||
- 怎么构建和发布
|
||||
|
||||
## 阅读建议
|
||||
|
||||
如果你是第一次参与这个项目的开发,推荐按下面顺序阅读:
|
||||
|
||||
1. `local-development.md`
|
||||
2. `debugging.md`
|
||||
3. `renderer-development.md`
|
||||
4. `ipc-development.md`
|
||||
5. `release-process.md`
|
||||
|
||||
## 指南地图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Guides[guides/]
|
||||
Local[local-development]
|
||||
Debug[debugging]
|
||||
Renderer[renderer-development]
|
||||
IPC[ipc-development]
|
||||
Release[release-process]
|
||||
|
||||
Guides --> Local
|
||||
Guides --> Debug
|
||||
Guides --> Renderer
|
||||
Guides --> IPC
|
||||
Guides --> Release
|
||||
```
|
||||
|
||||
## 按任务查阅
|
||||
|
||||
你可以按当前任务来选文档:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Task[当前任务]
|
||||
Start[启动项目]
|
||||
Fix[排查问题]
|
||||
UI[修改前端]
|
||||
Bridge[修改 IPC]
|
||||
Ship[构建 / 发布]
|
||||
|
||||
Task --> Start
|
||||
Task --> Fix
|
||||
Task --> UI
|
||||
Task --> Bridge
|
||||
Task --> Ship
|
||||
|
||||
Start --> LocalDoc[local-development.md]
|
||||
Fix --> DebugDoc[debugging.md]
|
||||
UI --> RendererDoc[renderer-development.md]
|
||||
Bridge --> IPCDoc[ipc-development.md]
|
||||
Ship --> ReleaseDoc[release-process.md]
|
||||
```
|
||||
|
||||
## 当前文档一览
|
||||
|
||||
| 文档 | 主要内容 |
|
||||
| --- | --- |
|
||||
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
|
||||
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
|
||||
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |
|
||||
| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 |
|
||||
| `release-process.md` | 构建、发布、更新产物与上传流程 |
|
||||
|
||||
## 与其他文档目录的关系
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Architecture[architecture/]
|
||||
Modules[modules/]
|
||||
Guides[guides/]
|
||||
|
||||
Architecture --> Modules
|
||||
Modules --> Guides
|
||||
```
|
||||
|
||||
理解方式:
|
||||
|
||||
- 先看 `architecture/`
|
||||
建立系统级认知
|
||||
- 再看 `modules/`
|
||||
理解业务边界
|
||||
- 最后看 `guides/`
|
||||
落到具体开发动作
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 改代码前,先看对应模块文档,再看对应 guide
|
||||
- 如果是跨层改动,优先先看 `ipc-development.md`
|
||||
- 如果是页面交互问题,优先结合 `renderer-development.md` 与模块文档一起看
|
||||
- 如果是运行时问题,优先从 `debugging.md` 开始
|
||||
|
||||
## 后续可继续补充的指南
|
||||
|
||||
随着文档继续完善,后续可以考虑新增:
|
||||
|
||||
- `testing.md`
|
||||
- `database-development.md`
|
||||
- `erp-automation.md`
|
||||
- `config-management.md`
|
||||
|
||||
后续新增指南时,建议同步更新这份索引页,让它持续作为 `guides/` 的入口文档。
|
||||
189
docs/developer/guides/debugging.md
Normal file
189
docs/developer/guides/debugging.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 调试指南
|
||||
|
||||
本文档说明项目里最常见的调试入口、日志观察方式和问题定位路径。
|
||||
|
||||
## 1. 调试总览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Problem[出现问题]
|
||||
Area{问题在哪一层}
|
||||
Renderer[Renderer]
|
||||
Preload[Preload / IPC]
|
||||
Main[Main / Services]
|
||||
External[ERP / DB / Update]
|
||||
|
||||
Problem --> Area
|
||||
Area --> Renderer
|
||||
Area --> Preload
|
||||
Area --> Main
|
||||
Area --> External
|
||||
```
|
||||
|
||||
## 2. 常见调试入口
|
||||
|
||||
项目里当前有几个现成的调试入口:
|
||||
|
||||
```bash
|
||||
npm run debug:erp-login
|
||||
npm run debug:config-path
|
||||
npm run test:rustfs
|
||||
```
|
||||
|
||||
对应文件:
|
||||
|
||||
- `src/main/tools/erp-login-debug.ts`
|
||||
- `src/main/tools/config-path-debug.ts`
|
||||
- `src/main/tools/rustfs-test.ts`
|
||||
|
||||
## 3. 调试分层思路
|
||||
|
||||
### 3.1 Renderer 问题
|
||||
|
||||
适合从这里开始:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/pages/*`
|
||||
- `src/renderer/src/hooks/*`
|
||||
|
||||
常见现象:
|
||||
|
||||
- 页面不更新
|
||||
- 弹窗打不开
|
||||
- 表单状态异常
|
||||
- 请求重复触发
|
||||
|
||||
### 3.2 Preload / IPC 问题
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Renderer --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> Service
|
||||
```
|
||||
|
||||
定位顺序建议:
|
||||
|
||||
1. renderer 是否正确调用 `window.electron.xxx`
|
||||
2. preload facade 是否暴露了正确接口
|
||||
3. handler 是否已注册
|
||||
4. service 是否返回了预期结构
|
||||
|
||||
### 3.3 Main 进程问题
|
||||
|
||||
适合从这里开始:
|
||||
|
||||
- `src/main/index.ts`
|
||||
- `src/main/bootstrap/*`
|
||||
- `src/main/ipc/*`
|
||||
- `src/main/services/*`
|
||||
|
||||
常见现象:
|
||||
|
||||
- 启动失败
|
||||
- 数据库连接失败
|
||||
- ERP 登录失败
|
||||
- 更新检查失败
|
||||
|
||||
## 4. Cleaner 调试路径
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CleanerIssue[Cleaner 问题]
|
||||
UI[CleanerPage / useCleaner]
|
||||
Validation[validation-handler / service]
|
||||
Handler[cleaner-handler]
|
||||
AppSvc[cleaner-application-service]
|
||||
ERP[erp/cleaner.ts]
|
||||
Report[report / rustfs]
|
||||
|
||||
CleanerIssue --> UI
|
||||
UI --> Validation
|
||||
Validation --> Handler
|
||||
Handler --> AppSvc
|
||||
AppSvc --> ERP
|
||||
ERP --> Report
|
||||
```
|
||||
|
||||
## 5. Extractor 调试路径
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ExtractorIssue[Extractor 问题]
|
||||
Input[ExtractorPage / OrderNumberInput]
|
||||
Hook[useExtractor]
|
||||
Shared[useSharedProductionIds]
|
||||
Handler[extractor-handler]
|
||||
Service[erp/extractor.ts]
|
||||
|
||||
ExtractorIssue --> Input
|
||||
Input --> Hook
|
||||
Input --> Shared
|
||||
Hook --> Handler
|
||||
Handler --> Service
|
||||
```
|
||||
|
||||
## 6. Update 调试路径
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UpdateIssue[Update 问题]
|
||||
Hook[useAppBootstrap]
|
||||
Dialog[UpdateDialog / useUpdateDialogState]
|
||||
Handler[update-handler]
|
||||
Service[UpdateService]
|
||||
Catalog[UpdateCatalogService]
|
||||
Installer[UpdateInstaller]
|
||||
Storage[UpdateStorageClient]
|
||||
|
||||
UpdateIssue --> Hook
|
||||
UpdateIssue --> Dialog
|
||||
Hook --> Handler
|
||||
Dialog --> Handler
|
||||
Handler --> Service
|
||||
Service --> Catalog
|
||||
Service --> Installer
|
||||
Service --> Storage
|
||||
```
|
||||
|
||||
## 7. 认证调试路径
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as useAppBootstrap
|
||||
participant Auth as auth-handler
|
||||
participant AppSvc as auth-application-service
|
||||
participant Session as session-manager
|
||||
|
||||
App->>Auth: silentLogin / login / switchUser
|
||||
Auth->>AppSvc: application service
|
||||
AppSvc->>Session: user resolution
|
||||
Session-->>AppSvc: session result
|
||||
AppSvc-->>Auth: response
|
||||
Auth-->>App: auth state
|
||||
```
|
||||
|
||||
## 8. 日志观察建议
|
||||
|
||||
调试时优先关注:
|
||||
|
||||
- renderer 控制台输出
|
||||
- main 进程日志
|
||||
- 关键 application service 的 logger 输出
|
||||
- audit log(如果问题涉及登录、清理等操作记录)
|
||||
|
||||
## 9. 定位建议
|
||||
|
||||
出现问题时,建议优先回答这几个问题:
|
||||
|
||||
1. 问题发生在哪一层
|
||||
2. 是状态流问题还是外部依赖问题
|
||||
3. 是请求没发出、没到 handler,还是 service 失败
|
||||
4. 是同步返回问题,还是事件推送问题
|
||||
|
||||
## 10. 调试原则
|
||||
|
||||
- 先缩小层级,再深入代码
|
||||
- 先看入口与边界,再看实现细节
|
||||
- 能复现就尽量用最小路径复现
|
||||
- 复杂主链路优先画调用链再改代码
|
||||
159
docs/developer/guides/ipc-development.md
Normal file
159
docs/developer/guides/ipc-development.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# IPC 开发指南
|
||||
|
||||
本文档说明在项目中新增或修改一个 IPC 能力时,推荐的实现路径和注意事项。
|
||||
|
||||
## 1. IPC 开发原则
|
||||
|
||||
当前项目的 IPC 目标结构是:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Renderer[Renderer]
|
||||
Preload[Preload Facade]
|
||||
Handler[IPC Handler]
|
||||
AppService[Application Service]
|
||||
Domain[Domain Service / DAO]
|
||||
|
||||
Renderer --> Preload --> Handler --> AppService --> Domain
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- renderer 不直接感知 IPC channel 细节
|
||||
- preload 负责 facade 化
|
||||
- handler 保持薄壳
|
||||
- 业务逻辑尽量放到 application service 或 domain service
|
||||
|
||||
## 2. 新增一个 IPC 能力的推荐步骤
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Need[需要新增能力]
|
||||
Types[定义类型]
|
||||
Service[实现 service]
|
||||
Handler[注册 handler]
|
||||
Preload[暴露 preload API]
|
||||
Renderer[接入 renderer]
|
||||
Test[补测试]
|
||||
|
||||
Need --> Types --> Service --> Handler --> Preload --> Renderer --> Test
|
||||
```
|
||||
|
||||
## 3. 第一步:定义类型
|
||||
|
||||
优先在稳定类型层定义:
|
||||
|
||||
- request 类型
|
||||
- response 类型
|
||||
- preload 暴露面类型
|
||||
|
||||
常见位置:
|
||||
|
||||
- `src/main/types/`
|
||||
- `src/preload/index.d.ts`
|
||||
|
||||
## 4. 第二步:实现 service
|
||||
|
||||
如果能力有实际业务逻辑,优先先写 service。
|
||||
|
||||
不要直接把逻辑堆到 handler 里。
|
||||
|
||||
示意结构:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Request[Request]
|
||||
Handler[Handler]
|
||||
Service[Application Service]
|
||||
Repo[Repository / DAO]
|
||||
Response[Response]
|
||||
|
||||
Request --> Handler
|
||||
Handler --> Service
|
||||
Service --> Repo
|
||||
Repo --> Service
|
||||
Service --> Handler
|
||||
Handler --> Response
|
||||
```
|
||||
|
||||
## 5. 第三步:注册 handler
|
||||
|
||||
常见位置:
|
||||
|
||||
- `src/main/ipc/<module>-handler.ts`
|
||||
- `src/main/ipc/index.ts`
|
||||
|
||||
handler 里建议只做:
|
||||
|
||||
- 接收参数
|
||||
- 转发给 service
|
||||
- 用统一错误包装返回 `IpcResult`
|
||||
|
||||
## 6. 第四步:接到 preload
|
||||
|
||||
常见位置:
|
||||
|
||||
- `src/preload/api/<module>.ts`
|
||||
- `src/preload/api/index.ts`
|
||||
- `src/preload/index.d.ts`
|
||||
|
||||
preload 的职责是把主进程能力变成 renderer 可调用的 facade,而不是承载业务判断。
|
||||
|
||||
## 7. 第五步:接到 renderer
|
||||
|
||||
renderer 侧通常有两种接法:
|
||||
|
||||
- 直接在页面 hook 中调用
|
||||
- 先落一层 hook / helper,再被页面使用
|
||||
|
||||
建议优先把复杂调用路径集中到 hook。
|
||||
|
||||
## 8. 典型示例路径
|
||||
|
||||
以一个校验相关能力为例:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as useCleaner / useValidation
|
||||
participant Preload as preload.validation
|
||||
participant Handler as validation-handler
|
||||
participant AppSvc as validation-application-service
|
||||
|
||||
UI->>Preload: validate(request)
|
||||
Preload->>Handler: invoke
|
||||
Handler->>AppSvc: validate(...)
|
||||
AppSvc-->>Handler: response
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>UI: normalized result
|
||||
```
|
||||
|
||||
## 9. 修改 IPC 时优先检查的文件
|
||||
|
||||
- `src/main/ipc/index.ts`
|
||||
- `src/main/ipc/<module>-handler.ts`
|
||||
- `src/main/services/<module>/...`
|
||||
- `src/preload/api/<module>.ts`
|
||||
- `src/preload/api/index.ts`
|
||||
- `src/preload/index.d.ts`
|
||||
- renderer 对应 hook / page
|
||||
|
||||
## 10. 测试建议
|
||||
|
||||
如果是新增 IPC 能力,建议至少补:
|
||||
|
||||
- handler 单测
|
||||
- application service 单测
|
||||
- preload surface 或 renderer 状态测试(视复杂度而定)
|
||||
|
||||
## 11. 常见反模式
|
||||
|
||||
- 直接在页面里拼 IPC channel
|
||||
- handler 里写完整业务流程
|
||||
- preload 里堆业务分支
|
||||
- 改了主进程返回结构但不更新 renderer 类型
|
||||
|
||||
## 12. 实践建议
|
||||
|
||||
- 优先复用现有领域模块
|
||||
- 先想边界,再写调用
|
||||
- 先让主进程能力清晰,再接 renderer
|
||||
193
docs/developer/guides/local-development.md
Normal file
193
docs/developer/guides/local-development.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# 本地开发指南
|
||||
|
||||
本文档说明如何在本地启动、检查、构建和验证项目。
|
||||
|
||||
## 1. 开发环境概览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Clone[拉取代码]
|
||||
Install[安装依赖]
|
||||
Config[准备配置]
|
||||
Dev[启动开发环境]
|
||||
Verify[类型检查 / lint / 测试]
|
||||
|
||||
Clone --> Install --> Config --> Dev --> Verify
|
||||
```
|
||||
|
||||
## 2. 基础要求
|
||||
|
||||
- Node.js >= 18
|
||||
- npm >= 9
|
||||
- 本地可访问 ERP 系统
|
||||
- 可访问 MySQL 或 SQL Server
|
||||
|
||||
## 3. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 4. 准备配置
|
||||
|
||||
项目使用 `config.yaml` 作为主配置文件。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Config[config.yaml]
|
||||
ERP[ERP URL]
|
||||
DB[数据库配置]
|
||||
Update[更新配置]
|
||||
Paths[路径配置]
|
||||
|
||||
Config --> ERP
|
||||
Config --> DB
|
||||
Config --> Update
|
||||
Config --> Paths
|
||||
```
|
||||
|
||||
至少要确认这些配置可用:
|
||||
|
||||
- ERP URL
|
||||
- 当前使用的数据库类型
|
||||
- 数据库连接信息
|
||||
|
||||
说明:
|
||||
|
||||
- ERP 用户名和密码不是放在 `config.yaml`
|
||||
- 这部分在应用设置页中按用户存储
|
||||
|
||||
## 5. 启动开发环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发启动链路如下:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Dev as npm run dev
|
||||
participant Vite as electron-vite
|
||||
participant Main as main process
|
||||
participant Preload as preload build
|
||||
participant Renderer as renderer dev server
|
||||
|
||||
Dev->>Vite: electron-vite dev
|
||||
Vite->>Main: build main
|
||||
Vite->>Preload: build preload
|
||||
Vite->>Renderer: start renderer dev server
|
||||
Vite->>Main: launch electron
|
||||
```
|
||||
|
||||
## 6. 常用开发命令
|
||||
|
||||
```bash
|
||||
# 启动开发环境
|
||||
npm run dev
|
||||
|
||||
# 类型检查
|
||||
npm run typecheck
|
||||
|
||||
# 代码格式化
|
||||
npm run format
|
||||
|
||||
# lint
|
||||
npm run lint
|
||||
|
||||
# 单测
|
||||
npm run test:run
|
||||
|
||||
# E2E
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 7. 构建命令
|
||||
|
||||
当前正式维护的是 Windows 构建链路。
|
||||
|
||||
```bash
|
||||
# 常规构建
|
||||
npm run build
|
||||
|
||||
# Windows 安装版
|
||||
npm run build:win
|
||||
|
||||
# 仅生成 unpack 目录
|
||||
npm run build:unpack
|
||||
```
|
||||
|
||||
构建路径如下:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Build[build]
|
||||
Typecheck[typecheck]
|
||||
ElectronVite[electron-vite build]
|
||||
Updater[build:updater]
|
||||
Builder[electron-builder]
|
||||
|
||||
Build --> Typecheck
|
||||
Build --> ElectronVite
|
||||
Build --> Updater
|
||||
Build --> Builder
|
||||
```
|
||||
|
||||
## 8. 日常验证建议
|
||||
|
||||
修改代码后,建议至少跑:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx eslint <changed files>
|
||||
```
|
||||
|
||||
如果改到关键主链路,再补:
|
||||
|
||||
```bash
|
||||
npx vitest run <related tests>
|
||||
```
|
||||
|
||||
## 9. 常见本地问题
|
||||
|
||||
### 9.1 `npm run dev` 无法启动
|
||||
|
||||
优先检查:
|
||||
|
||||
- `config.yaml` 是否存在
|
||||
- 数据库配置是否正确
|
||||
- 当前终端里是否残留异常环境变量
|
||||
|
||||
### 9.2 类型检查失败
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
TypeError[类型错误]
|
||||
Main{node 还是 web}
|
||||
Node[node tsconfig]
|
||||
Web[web tsconfig]
|
||||
Fix[修正类型引用边界]
|
||||
|
||||
TypeError --> Main
|
||||
Main --> Node
|
||||
Main --> Web
|
||||
Node --> Fix
|
||||
Web --> Fix
|
||||
```
|
||||
|
||||
### 9.3 ERP 登录相关问题
|
||||
|
||||
优先检查:
|
||||
|
||||
- 设置页中的 ERP 账号密码
|
||||
- ERP URL
|
||||
- 网络可达性
|
||||
- 是否可用调试脚本复现
|
||||
|
||||
## 10. 相关文件
|
||||
|
||||
- `package.json`
|
||||
- `config.yaml`
|
||||
- `src/main/index.ts`
|
||||
- `src/main/bootstrap/runtime.ts`
|
||||
- `electron-builder.yml`
|
||||
138
docs/developer/guides/release-process.md
Normal file
138
docs/developer/guides/release-process.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 发布流程指南
|
||||
|
||||
本文档说明当前项目的构建、发布和上传链路。
|
||||
|
||||
当前正式维护的是 Windows 发布流程。
|
||||
|
||||
## 1. 发布链路总览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Prepare[release:prepare]
|
||||
Build[build / build:win]
|
||||
Updater[build:updater]
|
||||
Publish[release:publish]
|
||||
Upload[release:upload]
|
||||
|
||||
Prepare --> Build
|
||||
Build --> Updater
|
||||
Updater --> Publish
|
||||
Publish --> Upload
|
||||
```
|
||||
|
||||
## 2. 当前相关命令
|
||||
|
||||
```bash
|
||||
npm run release:prepare
|
||||
npm run build:win
|
||||
npm run release:publish
|
||||
npm run release:upload
|
||||
```
|
||||
|
||||
以及构建相关命令:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run build:updater
|
||||
npm run build:unpack
|
||||
```
|
||||
|
||||
## 3. 相关脚本
|
||||
|
||||
当前发布链路涉及这些脚本:
|
||||
|
||||
- `scripts/prepare-release.js`
|
||||
- `scripts/publish-release.js`
|
||||
- `scripts/upload-release.js`
|
||||
- `scripts/compile-updater.js`
|
||||
|
||||
## 4. 构建阶段
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Prebuild[prebuild]
|
||||
Typecheck[typecheck]
|
||||
Vite[electron-vite build]
|
||||
Updater[compile-updater]
|
||||
Builder[electron-builder --win]
|
||||
|
||||
Prebuild --> Typecheck --> Vite --> Updater --> Builder
|
||||
```
|
||||
|
||||
这一阶段大致会完成:
|
||||
|
||||
- 清理旧产物
|
||||
- 类型检查
|
||||
- 构建 main / preload / renderer
|
||||
- 构建更新相关产物
|
||||
- 生成 Windows 安装包
|
||||
|
||||
## 5. 发布前建议检查
|
||||
|
||||
发布前建议确认:
|
||||
|
||||
- `npm run typecheck` 通过
|
||||
- 关键测试通过
|
||||
- `config.yaml` / 发布配置没有误改
|
||||
- 更新目录与版本号符合预期
|
||||
- release 文案、构建产物和上传目标一致
|
||||
|
||||
## 6. 更新链路关系
|
||||
|
||||
发布流程和 update 模块关系很强:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Release[发布脚本]
|
||||
Artifact[构建产物]
|
||||
Storage[对象存储 / 发布目录]
|
||||
Update[UpdateService]
|
||||
Client[客户端 UpdateDialog]
|
||||
|
||||
Release --> Artifact
|
||||
Artifact --> Storage
|
||||
Storage --> Update
|
||||
Update --> Client
|
||||
```
|
||||
|
||||
也就是说,发布流程最终会直接影响:
|
||||
|
||||
- `UpdateCatalogService`
|
||||
- `UpdateDialog`
|
||||
- 客户端是否能正确检测与安装更新
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### 7.1 构建失败
|
||||
|
||||
优先检查:
|
||||
|
||||
- `npm run typecheck`
|
||||
- 更新编译脚本是否正常
|
||||
- Windows 构建配置是否被误改
|
||||
|
||||
### 7.2 发布后客户端看不到更新
|
||||
|
||||
优先检查:
|
||||
|
||||
- 发布目录是否正确上传
|
||||
- 版本号与 channel 是否正确
|
||||
- update catalog 是否包含该版本
|
||||
- 客户端 `UpdateService` 是否成功拉取目录
|
||||
|
||||
### 7.3 上传成功但安装失败
|
||||
|
||||
优先检查:
|
||||
|
||||
- `UpdateInstaller` 的下载与校验逻辑
|
||||
- 产物哈希是否正确
|
||||
- 客户端本地下载路径与安装流程
|
||||
|
||||
## 8. 相关文件
|
||||
|
||||
- `package.json`
|
||||
- `electron-builder.yml`
|
||||
- `scripts/prepare-release.js`
|
||||
- `scripts/publish-release.js`
|
||||
- `scripts/upload-release.js`
|
||||
- `src/main/services/update/*`
|
||||
164
docs/developer/guides/renderer-development.md
Normal file
164
docs/developer/guides/renderer-development.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Renderer 开发指南
|
||||
|
||||
本文档说明在当前项目里开发 React 渲染层时,推荐的组织方式和常见改动路径。
|
||||
|
||||
## 1. Renderer 结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx]
|
||||
Pages[pages/]
|
||||
Components[components/]
|
||||
Hooks[hooks/]
|
||||
Stores[stores/]
|
||||
Lib[lib/]
|
||||
|
||||
App --> Pages
|
||||
Pages --> Components
|
||||
Pages --> Hooks
|
||||
Hooks --> Stores
|
||||
Hooks --> Lib
|
||||
```
|
||||
|
||||
## 2. 开发原则
|
||||
|
||||
当前 renderer 层推荐遵循这些边界:
|
||||
|
||||
- 页面组件优先作为组装层
|
||||
- 复杂流程优先下沉到 hook
|
||||
- 纯逻辑优先抽到 helper / lib
|
||||
- 非首屏重型弹窗优先考虑懒加载
|
||||
- bridge 调用优先放在 hook,不直接散落在组件树中
|
||||
|
||||
## 3. 页面开发路径
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Feature[新增或修改页面功能]
|
||||
Page[页面容器]
|
||||
Hook[页面 Hook]
|
||||
Components[子组件]
|
||||
Helpers[helper / lib]
|
||||
Preload[window.electron facade]
|
||||
|
||||
Feature --> Page
|
||||
Page --> Hook
|
||||
Page --> Components
|
||||
Hook --> Helpers
|
||||
Hook --> Preload
|
||||
```
|
||||
|
||||
## 4. 当前页面入口
|
||||
|
||||
主要页面:
|
||||
|
||||
- `ExtractorPage.tsx`
|
||||
- `CleanerPage.tsx`
|
||||
- `SettingsPage.tsx`
|
||||
|
||||
主壳层:
|
||||
|
||||
- `App.tsx`
|
||||
- `AuthenticatedAppShell.tsx`
|
||||
- `UnauthenticatedApp.tsx`
|
||||
|
||||
## 5. Hook 组织建议
|
||||
|
||||
推荐把 hook 分成几类:
|
||||
|
||||
- 页面启动/编排 hook
|
||||
例如 `useAppBootstrap`
|
||||
- 业务流程 hook
|
||||
例如 `useCleaner`、`useExtractor`
|
||||
- 辅助状态 hook
|
||||
例如 `usePersistentTextState`、`useSharedProductionIds`
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Page[Page]
|
||||
Bootstrap[Bootstrap Hook]
|
||||
Domain[Domain Hook]
|
||||
Helper[Helper Hook]
|
||||
|
||||
Page --> Bootstrap
|
||||
Page --> Domain
|
||||
Domain --> Helper
|
||||
```
|
||||
|
||||
## 6. 当前推荐风格
|
||||
|
||||
结合近期重构,当前 renderer 更推荐:
|
||||
|
||||
- `App.tsx` 保持薄入口
|
||||
- `CleanerPage` 保持页面布局和弹窗编排
|
||||
- 重型局部区域拆成子组件
|
||||
- 异步状态收敛到 hook
|
||||
|
||||
## 7. 状态更新建议
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Input[用户输入]
|
||||
Local[局部 state]
|
||||
Derived[派生状态]
|
||||
Async[异步请求]
|
||||
UI[UI 更新]
|
||||
|
||||
Input --> Local
|
||||
Local --> Derived
|
||||
Local --> Async
|
||||
Derived --> UI
|
||||
Async --> UI
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 能派生的状态尽量派生,不额外存储
|
||||
- 输入态不要直接挂太多高频副作用
|
||||
- 非紧急 UI 更新可考虑 `startTransition`
|
||||
|
||||
## 8. 弹窗开发建议
|
||||
|
||||
当前项目弹窗较多,建议遵循:
|
||||
|
||||
- 非首屏关键弹窗优先懒加载
|
||||
- 弹窗状态尽量放在页面或页面 hook 中统一管理
|
||||
- 弹窗本身专注展示和内部交互
|
||||
|
||||
## 9. 典型改动路径
|
||||
|
||||
### 改 Cleaner 页面
|
||||
|
||||
- `CleanerPage.tsx`
|
||||
- `components/cleaner/*`
|
||||
- `useCleaner.ts`
|
||||
- `hooks/cleaner/*`
|
||||
|
||||
### 改 Extractor 页面
|
||||
|
||||
- `ExtractorPage.tsx`
|
||||
- `useExtractor.ts`
|
||||
- `useSharedProductionIds.ts`
|
||||
|
||||
### 改更新弹窗
|
||||
|
||||
- `UpdateDialog.tsx`
|
||||
- `useUpdateDialogState.ts`
|
||||
- `useAppBootstrap.ts`
|
||||
|
||||
## 10. 测试建议
|
||||
|
||||
当前 renderer 测试更适合先从:
|
||||
|
||||
- 状态 helper
|
||||
- hook 决策逻辑
|
||||
- 与 preload 调用边界有关的轻量测试
|
||||
|
||||
开始补,而不是一上来就做全量 UI 集成测试。
|
||||
|
||||
## 11. 常见反模式
|
||||
|
||||
- 页面组件同时承载过多副作用
|
||||
- 在组件中直接散布大量 `window.electron.xxx`
|
||||
- 一个 hook 同时管理初始化、交互、请求、持久化和对话框
|
||||
- 非首屏重型组件全部静态导入
|
||||
159
docs/developer/modules/README.md
Normal file
159
docs/developer/modules/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 模块文档索引
|
||||
|
||||
本目录收录项目核心业务模块的开发文档。
|
||||
|
||||
这些文档的目标不是替代源码,而是帮助开发者快速理解:
|
||||
|
||||
- 每个模块负责什么
|
||||
- 模块的入口文件在哪里
|
||||
- 模块之间如何协作
|
||||
- 主要数据和调用链怎么流动
|
||||
- 改某个功能时应该先看哪些文件
|
||||
|
||||
## 推荐阅读方式
|
||||
|
||||
如果你是第一次进入模块层,建议按下面顺序阅读:
|
||||
|
||||
1. `auth`
|
||||
2. `extractor`
|
||||
3. `validation`
|
||||
4. `cleaner`
|
||||
5. `update`
|
||||
6. `settings`
|
||||
|
||||
这个顺序基本对应项目的主要业务路径和依赖关系。
|
||||
|
||||
## 模块关系图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Auth[auth]
|
||||
Extractor[extractor]
|
||||
Validation[validation]
|
||||
Cleaner[cleaner]
|
||||
Update[update]
|
||||
Settings[settings]
|
||||
|
||||
Auth --> Extractor
|
||||
Auth --> Cleaner
|
||||
Auth --> Settings
|
||||
Auth --> Update
|
||||
Extractor --> Validation
|
||||
Validation --> Cleaner
|
||||
```
|
||||
|
||||
可以把它理解成:
|
||||
|
||||
- `auth`
|
||||
提供用户上下文,是多个模块的前置条件
|
||||
- `extractor`
|
||||
负责输入和提取,是共享订单号的来源之一
|
||||
- `validation`
|
||||
把共享数据和数据库数据转成 Cleaner 可消费结果
|
||||
- `cleaner`
|
||||
消费校验结果并执行 ERP 清理
|
||||
- `update`
|
||||
根据当前用户上下文给出更新能力和状态
|
||||
- `settings`
|
||||
提供 ERP 凭据等配置能力
|
||||
|
||||
## 模块目录一览
|
||||
|
||||
| 模块 | 文档 | 核心职责 |
|
||||
| --- | --- | --- |
|
||||
| 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 凭据加载与保存、当前用户配置管理 |
|
||||
|
||||
## 模块入口地图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Renderer[Renderer Pages / Hooks]
|
||||
Preload[Preload APIs]
|
||||
IPC[IPC Handlers]
|
||||
Services[Main Services]
|
||||
Modules[业务模块文档]
|
||||
|
||||
Renderer --> Preload
|
||||
Preload --> IPC
|
||||
IPC --> Services
|
||||
Modules --> Renderer
|
||||
Modules --> IPC
|
||||
Modules --> Services
|
||||
```
|
||||
|
||||
阅读模块文档时,建议同时关注三层入口:
|
||||
|
||||
- renderer 入口
|
||||
页面、组件、hooks
|
||||
- IPC 入口
|
||||
handler
|
||||
- main service 入口
|
||||
application service / domain service
|
||||
|
||||
## 按任务选择阅读路径
|
||||
|
||||
如果你是按任务来看文档,可以参考下面这张图:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Task[当前任务]
|
||||
Login[登录 / 用户切换]
|
||||
Extract[订单提取]
|
||||
Validate[物料校验]
|
||||
Clean[ERP 清理]
|
||||
UpdateTask[应用更新]
|
||||
Config[配置修改]
|
||||
|
||||
Task --> Login
|
||||
Task --> Extract
|
||||
Task --> Validate
|
||||
Task --> Clean
|
||||
Task --> UpdateTask
|
||||
Task --> Config
|
||||
|
||||
Login --> AuthDoc[auth.md]
|
||||
Extract --> ExtractorDoc[extractor.md]
|
||||
Validate --> ValidationDoc[validation.md]
|
||||
Clean --> CleanerDoc[cleaner.md]
|
||||
UpdateTask --> UpdateDoc[update.md]
|
||||
Config --> SettingsDoc[settings.md]
|
||||
```
|
||||
|
||||
## 和 architecture 文档的关系
|
||||
|
||||
`modules/` 和 `architecture/` 的关系如下:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Architecture[architecture/]
|
||||
Modules[modules/]
|
||||
Guides[guides/]
|
||||
|
||||
Architecture --> Modules
|
||||
Modules --> Guides
|
||||
```
|
||||
|
||||
理解方式可以是:
|
||||
|
||||
- 先看 `architecture/`
|
||||
建立系统整体认知
|
||||
- 再看 `modules/`
|
||||
深入理解业务模块
|
||||
- 最后看 `guides/`
|
||||
落到具体开发动作
|
||||
|
||||
## 后续扩展建议
|
||||
|
||||
如果后续业务边界继续演进,可以在这里继续增加模块文档,例如:
|
||||
|
||||
- `report.md`
|
||||
- `materials.md`
|
||||
- `config.md`
|
||||
- `logger.md`
|
||||
|
||||
新增模块文档时,建议同步更新这份索引页,让 `modules/README.md` 持续保持为模块层总入口。
|
||||
119
docs/developer/modules/auth.md
Normal file
119
docs/developer/modules/auth.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Auth 模块
|
||||
|
||||
`Auth` 模块负责桌面端用户认证、silent login、管理员代切用户,以及把用户上下文同步给更新等后续模块。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 获取机器名
|
||||
- 执行 silent login
|
||||
- 用户名密码登录
|
||||
- 管理员查看用户列表并切换用户
|
||||
- 登出并清理会话
|
||||
- 同步当前用户类型到更新模块
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[useAppBootstrap]
|
||||
Preload[preload.auth]
|
||||
Handler[auth-handler]
|
||||
AppSvc[auth-application-service]
|
||||
Session[session-manager]
|
||||
Update[update-service]
|
||||
|
||||
App --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> AppSvc
|
||||
AppSvc --> Session
|
||||
AppSvc --> Update
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/renderer/src/hooks/useAppBootstrap.ts`
|
||||
- `src/renderer/src/components/app/UnauthenticatedApp.tsx`
|
||||
- `src/main/ipc/auth-handler.ts`
|
||||
- `src/main/services/auth/auth-application-service.ts`
|
||||
- `src/main/services/user/session-manager.ts`
|
||||
|
||||
## 4. 认证主流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as useAppBootstrap
|
||||
participant Preload as preload.auth
|
||||
participant Handler as auth-handler
|
||||
participant AppSvc as auth-application-service
|
||||
participant Session as session-manager
|
||||
|
||||
App->>Preload: getComputerName()
|
||||
App->>Preload: silentLogin()
|
||||
Preload->>Handler: invoke
|
||||
Handler->>AppSvc: silentLogin()
|
||||
AppSvc->>Session: loginByComputerName()
|
||||
Session-->>AppSvc: userInfo
|
||||
AppSvc-->>Handler: login result
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>App: auth state
|
||||
```
|
||||
|
||||
## 5. 管理员分支
|
||||
|
||||
如果 silent login 或显式登录得到的是管理员账号,认证流程不会直接结束,而是进入“代切用户”分支。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Login[登录成功]
|
||||
Admin{是否 Admin}
|
||||
Select[getAllUsers]
|
||||
Switch[switchUser]
|
||||
Authenticated[进入已认证态]
|
||||
|
||||
Login --> Admin
|
||||
Admin -- 否 --> Authenticated
|
||||
Admin -- 是 --> Select
|
||||
Select --> Switch
|
||||
Switch --> Authenticated
|
||||
```
|
||||
|
||||
## 6. 与更新模块的关系
|
||||
|
||||
Auth 模块和 Update 模块之间有明确联动:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Auth[AuthApplicationService]
|
||||
UserType[UserType]
|
||||
Update[UpdateService]
|
||||
|
||||
Auth --> UserType
|
||||
UserType --> Update
|
||||
```
|
||||
|
||||
在以下时机会同步用户上下文:
|
||||
|
||||
- silent login 成功
|
||||
- 显式登录成功
|
||||
- 用户切换成功
|
||||
- logout
|
||||
|
||||
## 7. 最近的结构优化
|
||||
|
||||
Auth 相关逻辑最近做过两项关键收敛:
|
||||
|
||||
- 把编排逻辑从 `auth-handler` 下沉到 `auth-application-service`
|
||||
- 在 `silentLogin()` 中加入并发去重,避免重复 silent login 触发连接风暴
|
||||
|
||||
## 8. 常见改动点
|
||||
|
||||
- 改前端启动认证:`useAppBootstrap.ts`
|
||||
- 改登录与切换流程:`auth-application-service.ts`
|
||||
- 改会话层:`session-manager.ts`
|
||||
- 改 IPC 契约:`auth-handler.ts`
|
||||
|
||||
## 9. 修改建议
|
||||
|
||||
- 页面不要直接堆认证细节,优先继续收敛到 bootstrap hook
|
||||
- 用户上下文变化时,记得考虑 update 状态是否需要同步
|
||||
- silent login 流程不要破坏当前的防重入保护
|
||||
197
docs/developer/modules/cleaner.md
Normal file
197
docs/developer/modules/cleaner.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Cleaner 模块
|
||||
|
||||
`Cleaner` 模块负责物料校验结果的展示、筛选、负责人分配、删除计划保存,以及最终 ERP 清理执行与报告展示。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 展示校验后的物料列表
|
||||
- 负责人筛选与内联编辑
|
||||
- 勾选待处理物料
|
||||
- 保存删除计划到数据库
|
||||
- 执行 ERP 清理
|
||||
- 展示执行进度和执行报告
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Page[CleanerPage]
|
||||
Hook[useCleaner]
|
||||
Sidebar[CleanerSidebar]
|
||||
Toolbar[CleanerToolbar]
|
||||
Table[CleanerResultsTable]
|
||||
Bar[CleanerExecutionBar]
|
||||
Helpers[hooks/cleaner/helpers.ts]
|
||||
API[hooks/cleaner/api.ts]
|
||||
Preload[preload.cleaner / validation / materials]
|
||||
Handler[cleaner-handler / validation-handler]
|
||||
MainSvc[cleaner-application-service]
|
||||
|
||||
Page --> Hook
|
||||
Page --> Sidebar
|
||||
Page --> Toolbar
|
||||
Page --> Table
|
||||
Page --> Bar
|
||||
Hook --> Helpers
|
||||
Hook --> API
|
||||
API --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> MainSvc
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
- `src/renderer/src/hooks/cleaner/api.ts`
|
||||
- `src/renderer/src/hooks/cleaner/helpers.ts`
|
||||
- `src/renderer/src/components/cleaner/CleanerSidebar.tsx`
|
||||
- `src/renderer/src/components/cleaner/CleanerToolbar.tsx`
|
||||
- `src/renderer/src/components/cleaner/CleanerResultsTable.tsx`
|
||||
- `src/renderer/src/components/cleaner/CleanerExecutionBar.tsx`
|
||||
- `src/main/ipc/cleaner-handler.ts`
|
||||
- `src/main/services/cleaner/cleaner-application-service.ts`
|
||||
|
||||
## 4. 页面主流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Load[页面初始化]
|
||||
Validate[获取并校验物料]
|
||||
Results[validationResults]
|
||||
Filter[筛选与隐藏]
|
||||
Select[勾选与负责人编辑]
|
||||
Plan[保存删除计划]
|
||||
Execute[执行 ERP 清理]
|
||||
Report[执行报告 / 查看报告]
|
||||
|
||||
Load --> Validate
|
||||
Validate --> Results
|
||||
Results --> Filter
|
||||
Results --> Select
|
||||
Select --> Plan
|
||||
Plan --> Execute
|
||||
Execute --> Report
|
||||
```
|
||||
|
||||
## 5. 前端状态组织
|
||||
|
||||
当前 `useCleaner` 管理的主要状态包括:
|
||||
|
||||
- 页面初始化与权限
|
||||
- 校验结果与筛选结果
|
||||
- 勾选状态与隐藏状态
|
||||
- 负责人编辑状态
|
||||
- 执行设置
|
||||
- 进度状态
|
||||
- 报告弹窗状态
|
||||
- 确认弹窗状态
|
||||
|
||||
可以理解成:
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((useCleaner))
|
||||
权限与初始化
|
||||
isAdmin
|
||||
currentUsername
|
||||
managers
|
||||
校验结果
|
||||
validationResults
|
||||
filteredResults
|
||||
selectedItems
|
||||
hiddenItems
|
||||
执行状态
|
||||
isRunning
|
||||
isExecuting
|
||||
progress
|
||||
reportData
|
||||
设置
|
||||
dryRun
|
||||
headless
|
||||
processConcurrency
|
||||
交互
|
||||
editingCell
|
||||
confirmDialog
|
||||
dialogs
|
||||
```
|
||||
|
||||
## 6. 主进程执行链路
|
||||
|
||||
Cleaner 真正执行 ERP 清理时,主进程调用链大致如下:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as useCleaner
|
||||
participant Preload as preload.cleaner
|
||||
participant Handler as cleaner-handler
|
||||
participant AppSvc as cleaner-application-service
|
||||
participant ERP as CleanerService / ErpAuthService
|
||||
participant Report as report / rustfs
|
||||
|
||||
UI->>Preload: runCleaner(input)
|
||||
Preload->>Handler: invoke
|
||||
Handler->>AppSvc: runCleaner(...)
|
||||
AppSvc->>ERP: 登录并执行清理
|
||||
ERP-->>AppSvc: cleaner result
|
||||
AppSvc->>Report: 生成并上传报告
|
||||
AppSvc-->>Handler: result
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>UI: 执行结果
|
||||
```
|
||||
|
||||
## 7. 模块边界
|
||||
|
||||
Cleaner 依赖多个模块:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Cleaner[Cleaner]
|
||||
Validation[Validation]
|
||||
Materials[Materials / MaterialType]
|
||||
Report[Report]
|
||||
Config[Config]
|
||||
ERP[ERP Services]
|
||||
|
||||
Cleaner --> Validation
|
||||
Cleaner --> Materials
|
||||
Cleaner --> Report
|
||||
Cleaner --> Config
|
||||
Cleaner --> ERP
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `validation`
|
||||
提供校验结果和 Cleaner 可消费数据
|
||||
- `materials`
|
||||
提供负责人和删除计划相关能力
|
||||
- `report`
|
||||
提供报告查看与生成
|
||||
- `config`
|
||||
提供执行配置
|
||||
|
||||
## 8. 最近的结构优化
|
||||
|
||||
这一块近期做过两轮收敛:
|
||||
|
||||
- `CleanerPage` 拆成 `Sidebar / Toolbar / ResultsTable / ExecutionBar`
|
||||
- `useCleaner` 内部 API / helpers 已经第一轮抽离
|
||||
|
||||
同时页面中的重型弹窗也已经改成按需加载。
|
||||
|
||||
## 9. 常见改动点
|
||||
|
||||
- 改筛选或展示:`CleanerPage.tsx` 与 `components/cleaner/*`
|
||||
- 改前端执行逻辑:`useCleaner.ts`
|
||||
- 改校验请求与导出:`hooks/cleaner/api.ts`
|
||||
- 改纯逻辑:`hooks/cleaner/helpers.ts`
|
||||
- 改主进程执行:`cleaner-application-service.ts`
|
||||
- 改 ERP 清理细节:`src/main/services/erp/cleaner.ts`
|
||||
|
||||
## 10. 修改建议
|
||||
|
||||
- 优先保持页面组件继续做“组装层”
|
||||
- 如果新增复杂交互,优先下沉到 hook 或 helper
|
||||
- 执行链路的真实业务逻辑放在主进程 service
|
||||
- 报告、导出、上传等后处理不要塞回 UI 层
|
||||
133
docs/developer/modules/extractor.md
Normal file
133
docs/developer/modules/extractor.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Extractor 模块
|
||||
|
||||
`Extractor` 模块负责接收订单号输入、触发提取流程、同步共享订单号,并把提取结果导入后续链路可消费的数据形态。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 接收和持久化订单号输入
|
||||
- 将订单号同步为共享 `Production IDs`
|
||||
- 触发批量提取流程
|
||||
- 展示提取进度和日志
|
||||
- 为 `Cleaner` 等后续模块提供共享订单号基础
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Page[ExtractorPage]
|
||||
Input[OrderNumberInput]
|
||||
Persist[usePersistentTextState]
|
||||
Shared[useSharedProductionIds]
|
||||
Hook[useExtractor]
|
||||
Preload[preload.extractor / validation]
|
||||
Handler[extractor-handler]
|
||||
Service[ERP Extractor Service]
|
||||
|
||||
Page --> Input
|
||||
Page --> Persist
|
||||
Page --> Shared
|
||||
Page --> Hook
|
||||
Hook --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> Service
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
- `src/renderer/src/hooks/useExtractor.ts`
|
||||
- `src/renderer/src/hooks/usePersistentTextState.ts`
|
||||
- `src/renderer/src/hooks/useSharedProductionIds.ts`
|
||||
- `src/renderer/src/components/OrderNumberInput.tsx`
|
||||
- `src/main/ipc/extractor-handler.ts`
|
||||
- `src/main/services/erp/extractor.ts`
|
||||
|
||||
## 4. 主要流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as ExtractorPage
|
||||
participant Persist as usePersistentTextState
|
||||
participant Shared as useSharedProductionIds
|
||||
participant Hook as useExtractor
|
||||
participant Preload as preload.extractor
|
||||
participant Main as extractor-handler / extractor service
|
||||
|
||||
UI->>Persist: 保存输入
|
||||
UI->>Shared: debounce 同步共享 IDs
|
||||
UI->>Hook: startExtraction(orderNumbers)
|
||||
Hook->>Preload: setSharedProductionIds()
|
||||
Hook->>Preload: runExtractor()
|
||||
Preload->>Main: invoke
|
||||
Main-->>Preload: 提取结果
|
||||
Preload-->>Hook: success / error / progress
|
||||
Hook-->>UI: 更新日志与状态
|
||||
```
|
||||
|
||||
## 5. 关键状态
|
||||
|
||||
当前前端侧最重要的状态包括:
|
||||
|
||||
- `orderNumbers`
|
||||
用户输入的订单号文本
|
||||
- `isRunning`
|
||||
是否正在提取
|
||||
- `progress`
|
||||
当前提取进度
|
||||
- `logs`
|
||||
提取过程日志
|
||||
- `error`
|
||||
当前错误
|
||||
- `isComplete`
|
||||
提取是否结束
|
||||
|
||||
## 6. 与其他模块的关系
|
||||
|
||||
Extractor 与其他模块的关系如下:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Extractor[Extractor]
|
||||
SharedIds[shared Production IDs]
|
||||
Validation[Validation]
|
||||
Cleaner[Cleaner]
|
||||
|
||||
Extractor --> SharedIds
|
||||
SharedIds --> Validation
|
||||
Validation --> Cleaner
|
||||
```
|
||||
|
||||
它最重要的跨模块输出不是页面本身,而是:
|
||||
|
||||
- 共享 `Production IDs`
|
||||
- 导入数据库的数据
|
||||
|
||||
## 7. 最近的结构优化
|
||||
|
||||
最近这一块做过两类收敛:
|
||||
|
||||
- 把订单号持久化抽到 `usePersistentTextState`
|
||||
- 把共享订单号同步抽到 `useSharedProductionIds`
|
||||
|
||||
这样页面不再自己同时处理:
|
||||
|
||||
- 输入状态
|
||||
- `sessionStorage`
|
||||
- bridge 副作用
|
||||
|
||||
## 8. 常见改动点
|
||||
|
||||
如果你要改 Extractor,通常会落在这些位置:
|
||||
|
||||
- 改输入与格式统计:`OrderNumberInput.tsx`
|
||||
- 改页面交互:`ExtractorPage.tsx`
|
||||
- 改前端提取编排:`useExtractor.ts`
|
||||
- 改共享订单号同步:`useSharedProductionIds.ts`
|
||||
- 改主进程执行:`extractor-handler.ts` / `erp/extractor.ts`
|
||||
|
||||
## 9. 修改建议
|
||||
|
||||
- 输入变化不要直接叠加更多高频副作用
|
||||
- 共享订单号写入尽量维持单一入口
|
||||
- 提取日志和进度流优先保持事件推送式结构
|
||||
- 如果新增提取后处理,优先放在主进程 service,而不是塞回页面
|
||||
103
docs/developer/modules/settings.md
Normal file
103
docs/developer/modules/settings.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Settings 模块
|
||||
|
||||
`Settings` 模块当前主要负责 ERP 登录凭据的查看、编辑和保存,并通过当前用户上下文对配置进行按用户管理。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 加载当前用户的 ERP 配置
|
||||
- 编辑 ERP 用户名和密码
|
||||
- 保存配置到后端持久化存储
|
||||
- 提示保存结果
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Page[SettingsPage]
|
||||
Preload[preload.settings]
|
||||
Handler[settings-handler]
|
||||
Config[Config / User ERP Config Service]
|
||||
Storage[数据库中的用户配置]
|
||||
|
||||
Page --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> Config
|
||||
Config --> Storage
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/renderer/src/pages/SettingsPage.tsx`
|
||||
- `src/main/ipc/settings-handler.ts`
|
||||
- `src/main/services/config/config-manager.ts`
|
||||
- `src/main/services/user/user-erp-config-service.ts`
|
||||
|
||||
## 4. 主流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Page as SettingsPage
|
||||
participant Preload as preload.settings
|
||||
participant Handler as settings-handler
|
||||
participant Service as config / user-erp-config-service
|
||||
|
||||
Page->>Preload: getSettings()
|
||||
Preload->>Handler: invoke
|
||||
Handler->>Service: load current user config
|
||||
Service-->>Handler: settings payload
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>Page: ERP credentials
|
||||
|
||||
Page->>Preload: saveSettings(payload)
|
||||
Preload->>Handler: invoke
|
||||
Handler->>Service: persist config
|
||||
Service-->>Handler: save result
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>Page: success / error
|
||||
```
|
||||
|
||||
## 5. 页面状态
|
||||
|
||||
当前设置页非常轻量,主要状态包括:
|
||||
|
||||
- `credentials`
|
||||
- `isModified`
|
||||
- `isLoading`
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Load[加载配置]
|
||||
Edit[编辑账号密码]
|
||||
Dirty[isModified = true]
|
||||
Save[保存配置]
|
||||
Success[提示成功]
|
||||
|
||||
Load --> Edit
|
||||
Edit --> Dirty
|
||||
Dirty --> Save
|
||||
Save --> Success
|
||||
```
|
||||
|
||||
## 6. 与其他模块的关系
|
||||
|
||||
Settings 模块与这些模块关系较强:
|
||||
|
||||
- `auth`
|
||||
当前用户决定读取和保存哪份 ERP 配置
|
||||
- `cleaner`
|
||||
Cleaner 执行时会读取 ERP 账号密码
|
||||
- `extractor`
|
||||
提取链路也依赖 ERP 登录能力
|
||||
|
||||
## 7. 常见改动点
|
||||
|
||||
- 改页面交互:`SettingsPage.tsx`
|
||||
- 改 IPC 契约:`settings-handler.ts`
|
||||
- 改配置存储逻辑:`user-erp-config-service.ts`
|
||||
- 改全局配置:`config-manager.ts`
|
||||
|
||||
## 8. 修改建议
|
||||
|
||||
- 保持“页面只编辑当前用户配置”的边界清晰
|
||||
- 不要把 ERP 凭据保存逻辑重新分散到多个模块
|
||||
- 如果后续扩展更多设置项,建议引入更清晰的分组和局部表单结构
|
||||
153
docs/developer/modules/update.md
Normal file
153
docs/developer/modules/update.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Update 模块
|
||||
|
||||
`Update` 模块负责应用版本目录拉取、状态广播、更新包下载、安装器启动,以及为不同用户类型生成不同的更新视图。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 检查更新是否可用
|
||||
- 拉取更新目录
|
||||
- 为 `User` / `Admin` 生成不同的更新决策
|
||||
- 下载更新包并校验
|
||||
- 启动安装流程
|
||||
- 广播更新状态给 renderer
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Hook[useAppBootstrap]
|
||||
Dialog[UpdateDialog / useUpdateDialogState]
|
||||
Preload[preload.update]
|
||||
Handler[update-handler]
|
||||
Service[UpdateService]
|
||||
Catalog[UpdateCatalogService]
|
||||
Installer[UpdateInstaller]
|
||||
Storage[UpdateStorageClient]
|
||||
Publisher[UpdateStatusPublisher]
|
||||
|
||||
Hook --> Preload
|
||||
Dialog --> Preload
|
||||
Preload --> Handler
|
||||
Handler --> Service
|
||||
Service --> Catalog
|
||||
Service --> Installer
|
||||
Service --> Storage
|
||||
Service --> Publisher
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/renderer/src/hooks/useAppBootstrap.ts`
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
- `src/renderer/src/hooks/useUpdateDialogState.ts`
|
||||
- `src/main/ipc/update-handler.ts`
|
||||
- `src/main/services/update/update-service.ts`
|
||||
- `src/main/services/update/update-catalog-service.ts`
|
||||
- `src/main/services/update/update-installer.ts`
|
||||
- `src/main/services/update/update-storage-client.ts`
|
||||
- `src/main/services/update/update-status-publisher.ts`
|
||||
|
||||
## 4. 更新数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Hook as useAppBootstrap
|
||||
participant Dialog as useUpdateDialogState
|
||||
participant Preload as preload.update
|
||||
participant Handler as update-handler
|
||||
participant Service as UpdateService
|
||||
participant Catalog as UpdateCatalogService
|
||||
|
||||
Hook->>Preload: getStatus()
|
||||
Hook->>Preload: getCatalog()
|
||||
Dialog->>Preload: getChangelog(release)
|
||||
Preload->>Handler: invoke
|
||||
Handler->>Service: getStatus / getCatalog / getChangelog
|
||||
Service->>Catalog: resolve dialog catalog
|
||||
Catalog-->>Service: release decisions
|
||||
Service-->>Handler: update data
|
||||
Handler-->>Preload: IpcResult
|
||||
Preload-->>Hook: status / catalog
|
||||
Preload-->>Dialog: changelog
|
||||
```
|
||||
|
||||
## 5. 状态模型
|
||||
|
||||
更新模块当前最核心的是 `UpdateStatus`:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> checking
|
||||
checking --> available
|
||||
checking --> downloaded
|
||||
checking --> error
|
||||
available --> downloading
|
||||
downloading --> downloaded
|
||||
downloading --> error
|
||||
downloaded --> installing
|
||||
installing --> [*]
|
||||
```
|
||||
|
||||
同时 `UpdateDialogCatalog` 会根据用户角色形成不同视图:
|
||||
|
||||
- `user`
|
||||
- `admin`
|
||||
- `disabled`
|
||||
|
||||
## 6. 用户与管理员差异
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Context[当前用户类型]
|
||||
User[User]
|
||||
Admin[Admin]
|
||||
UserCatalog[推荐稳定版]
|
||||
AdminCatalog[Stable + Preview 目录]
|
||||
|
||||
Context --> User
|
||||
Context --> Admin
|
||||
User --> UserCatalog
|
||||
Admin --> AdminCatalog
|
||||
```
|
||||
|
||||
普通用户主要消费:
|
||||
|
||||
- 推荐版本
|
||||
- 已下载版本
|
||||
- 安装动作
|
||||
|
||||
管理员主要消费:
|
||||
|
||||
- 完整版本目录
|
||||
- Stable / Preview 版本切换
|
||||
- 手动下载并安装
|
||||
|
||||
## 7. 最近的结构优化
|
||||
|
||||
Update 模块已经做过多轮职责拆分:
|
||||
|
||||
- 版本目录决策拆到 `update-catalog-service`
|
||||
- 下载与安装拆到 `update-installer`
|
||||
- 状态广播拆到 `update-status-publisher`
|
||||
- 对象存储访问拆到 `update-storage-client`
|
||||
|
||||
同时前端侧:
|
||||
|
||||
- `useUpdateDialogState` 收敛了选中版本和 changelog 状态
|
||||
- `UpdateDialog` 已改成按需加载
|
||||
|
||||
## 8. 常见改动点
|
||||
|
||||
- 改 renderer 状态流:`useAppBootstrap.ts` / `useUpdateDialogState.ts`
|
||||
- 改弹窗展示:`UpdateDialog.tsx`
|
||||
- 改更新检查与轮询:`update-service.ts`
|
||||
- 改版本决策:`update-catalog-service.ts`
|
||||
- 改安装流程:`update-installer.ts`
|
||||
|
||||
## 9. 修改建议
|
||||
|
||||
- 更新决策逻辑优先放在 main service,不要回流到 renderer
|
||||
- changelog、catalog、status 要保持边界清晰
|
||||
- 用户类型变化时要考虑 status/catalog 的复位逻辑
|
||||
- 如果新增发布通道,优先扩展 catalog service
|
||||
147
docs/developer/modules/validation.md
Normal file
147
docs/developer/modules/validation.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Validation 模块
|
||||
|
||||
`Validation` 模块负责共享订单号管理、输入识别、数据库校验查询、物料结果富化,以及为 Cleaner 提供可消费的数据。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 存储与读取共享 `Production IDs`
|
||||
- 将输入转换为可校验的 source numbers
|
||||
- 查询数据库中的物料记录
|
||||
- 结合类型关键词和已标记物料生成校验结果
|
||||
- 为 Cleaner 提供订单号与物料代码
|
||||
|
||||
## 2. 模块结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Handler[validation-handler]
|
||||
AppSvc[validation-application-service]
|
||||
Store[shared-production-ids-store]
|
||||
Input[production-input-service]
|
||||
DB[validation-database]
|
||||
DAO[DAO / database services]
|
||||
|
||||
Handler --> Store
|
||||
Handler --> AppSvc
|
||||
AppSvc --> Input
|
||||
AppSvc --> DB
|
||||
DB --> DAO
|
||||
```
|
||||
|
||||
## 3. 关键入口文件
|
||||
|
||||
- `src/main/ipc/validation-handler.ts`
|
||||
- `src/main/services/validation/validation-application-service.ts`
|
||||
- `src/main/services/validation/shared-production-ids-store.ts`
|
||||
- `src/main/services/validation/production-input-service.ts`
|
||||
- `src/main/services/validation/validation-database.ts`
|
||||
- `src/renderer/src/hooks/useValidation.ts`
|
||||
|
||||
## 4. 主流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Renderer / useValidation / useCleaner
|
||||
participant Handler as validation-handler
|
||||
participant Store as shared-production-ids-store
|
||||
participant AppSvc as validation-application-service
|
||||
participant Input as production-input-service
|
||||
participant DB as validation-database
|
||||
|
||||
UI->>Handler: set/get shared Production IDs
|
||||
Handler->>Store: read/write sender scoped IDs
|
||||
|
||||
UI->>Handler: validate(request)
|
||||
Handler->>AppSvc: validate(...)
|
||||
AppSvc->>Input: resolve source numbers
|
||||
AppSvc->>DB: query material records
|
||||
DB-->>AppSvc: rows
|
||||
AppSvc-->>Handler: validation results + stats
|
||||
Handler-->>UI: response
|
||||
```
|
||||
|
||||
## 5. 共享 Production IDs
|
||||
|
||||
共享订单号是 Validation 模块最重要的跨页面状态之一。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Extractor[Extractor]
|
||||
Store[shared-production-ids-store]
|
||||
Validation[Validation]
|
||||
Cleaner[Cleaner]
|
||||
|
||||
Extractor --> Store
|
||||
Store --> Validation
|
||||
Validation --> Cleaner
|
||||
```
|
||||
|
||||
这个状态当前按 `senderId` 维度存储,主要被:
|
||||
|
||||
- `Extractor`
|
||||
写入
|
||||
- `Validation`
|
||||
读取和解析
|
||||
- `Cleaner`
|
||||
间接消费
|
||||
|
||||
## 6. 结果生成逻辑
|
||||
|
||||
校验结果不仅是数据库原始数据,还会叠加:
|
||||
|
||||
- 已标记删除状态
|
||||
- 负责人关键词匹配
|
||||
- 用户权限作用域
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DBRows[数据库物料记录]
|
||||
Marked[已标记物料]
|
||||
Keywords[类型关键词]
|
||||
Scope[用户作用域]
|
||||
Result[ValidationResult]
|
||||
|
||||
DBRows --> Result
|
||||
Marked --> Result
|
||||
Keywords --> Result
|
||||
Scope --> Result
|
||||
```
|
||||
|
||||
## 7. 模块输出
|
||||
|
||||
Validation 主要对外输出两类数据:
|
||||
|
||||
- `ValidationResponse`
|
||||
提供给校验页和 Cleaner 页
|
||||
- `CleanerData`
|
||||
提供给 Cleaner 执行前的数据准备
|
||||
|
||||
## 8. 最近的结构优化
|
||||
|
||||
这一块已经从早期的大 `validation-handler` 中拆分出来:
|
||||
|
||||
- `shared-production-ids-store`
|
||||
- `validation-database`
|
||||
- `production-input-service`
|
||||
- `validation-application-service`
|
||||
|
||||
这样之后:
|
||||
|
||||
- handler 只做 IPC 壳
|
||||
- 共享状态有独立归属
|
||||
- 数据库方言差异有独立封装
|
||||
|
||||
## 9. 常见改动点
|
||||
|
||||
- 改共享订单号逻辑:`shared-production-ids-store.ts`
|
||||
- 改输入识别:`production-input-service.ts`
|
||||
- 改数据库差异:`validation-database.ts`
|
||||
- 改校验结果富化:`validation-application-service.ts`
|
||||
- 改 renderer 侧调用:`useValidation.ts`
|
||||
|
||||
## 10. 修改建议
|
||||
|
||||
- 不要再把共享状态放回 handler
|
||||
- 数据库分支优先收敛在 `validation-database`
|
||||
- 校验结果组装逻辑尽量集中在 application service
|
||||
- 跨模块共享数据要保持单向来源清晰
|
||||
236
docs/erp-login-debug-guide.md
Normal file
236
docs/erp-login-debug-guide.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# ERP 登录调试工具使用说明
|
||||
|
||||
## 目的
|
||||
|
||||
用于人工调试 ERP 登录流程,定位主界面特征元素,以便优化登录成功的判定逻辑。
|
||||
|
||||
## 前置准备
|
||||
|
||||
### 1. 配置 ERP 登录信息
|
||||
|
||||
编辑 `src/main/tools/erp-login-debug.ts` 文件,修改以下配置:
|
||||
|
||||
```typescript
|
||||
const ERP_CONFIG = {
|
||||
url: 'https://your-erp-server.com', // ← 修改为你的 ERP 地址
|
||||
username: 'your_username', // ← 修改为你的用户名
|
||||
password: 'your_password' // ← 修改为你的密码
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 确保 tsx 已安装
|
||||
|
||||
如果运行时报错提示找不到 `tsx`,请安装:
|
||||
|
||||
```bash
|
||||
npm install -g tsx
|
||||
# 或作为项目依赖
|
||||
npm install --save-dev tsx
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方式一:使用 npm 脚本(推荐)
|
||||
|
||||
```bash
|
||||
npm run debug:erp-login
|
||||
```
|
||||
|
||||
### 方式二:直接运行
|
||||
|
||||
```bash
|
||||
npx tsx src/main/tools/erp-login-debug.ts
|
||||
```
|
||||
|
||||
## 调试流程
|
||||
|
||||
### 步骤 1:启动脚本
|
||||
|
||||
运行命令后,脚本会显示配置信息并等待你确认:
|
||||
|
||||
```
|
||||
============================================================
|
||||
ERP 登录调试工具
|
||||
============================================================
|
||||
目标 URL: https://your-erp-server.com
|
||||
用户名:your_username
|
||||
密码: ***
|
||||
============================================================
|
||||
|
||||
操作步骤:
|
||||
1. 浏览器将自动打开并尝试登录
|
||||
2. 如果登录失败,请检查配置或手动重试
|
||||
3. 登录成功后,会自动暂停并打开开发者工具
|
||||
4. 使用元素选择器定位主界面特征元素
|
||||
5. 记录元素选择器,按 Ctrl+C 退出脚本
|
||||
|
||||
按 Enter 键开始...
|
||||
```
|
||||
|
||||
### 步骤 2:自动登录
|
||||
|
||||
脚本会自动执行:
|
||||
|
||||
- 打开浏览器
|
||||
- 导航到登录页面
|
||||
- 输入用户名和密码
|
||||
- 点击登录按钮
|
||||
- 处理强制登录确认对话框(如果有)
|
||||
|
||||
### 步骤 3:人工元素定位
|
||||
|
||||
登录成功后,脚本会暂停并显示:
|
||||
|
||||
```
|
||||
============================================================
|
||||
✓ 登录成功!
|
||||
============================================================
|
||||
|
||||
现在进入调试模式,请进行以下操作:
|
||||
|
||||
1. 按 F12 打开浏览器开发者工具
|
||||
2. 使用元素选择器 (Ctrl+Shift+C) 点击主界面特征元素
|
||||
3. 在 Elements 面板中右键元素 → Copy → Copy selector
|
||||
4. 或者使用 Playwright Inspector:
|
||||
- 在控制台输入:await page.pause()
|
||||
- 使用 Inspector 的元素选择工具
|
||||
|
||||
建议定位的特征元素:
|
||||
- 主界面顶部导航栏
|
||||
- 侧边菜单栏
|
||||
- 主内容区域的唯一标识
|
||||
- 用户信息显示区域
|
||||
- 任何登录后独有的界面元素
|
||||
|
||||
============================================================
|
||||
```
|
||||
|
||||
### 步骤 4:记录元素选择器
|
||||
|
||||
在开发者工具中:
|
||||
|
||||
1. **使用元素选择器** (Ctrl+Shift+C) 点击界面元素
|
||||
2. **在 Elements 面板** 查看元素 HTML
|
||||
3. **右键元素** → Copy → 选择以下之一:
|
||||
- `Copy selector` - CSS 选择器
|
||||
- `Copy XPath` - XPath 路径
|
||||
- `Copy JS path` - JavaScript 路径
|
||||
|
||||
### 步骤 5:更新 locators.ts
|
||||
|
||||
将找到的元素选择器添加到 `src/main/services/erp/locators.ts`:
|
||||
|
||||
```typescript
|
||||
export const ERP_LOCATORS = {
|
||||
// ... 现有配置 ...
|
||||
|
||||
// 新增:主界面特征元素(用于登录成功判定)
|
||||
mainPage: {
|
||||
topNavigationBar: '#top-nav', // 顶部导航栏
|
||||
sideMenu: '.side-menu', // 侧边菜单
|
||||
userProfile: '.user-profile', // 用户信息
|
||||
welcomeMessage: 'internal:has-text="欢迎"' // 欢迎消息
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 6:退出脚本
|
||||
|
||||
按 `Ctrl+C` 终止脚本,浏览器会在 5 秒后自动关闭。
|
||||
|
||||
## Playwright Inspector 使用技巧
|
||||
|
||||
### 开启 Inspector
|
||||
|
||||
在脚本暂停时,在浏览器控制台输入:
|
||||
|
||||
```javascript
|
||||
await page.pause()
|
||||
```
|
||||
|
||||
会打开 Playwright Inspector,提供:
|
||||
|
||||
- 元素选择器
|
||||
- 实时 locator 测试
|
||||
- 代码生成
|
||||
|
||||
### 测试 Locator
|
||||
|
||||
在 Inspector 控制台测试 locator 是否有效:
|
||||
|
||||
```javascript
|
||||
// 测试 CSS 选择器
|
||||
await page.locator('#top-nav').count()
|
||||
|
||||
// 测试 role-based 选择器
|
||||
await page.getByRole('navigation').count()
|
||||
|
||||
// 测试文本选择器
|
||||
await page.getByText('欢迎').count()
|
||||
```
|
||||
|
||||
如果返回数量 > 0,说明选择器有效。
|
||||
|
||||
## 推荐的特征元素
|
||||
|
||||
选择登录成功判定元素时,优先选择:
|
||||
|
||||
1. **唯一性** - 只在登录后出现
|
||||
2. **稳定性** - 不易随版本变更
|
||||
3. **易定位** - 有明确的 id、class 或文本
|
||||
|
||||
### 推荐元素示例
|
||||
|
||||
| 元素类型 | 选择器示例 | 说明 |
|
||||
| ------------ | ----------------------- | ---------------------- |
|
||||
| 顶部导航栏 | `#top-nav` | 登录后才会显示的主导航 |
|
||||
| 用户菜单 | `.user-menu` | 显示当前用户名的菜单 |
|
||||
| 欢迎消息 | `text=欢迎` | 包含用户名的欢迎语 |
|
||||
| 工作台标题 | `h1:has-text("工作台")` | 主界面标题 |
|
||||
| 功能模块网格 | `.module-grid` | 功能模块入口区域 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 登录失败,提示找不到元素
|
||||
|
||||
**A**: 检查以下几点:
|
||||
|
||||
1. ERP URL 是否正确
|
||||
2. 用户名密码是否正确
|
||||
3. 网络连接是否正常
|
||||
4. ERP 系统是否可访问
|
||||
5. 是否需要验证码(如果 ERP 有验证码,需要手动输入)
|
||||
|
||||
### Q: 登录后没有暂停
|
||||
|
||||
**A**: 检查控制台输出,可能登录流程中抛出了异常。查看错误信息并修复。
|
||||
|
||||
### Q: 如何调试特定页面?
|
||||
|
||||
**A**: 修改脚本中的登录后逻辑,导航到特定页面:
|
||||
|
||||
```typescript
|
||||
// 登录后导航到特定页面
|
||||
await page.goto(`${ERP_CONFIG.url}/yonbip/sc`)
|
||||
await page.waitForTimeout(3000)
|
||||
```
|
||||
|
||||
### Q: 如何保存调试会话?
|
||||
|
||||
**A**: Playwright 支持录制 trace:
|
||||
|
||||
```typescript
|
||||
await context.tracing.start({ screenshots: true, snapshots: true })
|
||||
// ... 操作 ...
|
||||
await context.tracing.stop({ path: 'trace.zip' })
|
||||
```
|
||||
|
||||
然后使用 `npx playwright show-trace trace.zip` 查看。
|
||||
|
||||
## 下一步
|
||||
|
||||
找到稳定的主界面元素后,修改以下文件优化登录判定:
|
||||
|
||||
1. **更新 locators.ts** - 添加主界面元素定位器
|
||||
2. **修改 erp-auth.ts** - 在登录成功后等待主界面元素
|
||||
3. **更新测试** - 验证新的登录判定逻辑
|
||||
241
docs/erp-login-debug-quickref.md
Normal file
241
docs/erp-login-debug-quickref.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# ERP 登录调试工具 - 快速参考
|
||||
|
||||
## 创建的文件
|
||||
|
||||
### 1. 调试脚本
|
||||
|
||||
**路径**: `src/main/tools/erp-login-debug.ts`
|
||||
|
||||
用途:人工调试 ERP 登录流程,定位主界面特征元素
|
||||
|
||||
### 2. 使用文档
|
||||
|
||||
**路径**: `docs/erp-login-debug-guide.md`
|
||||
|
||||
详细的调试工具使用说明
|
||||
|
||||
### 3. package.json 更新
|
||||
|
||||
添加了新的 npm 脚本和依赖:
|
||||
|
||||
- `debug:erp-login` - 运行调试脚本
|
||||
- `debug:config-path` - 运行配置路径调试(已有)
|
||||
- `tsx` - TypeScript 执行器依赖
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 步骤 1:配置登录信息
|
||||
|
||||
编辑 `src/main/tools/erp-login-debug.ts` 第 19-23 行:
|
||||
|
||||
```typescript
|
||||
const ERP_CONFIG = {
|
||||
url: 'https://your-erp-server.com', // ← 修改
|
||||
username: 'your_username', // ← 修改
|
||||
password: 'your_password' // ← 修改
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2:运行调试
|
||||
|
||||
```bash
|
||||
npm run debug:erp-login
|
||||
```
|
||||
|
||||
### 步骤 3:定位元素
|
||||
|
||||
登录成功后:
|
||||
|
||||
1. 按 **F12** 打开开发者工具
|
||||
2. 按 **Ctrl+Shift+C** 启用元素选择器
|
||||
3. 点击主界面特征元素
|
||||
4. 右键 → Copy → Copy selector
|
||||
|
||||
### 步骤 4:更新定位器
|
||||
|
||||
将找到的元素添加到 `src/main/services/erp/locators.ts`:
|
||||
|
||||
```typescript
|
||||
export const ERP_LOCATORS = {
|
||||
// ... 现有配置 ...
|
||||
|
||||
// 新增:主界面特征元素
|
||||
mainPage: {
|
||||
// 在此添加找到的元素
|
||||
topNav: '#top-nav',
|
||||
userMenu: '.user-menu'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 脚本功能
|
||||
|
||||
### 自动执行
|
||||
|
||||
- ✅ 启动浏览器(可见窗口,非无头模式)
|
||||
- ✅ 导航到登录页面
|
||||
- ✅ 输入用户名和密码
|
||||
- ✅ 点击登录按钮
|
||||
- ✅ 处理强制登录确认对话框
|
||||
|
||||
### 调试支持
|
||||
|
||||
- ✅ 登录成功后自动暂停
|
||||
- ✅ 保持浏览器打开
|
||||
- ✅ 支持 F12 开发者工具
|
||||
- ✅ 支持 Playwright Inspector
|
||||
|
||||
### 安全特性
|
||||
|
||||
- ✅ 密码显示为星号
|
||||
- ✅ 需要按 Enter 确认后才开始
|
||||
- ✅ 退出前 5 秒缓冲时间
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 运行调试脚本
|
||||
npm run debug:erp-login
|
||||
|
||||
# 或使用 npx 直接运行
|
||||
npx tsx src/main/tools/erp-login-debug.ts
|
||||
|
||||
# 查看帮助
|
||||
npx tsx --help
|
||||
```
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 测试 Locator 有效性
|
||||
|
||||
在浏览器控制台(登录后暂停时):
|
||||
|
||||
```javascript
|
||||
// 测试 CSS 选择器
|
||||
await page.locator('#top-nav').count()
|
||||
|
||||
// 测试文本选择器
|
||||
await page.getByText('欢迎').isVisible()
|
||||
|
||||
// 测试 role 选择器
|
||||
await page.getByRole('navigation').count()
|
||||
```
|
||||
|
||||
返回值 > 0 或 true 表示选择器有效。
|
||||
|
||||
### 查看元素详细信息
|
||||
|
||||
```javascript
|
||||
// 获取元素 HTML
|
||||
const element = await page.$('#top-nav')
|
||||
console.log(await element.innerHTML())
|
||||
|
||||
// 获取元素属性
|
||||
console.log(await element.getAttributes())
|
||||
```
|
||||
|
||||
### 截图保存
|
||||
|
||||
```javascript
|
||||
// 全屏截图
|
||||
await page.screenshot({ path: 'login-success.png' })
|
||||
|
||||
// 元素截图
|
||||
const element = await page.$('#top-nav')
|
||||
await element.screenshot({ path: 'top-nav.png' })
|
||||
```
|
||||
|
||||
## 推荐的特征元素
|
||||
|
||||
选择登录成功判定元素的标准:
|
||||
|
||||
| 标准 | 说明 | 示例 |
|
||||
| ---------- | -------------- | ------------------- |
|
||||
| **唯一性** | 只在登录后出现 | 用户菜单、工作台 |
|
||||
| **稳定性** | 不易随版本变更 | ID 选择器优于 class |
|
||||
| **易定位** | 有明确的标识 | 有 id、独特文本 |
|
||||
|
||||
### 推荐元素类型
|
||||
|
||||
1. **顶部导航栏** - `#top-nav`, `.navbar`
|
||||
2. **用户信息区域** - `.user-info`, `.user-menu`
|
||||
3. **欢迎消息** - 包含用户名的文本
|
||||
4. **功能模块入口** - 主界面的模块网格
|
||||
5. **侧边菜单栏** - `.sidebar`, `.menu`
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题:脚本启动后立即退出
|
||||
|
||||
**原因**: tsx 未安装
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 问题:找不到用户名/密码输入框
|
||||
|
||||
**原因**:
|
||||
|
||||
1. ERP URL 不正确
|
||||
2. 页面结构已变更
|
||||
3. 登录页面加载超时
|
||||
|
||||
**解决**:
|
||||
|
||||
1. 检查 ERP_CONFIG.url 是否正确
|
||||
2. 手动打开 URL 确认页面结构
|
||||
3. 增加 timeout 值(第 25 行)
|
||||
|
||||
### 问题:登录后没有暂停
|
||||
|
||||
**原因**: 登录流程抛出异常
|
||||
|
||||
**解决**: 查看控制台错误信息,检查:
|
||||
|
||||
- 网络连接
|
||||
- ERP 系统可用性
|
||||
- 用户名密码正确性
|
||||
|
||||
### 问题:无法定位元素
|
||||
|
||||
**原因**:
|
||||
|
||||
1. 元素在 iframe 中
|
||||
2. 元素动态加载
|
||||
3. 选择器不正确
|
||||
|
||||
**解决**:
|
||||
|
||||
1. 检查元素是否在嵌套 iframe 中
|
||||
2. 增加等待时间 `await page.waitForTimeout(2000)`
|
||||
3. 使用更具体的选择器
|
||||
|
||||
## 下一步
|
||||
|
||||
找到稳定的主界面元素后:
|
||||
|
||||
1. **更新 locators.ts**
|
||||
- 添加 `mainPage` 配置节
|
||||
- 定义登录成功判定元素
|
||||
|
||||
2. **修改 erp-auth.ts**
|
||||
- 在 `login()` 方法末尾
|
||||
- 等待主界面元素出现
|
||||
- 作为登录成功的最终判定
|
||||
|
||||
3. **验证修改**
|
||||
- 重新运行调试脚本
|
||||
- 确认新的判定逻辑有效
|
||||
- 更新相关文档
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------------------------------- | ---------- |
|
||||
| `src/main/tools/erp-login-debug.ts` | 调试脚本 |
|
||||
| `src/main/services/erp/locators.ts` | 元素定位器 |
|
||||
| `src/main/services/erp/erp-auth.ts` | 登录服务 |
|
||||
| `docs/erp-login-debug-guide.md` | 详细文档 |
|
||||
1268
docs/extractor-start-button-flow.md
Normal file
1268
docs/extractor-start-button-flow.md
Normal file
File diff suppressed because it is too large
Load Diff
487
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
487
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 配置保存优化设计文档
|
||||
|
||||
**日期:** 2026-03-03
|
||||
**分支:** fix/settings-partial-save
|
||||
**状态:** 设计阶段
|
||||
|
||||
---
|
||||
|
||||
## 问题描述
|
||||
|
||||
当前设置界面只能配置 3 个字段(ERP URL、用户名、密码),但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE`、`VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。
|
||||
|
||||
### 根本原因
|
||||
|
||||
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
||||
|
||||
**数据流问题:**
|
||||
|
||||
```
|
||||
SettingsPage (只修改 ERP URL)
|
||||
↓ 发送完整的 settings 对象
|
||||
ConfigManager.saveAllSettings()
|
||||
↓ 覆盖所有字段到缓存
|
||||
.env 文件被完全重写(丢失未被 UI 包含的字段)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
采用 **方案 A(深度合并)+ 方案 C(字段白名单)** 的组合策略:
|
||||
|
||||
### 核心策略
|
||||
|
||||
1. **部分更新**:只更新传入的字段,保留其他字段不变
|
||||
2. **白名单验证**:只允许 UI 支持的字段被修改
|
||||
3. **备份机制**:保存前备份,失败可回滚
|
||||
4. **安全日志**:记录所有配置变更操作
|
||||
|
||||
---
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ SettingsPage │
|
||||
│ (Renderer) │
|
||||
└────────┬────────┘
|
||||
│ 只发送支持的字段
|
||||
│ { erp: { url, username, password } }
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Settings Handler│
|
||||
│ (IPC Bridge) │
|
||||
└────────┬────────┘
|
||||
│ 传递部分配置 (Partial<SettingsData>)
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ ConfigManager │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 1. 验证字段白名单 │ │
|
||||
│ │ 2. 深度合并当前配置 │ │
|
||||
│ │ 3. 备份 .env 文件 │ │
|
||||
│ │ 4. 原子写入新配置 │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 改动点
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
| -------------------------------------------- | -------- | ------------------------------------------------ |
|
||||
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
||||
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
||||
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
||||
|
||||
---
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 深度合并工具函数
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 深度合并两个对象,只更新 target 中存在的字段
|
||||
* 保留 source 中 target 没有的字段
|
||||
*/
|
||||
function deepMerge<T>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
|
||||
for (const key in target) {
|
||||
if (key in target) {
|
||||
const targetValue = target[key]
|
||||
const sourceValue = result[key]
|
||||
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMerge(sourceValue, targetValue)
|
||||
} else if (targetValue !== undefined) {
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 字段白名单验证
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 定义 UI 可编辑的字段路径
|
||||
* 使用点号表示法:'section.field'
|
||||
*/
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password'
|
||||
// 未来扩展:
|
||||
// 'database.dbType',
|
||||
// 'paths.dataDir',
|
||||
// ...
|
||||
]
|
||||
|
||||
/**
|
||||
* 验证配置更新是否只包含允许的字段
|
||||
*/
|
||||
function validateEditableFields(settings: Partial<SettingsData>): {
|
||||
valid: boolean
|
||||
invalidFields: string[]
|
||||
} {
|
||||
const invalidFields: string[] = []
|
||||
|
||||
for (const [section, values] of Object.entries(settings)) {
|
||||
if (values && typeof values === 'object') {
|
||||
for (const field of Object.keys(values)) {
|
||||
const fieldPath = `${section}.${field}`
|
||||
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
|
||||
invalidFields.push(fieldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: invalidFields.length === 0,
|
||||
invalidFields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 部分保存方法
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 保存部分配置(只更新传入的字段)
|
||||
*/
|
||||
public async savePartialSettings(
|
||||
settings: Partial<SettingsData>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 步骤 1: 验证字段白名单
|
||||
const validation = validateEditableFields(settings)
|
||||
if (!validation.valid) {
|
||||
log.warn('Attempted to save non-editable fields', {
|
||||
invalidFields: validation.invalidFields
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤 2: 读取当前配置
|
||||
const currentSettings = this.getAllSettings()
|
||||
|
||||
// 步骤 3: 深度合并
|
||||
const mergedSettings = deepMerge(currentSettings, settings)
|
||||
|
||||
// 步骤 4: 备份并保存
|
||||
const backupSuccess = await this.backupEnvFile()
|
||||
if (!backupSuccess) {
|
||||
log.warn('Failed to backup .env file, proceeding with caution')
|
||||
}
|
||||
|
||||
const saveSuccess = await this.saveAllSettings(mergedSettings)
|
||||
|
||||
if (!saveSuccess) {
|
||||
// 保存失败,尝试恢复备份
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: '保存配置失败,已恢复原配置'
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Settings saved successfully', {
|
||||
updatedFields: Object.keys(settings)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error in savePartialSettings', { error: message })
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: `保存配置时发生错误:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 备份与恢复机制
|
||||
|
||||
```typescript
|
||||
private backupPath: string
|
||||
|
||||
constructor() {
|
||||
// ...
|
||||
this.backupPath = path.resolve(__dirname, '../../.env.backup')
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份当前 .env 文件
|
||||
*/
|
||||
private async backupEnvFile(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
fs.copyFileSync(this.envPath, this.backupPath)
|
||||
log.debug('Backup created', { path: this.backupPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to backup .env file', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从备份恢复 .env 文件
|
||||
*/
|
||||
private async restoreBackup(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.envPath)
|
||||
await this.loadEnvFile() // 重新加载到缓存
|
||||
log.info('Restored from backup')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to restore backup', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IPC 调用链路调整
|
||||
|
||||
### settings-handler.ts
|
||||
|
||||
```typescript
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings', {
|
||||
sections: Object.keys(settings)
|
||||
})
|
||||
|
||||
// 使用新的部分保存方法
|
||||
const result = await configManager.savePartialSettings(settings)
|
||||
|
||||
if (result.success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings', {
|
||||
error: result.error
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || '保存设置失败'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `保存设置失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**关键改动:**
|
||||
|
||||
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
||||
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
||||
|
||||
---
|
||||
|
||||
## 前端优化(双重保险)
|
||||
|
||||
### SettingsPage.tsx
|
||||
|
||||
```typescript
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
// 只发送 UI 支持的字段(双重保险)
|
||||
const partialSettings = {
|
||||
erp: {
|
||||
url: settings.erp?.url,
|
||||
username: settings.erp?.username,
|
||||
password: settings.erp?.password
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electron.settings.saveSettings(partialSettings)
|
||||
|
||||
if (result.success) {
|
||||
setIsModified(false)
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试场景
|
||||
|
||||
```typescript
|
||||
describe('ConfigManager.savePartialSettings', () => {
|
||||
it('应该只更新指定的字段,保留其他字段', async () => {
|
||||
const initial = {
|
||||
erp: { url: 'http://old.com', username: 'user1' },
|
||||
database: { dbType: 'mysql' }
|
||||
}
|
||||
|
||||
const update = {
|
||||
erp: { url: 'http://new.com' }
|
||||
}
|
||||
|
||||
await configManager.savePartialSettings(update)
|
||||
const result = configManager.getAllSettings()
|
||||
|
||||
expect(result.erp.url).toBe('http://new.com')
|
||||
expect(result.erp.username).toBe('user1') // 保留
|
||||
expect(result.database.dbType).toBe('mysql') // 保留
|
||||
})
|
||||
|
||||
it('应该拒绝未授权的字段更新', async () => {
|
||||
const invalidUpdate = {
|
||||
database: { dbType: 'postgres' }
|
||||
}
|
||||
|
||||
const result = await configManager.savePartialSettings(invalidUpdate)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不允许修改')
|
||||
})
|
||||
|
||||
it('保存失败时应该恢复备份', async () => {
|
||||
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
||||
throw new Error('Disk full')
|
||||
})
|
||||
|
||||
const result = await configManager.savePartialSettings({ erp: { url: 'x' } })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 手动验证步骤
|
||||
|
||||
1. 打开 `.env`,记录所有字段值
|
||||
2. 打开设置页面,只修改 ERP URL
|
||||
3. 点击保存
|
||||
4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值
|
||||
|
||||
---
|
||||
|
||||
## 未来扩展性
|
||||
|
||||
### 1. 白名单配置化
|
||||
|
||||
当设置页面需要支持更多配置时:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'database.dbType', // 新增
|
||||
'paths.dataDir', // 新增
|
||||
'extraction.batchSize' // 新增
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 按用户角色分级
|
||||
|
||||
```typescript
|
||||
const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
|
||||
Admin: ['*'],
|
||||
User: ['erp.url', 'erp.username', 'erp.password'],
|
||||
Guest: []
|
||||
}
|
||||
|
||||
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
|
||||
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
||||
// 验证逻辑...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 配置变更审计
|
||||
|
||||
```typescript
|
||||
interface ConfigChange {
|
||||
timestamp: Date
|
||||
user: string
|
||||
field: string
|
||||
oldValue: string
|
||||
newValue: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
下一步将创建详细的实施计划,包括:
|
||||
|
||||
1. 在 ConfigManager 中添加深度合并和验证函数
|
||||
2. 实现 `savePartialSettings()` 方法
|
||||
3. 添加备份与恢复机制
|
||||
4. 更新 IPC handler 调用
|
||||
5. 前端优化(只发送必要字段)
|
||||
6. 编写单元测试
|
||||
7. 集成测试和手动验证
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
| ---------------- | ---------- | ----------------------------- |
|
||||
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
||||
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
||||
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
||||
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### 相关文件
|
||||
|
||||
- `src/main/services/config/config-manager.ts` - 配置管理器
|
||||
- `src/main/ipc/settings-handler.ts` - IPC 处理器
|
||||
- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面
|
||||
- `src/main/types/settings.types.ts` - 类型定义
|
||||
|
||||
### 参考
|
||||
|
||||
- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖
|
||||
- 设计原则:安全优先、最小化修改、可扩展性
|
||||
1038
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
1038
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
# Electron 最佳实践优化计划
|
||||
|
||||
本文档基于 `$electron-best-practices` 对当前项目的审查结果整理而成,目标不是一次性重构整个 Electron 应用,而是按照“低风险、可验证、逐步收敛”的方式,分阶段提升主进程、preload、IPC、更新模块和工程配置的可维护性。
|
||||
|
||||
## 1. 计划背景
|
||||
|
||||
当前项目已经具备了较好的 Electron 基础结构:
|
||||
|
||||
- 主进程、preload、renderer 三层已分离
|
||||
- Renderer 通过 `contextBridge` 暴露能力
|
||||
- IPC 统一采用 `invoke/handle` 模式
|
||||
- 大部分主进程能力已经模块化到 `ipc/` 与 `services/`
|
||||
|
||||
但从长期维护角度看,项目仍存在几个明显问题:
|
||||
|
||||
- `src/main/index.ts` 入口文件承担职责过多
|
||||
- 部分 IPC handler 仍然是“厚编排层”
|
||||
- `src/preload/index.ts` 更像“接口总表”,不是按领域划分的 facade
|
||||
- 更新链路实现较重,边界尚不清晰
|
||||
- 打包配置存在模板残留,容易误导维护者
|
||||
- Electron 边界层测试尚未系统化
|
||||
|
||||
## 2. 优化目标
|
||||
|
||||
本轮优化聚焦以下目标:
|
||||
|
||||
- 让主进程入口只负责启动顺序,不承载业务细节
|
||||
- 让 IPC handler 回归“薄壳”,把编排逻辑下沉到应用服务层
|
||||
- 让 preload API 按业务领域组织,而不是按主进程实现镜像
|
||||
- 收敛更新模块的职责边界,降低后续维护复杂度
|
||||
- 清理打包与发布配置中的模板残留
|
||||
- 为 Electron 边界补足更稳定的测试支撑
|
||||
|
||||
## 3. 优化范围
|
||||
|
||||
本计划优先处理 Electron 工程化与可维护性问题,不把安全性作为唯一优先目标,但会顺带处理那些同时影响可维护性的边界设计问题。
|
||||
|
||||
本次计划重点覆盖:
|
||||
|
||||
- 主进程启动与应用初始化
|
||||
- IPC handler 与 application service 分层
|
||||
- preload API 结构
|
||||
- 更新模块
|
||||
- 打包配置
|
||||
- Electron 边界层测试
|
||||
|
||||
暂不作为本轮首要目标:
|
||||
|
||||
- 大规模 UI 重构
|
||||
- 业务流程重写
|
||||
- ERP 自动化细节重构
|
||||
|
||||
## 4. 当前问题总览
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MainIndex[src/main/index.ts]
|
||||
IPC[IPC Handlers]
|
||||
Preload[src/preload/index.ts]
|
||||
Update[update-service.ts]
|
||||
Builder[electron-builder.yml]
|
||||
Tests[tests/unit]
|
||||
|
||||
MainIndex -->|启动逻辑过重| Maintainability[维护成本上升]
|
||||
IPC -->|编排过厚| Maintainability
|
||||
Preload -->|API 面过大| Maintainability
|
||||
Update -->|职责边界不清| Maintainability
|
||||
Builder -->|模板残留| Maintainability
|
||||
Tests -->|边界覆盖不足| Maintainability
|
||||
```
|
||||
|
||||
## 5. 分阶段执行计划
|
||||
|
||||
### Phase 1: 收敛主进程入口
|
||||
|
||||
目标:
|
||||
|
||||
- 让 `src/main/index.ts` 只表达启动顺序
|
||||
- 把运行时检查、窗口创建、异常守卫、模块初始化拆到独立函数或模块
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/main/index.ts`
|
||||
- `src/main/bootstrap/` 下新增或调整模块
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `bootstrapApp()`
|
||||
- `createMainWindow()`
|
||||
- `setupProcessGuards()`
|
||||
- `verifyPlaywrightRuntime()`
|
||||
- `initializeAppServices()`
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低入口文件修改风险
|
||||
- 提高启动问题排查效率
|
||||
- 为多窗口、多实例策略预留更清晰的扩展点
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低到中
|
||||
|
||||
验证方式:
|
||||
|
||||
- 应用冷启动成功
|
||||
- 窗口创建与关闭流程正常
|
||||
- 异常日志、更新初始化、IPC 注册行为不回归
|
||||
|
||||
### Phase 2: 让 IPC Handler 回归薄壳
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `cleaner-handler`、`auth-handler` 这类厚编排层下沉到应用服务
|
||||
- 明确 handler、application service、infrastructure service 的职责边界
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/main/ipc/cleaner-handler.ts`
|
||||
- `src/main/ipc/auth-handler.ts`
|
||||
- `src/main/ipc/update-handler.ts`
|
||||
- `src/main/services/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `CleanerApplicationService`
|
||||
- `AuthApplicationService`
|
||||
- `UpdateApplicationService`
|
||||
|
||||
handler 只负责:
|
||||
|
||||
- 接收请求
|
||||
- 调用 service
|
||||
- 映射返回结构
|
||||
- 统一错误包装
|
||||
|
||||
预期收益:
|
||||
|
||||
- 提升主进程业务编排的可测试性
|
||||
- 降低 handler 文件复杂度
|
||||
- 让业务流程更容易被复用和替换
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中
|
||||
|
||||
验证方式:
|
||||
|
||||
- 关键 IPC 流程回归测试
|
||||
- 原有 renderer 调用协议保持不变
|
||||
- 清理、登录、更新等主流程手动验收通过
|
||||
|
||||
### Phase 3: 重组 Preload API
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `src/preload/index.ts` 从“大总表”改造成“按领域组织的 facade”
|
||||
- 稳定 renderer 对 Electron 能力的访问边界
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/preload/index.ts`
|
||||
- `src/preload/index.d.ts`
|
||||
- `src/main/types/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `authApi`
|
||||
- `cleanerApi`
|
||||
- `updateApi`
|
||||
- `reportApi`
|
||||
- `fileApi`
|
||||
|
||||
建议原则:
|
||||
|
||||
- renderer 只拿到业务语义接口
|
||||
- preload 不原样映射主进程实现细节
|
||||
- 类型定义集中管理,避免 renderer 和 main 双边漂移
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低渲染层对 IPC 细节的耦合
|
||||
- 提高 preload 的可读性和可扩展性
|
||||
- 为后续做 runtime schema 校验打基础
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中
|
||||
|
||||
验证方式:
|
||||
|
||||
- `npm run typecheck`
|
||||
- preload surface 测试通过
|
||||
- 主要页面功能回归正常
|
||||
|
||||
### Phase 4: 收敛更新模块边界
|
||||
|
||||
目标:
|
||||
|
||||
- 把更新服务中的下载、状态管理、安装协调、日志处理边界进一步明确
|
||||
- 降低自定义更新链路的维护成本
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/main/services/update/update-service.ts`
|
||||
- `src/main/ipc/update-handler.ts`
|
||||
- `src/main/types/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `UpdateStateMachine`
|
||||
- `UpdateDownloadService`
|
||||
- `UpdateInstallCoordinator`
|
||||
- `UpdateEventBridge`
|
||||
|
||||
预期收益:
|
||||
|
||||
- 更新问题更容易定位
|
||||
- 状态流转更容易测试
|
||||
- 后续切换更新策略时影响面更小
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中到高
|
||||
|
||||
验证方式:
|
||||
|
||||
- 更新检查、下载、安装提示链路验证
|
||||
- 状态事件顺序测试
|
||||
- 失败重试与异常日志验证
|
||||
|
||||
### Phase 5: 清理打包与发布配置
|
||||
|
||||
目标:
|
||||
|
||||
- 让构建配置更贴近当前项目实际维护范围
|
||||
- 清理无效、模板化或误导性的配置项
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `electron-builder.yml`
|
||||
- `package.json`
|
||||
- `scripts/` 下发布相关脚本
|
||||
- `docs/releases/` 与构建说明文档
|
||||
|
||||
重点检查项:
|
||||
|
||||
- 实际支持的平台范围
|
||||
- 发布目标与渠道
|
||||
- 无关权限说明
|
||||
- Windows 优先配置是否清晰
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低发版配置理解成本
|
||||
- 减少“看似支持、实际无人维护”的伪能力
|
||||
- 让发布文档与配置保持一致
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低
|
||||
|
||||
验证方式:
|
||||
|
||||
- 本地构建通过
|
||||
- 发布脚本执行链路无回归
|
||||
- 文档与配置一致性核对完成
|
||||
|
||||
### Phase 6: 补强 Electron 边界层测试
|
||||
|
||||
目标:
|
||||
|
||||
- 让最容易劣化的 Electron 边界层拥有稳定回归保护
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `tests/unit/preload-surface.test.ts`
|
||||
- `tests/unit/ipc-index.test.ts`
|
||||
- 新增 `tests/unit/update-service.*`
|
||||
- 新增 `tests/unit/cleaner-handler.*`
|
||||
- 新增 `tests/unit/auth-handler.*`
|
||||
|
||||
优先补测内容:
|
||||
|
||||
- 主进程启动编排
|
||||
- preload surface 稳定性
|
||||
- IPC 返回包装与错误路径
|
||||
- update service 状态迁移
|
||||
- handler 与 service 交互边界
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低后续拆分时的回归风险
|
||||
- 提升主进程重构信心
|
||||
- 让 Electron 工程层而非仅业务工具层获得测试保护
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低
|
||||
|
||||
验证方式:
|
||||
|
||||
- 单元测试通过
|
||||
- 关键流程 smoke test 通过
|
||||
|
||||
## 6. 推荐执行顺序
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Phase 1 主进程入口收敛]
|
||||
B[Phase 2 IPC Handler 薄壳化]
|
||||
C[Phase 3 Preload API 重组]
|
||||
D[Phase 4 更新模块收敛]
|
||||
E[Phase 5 构建配置清理]
|
||||
F[Phase 6 Electron 边界测试补强]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
D --> E
|
||||
B --> F
|
||||
C --> F
|
||||
D --> F
|
||||
```
|
||||
|
||||
建议优先顺序:
|
||||
|
||||
1. 先做主进程入口收敛
|
||||
2. 再做 IPC handler 薄壳化
|
||||
3. 然后重组 preload API
|
||||
4. 再处理更新模块
|
||||
5. 清理打包配置
|
||||
6. 在每一阶段同步补强测试
|
||||
|
||||
说明:
|
||||
|
||||
- `Phase 1` 与 `Phase 2` 性价比最高
|
||||
- `Phase 3` 适合在 handler 边界稳定后推进
|
||||
- `Phase 4` 风险相对更高,应放在前面边界清晰后再处理
|
||||
- `Phase 6` 不应完全放到最后,建议伴随每阶段一起推进
|
||||
|
||||
## 7. 每阶段完成标准
|
||||
|
||||
每一阶段建议采用统一完成标准:
|
||||
|
||||
- 相关模块职责边界变清晰
|
||||
- 对外接口保持兼容或完成显式迁移
|
||||
- `npm run lint` 通过
|
||||
- `npm run typecheck` 通过
|
||||
- 相关单元测试通过
|
||||
- 关键手工路径验证完成
|
||||
- 对应文档同步更新
|
||||
|
||||
## 8. 本计划与现有重构工作的衔接
|
||||
|
||||
当前已经完成的两轮重构:
|
||||
|
||||
- `validation-handler` 第一阶段拆分
|
||||
- `useCleaner` 第一阶段拆分
|
||||
|
||||
它们为本计划提供了两个基础:
|
||||
|
||||
- 团队已经验证“先拆超大文件,再保持对外行为不变”的策略可行
|
||||
- 后续继续拆 `cleaner-handler`、`preload`、`App` 时,可以沿用相同方法论
|
||||
|
||||
因此,Electron 向优化建议优先从主进程和边界层继续推进,而不是立刻进入更深的 UI 重构。
|
||||
|
||||
## 9. 后续建议
|
||||
|
||||
建议后续执行方式如下:
|
||||
|
||||
1. 先按本计划完成 `Phase 1`
|
||||
2. 每完成一个阶段,单独补一份重构说明文档
|
||||
3. 每个阶段单独提交,避免一次性大改
|
||||
4. 每阶段结束后重新运行 `lint`、`typecheck` 和对应测试
|
||||
|
||||
如果后续决定正式执行,本计划可作为 Electron 工程化重构的主索引文档持续维护。
|
||||
@@ -0,0 +1,495 @@
|
||||
# React 最佳实践优化计划
|
||||
|
||||
本文档基于 `$vercel-react-best-practices` 对当前项目 React 渲染层的审查结果整理而成,目标不是立刻重写页面,而是按“先收敛数据流,再拆重型组件,最后做体验与性能微调”的顺序,逐步降低渲染层维护成本。
|
||||
|
||||
## 1. 审查背景
|
||||
|
||||
当前项目的 React 层已经具备一些不错的基础:
|
||||
|
||||
- 页面与 Electron 主进程通过 preload facade 通信
|
||||
- 关键业务已经逐步抽到 hook 和 service
|
||||
- `Cleaner` 相关逻辑已经做过第一轮拆分
|
||||
- 更新、认证、日志、校验等流程已有一定模块意识
|
||||
|
||||
但从 `$vercel-react-best-practices` 的角度看,当前主要问题仍集中在:
|
||||
|
||||
- 页面容器组件承担过多状态与副作用
|
||||
- 大 hook 同时管理初始化、交互状态、远程请求、持久化
|
||||
- effect 数量偏多,且存在“启动时拉很多东西、认证后再拉一遍”的流程
|
||||
- 重型 UI 区块还没有进一步拆成可稳定复用的小边界
|
||||
- 某些异步请求与 UI 更新仍可进一步并行化、延后 await 或降低重渲染范围
|
||||
|
||||
## 2. Skill 视角下的主要问题
|
||||
|
||||
### 2.1 高优先级: `App.tsx` 仍是重型入口容器
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
|
||||
问题表现:
|
||||
|
||||
- 认证初始化、更新订阅、页面导航、顶部壳层、登录/选人/更新弹窗都集中在一个组件里
|
||||
- `initializeAuth()` 同时负责获取机器名、silent login、admin 用户分流、错误兜底
|
||||
- `useEffect` 和回调之间仍有较强耦合,后续继续扩展容易变成新的“前端编排中心”
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `rerender-split-combined-hooks`
|
||||
- `rerender-move-effect-to-event`
|
||||
- `advanced-init-once`
|
||||
|
||||
建议方向:
|
||||
|
||||
- 抽出 `useAppBootstrap`
|
||||
- 抽出 `useUpdateController`
|
||||
- 把顶部壳层拆成 `AppShell`
|
||||
- 把未认证态与已认证态拆成两条渲染分支组件
|
||||
|
||||
### 2.2 高优先级: `CleanerPage` + `useCleaner` 仍然是“大页面 + 大 hook”模式
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
|
||||
问题表现:
|
||||
|
||||
- `CleanerPage` 同时渲染左侧筛选区、顶部工具栏、结果表格、底部执行区和多个弹窗
|
||||
- `useCleaner` 同时承担权限初始化、sessionStorage 同步、配置加载、进度订阅、校验请求、导出、执行删除、内联编辑、确认弹窗状态
|
||||
- hook 返回面非常大,页面对 hook 的内部结构有明显耦合
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `rerender-split-combined-hooks`
|
||||
- `rerender-derived-state-no-effect`
|
||||
- `rerender-no-inline-components`
|
||||
- `rendering-content-visibility`
|
||||
|
||||
建议方向:
|
||||
|
||||
- `useCleanerPageState`
|
||||
- `useCleanerExecution`
|
||||
- `useCleanerSelection`
|
||||
- `CleanerSidebar`
|
||||
- `CleanerToolbar`
|
||||
- `CleanerResultsTable`
|
||||
- `CleanerExecutionBar`
|
||||
|
||||
### 2.3 中优先级: `ExtractorPage` 里存在“输入变化即触发跨模块副作用”的同步路径
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
|
||||
问题表现:
|
||||
|
||||
- `orderNumbers` 每次变化都会写 `sessionStorage`
|
||||
- 同时每次变化都会调用 `window.electron.validation.setSharedProductionIds()` 或 `clearSharedProductionIds()`
|
||||
- 这条路径把“输入态”和“共享业务态”绑得很紧,后续如果输入组件更复杂,容易造成高频桥接调用
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `rerender-move-effect-to-event`
|
||||
- `client-localstorage-schema`
|
||||
- `js-cache-storage`
|
||||
|
||||
建议方向:
|
||||
|
||||
- 只在“格式化完成 / 用户提交 / debounce 稳定后”同步共享订单号
|
||||
- 把 sessionStorage 读写抽到专门 persistence helper
|
||||
- 为共享 Production ID 增加单独同步入口,而不是输入 effect 隐式触发
|
||||
|
||||
### 2.4 中优先级: 更新对话框的异步拉取和状态切换还可以再收敛
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
- `src/renderer/src/App.tsx`
|
||||
|
||||
问题表现:
|
||||
|
||||
- `App` 负责状态订阅和 catalog/status 刷新
|
||||
- `UpdateDialog` 内部再负责根据选中版本拉 changelog
|
||||
- 当前实现是可工作的,但状态来源分散,后续容易出现 “catalog 变了 / changelog 还在旧请求中” 的边界问题
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `async-defer-await`
|
||||
- `async-parallel`
|
||||
- `rerender-dependencies`
|
||||
- `rendering-usetransition-loading`
|
||||
|
||||
建议方向:
|
||||
|
||||
- 建立 `useUpdateDialogState`
|
||||
- catalog/status/changelog 分层管理
|
||||
- 选版本后的 changelog 拉取用请求标识或最新值保护
|
||||
- 对切换版本时的 UI 更新引入 `startTransition`
|
||||
|
||||
### 2.5 中优先级: 页面级异步初始化还缺少统一“启动编排 hook”
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
|
||||
问题表现:
|
||||
|
||||
- 认证初始化、Cleaner 初始化、配置加载、进度订阅分别散在多个组件和 hook 的 `useEffect` 中
|
||||
- 目前逻辑可读,但入口分散,出现启动问题时需要在多个位置来回追
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `advanced-init-once`
|
||||
- `async-parallel`
|
||||
- `rerender-split-combined-hooks`
|
||||
|
||||
建议方向:
|
||||
|
||||
- `useAppBootstrap`
|
||||
- `useCleanerBootstrap`
|
||||
- 把“初始加载”“事件订阅”“持久化恢复”拆成更小的 effect 组
|
||||
|
||||
### 2.6 中优先级: 组件树里还有一些可延迟加载的重型弹窗
|
||||
|
||||
涉及文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
- `src/renderer/src/components/ReportViewerDialog.tsx`
|
||||
- `src/renderer/src/components/ExecutionReportDialog.tsx`
|
||||
- `src/renderer/src/components/MaterialTypeManagementDialog.tsx`
|
||||
|
||||
问题表现:
|
||||
|
||||
- 多个重型弹窗在页面初始渲染时就参与静态导入
|
||||
- 像 `ReportViewerDialog`、Markdown 渲染、报告浏览、类型管理这类功能明显不是首屏关键路径
|
||||
|
||||
与 skill 对应:
|
||||
|
||||
- `bundle-dynamic-imports`
|
||||
- `bundle-conditional`
|
||||
- `bundle-defer-third-party`
|
||||
|
||||
建议方向:
|
||||
|
||||
- 对非首屏弹窗引入 `React.lazy`
|
||||
- 在用户点击前后再加载重型内容
|
||||
- 优先收敛 `ReportViewerDialog` 与 `UpdateDialog`
|
||||
|
||||
## 3. 当前问题总览
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx]
|
||||
CleanerPage[CleanerPage.tsx]
|
||||
UseCleaner[useCleaner.ts]
|
||||
ExtractorPage[ExtractorPage.tsx]
|
||||
UpdateDialog[UpdateDialog.tsx]
|
||||
Dialogs[Heavy Dialogs]
|
||||
|
||||
App -->|认证 更新 导航混合| Maintainability[维护成本上升]
|
||||
CleanerPage -->|页面职责过大| Maintainability
|
||||
UseCleaner -->|状态 请求 持久化混合| Maintainability
|
||||
ExtractorPage -->|输入驱动副作用| Maintainability
|
||||
UpdateDialog -->|异步状态来源分散| Maintainability
|
||||
Dialogs -->|非首屏静态导入| Bundle[首屏与包体压力]
|
||||
```
|
||||
|
||||
## 4. 优化目标
|
||||
|
||||
本轮 React 向优化聚焦以下目标:
|
||||
|
||||
- 让页面容器组件回归“组装层”
|
||||
- 让 hook 边界按职责拆清,不再兼做状态、初始化、请求和交互编排
|
||||
- 让跨模块副作用从输入/渲染 effect 中收敛到更稳定的事件或 bootstrap 层
|
||||
- 让重型弹窗按需加载,减少首屏包体负担
|
||||
- 让异步加载流程更并行、更可追踪、更容易测试
|
||||
|
||||
## 5. 分阶段执行计划
|
||||
|
||||
### Phase 1: 收敛应用入口与认证启动流
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `App.tsx` 从“大容器”拆成更清晰的组装层
|
||||
- 明确认证、更新、壳层 UI 的职责边界
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/hooks/useAuth.ts`
|
||||
- `src/renderer/src/hooks/useLogger.ts`
|
||||
- 新增 `src/renderer/src/hooks/useAppBootstrap.ts`
|
||||
- 新增 `src/renderer/src/components/app/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `useAppBootstrap()`
|
||||
- `AuthenticatedApp`
|
||||
- `UnauthenticatedApp`
|
||||
- `AppShell`
|
||||
- `UpdateEntryButton`
|
||||
|
||||
预期收益:
|
||||
|
||||
- 减少 `App.tsx` 的状态面
|
||||
- 降低启动 effect 的复杂度
|
||||
- 让认证与更新逻辑更容易测试
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中
|
||||
|
||||
验证方式:
|
||||
|
||||
- silent login / 登录 / 管理员选人流程回归正常
|
||||
- 更新状态订阅正常
|
||||
- `npm run typecheck` 与相关测试通过
|
||||
|
||||
### Phase 2: 拆分 `CleanerPage` 与 `useCleaner`
|
||||
|
||||
目标:
|
||||
|
||||
- 进一步拆解 Cleaner 的页面结构和 hook 职责
|
||||
- 降低单个 hook / 页面承载的状态数量
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
- `src/renderer/src/hooks/cleaner/`
|
||||
- 新增 `src/renderer/src/components/cleaner/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `useCleanerBootstrap`
|
||||
- `useCleanerSelection`
|
||||
- `useCleanerExecution`
|
||||
- `CleanerSidebar`
|
||||
- `CleanerToolbar`
|
||||
- `CleanerResultsTable`
|
||||
- `CleanerExecutionFooter`
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低重渲染范围
|
||||
- 提高 Cleaner 页面可读性
|
||||
- 为表格和执行区单独补测试创造条件
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中到高
|
||||
|
||||
验证方式:
|
||||
|
||||
- 校验、筛选、勾选、编辑负责人、执行删除、导出流程手工验证
|
||||
- `cleaner` 相关单测通过
|
||||
|
||||
### Phase 3: 收敛 Extractor 与共享 Production ID 同步
|
||||
|
||||
目标:
|
||||
|
||||
- 把输入态和共享业务态解耦
|
||||
- 降低输入变化带来的高频副作用
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
- `src/renderer/src/hooks/useExtractor.ts`
|
||||
- 新增 `src/renderer/src/hooks/useSharedProductionIds.ts`
|
||||
- 新增 `src/renderer/src/lib/session-storage/`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- 仅在提交或 debounce 后同步共享 ID
|
||||
- 抽出 `usePersistentTextState`
|
||||
- 把 sessionStorage 与 Electron bridge 副作用集中管理
|
||||
|
||||
预期收益:
|
||||
|
||||
- 提高输入响应稳定性
|
||||
- 降低桥接调用频率
|
||||
- 更符合“interaction in event handlers, not passive effects”的原则
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低到中
|
||||
|
||||
验证方式:
|
||||
|
||||
- 提取页输入、重置、共享订单号联动正常
|
||||
- Cleaner 过滤模式仍能读取共享订单号
|
||||
|
||||
### Phase 4: 收敛更新弹窗与异步加载路径
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `UpdateDialog` 的异步状态切换和版本详情拉取独立出来
|
||||
- 降低 `App` 与弹窗之间的状态耦合
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
- `src/renderer/src/App.tsx`
|
||||
- 新增 `src/renderer/src/hooks/useUpdateDialogState.ts`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `useUpdateCatalog`
|
||||
- `useReleaseChangelog`
|
||||
- 版本切换时用 `startTransition`
|
||||
- changelog 拉取加最新请求保护
|
||||
|
||||
预期收益:
|
||||
|
||||
- 更新弹窗行为更稳定
|
||||
- 降低状态竞争和旧请求覆盖新状态的风险
|
||||
- 提升大型 Markdown 内容切换时的交互流畅度
|
||||
|
||||
风险等级:
|
||||
|
||||
- 中
|
||||
|
||||
验证方式:
|
||||
|
||||
- User/Admin 更新流程验证
|
||||
- 版本切换与 changelog 展示正常
|
||||
|
||||
### Phase 5: 做弹窗与重型模块按需加载
|
||||
|
||||
目标:
|
||||
|
||||
- 把非首屏关键弹窗改成按需加载
|
||||
- 降低 renderer 初始包体
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- `src/renderer/src/App.tsx`
|
||||
- `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- `src/renderer/src/components/ReportViewerDialog.tsx`
|
||||
- `src/renderer/src/components/MaterialTypeManagementDialog.tsx`
|
||||
- `src/renderer/src/components/ExecutionReportDialog.tsx`
|
||||
- `src/renderer/src/components/UpdateDialog.tsx`
|
||||
|
||||
建议拆分方向:
|
||||
|
||||
- `React.lazy`
|
||||
- 懒加载弹窗容器
|
||||
- 打开前预加载关键模块
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低首屏 JS 负担
|
||||
- 让常用流程优先加载
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低
|
||||
|
||||
验证方式:
|
||||
|
||||
- 首屏功能正常
|
||||
- 弹窗首次打开正常
|
||||
- 打包后 smoke test 正常
|
||||
|
||||
### Phase 6: 补强 React 渲染层测试
|
||||
|
||||
目标:
|
||||
|
||||
- 给这轮 React 收敛提供稳定回归保护
|
||||
|
||||
建议涉及文件:
|
||||
|
||||
- 新增 `App` 相关组件测试
|
||||
- 新增 `CleanerPage` / `useCleaner` 相关测试
|
||||
- 新增 `UpdateDialog` 状态流测试
|
||||
- 新增 `ExtractorPage` 共享订单号同步测试
|
||||
|
||||
优先补测内容:
|
||||
|
||||
- 认证启动分支
|
||||
- 更新弹窗状态切换
|
||||
- Cleaner 筛选与执行状态切换
|
||||
- Extractor 输入与共享 ID 同步
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低后续 UI/状态重构风险
|
||||
- 提高页面容器层的修改信心
|
||||
|
||||
风险等级:
|
||||
|
||||
- 低
|
||||
|
||||
验证方式:
|
||||
|
||||
- 单元测试 / 组件测试通过
|
||||
- 关键页面 smoke test 正常
|
||||
|
||||
## 6. 推荐执行顺序
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Phase 1 App 入口与认证收敛]
|
||||
B[Phase 2 Cleaner 页面与 Hook 拆分]
|
||||
C[Phase 3 Extractor 同步路径收敛]
|
||||
D[Phase 4 UpdateDialog 异步状态收敛]
|
||||
E[Phase 5 弹窗按需加载]
|
||||
F[Phase 6 React 层测试补强]
|
||||
|
||||
A --> B
|
||||
A --> D
|
||||
B --> F
|
||||
C --> F
|
||||
D --> F
|
||||
E --> F
|
||||
```
|
||||
|
||||
建议优先顺序:
|
||||
|
||||
1. 先做 `App` 入口与认证启动流收敛
|
||||
2. 再做 `CleanerPage + useCleaner`
|
||||
3. 然后收敛 `ExtractorPage` 的共享 ID 同步
|
||||
4. 再处理 `UpdateDialog`
|
||||
5. 最后做弹窗按需加载
|
||||
6. 测试补强贯穿整个过程
|
||||
|
||||
## 7. 每阶段完成标准
|
||||
|
||||
每一阶段建议采用统一完成标准:
|
||||
|
||||
- 页面或 hook 的职责边界明显变清晰
|
||||
- 对外行为保持兼容
|
||||
- `npm run typecheck` 通过
|
||||
- 相关单元测试 / 组件测试通过
|
||||
- 关键页面功能手工验证通过
|
||||
- 对应说明文档同步更新
|
||||
|
||||
## 8. 与现有重构工作的衔接
|
||||
|
||||
当前已经完成的工作为这轮 React 优化提供了基础:
|
||||
|
||||
- `validation-handler` 已拆成更清晰的主进程结构
|
||||
- `useCleaner` 已做过第一轮内部 helpers/api 抽离
|
||||
- Electron 侧 preload、update、handler、bootstrap 已经收敛
|
||||
|
||||
这意味着 React 侧现在可以更放心地继续拆:
|
||||
|
||||
- 页面入口不会再同时背负太多主进程耦合
|
||||
- 更新弹窗可以直接依托已收敛的 update service / preload facade
|
||||
- Cleaner 页面可以聚焦 UI 与状态,不必再同时处理主进程边界混乱问题
|
||||
|
||||
## 9. 后续建议
|
||||
|
||||
建议执行方式如下:
|
||||
|
||||
1. 先从 `Phase 1` 开始,优先收敛 `App.tsx`
|
||||
2. 每完成一个阶段,单独提交
|
||||
3. 对 `Cleaner` 和 `UpdateDialog` 每完成一轮都补测试
|
||||
4. 在大页面拆分后,再做 bundle 与懒加载优化
|
||||
|
||||
如果后续决定正式执行,本计划可作为 React 渲染层重构的主索引文档持续维护。
|
||||
@@ -0,0 +1,212 @@
|
||||
# ReportAnalysisDialog 组件重构分析
|
||||
|
||||
## 📊 当前状态分析
|
||||
|
||||
### 基本指标
|
||||
|
||||
- **总行数**: 948 行
|
||||
- **函数/声明**: 9 个
|
||||
- **React Hooks**: 20 个使用
|
||||
- **职责数量**: 5+ 个主要职责
|
||||
|
||||
### 组件职责分析
|
||||
|
||||
#### 1. 数据获取与解析 (~150 行)
|
||||
|
||||
- `loadAndAnalyzeReports` - 数据加载逻辑
|
||||
- `extractReportValues` - 报告内容解析
|
||||
- `parseDurationToSeconds` - 时间解析
|
||||
|
||||
#### 2. 数据聚合与转换 (~200 行)
|
||||
|
||||
- `chartData` useMemo - 按日期聚合
|
||||
- `comparisonData` useMemo - 按用户聚合
|
||||
- `comparisonChartData` useMemo - 图表数据格式化
|
||||
- `allUsers` useMemo - 用户列表提取
|
||||
|
||||
#### 3. 状态管理 (~100 行)
|
||||
|
||||
- 6 个 useState hooks
|
||||
- 5 个 useCallback handlers
|
||||
- 复杂的状态交互逻辑
|
||||
|
||||
#### 4. UI 控制与交互 (~200 行)
|
||||
|
||||
- 指标选择按钮
|
||||
- 视图模式切换
|
||||
- 用户筛选器
|
||||
- 加载/错误状态显示
|
||||
|
||||
#### 5. 图表渲染 (~300 行)
|
||||
|
||||
- Recharts 图表配置
|
||||
- 两个不同的视图模式
|
||||
- 自定义 Tooltip 组件
|
||||
- 图表样式和布局
|
||||
|
||||
## 🎯 重构目标
|
||||
|
||||
### 主要问题
|
||||
|
||||
1. **单一文件过大**: 难以维护和理解
|
||||
2. **职责混乱**: 数据获取、处理、UI 混在一起
|
||||
3. **复用性差**: 逻辑和 UI 紧耦合
|
||||
4. **测试困难**: 难以单独测试各个部分
|
||||
|
||||
### 重构原则
|
||||
|
||||
1. **单一职责**: 每个模块只负责一件事
|
||||
2. **可复用性**: 提取通用逻辑到 hooks
|
||||
3. **可测试性**: 分离逻辑和 UI
|
||||
4. **可维护性**: 清晰的文件结构
|
||||
|
||||
## 📦 建议的文件结构
|
||||
|
||||
```
|
||||
src/renderer/src/components/report-analysis/
|
||||
├── index.tsx # 主组件入口 (~150 行)
|
||||
├── hooks/
|
||||
│ ├── useReportData.ts # 数据获取和解析 (~100 行)
|
||||
│ ├── useChartData.ts # 数据聚合和转换 (~150 行)
|
||||
│ └── useReportFilters.ts # 筛选状态管理 (~80 行)
|
||||
├── components/
|
||||
│ ├── ReportChart.tsx # 图表组件 (~200 行)
|
||||
│ ├── MetricSelector.tsx # 指标选择器 (~80 行)
|
||||
│ ├── ViewModeToggle.tsx # 视图模式切换 (~50 行)
|
||||
│ ├── UserFilter.tsx # 用户筛选器 (~100 行)
|
||||
│ ├── CustomTooltip.tsx # 自定义 tooltip (~100 行)
|
||||
│ ├── ComparisonTooltip.tsx # 对比 tooltip (~80 行)
|
||||
│ └── LoadingState.tsx # 加载状态组件 (~60 行)
|
||||
├── utils/
|
||||
│ ├── parser.ts # 报告解析工具 (~100 行)
|
||||
│ ├── aggregators.ts # 数据聚合函数 (~120 行)
|
||||
│ └── formatters.ts # 格式化工具 (~60 行)
|
||||
└── types.ts # 类型定义 (~80 行)
|
||||
```
|
||||
|
||||
## 🔧 重构方案
|
||||
|
||||
### 方案 A: 完全重构 (推荐)
|
||||
|
||||
**优点**: 最大程度的解耦和可维护性
|
||||
**缺点**: 需要更多时间,可能引入新问题
|
||||
**时间估计**: 2-3 小时
|
||||
|
||||
### 方案 B: 渐进式重构
|
||||
|
||||
**优点**: 风险较低,可以逐步验证
|
||||
**缺点**: 过渡期代码可能不够优雅
|
||||
**时间估计**: 1-2 小时
|
||||
|
||||
### 方案 C: 最小化重构
|
||||
|
||||
**优点**: 改动最小,风险最低
|
||||
**缺点**: 解决根本问题有限
|
||||
**时间估计**: 30-45 分钟
|
||||
|
||||
## 📝 详细重构步骤
|
||||
|
||||
### Phase 1: 提取类型和工具函数 (低风险)
|
||||
|
||||
1. 创建 `types.ts` - 集中管理所有类型定义
|
||||
2. 创建 `utils/parser.ts` - 提取报告解析逻辑
|
||||
3. 创建 `utils/aggregators.ts` - 提取数据聚合逻辑
|
||||
|
||||
### Phase 2: 提取自定义 Hooks (中风险)
|
||||
|
||||
1. 创建 `hooks/useReportData.ts` - 数据获取和解析
|
||||
2. 创建 `hooks/useChartData.ts` - 数据聚合和转换
|
||||
3. 创建 `hooks/useReportFilters.ts` - 筛选状态管理
|
||||
|
||||
### Phase 3: 提取 UI 组件 (中风险)
|
||||
|
||||
1. 创建 `components/MetricSelector.tsx`
|
||||
2. 创建 `components/ViewModeToggle.tsx`
|
||||
3. 创建 `components/UserFilter.tsx`
|
||||
4. 创建 `components/ReportChart.tsx`
|
||||
|
||||
### Phase 4: 重构主组件 (高风险)
|
||||
|
||||
1. 简化 `index.tsx` 只保留组合逻辑
|
||||
2. 添加错误边界
|
||||
3. 优化加载状态
|
||||
|
||||
## 🎯 重构后的预期效果
|
||||
|
||||
### 代码行数分布
|
||||
|
||||
- 主组件: ~150 行 (减少 84%)
|
||||
- 每个 hook: ~80-150 行
|
||||
- 每个 UI 组件: ~50-200 行
|
||||
- 工具函数: ~60-120 行
|
||||
|
||||
### 可维护性提升
|
||||
|
||||
- ✅ 单个文件更小,更易理解
|
||||
- ✅ 职责清晰,修改影响范围小
|
||||
- ✅ 更容易进行单元测试
|
||||
- ✅ 可以独立优化各个部分
|
||||
|
||||
### 性能影响
|
||||
|
||||
- ➡️ 性能基本不变或略有提升
|
||||
- ➡️ 代码分割优化可能略微改善首次加载
|
||||
- ➡️ 更好的 memoization 机会
|
||||
|
||||
## 🚨 风险评估
|
||||
|
||||
### 高风险区域
|
||||
|
||||
- 图表配置逻辑(Recharts 配置复杂)
|
||||
- 数据转换和聚合(业务逻辑密集)
|
||||
- 状态同步(多个状态之间的交互)
|
||||
|
||||
### 缓解措施
|
||||
|
||||
- 保持现有测试通过
|
||||
- 逐步重构,每步验证
|
||||
- 添加 TypeScript 严格检查
|
||||
- 保留原有功能注释
|
||||
|
||||
## 📋 验证清单
|
||||
|
||||
重构完成后需要验证:
|
||||
|
||||
- [ ] 所有现有功能正常工作
|
||||
- [ ] 单元测试通过
|
||||
- [ ] E2E 测试通过
|
||||
- [ ] 类型检查无错误
|
||||
- [ ] 性能无明显下降
|
||||
- [ ] 代码风格符合规范
|
||||
|
||||
## 🤔 建议的实施顺序
|
||||
|
||||
### 推荐方案: 渐进式重构 (方案 B)
|
||||
|
||||
**第1步**: 提取类型和工具函数 (15分钟)
|
||||
|
||||
- 创建类型定义文件
|
||||
- 提取解析工具函数
|
||||
- 验证编译和测试
|
||||
|
||||
**第2步**: 提取自定义 Hooks (30分钟)
|
||||
|
||||
- 提取数据获取逻辑
|
||||
- 提取数据聚合逻辑
|
||||
- 提取筛选状态管理
|
||||
- 验证功能正常
|
||||
|
||||
**第3步**: 提取 UI 组件 (30分钟)
|
||||
|
||||
- 提取控制面板组件
|
||||
- 提取图表组件
|
||||
- 提取状态显示组件
|
||||
- 验证交互正常
|
||||
|
||||
**第4步**: 简化主组件 (15分钟)
|
||||
|
||||
- 重构为组合式组件
|
||||
- 清理代码和注释
|
||||
- 最终验证
|
||||
|
||||
**总计**: 约 90 分钟,分4个阶段,每个阶段都可以独立验证
|
||||
130
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
130
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Implementation Plan: Auto-import Extracted Data to Database
|
||||
|
||||
## Overview
|
||||
|
||||
Implement automatic database import after ERP data extraction completes. The merged Excel file will be read and written to the `dbo_DiscreteMaterialPlanData` table.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Trigger**: Automatic after extraction completes
|
||||
- **Delete Strategy**: Batch delete by `SourceNumber` before insert
|
||||
- **Batch Insert**: 1000 records per batch
|
||||
- **Field Mapping**: 28 Excel fields → database columns (skip 打印人, 打印日期, BOMVersion)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ExtractorService
|
||||
│
|
||||
├── extract() → download + merge Excel
|
||||
│
|
||||
└── NEW: importToDatabase(mergedFile)
|
||||
│
|
||||
▼
|
||||
DataImportService
|
||||
│
|
||||
├── readExcelFile() → records + sourceNumbers
|
||||
├── deleteExistingRecords(sourceNumbers)
|
||||
└── batchInsert(records, batchSize=1000)
|
||||
│
|
||||
▼
|
||||
DiscreteMaterialPlanDAO
|
||||
├── deleteBySourceNumbers()
|
||||
└── batchInsert()
|
||||
```
|
||||
|
||||
## Field Mapping
|
||||
|
||||
| Excel Header | Database Column | Notes |
|
||||
| ------------ | ------------------------ | ---------------- |
|
||||
| 工厂 | Factory | |
|
||||
| 备料状态 | MaterialStatus | |
|
||||
| 备料计划单号 | PlanNumber | |
|
||||
| 来源单号 | SourceNumber | **Deletion key** |
|
||||
| 备料类型 | MaterialType | |
|
||||
| 产品编码 | ProductCode | |
|
||||
| 产品名称 | ProductName | |
|
||||
| 产品计划数量 | ProductPlanQuantity | decimal |
|
||||
| 产品单位 | ProductUnit | |
|
||||
| 用料部门 | UseDepartment | |
|
||||
| 备注 | Remark | |
|
||||
| 制单人 | Creator | |
|
||||
| 制单日期 | CreateDate | date |
|
||||
| 审批人 | Approver | |
|
||||
| 审批日期 | ApproveDate | date |
|
||||
| 序号 | SequenceNumber | int |
|
||||
| 材料编码 | MaterialCode | |
|
||||
| 材料名称 | MaterialName | |
|
||||
| 规格 | Specification | |
|
||||
| 型号 | Model | |
|
||||
| 图号 | DrawingNumber | |
|
||||
| 物料材质 | MaterialQuality | |
|
||||
| 计划数量 | PlanQuantity | decimal |
|
||||
| 单位 | Unit | |
|
||||
| 需用日期 | RequiredDate | date |
|
||||
| 发料仓库 | Warehouse | |
|
||||
| 单位用量 | UnitUsage | decimal |
|
||||
| 累计出库数量 | CumulativeOutputQuantity | decimal |
|
||||
| 打印人 | ❌ SKIP | Not in DB |
|
||||
| 打印日期 | ❌ SKIP | Not in DB |
|
||||
| - | BOMVersion | SKIP (no source) |
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### 1. NEW: `src/main/services/database/data-importer.ts`
|
||||
|
||||
Main import service with:
|
||||
|
||||
- `importFromExcel(filePath)` - Main entry point
|
||||
- `readExcelFile(filePath)` - Parse Excel using ExcelJS
|
||||
- Map Excel columns to database fields
|
||||
- Return records and unique SourceNumbers
|
||||
|
||||
### 2. MODIFY: `src/main/services/database/discrete-material-plan-dao.ts`
|
||||
|
||||
Add methods:
|
||||
|
||||
- `deleteBySourceNumbers(sourceNumbers: string[])` - Batch delete
|
||||
- `batchInsert(records: MaterialPlanRecord[], batchSize: number)` - Batch insert
|
||||
|
||||
### 3. MODIFY: `src/main/services/erp/extractor.ts`
|
||||
|
||||
- After successful merge, call `importToDatabase(mergedFile)`
|
||||
- Add import results to `ExtractorResult`
|
||||
|
||||
### 4. MODIFY: `src/main/types/extractor.types.ts`
|
||||
|
||||
Add types:
|
||||
|
||||
```typescript
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
recordsImported: number
|
||||
recordsDeleted: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ExtractorResult {
|
||||
// existing fields...
|
||||
importResult?: ImportResult
|
||||
}
|
||||
```
|
||||
|
||||
### 5. MODIFY: `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
|
||||
- Display import results
|
||||
- Show records deleted/imported counts
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Extend `DiscreteMaterialPlanDAO` with insert/delete methods
|
||||
2. Create `DataImportService`
|
||||
3. Integrate into `ExtractorService`
|
||||
4. Update types
|
||||
5. Update UI
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. Unit test DAO methods
|
||||
2. Integration test with sample Excel file
|
||||
3. E2E test extraction → import flow
|
||||
274
docs/portable-auto-update-architecture.md
Normal file
274
docs/portable-auto-update-architecture.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# ERPAuto 便携版自动更新说明
|
||||
|
||||
## 概览
|
||||
|
||||
当前实现的是一套面向 Windows 便携版的自定义更新系统,核心特点如下:
|
||||
|
||||
- 基于 S3 兼容对象存储分发更新包
|
||||
- 按登录用户角色决定更新通道和行为
|
||||
- `User` 只跟随 `Stable`
|
||||
- `Admin` 同时可见 `Stable` 和 `Preview`
|
||||
- 更新包可后台下载,但安装必须由用户触发
|
||||
- 安装阶段使用独立的原生 `portable-updater.exe` 完成 exe 替换
|
||||
|
||||
## 核心组件
|
||||
|
||||
- 主进程更新服务
|
||||
路径:[`src/main/services/update/update-service.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-service.ts)
|
||||
- 更新规则工具
|
||||
路径:[`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||
- 更新 IPC
|
||||
路径:[`src/main/ipc/update-handler.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/ipc/update-handler.ts)
|
||||
- 前端更新弹窗
|
||||
路径:[`src/renderer/src/components/UpdateDialog.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/components/UpdateDialog.tsx)
|
||||
- 前端更新入口
|
||||
路径:[`src/renderer/src/App.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/App.tsx)
|
||||
- 原生更新器
|
||||
路径:[`build/PortableUpdater.cs`](/d:/FileLib/Projects/CodeMigration/ERPAuto/build/PortableUpdater.cs)
|
||||
- 更新器编译脚本
|
||||
路径:[`scripts/compile-updater.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||
- 发布准备脚本
|
||||
路径:[`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
- 发布上传脚本
|
||||
路径:[`scripts/upload-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||
|
||||
## 角色策略
|
||||
|
||||
### `User`
|
||||
|
||||
- 只读取 `stable/index.json`
|
||||
- 目标版本永远是最新 `Stable`
|
||||
- 如果当前客户端是 `Preview`,即使本地版本号更高,也会被视为“需要更新回稳定版”
|
||||
- 后台会自动下载推荐的 `Stable`
|
||||
- 用户点击后执行安装
|
||||
|
||||
### `Admin`
|
||||
|
||||
- 同时读取 `stable/index.json` 和 `preview/index.json`
|
||||
- 不自动下载
|
||||
- 只展示可选版本和更新说明
|
||||
- 由管理员手动选择版本并触发下载、安装
|
||||
|
||||
## 当前安装包身份
|
||||
|
||||
构建时会注入 `__APP_CHANNEL__`,用于标识当前客户端自身是 `stable` 还是 `preview`。
|
||||
|
||||
这个值的作用非常关键:
|
||||
|
||||
- 决定 `User` 是否需要从 `Preview` 洗回 `Stable`
|
||||
- 决定 `Admin` 当前处于哪条版本线
|
||||
- 决定更新弹窗中当前通道的展示
|
||||
|
||||
相关声明:
|
||||
|
||||
- [`src/shared/app-env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/shared/app-env.d.ts)
|
||||
- [`src/renderer/src/env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/env.d.ts)
|
||||
|
||||
## 远端目录结构
|
||||
|
||||
更新目录按通道分开:
|
||||
|
||||
```text
|
||||
updates/win-portable/stable/index.json
|
||||
updates/win-portable/stable/artifacts/erpauto-<version>-stable-portable.exe
|
||||
updates/win-portable/stable/changelogs/<version>.md
|
||||
|
||||
updates/win-portable/preview/index.json
|
||||
updates/win-portable/preview/artifacts/erpauto-<version>-preview-portable.exe
|
||||
updates/win-portable/preview/changelogs/<version>.md
|
||||
```
|
||||
|
||||
`index.json` 的每个条目至少包含:
|
||||
|
||||
- `version`
|
||||
- `channel`
|
||||
- `artifactKey`
|
||||
- `sha256`
|
||||
- `size`
|
||||
- `publishedAt`
|
||||
- `changelogKey`
|
||||
- `notesSummary`
|
||||
|
||||
## 总体架构图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Electron Renderer] -->|IPC| B[Update Handler]
|
||||
B --> C[Update Service]
|
||||
C --> D[S3-Compatible Object Storage]
|
||||
C --> E[Local Cache<br/>pending-update]
|
||||
C --> F[portable-updater.exe]
|
||||
F --> G[Replace Old EXE]
|
||||
G --> H[Launch New EXE]
|
||||
```
|
||||
|
||||
## 登录后的更新时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User/Admin
|
||||
participant R as Renderer
|
||||
participant A as Auth Handler
|
||||
participant S as Update Service
|
||||
participant O as Object Storage
|
||||
|
||||
U->>R: 登录 / 无感登录
|
||||
R->>A: auth.login / auth.silentLogin
|
||||
A->>S: setUserContext(userType)
|
||||
S->>O: 读取 stable/index.json
|
||||
alt Admin
|
||||
S->>O: 读取 preview/index.json
|
||||
end
|
||||
S->>S: 计算推荐版本与状态
|
||||
alt User 且需要更新
|
||||
S->>O: 下载最新 Stable
|
||||
S->>S: 校验 sha256
|
||||
S-->>R: UPDATE_STATUS_CHANGED(downloaded)
|
||||
else Admin
|
||||
S-->>R: UPDATE_STATUS_CHANGED(available)
|
||||
end
|
||||
```
|
||||
|
||||
## `User` 更新决策图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[当前用户是 User] --> B[读取 stable 最新版本]
|
||||
B --> C{当前通道是 preview?}
|
||||
C -- 是 --> D[强制推荐回 Stable]
|
||||
C -- 否 --> E{当前版本 != 最新 Stable?}
|
||||
E -- 是 --> F[推荐最新 Stable]
|
||||
E -- 否 --> G[不提示更新]
|
||||
D --> H[后台自动下载]
|
||||
F --> H
|
||||
H --> I[校验 sha256]
|
||||
I --> J[导航栏显示 立即更新]
|
||||
```
|
||||
|
||||
## `Admin` 更新决策图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[当前用户是 Admin] --> B[读取 stable 目录]
|
||||
A --> C[读取 preview 目录]
|
||||
B --> D[合并目录]
|
||||
C --> D
|
||||
D --> E{是否存在更高版本?}
|
||||
E -- 是 --> F[推荐更高版本]
|
||||
E -- 否 --> G{是否存在跨通道可切换版本?}
|
||||
G -- 是 --> H[推荐跨通道版本]
|
||||
G -- 否 --> I[不展示更新]
|
||||
F --> J[打开弹窗后手动下载]
|
||||
H --> J
|
||||
```
|
||||
|
||||
## 安装阶段时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Renderer
|
||||
participant S as Update Service
|
||||
participant P as portable-updater.exe
|
||||
participant X as Current Portable EXE
|
||||
participant N as New Downloaded EXE
|
||||
|
||||
R->>S: installDownloaded()
|
||||
S->>P: 启动 portable-updater.exe
|
||||
S->>X: app.quit()
|
||||
P->>X: 等待旧进程退出
|
||||
P->>X: 等待文件解锁
|
||||
P->>X: 备份为 .bak
|
||||
P->>N: 移动到目标路径
|
||||
P->>X: 启动新版本
|
||||
P->>X: 删除 .bak
|
||||
```
|
||||
|
||||
## 本地目录与日志
|
||||
|
||||
### 下载缓存
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\pending-update\
|
||||
```
|
||||
|
||||
### 更新器运行文件与日志
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\updates\
|
||||
portable-updater.exe
|
||||
portable-update.log
|
||||
portable-launch.log
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
### 1. 构建
|
||||
|
||||
```powershell
|
||||
$env:APP_CHANNEL="stable"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 2. 准备发布目录
|
||||
|
||||
```powershell
|
||||
node scripts/prepare-release.js --channel stable --changelog docs/releases/1.3.2-rebuild.md
|
||||
```
|
||||
|
||||
### 3. 上传到对象存储
|
||||
|
||||
```powershell
|
||||
npm run release:upload -- --channel stable --verify
|
||||
```
|
||||
|
||||
## 发布流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[build:win] --> B[生成 dist/erpauto-portable.exe]
|
||||
B --> C[prepare-release]
|
||||
C --> D[复制 artifact]
|
||||
C --> E[复制 changelog]
|
||||
C --> F[生成 index.json]
|
||||
F --> G[release-upload]
|
||||
G --> H[上传到对象存储]
|
||||
H --> I[verify 远端 index.json]
|
||||
```
|
||||
|
||||
## 版本排序规则
|
||||
|
||||
为了避免“后发布低版本覆盖高版本”的问题,当前排序规则是:
|
||||
|
||||
- 优先按版本号降序
|
||||
- 同版本再按 `publishedAt` 降序
|
||||
|
||||
这条规则同时存在于:
|
||||
|
||||
- [`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||
- [`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
|
||||
## 失败保护
|
||||
|
||||
当前实现包含这些基本保护:
|
||||
|
||||
- 缺失 `preview/index.json` 时,按空列表处理,不中断整体更新检查
|
||||
- 更新包下载完成后必须校验 `sha256`
|
||||
- `portable-updater.exe` 会等待旧进程退出和目标文件解锁
|
||||
- 替换前先备份旧 exe 为 `.bak`
|
||||
- 替换失败时尝试回滚
|
||||
|
||||
## 当前已验证通过的能力
|
||||
|
||||
- `1.3.1 -> 1.3.2` 的 `Stable` 发布链路已打通
|
||||
- 新分支实现能够成功构建 Windows 便携版
|
||||
- 原生 `portable-updater.exe` 能成功编译并被打包带入资源目录
|
||||
- 本地发布目录生成正常
|
||||
- 上传脚本可将更新包和索引发布到对象存储
|
||||
- 客户端真实升级流程已验证通过
|
||||
|
||||
## 后续可继续优化的点
|
||||
|
||||
- 将 `UpdateService` 进一步拆分,降低文件复杂度
|
||||
- 将日志策略区分成“正式日志”和“诊断日志”
|
||||
- 增加更多针对下载与安装阶段的单测
|
||||
- 将完整构建发布链路整理为一键化脚本
|
||||
11
docs/releases/1.3.1-rebuild.md
Normal file
11
docs/releases/1.3.1-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.3.1 Rebuild
|
||||
|
||||
## Highlights
|
||||
|
||||
- Rebuilt the portable auto-update flow on a clean branch.
|
||||
- Added role-aware update catalog handling for `Stable` and `Preview`.
|
||||
- Added native `portable-updater.exe` handoff for portable upgrades.
|
||||
|
||||
## Notes
|
||||
|
||||
- This release is intended for rebuild validation on the new implementation branch.
|
||||
11
docs/releases/1.3.2-rebuild.md
Normal file
11
docs/releases/1.3.2-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.3.2 Rebuild
|
||||
|
||||
## Highlights
|
||||
|
||||
- Published the clean-branch portable auto-update implementation.
|
||||
- Added role-aware update checks, changelog loading, and update dialog UI.
|
||||
- Added native `portable-updater.exe` build and packaging flow.
|
||||
|
||||
## Notes
|
||||
|
||||
- This release is intended to validate `1.3.1 -> 1.3.2` upgrade flow on the rebuilt implementation.
|
||||
44
docs/releases/1.4.0.md
Normal file
44
docs/releases/1.4.0.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 1.4.0
|
||||
|
||||
## 亮点
|
||||
|
||||
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||
- 更新策略与登录用户角色联动:
|
||||
- `User` 只接收稳定版更新
|
||||
- `Admin` 可查看并切换稳定版与预览版
|
||||
- 更新包下载完成后,可在应用内查看更新说明并执行自动替换升级。
|
||||
|
||||
## 自动更新
|
||||
|
||||
- 新增便携版更新服务,支持:
|
||||
- 登录后自动检查更新
|
||||
- 后台下载更新包
|
||||
- 展示更新状态与更新日志
|
||||
- 退出后自动替换旧版本并重启
|
||||
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||
- 支持预览版与稳定版分通道发布,并兼容普通用户从 `preview` 回退到 `stable` 的场景。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 顶部导航新增更新入口。
|
||||
- 新增更新对话框,可展示 changelog 并执行安装。
|
||||
- 报告查看器增强了 Markdown 渲染体验,支持 GitHub 风格样式与代码高亮。
|
||||
|
||||
## 发布与维护
|
||||
|
||||
- 新增一键发布命令:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
- 发布脚本会自动串联构建、整理发布物料、上传和远端索引校验。
|
||||
- 上传逻辑已优化为默认增量上传,只上传当前版本的 artifact、changelog 和 `index.json`。
|
||||
- 补充了构建发布文档和自动更新架构文档,方便后续维护。
|
||||
|
||||
## 文档整理
|
||||
|
||||
- 浏览器部署文档已迁移并整理到 `docs/browser/`。
|
||||
- 新增构建与发布流程说明文档。
|
||||
- 精简了 `CLAUDE.md`,让 AI 代理指导文档更聚焦、更易维护。
|
||||
11
docs/releases/1.4.1.md
Normal file
11
docs/releases/1.4.1.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.4.1
|
||||
|
||||
## 改进
|
||||
|
||||
- 报告查看器的报告选择器升级为可搜索下拉框。
|
||||
- 报表较多时,可以通过输入关键字快速筛选目标报告,减少滚动查找成本。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 优化了报告选择交互,选择流程更适合长列表场景。
|
||||
- 同步合并 `dev` 分支中已完成的报告查看器可用性改进。
|
||||
11
docs/releases/1.4.2.md
Normal file
11
docs/releases/1.4.2.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.4.2
|
||||
|
||||
## 架构优化
|
||||
|
||||
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
|
||||
- 解耦更新服务职责,对话框改为懒加载以优化性能。
|
||||
|
||||
## 质量改进
|
||||
|
||||
- 修复类型检查问题,加固启动流程和认证健壮性。
|
||||
- 新增核心模块测试覆盖,完善开发者文档。
|
||||
13
docs/releases/1.5.0.md
Normal file
13
docs/releases/1.5.0.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 1.5.0
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||
- 支持取消下载,网络异常自动重试。
|
||||
- 下载完成后自动进入登录界面,无需重启应用。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 修复下载完成后卡在"认证中"的问题。
|
||||
- 修复速度和剩余时间显示为"计算中"的问题。
|
||||
7
docs/releases/1.5.1.md
Normal file
7
docs/releases/1.5.1.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 1.5.1
|
||||
|
||||
## 体验优化
|
||||
|
||||
- User 用户登录后立即进入应用,更新检查和下载在后台运行。
|
||||
- 下载完成后自动显示更新提示,整个过程对用户透明。
|
||||
- 优化登录流程体验,消除更新下载导致的阻塞时间。
|
||||
14
docs/releases/1.6.0.md
Normal file
14
docs/releases/1.6.0.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 1.6.0
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 新增管理员报表分析功能,支持多维度数据统计和可视化。
|
||||
- 提供按日期聚合和用户对比两种视图模式。
|
||||
- 支持处理订单数、删除物料数、错误数量等 7 种指标分析。
|
||||
- 提供每订单平均耗时等效率指标,帮助识别性能瓶颈。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 对比视图下自动限制指标单选,避免图表信息过载。
|
||||
- 切换视图模式时智能保留已选指标,提升交互流畅度。
|
||||
- 优化时间解析逻辑,准确提取执行耗时数据。
|
||||
42
docs/releases/1.6.1.md
Normal file
42
docs/releases/1.6.1.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 1.6.1
|
||||
|
||||
## 核心改进
|
||||
|
||||
- **重大重构**:将报告分析组件从 948 行单体组件重构为模块化架构,拆分为 11 个专注的模块文件。
|
||||
- **代码质量提升**:主组件代码量减少 79%(948 → 200 行),显著提升可维护性和可读性。
|
||||
- **架构优化**:分离数据获取、状态管理和 UI 渲染逻辑,遵循单一职责原则。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- **修复 tooltip 显示问题**:解决执行时间在提示框中重复显示的问题,现在只显示一次格式化后的时间值。
|
||||
- **统一时间格式**:所有时间数值统一保留 1 位小数,提升数据显示的一致性和专业度。
|
||||
- **优化界面布局**:精简 tooltip 底部信息,避免冗余内容干扰用户视线。
|
||||
|
||||
## 性能优化
|
||||
|
||||
- **组件渲染优化**:将 tooltip 组件移出父组件并使用 React.memo,减少不必要的重新渲染。
|
||||
- **正则表达式优化**:预编译正则表达式模式,避免在循环中重复创建,提升数据处理效率。
|
||||
- **状态更新优化**:使用函数式 setState 更新,避免闭包陷阱和过期的状态读取。
|
||||
- **回调函数优化**:使用 useCallback 稳定回调函数引用,减少子组件的不必要更新。
|
||||
|
||||
## 开发体验
|
||||
|
||||
- **模块化设计**:将复杂组件拆分为可复用的 hooks 和 UI 组件,便于单独测试和维护。
|
||||
- **类型安全**:完整的 TypeScript 类型定义,提升开发时的类型检查和 IDE 支持。
|
||||
- **代码组织**:清晰的文件结构(types、hooks、components、utils),便于团队协作和代码导航。
|
||||
- **向后兼容**:保持原有 API 接口不变,现有使用方式无需修改。
|
||||
|
||||
## 技术细节
|
||||
|
||||
- 应用 Vercel React 最佳实践,包括:
|
||||
- 避免内联组件定义(rerender-no-inline-components)
|
||||
- 提升正则表达式创建位置(js-hoist-regexp)
|
||||
- 使用函数式状态更新(rerender-functional-setState)
|
||||
- 最小化回调依赖项(rerender-dependencies)
|
||||
- 新增自定义 hooks:useReportData、useChartData、useReportFilters
|
||||
- 新增 UI 组件:MetricSelector、ViewModeToggle、UserFilter、ReportChart
|
||||
- 新增工具函数:数据解析器和聚合器
|
||||
|
||||
## 破坏性变更
|
||||
|
||||
无破坏性变更,所有现有功能保持完全兼容。
|
||||
106
docs/releases/README.md
Normal file
106
docs/releases/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 发布文档规范
|
||||
|
||||
## 文档定位
|
||||
|
||||
发布文档面向**最终用户**,不是技术开发日志。内容应该简洁、清晰、有价值。
|
||||
|
||||
## 内容风格
|
||||
|
||||
### ✅ 推荐写法
|
||||
|
||||
- **用户视角**:描述功能带来的价值,而非技术实现
|
||||
- **简洁明了**:每条更新 1-2 句话,避免冗长
|
||||
- **分类清晰**:按功能模块或改进类型分组
|
||||
|
||||
**示例**:
|
||||
|
||||
```markdown
|
||||
## 核心功能
|
||||
|
||||
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||
```
|
||||
|
||||
### ❌ 避免写法
|
||||
|
||||
- 技术细节(文件路径、代码实现、架构设计)
|
||||
- 开发过程描述("重构了"、"优化了算法")
|
||||
- 过长的段落(超过 2 行)
|
||||
|
||||
## 文档结构
|
||||
|
||||
### 标准格式
|
||||
|
||||
```markdown
|
||||
# {版本号}
|
||||
|
||||
## {分类 1}
|
||||
|
||||
- {更新点 1}
|
||||
- {更新点 2}
|
||||
|
||||
## {分类 2}
|
||||
|
||||
- {更新点 1}
|
||||
- {更新点 2}
|
||||
```
|
||||
|
||||
### 常见分类
|
||||
|
||||
- `核心功能` - 新功能、重大特性
|
||||
- `改进` / `体验优化` - 现有功能优化
|
||||
- `问题修复` - Bug 修复
|
||||
- `界面与交互` - UI/UX 改进
|
||||
|
||||
## 篇幅要求
|
||||
|
||||
- **小版本**(x.x.1):5-10 行
|
||||
- **中版本**(x.x.0):10-20 行
|
||||
- **大版本**(x.0.0):20-40 行
|
||||
|
||||
## 示例参考
|
||||
|
||||
### 简洁版(1.4.2)
|
||||
|
||||
```markdown
|
||||
# 1.4.2
|
||||
|
||||
## 架构优化
|
||||
|
||||
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
|
||||
- 解耦更新服务职责,对话框改为懒加载以优化性能。
|
||||
|
||||
## 质量改进
|
||||
|
||||
- 修复类型检查问题,加固启动流程和认证健壮性。
|
||||
- 新增核心模块测试覆盖,完善开发者文档。
|
||||
```
|
||||
|
||||
### 详细版(1.4.0)
|
||||
|
||||
```markdown
|
||||
# 1.4.0
|
||||
|
||||
## 亮点
|
||||
|
||||
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||
- 更新策略与登录用户角色联动。
|
||||
|
||||
## 自动更新
|
||||
|
||||
- 新增便携版更新服务,支持登录后自动检查更新。
|
||||
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
1. 创建版本文件:`docs/releases/{version}.md`
|
||||
2. 参考现有文档风格编写
|
||||
3. 提交 git:`git add docs/releases/{version}.md`
|
||||
4. 提交信息:`docs: add release notes for version {version}`
|
||||
|
||||
## 维护说明
|
||||
|
||||
- 发布文档一旦创建,**不再修改**(除非有重大错误)
|
||||
- 技术细节放入 `docs/` 下的专题文档
|
||||
- Changelog 由发布脚本自动生成,不手动维护
|
||||
62
docs/settings-partial-save.md
Normal file
62
docs/settings-partial-save.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Settings Partial Save Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Field Whitelist**: Only fields exposed in the UI can be modified
|
||||
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
|
||||
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
|
||||
|
||||
## Editable Fields
|
||||
|
||||
Currently editable via UI:
|
||||
|
||||
- `erp.url` - ERP system URL
|
||||
- `erp.username` - ERP login username
|
||||
- `erp.password` - ERP login password
|
||||
|
||||
## Adding New Editable Fields
|
||||
|
||||
To add a new field to the UI:
|
||||
|
||||
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'database.dbType' // Add new field here
|
||||
]
|
||||
```
|
||||
|
||||
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
||||
3. Update `handleSaveSettings` to include the new field
|
||||
|
||||
## API
|
||||
|
||||
### savePartialSettings(settings: Partial<SettingsData>)
|
||||
|
||||
Saves only the provided fields, preserving all existing configuration.
|
||||
|
||||
**Returns:** `{ success: boolean, error?: string }`
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Checks whitelist before applying changes
|
||||
- Returns error for unauthorized fields
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Unauthorized field**: Returns error message listing invalid fields
|
||||
- **Save failure**: Automatically restores from backup
|
||||
- **Backup failure**: Logs warning, continues with save
|
||||
|
||||
## Backup File
|
||||
|
||||
Location: `.env.backup` (in project root)
|
||||
|
||||
Created before every save operation. Used for rollback on failure.
|
||||
927
docs/settings-save-button-flow.md
Normal file
927
docs/settings-save-button-flow.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# 系统设置保存按钮工作流程分析
|
||||
|
||||
# System Settings Save Button Workflow Analysis
|
||||
|
||||
## 文档概述 / Document Overview
|
||||
|
||||
本文档详细分析了 ERPAuto 系统设置界面中保存按钮的完整工作流程,包括架构设计、数据流转、技术实现细节以及错误处理机制。
|
||||
|
||||
This document provides a comprehensive analysis of the save button workflow in the ERPAuto system settings interface, including architecture design, data flow, technical implementation details, and error handling mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 目录 / Table of Contents
|
||||
|
||||
1. [架构概览](#架构概览)
|
||||
2. [数据流程图](#数据流程图)
|
||||
3. [组件详解](#组件详解)
|
||||
4. [数据结构](#数据结构)
|
||||
5. [错误处理机制](#错误处理机制)
|
||||
6. [安全考虑](#安全考虑)
|
||||
7. [技术实现细节](#技术实现细节)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览 / Architecture Overview
|
||||
|
||||
### 系统架构 / System Architecture
|
||||
|
||||
系统设置保存功能采用典型的 Electron 三层架构模式:
|
||||
|
||||
The system settings save functionality follows the classic Electron three-tier architecture pattern:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Renderer Process 渲染进程"
|
||||
UI[SettingsPage.tsx<br/>UI Component]
|
||||
end
|
||||
|
||||
subgraph "Preload Script 预加载脚本"
|
||||
BRIDGE[contextBridge API<br/>Security Boundary]
|
||||
end
|
||||
|
||||
subgraph "Main Process 主进程"
|
||||
IPC[settings-handler.ts<br/>IPC Handler]
|
||||
SERVICE[ConfigManager.ts<br/>Configuration Service]
|
||||
FILE[.env File<br/>Persistent Storage]
|
||||
end
|
||||
|
||||
UI -->|IPC Invoke| BRIDGE
|
||||
BRIDGE -->|Secure Channel| IPC
|
||||
IPC -->|Business Logic| SERVICE
|
||||
SERVICE -->|Write| FILE
|
||||
FILE -->|Confirm| SERVICE
|
||||
SERVICE -->|Result| IPC
|
||||
IPC -->|Response| BRIDGE
|
||||
BRIDGE -->|Promise Resolve| UI
|
||||
|
||||
style UI fill:#e1f5ff
|
||||
style BRIDGE fill:#fff4e1
|
||||
style IPC fill:#ffe1f5
|
||||
style SERVICE fill:#e1ffe1
|
||||
style FILE fill:#f5f5f5
|
||||
```
|
||||
|
||||
### 核心设计模式 / Core Design Patterns
|
||||
|
||||
1. **单向数据流**:数据从 UI → Main Process → File,响应沿相反路径返回
|
||||
2. **安全隔离**:Preload 脚本作为安全桥梁,通过 `contextBridge` 暴露受限 API
|
||||
3. **单例模式**:ConfigManager 使用单例确保配置一致性
|
||||
4. **缓存优先**:配置读取优先从内存缓存获取,写入时同步到磁盘
|
||||
|
||||
---
|
||||
|
||||
## 数据流程图 / Data Flow Diagrams
|
||||
|
||||
### 完整保存流程 / Complete Save Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 用户 User
|
||||
participant UI as SettingsPage.tsx
|
||||
participant Preload as preload/index.ts
|
||||
participant IPC as settings-handler.ts
|
||||
participant Config as ConfigManager.ts
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: 点击保存按钮<br/>Click Save Button
|
||||
activate UI
|
||||
|
||||
UI->>UI: handleSaveSettings()
|
||||
Note over UI: 检查是否修改<br/>Check isModified
|
||||
|
||||
UI->>Preload: window.electron.settings<br/>.saveSettings(settings)
|
||||
activate Preload
|
||||
|
||||
Preload->>IPC: ipcRenderer.invoke<br/>('settings:saveSettings', settings)
|
||||
activate IPC
|
||||
|
||||
IPC->>IPC: 验证用户类型<br/>Validate User Type
|
||||
IPC->>Config: configManager<br/>.saveAllSettings(settings)
|
||||
activate Config
|
||||
|
||||
Config->>Config: 更新内存缓存<br/>Update Cache
|
||||
Note over Config: set('erp.url', value)<br/>set('erp.username', value)<br/>... (40+ fields)
|
||||
|
||||
Config->>File: fs.writeFileSync<br/>(.env, content)
|
||||
activate File
|
||||
File-->>Config: true/false
|
||||
deactivate File
|
||||
|
||||
Config-->>IPC: Promise<boolean>
|
||||
deactivate Config
|
||||
|
||||
IPC-->>Preload: {success, error?}
|
||||
deactivate IPC
|
||||
|
||||
Preload-->>UI: Promise resolve
|
||||
deactivate Preload
|
||||
|
||||
alt 保存成功 / Save Success
|
||||
UI->>UI: setIsModified(false)
|
||||
UI->>User: 显示成功消息<br/>Show Success Message
|
||||
else 保存失败 / Save Failed
|
||||
UI->>User: 显示错误消息<br/>Show Error Message
|
||||
end
|
||||
|
||||
deactivate UI
|
||||
```
|
||||
|
||||
### 数据转换流程 / Data Transformation Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "UI State"
|
||||
STATE[Settings Interface<br/>settings.erp.url = 'https://...']
|
||||
end
|
||||
|
||||
subgraph "Type Conversion"
|
||||
T1[SettingsData Object<br/>TypeScript Interface]
|
||||
end
|
||||
|
||||
subgraph "IPC Transport"
|
||||
JSON[JSON Serialization<br/>String Transfer]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
CACHE[Config Cache<br/>Map<string, string>]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
ENV[.env File Format<br/>KEY=VALUE]
|
||||
end
|
||||
|
||||
STATE -->|Object| T1
|
||||
T1 -->|JSON.stringify| JSON
|
||||
JSON -->|Deserialize| T1
|
||||
T1 -->|set key-value| CACHE
|
||||
CACHE -->|Format| ENV
|
||||
|
||||
style STATE fill:#e1f5ff
|
||||
style JSON fill:#fff4e1
|
||||
style CACHE fill:#e1ffe1
|
||||
style ENV fill:#f5f5f5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件详解 / Component Details
|
||||
|
||||
### 1. 渲染进程 / Renderer Process
|
||||
|
||||
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- 用户界面渲染和交互
|
||||
- 本地状态管理(settings, isModified, message)
|
||||
- 调用 IPC 通信
|
||||
|
||||
**关键函数 / Key Functions:**
|
||||
|
||||
```typescript
|
||||
// 第 61-73 行 / Lines 61-73
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const result = await window.electron.settings.saveSettings(settings as any)
|
||||
if (result.success) {
|
||||
setIsModified(false) // 清除修改标记
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态管理 / State Management:**
|
||||
|
||||
| 状态变量 | 类型 | 用途 |
|
||||
| ------------ | ---------------- | ----------------------------------------------------------- |
|
||||
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
||||
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
||||
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
||||
| `message` | `object \| null` | 临时消息,3秒后自动消失 |
|
||||
|
||||
**UI 交互逻辑 / UI Interaction Logic:**
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading: 组件挂载
|
||||
Loading --> Ready: loadSettings()
|
||||
Ready --> Modified: updateSettings()
|
||||
Modified --> Modified: 继续修改
|
||||
Modified --> Ready: 保存成功
|
||||
Modified --> Error: 保存失败
|
||||
Error --> Modified: 用户继续操作
|
||||
Ready --> [*]: 组件卸载
|
||||
|
||||
note right of Modified
|
||||
保存按钮启用
|
||||
Save Button Enabled
|
||||
end note
|
||||
|
||||
note right of Ready
|
||||
保存按钮禁用
|
||||
Save Button Disabled
|
||||
end note
|
||||
```
|
||||
|
||||
### 2. 预加载脚本 / Preload Script
|
||||
|
||||
#### preload/index.ts (`src/preload/index.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- 安全桥梁,暴露受限 API 到渲染进程
|
||||
- 类型安全的 IPC 通道定义
|
||||
|
||||
**关键代码 / Key Code:**
|
||||
|
||||
```typescript
|
||||
// 第 89-97 行 / Lines 89-97
|
||||
settings: {
|
||||
getUserType: () => ipcRenderer.invoke('settings:getUserType'),
|
||||
getSettings: () => ipcRenderer.invoke('settings:getSettings'),
|
||||
saveSettings: (settings: SettingsData) =>
|
||||
ipcRenderer.invoke('settings:saveSettings', settings),
|
||||
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
|
||||
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
|
||||
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
|
||||
}
|
||||
```
|
||||
|
||||
**安全隔离机制 / Security Isolation:**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Renderer[Renderer Process<br/>Untrusted Context]
|
||||
Preload[Preload Script<br/>Trusted Context]
|
||||
Main[Main Process<br/>Trusted Context]
|
||||
|
||||
Renderer -->|window.electron| Preload
|
||||
Preload -->|ipcRenderer.invoke| Main
|
||||
Main -->|Validation| Preload
|
||||
Preload -->|Return Promise| Renderer
|
||||
|
||||
style Renderer fill:#ffe1e1
|
||||
style Preload fill:#e1ffe1
|
||||
style Main fill:#e1e1ff
|
||||
```
|
||||
|
||||
### 3. 主进程 / Main Process
|
||||
|
||||
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- IPC 通道注册和处理
|
||||
- 权限验证(基于用户类型)
|
||||
- 业务逻辑协调
|
||||
|
||||
**保存设置处理函数 / Save Settings Handler:**
|
||||
|
||||
```typescript
|
||||
// 第 83-102 行 / Lines 83-102
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings')
|
||||
const success = await configManager.saveAllSettings(settings)
|
||||
if (success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings')
|
||||
return { success: false, error: '保存设置失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return { success: false, error: `保存设置失败:${message}` }
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**用户类型过滤 / User Type Filtering:**
|
||||
|
||||
```typescript
|
||||
// 第 31-54 行 / Lines 31-54
|
||||
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
|
||||
if (userType === 'Admin') {
|
||||
return settings // Admin 获取完整配置
|
||||
}
|
||||
|
||||
// User 用户获取受限配置
|
||||
return {
|
||||
erp: {
|
||||
username: settings.erp.username,
|
||||
password: settings.erp.password,
|
||||
headless: settings.erp.headless,
|
||||
url: settings.erp.url,
|
||||
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
|
||||
autoCloseBrowser: settings.erp.autoCloseBrowser
|
||||
},
|
||||
paths: settings.paths,
|
||||
execution: settings.execution,
|
||||
database: settings.database,
|
||||
extraction: settings.extraction,
|
||||
validation: settings.validation,
|
||||
ui: settings.ui
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**权限控制矩阵 / Permission Control Matrix:**
|
||||
|
||||
| 功能 / Feature | Admin | User | Guest |
|
||||
| -------------- | ----- | ------- | ----- |
|
||||
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
||||
| 保存设置 | ✅ | ✅ | ❌ |
|
||||
| 恢复默认值 | ✅ | ❌ | ❌ |
|
||||
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
|
||||
| 测试数据库连接 | ✅ | ✅ | ❌ |
|
||||
|
||||
### 4. 配置管理服务 / Configuration Manager Service
|
||||
|
||||
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- .env 文件读写
|
||||
- 配置缓存管理
|
||||
- 默认值管理
|
||||
- 类型转换和验证
|
||||
|
||||
**类结构 / Class Structure:**
|
||||
|
||||
```typescript
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null // 单例模式
|
||||
private envPath: string // .env 文件路径
|
||||
private configCache: Map<string, string> // 内存缓存
|
||||
private initialized: boolean = false // 初始化标记
|
||||
|
||||
// 单例获取方法
|
||||
public static getInstance(): ConfigManager
|
||||
|
||||
// 配置读取
|
||||
public get(key: string, defaultValue?: string): string | undefined
|
||||
public getBoolean(key: string, defaultValue?: boolean): boolean
|
||||
public getNumber(key: string, defaultValue?: number): number
|
||||
|
||||
// 配置写入
|
||||
public set(key: string, value: string | number | boolean): void
|
||||
|
||||
// 持久化
|
||||
public async save(): Promise<boolean>
|
||||
|
||||
// 高级操作
|
||||
public getAllSettings(): SettingsData
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean>
|
||||
public resetToDefaults(): SettingsData
|
||||
}
|
||||
```
|
||||
|
||||
**保存详细流程 / Save Detailed Flow:**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
START[saveAllSettings] --> STEP1[更新 ERP 配置 6 字段]
|
||||
STEP1 --> STEP2[更新数据库配置 7 字段]
|
||||
STEP2 --> STEP3[更新路径配置 3 字段]
|
||||
STEP3 --> STEP4[更新提取配置 5 字段]
|
||||
STEP4 --> STEP5[更新校验配置 5 字段]
|
||||
STEP5 --> STEP6[更新 UI 配置 3 字段]
|
||||
STEP6 --> STEP7[更新执行配置 1 字段]
|
||||
STEP7 --> SAVE[调用 save 方法]
|
||||
SAVE --> BUILD[构建 .env 内容]
|
||||
BUILD --> WRITE[写入文件系统]
|
||||
WRITE --> CHECK{检查结果}
|
||||
CHECK -->|成功| SUCCESS[返回 true]
|
||||
CHECK -->|失败| FAILURE[返回 false]
|
||||
```
|
||||
|
||||
**.env 文件格式 / .env File Format:**
|
||||
|
||||
```bash
|
||||
# ===========================
|
||||
# ERP 系统配置
|
||||
# ===========================
|
||||
ERP_URL=https://68.11.34.30:8082/
|
||||
ERP_USERNAME=
|
||||
ERP_PASSWORD=
|
||||
ERP_HEADLESS=true
|
||||
ERP_IGNORE_HTTPS_ERRORS=true
|
||||
ERP_AUTO_CLOSE_BROWSER=true
|
||||
|
||||
# ===========================
|
||||
# 数据库配置 - MySQL
|
||||
# ===========================
|
||||
DB_TYPE=mysql
|
||||
DB_NAME=BLD_DB
|
||||
DB_USERNAME=remote_user
|
||||
DB_PASSWORD=
|
||||
DB_MYSQL_HOST=192.168.31.83
|
||||
DB_MYSQL_PORT=3306
|
||||
DB_MYSQL_CHARSET=utf8mb4
|
||||
|
||||
# ===========================
|
||||
# 路径配置
|
||||
# ===========================
|
||||
PATH_DATA_DIR=D:/python/playwrite/data/
|
||||
PATH_DEFAULT_OUTPUT=离散备料计划维护_合并.xlsx
|
||||
PATH_VALIDATION_OUTPUT=物料状态校验结果.xlsx
|
||||
|
||||
# ... 更多配置节
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据结构 / Data Structures
|
||||
|
||||
### SettingsData 接口 / Interface Definition
|
||||
|
||||
**类型定义位置 / Type Definition Location:**
|
||||
`src/main/types/settings.types.ts` (第 136-151 行)
|
||||
|
||||
```typescript
|
||||
export interface SettingsData {
|
||||
erp: ErpConfig
|
||||
database: DatabaseConfig
|
||||
paths: PathsConfig
|
||||
extraction: ExtractionConfig
|
||||
validation: ValidationConfig
|
||||
ui: UiConfig
|
||||
execution: ExecutionConfig
|
||||
}
|
||||
```
|
||||
|
||||
### 完整数据结构树 / Complete Data Structure Tree
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Settings[SettingsData]
|
||||
|
||||
Settings --> Erp[ErpConfig]
|
||||
Erp --> Erp1[url: string]
|
||||
Erp --> Erp2[username: string]
|
||||
Erp --> Erp3[password: string]
|
||||
Erp --> Erp4[headless: boolean]
|
||||
Erp --> Erp5[ignoreHttpsErrors: boolean]
|
||||
Erp --> Erp6[autoCloseBrowser: boolean]
|
||||
|
||||
Settings --> DB[DatabaseConfig]
|
||||
DB --> DB1[dbType: mysql or sqlserver]
|
||||
DB --> DB2[server: string]
|
||||
DB --> DB3[mysqlHost: string]
|
||||
DB --> DB4[mysqlPort: number]
|
||||
DB --> DB5[database: string]
|
||||
DB --> DB6[username: string]
|
||||
DB --> DB7[password: string]
|
||||
|
||||
Settings --> Paths[PathsConfig]
|
||||
Paths --> Paths1[dataDir: string]
|
||||
Paths --> Paths2[defaultOutput: string]
|
||||
Paths --> Paths3[validationOutput: string]
|
||||
|
||||
Settings --> Extract[ExtractionConfig]
|
||||
Extract --> Extract1[batchSize: number]
|
||||
Extract --> Extract2[verbose: boolean]
|
||||
Extract --> Extract3[autoConvert: boolean]
|
||||
Extract --> Extract4[mergeBatches: boolean]
|
||||
Extract --> Extract5[enableDbPersistence: boolean]
|
||||
|
||||
Settings --> Valid[ValidationConfig]
|
||||
Valid --> Valid1[dataSource: ValidationDataSource]
|
||||
Valid --> Valid2[batchSize: number]
|
||||
Valid --> Valid3[matchMode: MatchMode]
|
||||
Valid --> Valid4[enableCrud: boolean]
|
||||
Valid --> Valid5[defaultManager: string]
|
||||
|
||||
Settings --> UI[UiConfig]
|
||||
UI --> UI1[fontFamily: string]
|
||||
UI --> UI2[fontSize: number]
|
||||
UI --> UI3[productionIdInputWidth: number]
|
||||
|
||||
Settings --> Exec[ExecutionConfig]
|
||||
Exec --> Exec1[dryRun: boolean]
|
||||
|
||||
style Settings fill:#e1f5ff
|
||||
style Erp fill:#ffe1f5
|
||||
style DB fill:#e1ffe1
|
||||
style Paths fill:#fff4e1
|
||||
style Extract fill:#f5e1ff
|
||||
style Valid fill:#ffe1e1
|
||||
style UI fill:#e1f5ff
|
||||
style Exec fill:#f5f5f5
|
||||
```
|
||||
|
||||
### IPC 通信数据格式 / IPC Communication Data Format
|
||||
|
||||
**请求格式 / Request Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"erp": {
|
||||
"url": "https://68.11.34.30:8082/",
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"headless": true,
|
||||
"ignoreHttpsErrors": true,
|
||||
"autoCloseBrowser": true
|
||||
},
|
||||
"database": { ... },
|
||||
"paths": { ... },
|
||||
"extraction": { ... },
|
||||
"validation": { ... },
|
||||
"ui": { ... },
|
||||
"execution": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式 / Response Format:**
|
||||
|
||||
```json
|
||||
// 成功 / Success
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
|
||||
// 失败 / Failure
|
||||
{
|
||||
"success": false,
|
||||
"error": "保存设置失败:Access denied"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理机制 / Error Handling Mechanism
|
||||
|
||||
### 错误处理层次 / Error Handling Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "UI Layer"
|
||||
UI_TRY[try-catch in handleSaveSettings]
|
||||
UI_MSG[showMessage display]
|
||||
end
|
||||
|
||||
subgraph "IPC Layer"
|
||||
IPC_TRY[try-catch in handler]
|
||||
IPC_LOG[Structured logging]
|
||||
IPC_RETURN[Return error object]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
SVC_TRY[try-catch in save]
|
||||
SVC_LOG[Console error log]
|
||||
SVC_RETURN[Return false]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
FS_CHECK[File exists check]
|
||||
FS_WRITE[Write with error handling]
|
||||
end
|
||||
|
||||
UI_TRY -->|Catch| UI_MSG
|
||||
IPC_TRY -->|Catch| IPC_LOG --> IPC_RETURN
|
||||
SVC_TRY -->|Catch| SVC_LOG --> SVC_RETURN
|
||||
FS_WRITE -->|Error| SVC_TRY
|
||||
|
||||
style UI_TRY fill:#ffe1e1
|
||||
style IPC_TRY fill:#ffe1e1
|
||||
style SVC_TRY fill:#ffe1e1
|
||||
```
|
||||
|
||||
### 错误场景分析 / Error Scenario Analysis
|
||||
|
||||
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
||||
| ------------------------- | ------------------- | --------------------- | ------------------------ |
|
||||
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
||||
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
||||
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
||||
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
|
||||
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
|
||||
|
||||
### 日志记录策略 / Logging Strategy
|
||||
|
||||
```typescript
|
||||
// Main Process 结构化日志 / Structured Logging
|
||||
log.info('Saving settings')
|
||||
log.info('Settings saved successfully')
|
||||
log.warn('Failed to save settings')
|
||||
log.error('Error saving settings', { error: message })
|
||||
```
|
||||
|
||||
**日志级别使用 / Log Level Usage:**
|
||||
|
||||
- `info`: 正常操作流程
|
||||
- `warn`: 潜在问题(如保存失败但未崩溃)
|
||||
- `error`: 严重错误(如异常抛出)
|
||||
|
||||
---
|
||||
|
||||
## 安全考虑 / Security Considerations
|
||||
|
||||
### 安全机制层级 / Security Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
L1[Layer 1: Context Isolation<br/>渲染进程隔离]
|
||||
L2[Layer 2: contextBridge<br/>受限 API 暴露]
|
||||
L3[Layer 3: User Type Filtering<br/>基于角色的访问控制]
|
||||
L4[Layer 4: File System Permissions<br/>.env 文件保护]
|
||||
|
||||
L1 --> L2 --> L3 --> L4
|
||||
|
||||
style L1 fill:#e1f5ff
|
||||
style L2 fill:#fff4e1
|
||||
style L3 fill:#e1ffe1
|
||||
style L4 fill:#ffe1f5
|
||||
```
|
||||
|
||||
### 关键安全措施 / Key Security Measures
|
||||
|
||||
1. **密码明文存储风险 / Password Storage Risk**
|
||||
- ⚠️ 当前:密码以明文形式存储在 .env 文件中
|
||||
- 🔒 建议:实现加密存储机制
|
||||
|
||||
2. **用户权限隔离 / User Permission Isolation**
|
||||
- ✅ 实现:基于用户类型过滤可见配置
|
||||
- ✅ 实现:Guest 用户无法访问设置页面
|
||||
|
||||
3. **IPC 通信安全 / IPC Communication Security**
|
||||
- ✅ 实现:使用 `contextBridge` 而非直接暴露
|
||||
- ✅ 实现:类型安全的 TypeScript 接口
|
||||
|
||||
4. **文件系统访问 / File System Access**
|
||||
- ✅ 实现:.env 文件仅主进程可访问
|
||||
- ⚠️ 风险:文件权限取决于操作系统
|
||||
|
||||
### 敏感数据流向 / Sensitive Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户输入
|
||||
participant UI as UI State (内存)
|
||||
participant IPC as IPC Channel
|
||||
participant Cache as Config Cache
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: password = "secret123"
|
||||
UI->>IPC: JSON 传输 (未加密)
|
||||
IPC->>Cache: Map.set('erp.password', 'secret123')
|
||||
Cache->>File: 写入明文到磁盘
|
||||
|
||||
Note over File: ⚠️ 安全风险:<br/>密码以明文形式持久化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术实现细节 / Technical Implementation Details
|
||||
|
||||
### 文件位置索引 / File Location Index
|
||||
|
||||
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
||||
| ---------------- | -------------------------------------------- | --------------------- |
|
||||
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
||||
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
||||
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
||||
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
|
||||
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
|
||||
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
|
||||
|
||||
### 性能特性 / Performance Characteristics
|
||||
|
||||
1. **异步操作 / Async Operations**
|
||||
- 所有 IPC 调用使用 `async/await` 模式
|
||||
- 避免阻塞主进程事件循环
|
||||
|
||||
2. **内存优化 / Memory Optimization**
|
||||
- 使用 Map 缓存配置,减少文件读取
|
||||
- 按需加载配置项
|
||||
|
||||
3. **写入策略 / Write Strategy**
|
||||
- 每次保存完整重写 .env 文件
|
||||
- 原子写入(writeFileSync)
|
||||
|
||||
### 依赖关系图 / Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[SettingsPage.tsx] -->|imports| B[lucide-react]
|
||||
A -->|uses| C[window.electron.settings]
|
||||
|
||||
C -->|exposed by| D[preload/index.ts]
|
||||
D -->|imports| E[electron API]
|
||||
D -->|imports| F[SettingsData Type]
|
||||
|
||||
G[settings-handler.ts] -->|imports| H[ipcMain]
|
||||
G -->|imports| I[ConfigManager]
|
||||
G -->|imports| J[SessionManager]
|
||||
G -->|imports| K[Logger]
|
||||
|
||||
I -->|imports| L[fs/path]
|
||||
I -->|imports| M[SettingsData Type]
|
||||
I -->|imports| N[DEFAULT_SETTINGS]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style D fill:#fff4e1
|
||||
style G fill:#ffe1f5
|
||||
style I fill:#e1ffe1
|
||||
```
|
||||
|
||||
### 关键代码片段分析 / Key Code Snippet Analysis
|
||||
|
||||
**1. 状态更新逻辑 / State Update Logic**
|
||||
|
||||
```typescript
|
||||
// SettingsPage.tsx 第 50-59 行
|
||||
const updateSettings = (category: string, key: string, value: any) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...(prev as any)[category],
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
setIsModified(true) // 标记为已修改
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 不可变更新模式(Immutable Update Pattern)
|
||||
- 使用展开运算符保持对象引用
|
||||
- 自动启用保存按钮
|
||||
|
||||
**2. 配置保存逻辑 / Configuration Save Logic**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 437-483 行
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
||||
// 批量更新缓存 (40+ 字段)
|
||||
this.set('erp.url', settings.erp.url)
|
||||
this.set('erp.username', settings.erp.username)
|
||||
// ... 更多字段
|
||||
|
||||
// 同步写入文件
|
||||
return this.save()
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 先更新内存,后写入磁盘
|
||||
- 失败时缓存保持不变
|
||||
- 返回布尔值表示成功/失败
|
||||
|
||||
**3. .env 文件生成逻辑 / .env File Generation**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 179-345 行
|
||||
public async save(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
|
||||
// 构建格式化的 .env 内容
|
||||
lines.push('# ===========================')
|
||||
lines.push('# ERP 系统配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
|
||||
|
||||
const content = lines.join('\n')
|
||||
fs.writeFileSync(this.envPath, content, 'utf-8')
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 添加注释分隔符提高可读性
|
||||
- 使用默认值作为后备
|
||||
- 同步写入确保一致性
|
||||
|
||||
---
|
||||
|
||||
## 扩展与改进建议 / Extension and Improvement Suggestions
|
||||
|
||||
### 短期改进 / Short-term Improvements
|
||||
|
||||
1. **输入验证 / Input Validation**
|
||||
- 添加 URL 格式验证
|
||||
- 密码强度检查
|
||||
- 端口号范围验证
|
||||
|
||||
2. **用户体验 / User Experience**
|
||||
- 添加保存进度指示器
|
||||
- 实现自动保存功能
|
||||
- 添加配置导入/导出
|
||||
|
||||
3. **错误处理 / Error Handling**
|
||||
- 更详细的错误消息
|
||||
- 错误恢复建议
|
||||
- 错误日志导出
|
||||
|
||||
### 长期改进 / Long-term Improvements
|
||||
|
||||
1. **安全性增强 / Security Enhancement**
|
||||
|
||||
```typescript
|
||||
// 建议实现密码加密
|
||||
interface SecureSettingsData extends SettingsData {
|
||||
erp: {
|
||||
...ErpConfig
|
||||
encryptedPassword: string // 替代明文密码
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **配置版本控制 / Configuration Versioning**
|
||||
- 实现配置历史记录
|
||||
- 支持回滚到之前版本
|
||||
- 配置变更审计日志
|
||||
|
||||
3. **实时配置重载 / Live Config Reload**
|
||||
- 监听 .env 文件变化
|
||||
- 自动重载配置
|
||||
- 通知相关服务更新
|
||||
|
||||
---
|
||||
|
||||
## 测试建议 / Testing Recommendations
|
||||
|
||||
### 单元测试 / Unit Tests
|
||||
|
||||
```typescript
|
||||
// 测试用例示例
|
||||
describe('ConfigManager', () => {
|
||||
it('should save settings successfully', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
const settings: SettingsData = {
|
||||
/* mock data */
|
||||
}
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file write errors', async () => {
|
||||
// Mock fs.writeFileSync to throw error
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 集成测试 / Integration Tests
|
||||
|
||||
```typescript
|
||||
describe('Settings Save Flow', () => {
|
||||
it('should complete full save cycle', async () => {
|
||||
// 1. User modifies settings
|
||||
// 2. Clicks save button
|
||||
// 3. Verifies .env file updated
|
||||
// 4. Confirms UI feedback
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 / Appendix
|
||||
|
||||
### 完整配置字段列表 / Complete Configuration Field List
|
||||
|
||||
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
||||
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
|
||||
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
||||
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
||||
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
||||
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
|
||||
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
|
||||
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
|
||||
| Execution | 1 | dryRun |
|
||||
| **总计 / Total** | **30** | |
|
||||
|
||||
### 相关文档 / Related Documentation
|
||||
|
||||
- [Electron Security Guidelines](https://www.electronjs.org/docs/latest/tutorial/security)
|
||||
- [IPC 通信最佳实践](https://www.electronjs.org/docs/latest/tutorial/ipc)
|
||||
- [环境变量管理规范](.env.example)
|
||||
|
||||
### 版本历史 / Version History
|
||||
|
||||
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
||||
| -------------- | ----------- | -------------------------- |
|
||||
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间 / Document Generated:** 2025-03-03
|
||||
**最后更新 / Last Updated:** 2025-03-03
|
||||
**维护者 / Maintainer:** ERPAuto Development Team
|
||||
246
docs/use-cleaner-refactor-overview.md
Normal file
246
docs/use-cleaner-refactor-overview.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# useCleaner 重构说明
|
||||
|
||||
本文档记录 `src/renderer/src/hooks/useCleaner.ts` 的第一阶段重构工作。目标不是一次性把整个 Cleaner 页面完全组件化,而是优先拆出共享类型、纯函数和 IPC 编排逻辑,让 `useCleaner` 从“大而全逻辑容器”逐步收敛为“组合层”。
|
||||
|
||||
## 1. 重构背景
|
||||
|
||||
重构前,`useCleaner.ts` 同时负责:
|
||||
|
||||
- 页面初始化
|
||||
- 权限判断
|
||||
- sessionStorage 持久化
|
||||
- 校验请求
|
||||
- 结果筛选
|
||||
- 勾选状态处理
|
||||
- 删除计划构建
|
||||
- 保存物料变更
|
||||
- Cleaner 执行编排
|
||||
- 导出编排
|
||||
- 弹窗确认
|
||||
- 报告状态维护
|
||||
|
||||
这导致它虽然名义上是一个 hook,但实际上已经接近一个“前端页面服务总线”。
|
||||
|
||||
## 2. 重构目标
|
||||
|
||||
本次重构目标是:
|
||||
|
||||
- 提取共享类型,消除重复定义
|
||||
- 提取纯函数,隔离无副作用逻辑
|
||||
- 提取 IPC / 异步编排,隔离对 `window.electron` 的直接调用
|
||||
- 保持 `useCleaner()` 返回值和 `CleanerPage.tsx` 使用方式不变
|
||||
|
||||
## 3. 重构后结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Page[CleanerPage.tsx]
|
||||
Hook[useCleaner.ts]
|
||||
|
||||
subgraph CleanerHookModules[Cleaner Hook Modules]
|
||||
Types[hooks/cleaner/types.ts]
|
||||
Helpers[hooks/cleaner/helpers.ts]
|
||||
Api[hooks/cleaner/api.ts]
|
||||
end
|
||||
|
||||
subgraph ExternalDeps[External Dependencies]
|
||||
Electron[window.electron]
|
||||
Store[useAppStore / Toast]
|
||||
Dialog[ConfirmDialog]
|
||||
end
|
||||
|
||||
Page --> Hook
|
||||
Hook --> Types
|
||||
Hook --> Helpers
|
||||
Hook --> Api
|
||||
Hook --> Store
|
||||
Hook --> Dialog
|
||||
Api --> Electron
|
||||
```
|
||||
|
||||
## 4. 本次拆分内容
|
||||
|
||||
### 4.1 共享类型
|
||||
|
||||
新增:
|
||||
|
||||
- `src/renderer/src/hooks/cleaner/types.ts`
|
||||
|
||||
统一收敛了以下类型:
|
||||
|
||||
- `ValidationRequest`
|
||||
- `ValidationResult`
|
||||
- `ValidationStats`
|
||||
- `ValidationResponsePayload`
|
||||
- `CleanerProgress`
|
||||
- `CleanerReportData`
|
||||
- `CleanerInitializationResult`
|
||||
- `CleanerConfigResult`
|
||||
|
||||
这一步解决了原来多个文件重复定义同类类型的问题,比如:
|
||||
|
||||
- `useCleaner.ts`
|
||||
- `useValidation.ts`
|
||||
- `ExecutionReportDialog.tsx`
|
||||
|
||||
### 4.2 纯函数与数据构造
|
||||
|
||||
新增:
|
||||
|
||||
- `src/renderer/src/hooks/cleaner/helpers.ts`
|
||||
|
||||
提取出的纯函数包括:
|
||||
|
||||
- `getStoredBoolean()`
|
||||
- `getStoredValidationMode()`
|
||||
- `filterValidationResults()`
|
||||
- `buildDeletionPlan()`
|
||||
- `buildExportItems()`
|
||||
|
||||
这些逻辑之前都散落在 `useCleaner.ts` 的 `useMemo` 或事件处理函数里,现在可以单独测试。
|
||||
|
||||
### 4.3 IPC 与异步编排
|
||||
|
||||
新增:
|
||||
|
||||
- `src/renderer/src/hooks/cleaner/api.ts`
|
||||
|
||||
提取出的异步编排包括:
|
||||
|
||||
- `initializeCleanerPage()`
|
||||
- `loadCleanerConfig()`
|
||||
- `runValidationRequest()`
|
||||
- `saveDeletionPlan()`
|
||||
- `reloadManagers()`
|
||||
- `runCleanerExecution()`
|
||||
- `exportCleanerResults()`
|
||||
|
||||
这样做之后,`useCleaner.ts` 不再需要在每个 handler 里直接拼接 `window.electron.xxx` 调用细节。
|
||||
|
||||
## 5. useCleaner 的角色变化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Before[重构前]
|
||||
A[useCleaner.ts]
|
||||
A --> A1[本地状态]
|
||||
A --> A2[筛选逻辑]
|
||||
A --> A3[删除计划构建]
|
||||
A --> A4[执行清理请求]
|
||||
A --> A5[导出请求]
|
||||
A --> A6[初始化请求]
|
||||
A --> A7[共享类型定义]
|
||||
end
|
||||
|
||||
subgraph After[重构后]
|
||||
B[useCleaner.ts]
|
||||
B --> B1[组合状态]
|
||||
B --> B2[调用 helpers]
|
||||
B --> B3[调用 api]
|
||||
|
||||
C[helpers.ts]
|
||||
D[api.ts]
|
||||
E[types.ts]
|
||||
|
||||
B --> C
|
||||
B --> D
|
||||
B --> E
|
||||
end
|
||||
```
|
||||
|
||||
重构后,`useCleaner.ts` 更接近“组合层”:
|
||||
|
||||
- 管理 React state
|
||||
- 串联用户交互流程
|
||||
- 调用 helpers 和 api
|
||||
- 将最终能力暴露给页面
|
||||
|
||||
## 6. 受影响的文件
|
||||
|
||||
### 6.1 主体修改
|
||||
|
||||
- `src/renderer/src/hooks/useCleaner.ts`
|
||||
- `src/renderer/src/hooks/useValidation.ts`
|
||||
- `src/renderer/src/components/ExecutionReportDialog.tsx`
|
||||
|
||||
### 6.2 新增模块
|
||||
|
||||
- `src/renderer/src/hooks/cleaner/types.ts`
|
||||
- `src/renderer/src/hooks/cleaner/helpers.ts`
|
||||
- `src/renderer/src/hooks/cleaner/api.ts`
|
||||
|
||||
### 6.3 新增测试
|
||||
|
||||
- `tests/unit/cleaner-helpers.test.ts`
|
||||
|
||||
## 7. 具体收益
|
||||
|
||||
### 7.1 类型一致性提升
|
||||
|
||||
之前 `ValidationResult`、`CleanerProgress` 在多个文件重复定义,修改字段时容易遗漏。
|
||||
现在统一从 `hooks/cleaner/types.ts` 引用,降低了类型漂移风险。
|
||||
|
||||
### 7.2 可测试性提升
|
||||
|
||||
原先删除计划构建、筛选和导出映射逻辑只能通过 hook 间接覆盖。
|
||||
现在这些逻辑已经被抽成纯函数,可以直接做单测。
|
||||
|
||||
### 7.3 Hook 复杂度下降
|
||||
|
||||
虽然 `useCleaner.ts` 还没有变成一个很小的文件,但其中的“细节密度”已经明显下降:
|
||||
|
||||
- 数据变换逻辑外提
|
||||
- API 编排逻辑外提
|
||||
- 重复类型移除
|
||||
|
||||
### 7.4 为下一步组件拆分做准备
|
||||
|
||||
后续如果要拆 `CleanerPage.tsx`:
|
||||
|
||||
- 左侧筛选区
|
||||
- 表格工具栏
|
||||
- 底部执行区
|
||||
|
||||
这些组件就可以直接消费已经整理好的 hook 能力,而不是继续把逻辑往页面里塞。
|
||||
|
||||
## 8. 验证方式
|
||||
|
||||
本次重构后执行了以下验证:
|
||||
|
||||
- `npm run typecheck:node`
|
||||
- `tests/unit/cleaner-helpers.test.ts`
|
||||
- `tests/unit/cleaner.test.ts`
|
||||
|
||||
## 9. 新增测试覆盖点
|
||||
|
||||
`tests/unit/cleaner-helpers.test.ts` 覆盖了:
|
||||
|
||||
- 非管理员筛选逻辑
|
||||
- 删除计划构建逻辑
|
||||
- 导出数据构建逻辑
|
||||
|
||||
## 10. 仍然保留在 useCleaner 中的内容
|
||||
|
||||
为了控制改动风险,这次没有继续下沉以下能力:
|
||||
|
||||
- `ConfirmDialog` 的 Promise 封装
|
||||
- 编辑状态 `editingCell / editValue`
|
||||
- `isRunning / isExecuting / isReportDialogOpen` 等 UI 状态
|
||||
- 页面层直接依赖的完整返回对象
|
||||
|
||||
这些能力仍然保留在 `useCleaner.ts`,因为它们和当前页面交互绑定较深。
|
||||
|
||||
## 11. 下一步建议
|
||||
|
||||
基于目前的结构,建议下一阶段继续做:
|
||||
|
||||
1. 拆 `CleanerPage.tsx` 为“左侧筛选区”和“右侧结果与执行区”两个子组件。
|
||||
2. 将 `showConfirmDialog()` 封装为独立 hook,例如 `useConfirmDialogController()`。
|
||||
3. 将 inline edit 相关逻辑提取到更专门的 manager-assignment controller。
|
||||
4. 视情况把 Cleaner 相关状态进一步收敛到专门 store 或 domain hook 中。
|
||||
|
||||
## 12. 总结
|
||||
|
||||
这次 `useCleaner` 重构的核心价值,不是“让文件立刻变得很小”,而是先把最容易复用、最适合测试、最不应继续堆在 hook 里的部分拆出来。
|
||||
|
||||
它为接下来的页面组件拆分提供了一个更稳的基础,也让 Cleaner 模块开始从“页面驱动逻辑”向“模块化前端能力”转变。
|
||||
287
docs/user-override-match-feature.md
Normal file
287
docs/user-override-match-feature.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 物料匹配算法增强 - 用户覆盖匹配功能
|
||||
|
||||
**实施日期**: 2026-03-03
|
||||
**功能版本**: 1.0
|
||||
**修改文件**: `src/main/ipc/validation-handler.ts`
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
为 **User 用户类型** 在物料清理界面增加了 **优先级3:用户覆盖匹配** 功能,确保 User 用户能够优先看到并管理与自己关键词匹配的物料。
|
||||
|
||||
---
|
||||
|
||||
## 实现的更改
|
||||
|
||||
### 1. 获取当前用户信息
|
||||
|
||||
**位置**: `validation-handler.ts:218-239`
|
||||
|
||||
```typescript
|
||||
// Get current user info
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
```
|
||||
|
||||
**说明**:
|
||||
|
||||
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
||||
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
||||
- 如果用户未登录,返回错误响应
|
||||
|
||||
### 2. 新增优先级3:用户覆盖匹配
|
||||
|
||||
**位置**: `validation-handler.ts:359-370`
|
||||
|
||||
```typescript
|
||||
// Priority 3: User Override Match (only for non-admin users)
|
||||
// Override with current user's typeKeyword if available
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // Force override with first match
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**匹配逻辑**:
|
||||
|
||||
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
||||
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
||||
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
||||
4. **强制覆盖**: 只要匹配成功,立即覆盖原有的 `managerName` 和 `matchedTypeKeyword`
|
||||
5. **无匹配时**: 保持优先级2的匹配结果不变
|
||||
|
||||
---
|
||||
|
||||
## 匹配优先级(更新后)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([物料数据]) --> P1{优先级1<br/>MaterialsToBeDeleted<br/>精确匹配?}
|
||||
P1 -->|MaterialCode匹配| M1[✅ 已标记删除<br/>isMarkedForDeletion=true]
|
||||
P1 -->|未匹配| P2{优先级2<br/>MaterialsTypeToBeDeleted<br/>包含匹配?}
|
||||
|
||||
P2 -->|匹配到| M2[⚠️ 类型匹配<br/>managerName=其他用户]
|
||||
P2 -->|未匹配| M3[❌ 未匹配<br/>managerName='']
|
||||
|
||||
M1 --> Check{用户类型?}
|
||||
M2 --> Check
|
||||
M3 --> Check
|
||||
|
||||
Check -->|Admin| Skip[跳过覆盖]
|
||||
Check -->|User| P3{优先级3<br/>用户覆盖匹配?}
|
||||
|
||||
P3 -->|匹配成功| Override[✅ 覆为当前用户<br/>managerName=当前用户]
|
||||
P3 -->|未匹配| Keep[保持原结果]
|
||||
|
||||
Skip --> End([返回结果])
|
||||
Override --> End
|
||||
Keep --> End
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1: User 用户匹配到自己的 typeKeyword
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2: User 用户覆盖其他用户的匹配
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**优先级2结果**: `managerName = "user2"`
|
||||
**优先级3结果**: `managerName = "user1"` ✅ 强制覆盖
|
||||
|
||||
### 场景3: User 用户无匹配关键词
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user2",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 保持优先级2的匹配结果
|
||||
|
||||
### 场景4: Admin 用户不执行覆盖
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `admin` (isAdmin=true)
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
- `{ materialName: "螺丝", managerName: "admin" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
||||
|
||||
### 场景5: 优先级1匹配不受影响
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料代码: `MAT001`
|
||||
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialCode": "MAT001",
|
||||
"managerName": "user2",
|
||||
"isMarkedForDeletion": true,
|
||||
"matchedTypeKeyword": undefined
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 优先级1的精确匹配不受覆盖影响
|
||||
|
||||
---
|
||||
|
||||
## 数据库配置示例
|
||||
|
||||
### MaterialsTypeToBeDeleted 表数据
|
||||
|
||||
| MaterialName | ManagerName | 说明 |
|
||||
| ------------ | ----------- | ------------------------------ |
|
||||
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
||||
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
||||
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
||||
| 电缆 | admin | admin 负责电缆类物料 |
|
||||
|
||||
### 匹配结果示例
|
||||
|
||||
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
||||
| -------- | -------- | ---------------- | ----------------- |
|
||||
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
||||
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
||||
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
||||
| 电缆 5m | user1 | admin | user1 (无匹配) |
|
||||
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 与前端协同
|
||||
|
||||
前端过滤器逻辑 (`CleanerPage.tsx`) 保持不变:
|
||||
|
||||
```typescript
|
||||
const filteredResults = React.useMemo(() => {
|
||||
let results = validationResults
|
||||
if (!isAdmin && currentUsername) {
|
||||
// User 只看到自己的物料 + 未分配的物料
|
||||
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
|
||||
}
|
||||
return results
|
||||
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
|
||||
```
|
||||
|
||||
**协同效果**:
|
||||
|
||||
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
||||
2. 前端过滤器只显示属于当前用户或未分配的物料
|
||||
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
||||
|
||||
---
|
||||
|
||||
## 代码审查检查点
|
||||
|
||||
- ✅ User 信息获取正确使用 `SessionManager`
|
||||
- ✅ 只对 `!isAdmin` 的用户执行覆盖逻辑
|
||||
- ✅ 使用相同的包含匹配规则 `materialName.includes(typeKeyword.materialName)`
|
||||
- ✅ 优先级1(精确匹配)不受覆盖影响
|
||||
- ✅ 无匹配时保持原有结果
|
||||
- ✅ 日志记录包含用户信息 `{ user: username, isAdmin }`
|
||||
- ✅ 未登录时返回明确的错误信息
|
||||
|
||||
---
|
||||
|
||||
## 潜在改进方向
|
||||
|
||||
1. **性能优化**: 如果 `typeKeywords` 数量很大,可以预先构建 `Map<username, typeKeyword[]>` 索引
|
||||
2. **日志增强**: 添加覆盖匹配的统计信息(覆盖了多少条记录)
|
||||
3. **配置开关**: 允许 Admin 用户通过配置启用/禁用覆盖功能
|
||||
4. **UI 反馈**: 在前端显示哪些物料是通过覆盖匹配分配的
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- **实现文件**: `src/main/ipc/validation-handler.ts` (Lines 218-239, 359-370)
|
||||
- **前端页面**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **会话管理**: `src/main/services/user/session-manager.ts`
|
||||
- **类型定义**: `src/main/types/validation.types.ts`
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
217
docs/validation-handler-refactor-overview.md
Normal file
217
docs/validation-handler-refactor-overview.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# validation-handler 重构说明
|
||||
|
||||
本文档记录 `src/main/ipc/validation-handler.ts` 的第一阶段重构工作,目标是把“超大 IPC Handler”拆回到更清晰的职责边界中,同时保持对外 IPC 协议和业务行为不变。
|
||||
|
||||
## 1. 重构背景
|
||||
|
||||
重构前,`validation-handler.ts` 同时承担了以下职责:
|
||||
|
||||
- IPC 通道注册
|
||||
- 跨页面共享 `Production ID` 状态
|
||||
- 数据库连接创建与释放
|
||||
- MySQL / SQL Server 方言分支
|
||||
- 输入识别与订单号解析
|
||||
- 物料校验结果组装
|
||||
- Cleaner 执行前数据准备
|
||||
- 物料查询与富化
|
||||
|
||||
这种结构的主要问题是:
|
||||
|
||||
- 文件过大,理解成本高
|
||||
- 数据库和业务规则直接堆叠在 IPC 层
|
||||
- 复用困难,后续其他模块无法直接复用这些逻辑
|
||||
- 单元测试难以细粒度编写
|
||||
|
||||
## 2. 重构目标
|
||||
|
||||
本次重构聚焦在“职责下沉、行为不变”:
|
||||
|
||||
- 保留原有 IPC channel 和返回结构
|
||||
- 将共享状态、数据库工厂、输入解析、验证业务流程拆出
|
||||
- 让 `validation-handler.ts` 回归为薄 IPC 壳层
|
||||
- 为后续继续拆 `cleaner-handler`、前端校验流程提供复用基础
|
||||
|
||||
## 3. 重构后结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Renderer[Renderer / Preload]
|
||||
Handler[validation-handler.ts]
|
||||
|
||||
subgraph ValidationServices[Validation Services]
|
||||
Store[shared-production-ids-store.ts]
|
||||
DbFactory[validation-database.ts]
|
||||
InputSvc[production-input-service.ts]
|
||||
AppSvc[validation-application-service.ts]
|
||||
end
|
||||
|
||||
subgraph ExistingServices[Existing Services]
|
||||
MaterialsDAO[MaterialsToBeDeletedDAO]
|
||||
PlanDAO[DiscreteMaterialPlanDAO]
|
||||
Session[SessionManager]
|
||||
end
|
||||
|
||||
Renderer --> Handler
|
||||
Handler --> Session
|
||||
Handler --> Store
|
||||
Handler --> AppSvc
|
||||
Handler --> MaterialsDAO
|
||||
|
||||
AppSvc --> Store
|
||||
AppSvc --> DbFactory
|
||||
AppSvc --> InputSvc
|
||||
AppSvc --> MaterialsDAO
|
||||
AppSvc --> PlanDAO
|
||||
```
|
||||
|
||||
## 4. 新增与调整的文件
|
||||
|
||||
### 4.1 IPC 薄壳
|
||||
|
||||
- `src/main/ipc/validation-handler.ts`
|
||||
|
||||
职责收敛为:
|
||||
|
||||
- 注册 IPC handler
|
||||
- 从 `SessionManager` 读取当前用户
|
||||
- 调用应用服务
|
||||
- 对简单 DAO 操作做最轻量转发
|
||||
|
||||
### 4.2 共享状态模块
|
||||
|
||||
- `src/main/services/validation/shared-production-ids-store.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 管理按 `senderId` 隔离的共享 `Production IDs`
|
||||
- 提供 `set/get/clear`
|
||||
|
||||
价值:
|
||||
|
||||
- 将原本散落在 handler 文件顶部的状态提升为独立服务
|
||||
- 后续如果要迁移到更持久的 session store,只需替换这一层
|
||||
|
||||
### 4.3 数据库创建与表名适配
|
||||
|
||||
- `src/main/services/validation/validation-database.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 创建用于 validation 相关流程的数据库服务
|
||||
- 提供 `getValidationTableName()` 做表名方言转换
|
||||
|
||||
价值:
|
||||
|
||||
- 收敛 MySQL / SQL Server 的连接逻辑
|
||||
- 避免 IPC 文件里反复出现数据库构造代码
|
||||
|
||||
### 4.4 输入解析服务
|
||||
|
||||
- `src/main/services/validation/production-input-service.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 读取 Production ID 文件
|
||||
- 识别输入是 `production_id`、`order_number` 还是 `unknown`
|
||||
- 从输入解析出订单号列表
|
||||
|
||||
价值:
|
||||
|
||||
- 把“输入解析规则”变成可复用、可测试的纯业务模块
|
||||
|
||||
### 4.5 应用服务
|
||||
|
||||
- `src/main/services/validation/validation-application-service.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 校验流程编排
|
||||
- Cleaner 数据准备
|
||||
- 物料按负责人查询 / 全量查询的富化逻辑
|
||||
- 统一管理数据库生命周期
|
||||
|
||||
价值:
|
||||
|
||||
- 形成明确的 application service 层
|
||||
- 让后续业务扩展不再从 IPC 文件开刀
|
||||
|
||||
## 5. 重构前后职责对比
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Before[重构前]
|
||||
A1[validation-handler.ts]
|
||||
A1 --> A2[IPC 注册]
|
||||
A1 --> A3[共享状态]
|
||||
A1 --> A4[数据库连接]
|
||||
A1 --> A5[输入解析]
|
||||
A1 --> A6[校验编排]
|
||||
A1 --> A7[物料富化]
|
||||
A1 --> A8[Cleaner 数据准备]
|
||||
end
|
||||
|
||||
subgraph After[重构后]
|
||||
B1[validation-handler.ts]
|
||||
B2[shared-production-ids-store.ts]
|
||||
B3[validation-database.ts]
|
||||
B4[production-input-service.ts]
|
||||
B5[validation-application-service.ts]
|
||||
|
||||
B1 --> B5
|
||||
B1 --> B2
|
||||
B5 --> B3
|
||||
B5 --> B4
|
||||
end
|
||||
```
|
||||
|
||||
## 6. 本次保留不变的部分
|
||||
|
||||
为了控制风险,这次没有修改以下内容:
|
||||
|
||||
- IPC channel 名称
|
||||
- Preload / Renderer 调用方式
|
||||
- 物料匹配规则
|
||||
- Cleaner 数据准备规则
|
||||
- DAO 层的既有 SQL 结构
|
||||
|
||||
也就是说,这次更像是一次“结构性搬迁”,不是业务规则改造。
|
||||
|
||||
## 7. 验证方式
|
||||
|
||||
本次重构完成后,做了以下验证:
|
||||
|
||||
- `npm run typecheck:node`
|
||||
- `tests/unit/shared-production-ids-store.test.ts`
|
||||
- `tests/unit/production-input-service.test.ts`
|
||||
- 既有 `tests/unit/ipc-index.test.ts`
|
||||
|
||||
## 8. 新增测试
|
||||
|
||||
新增测试文件:
|
||||
|
||||
- `tests/unit/shared-production-ids-store.test.ts`
|
||||
- `tests/unit/production-input-service.test.ts`
|
||||
|
||||
覆盖内容:
|
||||
|
||||
- sender 隔离存储
|
||||
- 去重行为
|
||||
- 清空逻辑
|
||||
- 输入类型识别
|
||||
|
||||
## 9. 收益总结
|
||||
|
||||
这次重构带来的直接收益:
|
||||
|
||||
- `validation-handler.ts` 不再承担过多业务职责
|
||||
- validation 相关逻辑形成了可复用服务层
|
||||
- 输入解析与共享状态有了独立测试入口
|
||||
- 后续继续拆 `cleaner-handler` 时,可以直接复用订单号解析和 cleaner 数据准备逻辑
|
||||
|
||||
## 10. 后续建议
|
||||
|
||||
建议在这个基础上继续推进:
|
||||
|
||||
1. 将 `validation-application-service.ts` 中的 SQL Server / MySQL 分支继续下沉到 repository 或 dialect adapter。
|
||||
2. 逐步给 `getCleanerData()`、`getMaterialsByManager()` 这类编排逻辑补更多单测。
|
||||
3. 把和 validation 强耦合的 renderer 逻辑改成显式依赖 application contract,而不是隐式依赖 payload shape。
|
||||
@@ -2,42 +2,40 @@ appId: com.electron.app
|
||||
productName: erpauto
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- from: build/bin/portable-updater.exe
|
||||
to: portable-updater.exe
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!electron-vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- 'package.json'
|
||||
# Include build output
|
||||
- 'out/**/*'
|
||||
# Include config.template.yaml in the build for reference
|
||||
- 'config.template.yaml'
|
||||
# Exclude Playwright browser downloads (manual install for company environment)
|
||||
- '!**/node_modules/playwright-core/.local-browsers/**'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
# Unpack playwright for native modules
|
||||
- '**/node_modules/playwright/**'
|
||||
- '**/node_modules/playwright-core/**'
|
||||
win:
|
||||
executableName: erpauto
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
portable:
|
||||
artifactName: ${name}-portable.${ext}
|
||||
# Portable app uses user data directory (AppData), not exe directory
|
||||
# This ensures config persists across app updates
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
|
||||
@@ -2,16 +2,58 @@ import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { execSync } from 'child_process'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// Get git hash (first 7 characters)
|
||||
const getGitHash = (): string => {
|
||||
try {
|
||||
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf-8' }).trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// Get version from package.json
|
||||
const require = createRequire(import.meta.url)
|
||||
const version = require('./package.json').version
|
||||
const gitHash = getGitHash()
|
||||
const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
main: {
|
||||
define: {
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
define: {
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__GIT_HASH__: JSON.stringify(gitHash),
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss()]
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'update-title',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<title>ERP Auto Tool</title>',
|
||||
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,18 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'**/dist',
|
||||
'**/out',
|
||||
'.agents/**',
|
||||
'.claude/**',
|
||||
'scripts/**',
|
||||
'src/main/tools/**',
|
||||
'tests/manual/**'
|
||||
]
|
||||
},
|
||||
tseslint.configs.recommended,
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
@@ -18,15 +29,24 @@ export default defineConfig(
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ['**/*.{js,ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': eslintPluginReactHooks,
|
||||
'react-refresh': eslintPluginReactRefresh
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
...eslintPluginReactRefresh.configs.vite.rules
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off'
|
||||
}
|
||||
},
|
||||
eslintConfigPrettier
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1772462852547,
|
||||
"name": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\app-2026-03-02.log",
|
||||
"hash": "baa4ab4c2dfd6ec62a003e44496d2f428a4ad4037ce2529a8da14da1b91ef7a2"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
4614
package-lock.json
generated
4614
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.0.0",
|
||||
"version": "1.6.1",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
@@ -12,32 +12,51 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"dev": "chcp 65001 && electron-vite dev",
|
||||
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"build:updater": "node scripts/compile-updater.js",
|
||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build:updater && npm run build && electron-builder --dir",
|
||||
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && npm run build:updater && electron-builder --win",
|
||||
"release:prepare": "node scripts/prepare-release.js",
|
||||
"release:publish": "node scripts/publish-release.js",
|
||||
"release:upload": "node scripts/upload-release.js",
|
||||
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
|
||||
"debug:config-path": "tsx src/main/tools/config-path-debug.ts",
|
||||
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"chromium-bidi": "^15.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
@@ -70,6 +89,7 @@
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.18"
|
||||
|
||||
57
scripts/compile-updater.js
Normal file
57
scripts/compile-updater.js
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const sourcePath = path.join(rootDir, 'build', 'PortableUpdater.cs')
|
||||
const outputDir = path.join(rootDir, 'build', 'bin')
|
||||
const outputPath = path.join(outputDir, 'portable-updater.exe')
|
||||
|
||||
function findCompiler() {
|
||||
const candidates = [
|
||||
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
|
||||
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'
|
||||
]
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || null
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('Skipping updater compilation on non-Windows platform.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Updater source not found: ${sourcePath}`)
|
||||
}
|
||||
|
||||
const compiler = findCompiler()
|
||||
if (!compiler) {
|
||||
throw new Error('Unable to find csc.exe for compiling portable-updater.exe')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
const compileArgs = ['/nologo', '/target:exe', '/optimize+', `/out:${outputPath}`, sourcePath]
|
||||
|
||||
const result = spawnSync(compiler, compileArgs, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`csc.exe failed with exit code ${result.status}`)
|
||||
}
|
||||
|
||||
console.log(`Portable updater compiled successfully: ${outputPath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`compile-updater failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
115
scripts/fix-auto-increment.js
Normal file
115
scripts/fix-auto-increment.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||
*
|
||||
* This script modifies the ID column to be AUTO_INCREMENT while preserving data
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
async function main() {
|
||||
const config = {
|
||||
host: '192.168.31.83',
|
||||
port: 3306,
|
||||
user: 'remote_user',
|
||||
password: '3.1415926Beeke',
|
||||
database: 'BLD_DB'
|
||||
}
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
console.log('Connecting to MySQL...')
|
||||
connection = await mysql.createConnection(config)
|
||||
console.log('Connected successfully!\n')
|
||||
|
||||
// Step 1: Check current table structure
|
||||
console.log('=== Step 1: Current table structure ===')
|
||||
const [columns] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY
|
||||
ORDINAL_POSITION
|
||||
`)
|
||||
console.table(columns)
|
||||
|
||||
// Step 2: Count records before modification
|
||||
console.log('\n=== Step 2: Record count before modification ===')
|
||||
const [countBefore] = await connection.execute(
|
||||
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||
)
|
||||
console.log(`Total records: ${countBefore[0].total}`)
|
||||
|
||||
// Step 3: Show sample data
|
||||
console.log('\n=== Step 3: Sample data ===')
|
||||
const [sample] = await connection.execute('SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 5')
|
||||
console.table(sample)
|
||||
|
||||
// Step 4: Check if ID is already AUTO_INCREMENT
|
||||
const idColumn = columns.find((col) => col.COLUMN_NAME === 'ID')
|
||||
if (idColumn && idColumn.EXTRA.includes('auto_increment')) {
|
||||
console.log('\n=== ID is already AUTO_INCREMENT! No modification needed. ===')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: Modify the ID column
|
||||
console.log('\n=== Step 4: Modifying ID column to AUTO_INCREMENT ===')
|
||||
await connection.execute(`
|
||||
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT
|
||||
`)
|
||||
console.log('Modification completed successfully!\n')
|
||||
|
||||
// Step 6: Verify the change
|
||||
console.log('=== Step 5: Verify modification ===')
|
||||
const [columnsAfter] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID'
|
||||
`)
|
||||
console.table(columnsAfter)
|
||||
|
||||
// Step 7: Verify data is still intact
|
||||
console.log('\n=== Step 6: Verify data integrity ===')
|
||||
const [countAfter] = await connection.execute(
|
||||
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||
)
|
||||
console.log(`Total records after modification: ${countAfter[0].total}`)
|
||||
|
||||
if (countBefore[0].total === countAfter[0].total) {
|
||||
console.log('\n✅ SUCCESS: All data preserved, AUTO_INCREMENT added to ID column!')
|
||||
} else {
|
||||
console.log('\n⚠️ WARNING: Record count changed! Please check data.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message)
|
||||
if (error.code) {
|
||||
console.error('Error code:', error.code)
|
||||
}
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end()
|
||||
console.log('\nConnection closed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
142
scripts/fix-computer-name-typo.js
Normal file
142
scripts/fix-computer-name-typo.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Fix ComputerNmae Typo in dbo_BIPUsers Table
|
||||
*
|
||||
* This script renames the column from 'ComputerNmae' to 'ComputerName'
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
async function main() {
|
||||
const config = {
|
||||
host: '192.168.31.83',
|
||||
port: 3306,
|
||||
user: 'remote_user',
|
||||
password: '3.1415926Beeke',
|
||||
database: 'BLD_DB'
|
||||
}
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
console.log('Connecting to MySQL...')
|
||||
connection = await mysql.createConnection(config)
|
||||
console.log('Connected successfully!\n')
|
||||
|
||||
// Step 1: Check current column name
|
||||
console.log('=== Step 1: Check current column name ===')
|
||||
const [columns] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND (COLUMN_NAME = 'ComputerNmae' OR COLUMN_NAME = 'ComputerName')
|
||||
ORDER BY
|
||||
ORDINAL_POSITION
|
||||
`)
|
||||
|
||||
if (columns.length === 0) {
|
||||
console.log('No ComputerNmae or ComputerName column found!')
|
||||
return
|
||||
}
|
||||
|
||||
console.table(columns)
|
||||
|
||||
const currentColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerNmae')
|
||||
const newColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerName')
|
||||
|
||||
if (newColumn) {
|
||||
console.log('\n=== Column is already named "ComputerName"! No modification needed. ===')
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentColumn) {
|
||||
console.log('\n=== ERROR: ComputerNmae column not found! ===')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Count records before modification
|
||||
console.log('\n=== Step 2: Record count before modification ===')
|
||||
const [countBefore] = await connection.execute('SELECT COUNT(*) AS total FROM dbo_BIPUsers')
|
||||
console.log(`Total records: ${countBefore[0].total}`)
|
||||
|
||||
// Step 3: Show sample data with the column
|
||||
console.log('\n=== Step 3: Sample data (showing ComputerNmae column) ===')
|
||||
const [sample] = await connection.execute(`
|
||||
SELECT ID, UserName, UserType, ComputerNmae, CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
LIMIT 5
|
||||
`)
|
||||
console.table(sample)
|
||||
|
||||
// Step 4: Rename the column
|
||||
console.log('\n=== Step 4: Renaming column ComputerNmae -> ComputerName ===')
|
||||
await connection.execute(`
|
||||
ALTER TABLE dbo_BIPUsers
|
||||
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL
|
||||
`)
|
||||
console.log('Column renamed successfully!\n')
|
||||
|
||||
// Step 5: Verify the change
|
||||
console.log('=== Step 5: Verify modification ===')
|
||||
const [columnsAfter] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ComputerName'
|
||||
`)
|
||||
console.table(columnsAfter)
|
||||
|
||||
// Step 6: Verify data is still intact
|
||||
console.log('\n=== Step 6: Verify data integrity ===')
|
||||
const [countAfter] = await connection.execute('SELECT COUNT(*) AS total FROM dbo_BIPUsers')
|
||||
console.log(`Total records after modification: ${countAfter[0].total}`)
|
||||
|
||||
// Step 7: Show sample data with new column name
|
||||
console.log('\n=== Step 7: Sample data (showing ComputerName column) ===')
|
||||
const [sampleAfter] = await connection.execute(`
|
||||
SELECT ID, UserName, UserType, ComputerName, CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
LIMIT 5
|
||||
`)
|
||||
console.table(sampleAfter)
|
||||
|
||||
if (countBefore[0].total === countAfter[0].total) {
|
||||
console.log(
|
||||
'\n✅ SUCCESS: All data preserved, column renamed from ComputerNmae to ComputerName!'
|
||||
)
|
||||
} else {
|
||||
console.log('\n⚠️ WARNING: Record count changed! Please check data.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message)
|
||||
if (error.code) {
|
||||
console.error('Error code:', error.code)
|
||||
}
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end()
|
||||
console.log('\nConnection closed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
13
scripts/fix-computer-name-typo.sql
Normal file
13
scripts/fix-computer-name-typo.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration script to fix ComputerNmae typo in dbo_BIPUsers table
|
||||
-- Changes column name from 'ComputerNmae' to 'ComputerName'
|
||||
-- Date: 2026-03-05
|
||||
|
||||
-- Rename the column (MySQL syntax)
|
||||
ALTER TABLE dbo_BIPUsers
|
||||
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL;
|
||||
|
||||
-- Verify the change
|
||||
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND COLUMN_NAME = 'ComputerName';
|
||||
82
scripts/fix-materials-type-auto-increment.sql
Normal file
82
scripts/fix-materials-type-auto-increment.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- ============================================================================
|
||||
-- Script: Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||
-- Description: Modify the ID column to be AUTO_INCREMENT while preserving data
|
||||
-- Database: MySQL
|
||||
-- ============================================================================
|
||||
|
||||
-- Step 1: Check current table structure
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY
|
||||
ORDINAL_POSITION;
|
||||
|
||||
-- Step 2: View current data before modification
|
||||
SELECT COUNT(*) AS total_records FROM dbo_MaterialsTypeToBeDeleted;
|
||||
SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 10;
|
||||
|
||||
-- Step 3: Check if ID is already AUTO_INCREMENT
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID';
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Modify the ID column to AUTO_INCREMENT
|
||||
-- Note: This assumes ID is already the PRIMARY KEY
|
||||
-- If not, you may need to add PRIMARY KEY constraint first
|
||||
-- ============================================================================
|
||||
|
||||
-- Option A: If ID is already PRIMARY KEY (most likely case)
|
||||
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT;
|
||||
|
||||
-- Option B: If ID is NOT PRIMARY KEY (uncomment if needed)
|
||||
-- First check if there's an existing primary key
|
||||
-- SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
-- WHERE TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
-- AND TABLE_SCHEMA = DATABASE() AND COLUMN_KEY = 'PRI';
|
||||
--
|
||||
-- If no primary key exists:
|
||||
-- ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
-- MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY;
|
||||
|
||||
-- Step 5: Verify the change
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID';
|
||||
|
||||
-- Step 6: Verify data is still intact
|
||||
SELECT COUNT(*) AS total_records_after FROM dbo_MaterialsTypeToBeDeleted;
|
||||
|
||||
-- ============================================================================
|
||||
-- Expected Results:
|
||||
-- After running this script, the ID column should show:
|
||||
-- EXTRA: 'auto_increment'
|
||||
--
|
||||
-- This will allow INSERT statements to omit the ID field, and MySQL will
|
||||
-- automatically generate the next sequential ID value.
|
||||
-- ============================================================================
|
||||
125
scripts/migrate-env-to-yaml.ts
Normal file
125
scripts/migrate-env-to-yaml.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Migration Script: .env to config.yaml
|
||||
*
|
||||
* Usage: npx tsx scripts/migrate-env-to-yaml.ts
|
||||
*
|
||||
* This script migrates the old .env configuration to the new YAML format.
|
||||
* ERP configuration is NOT migrated as it's now stored in the database per user.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const ENV_PATH = path.resolve(process.cwd(), '.env')
|
||||
const YAML_PATH = path.resolve(process.cwd(), 'config.yaml')
|
||||
const BACKUP_PATH = path.resolve(process.cwd(), '.env.backup')
|
||||
|
||||
interface EnvConfig {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string): EnvConfig {
|
||||
const result: EnvConfig = {}
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key && valueParts.length > 0) {
|
||||
result[key.trim()] = valueParts.join('=').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function migrate() {
|
||||
console.log('🔄 Starting migration from .env to config.yaml...\n')
|
||||
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
console.error('❌ .env file not found at:', ENV_PATH)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(ENV_PATH, 'utf-8')
|
||||
const env = parseEnvFile(envContent)
|
||||
|
||||
// Build configuration object (without ERP)
|
||||
const config = {
|
||||
database: {
|
||||
activeType: (env.DB_TYPE || 'mysql').toLowerCase() as 'mysql' | 'sqlserver',
|
||||
mysql: {
|
||||
host: env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(env.DB_MYSQL_PORT || '3306', 10),
|
||||
database: env.DB_NAME || '',
|
||||
username: env.DB_USERNAME || '',
|
||||
password: env.DB_PASSWORD || '',
|
||||
charset: env.DB_MYSQL_CHARSET || 'utf8mb4'
|
||||
},
|
||||
sqlserver: {
|
||||
server: env.DB_SERVER || 'localhost',
|
||||
port: parseInt(env.DB_SQLSERVER_PORT || '1433', 10),
|
||||
database: env.DB_NAME || '',
|
||||
username: env.DB_USERNAME || '',
|
||||
password: env.DB_PASSWORD || '',
|
||||
driver: env.DB_SQLSERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
dataDir: env.PATH_DATA_DIR || './data/',
|
||||
defaultOutput: env.PATH_DEFAULT_OUTPUT || 'output.xlsx',
|
||||
validationOutput: env.PATH_VALIDATION_OUTPUT || 'validation-result.xlsx'
|
||||
},
|
||||
extraction: {
|
||||
batchSize: parseInt(env.EXTRACTION_BATCH_SIZE || '100', 10),
|
||||
verbose: env.EXTRACTION_VERBOSE !== 'false',
|
||||
autoConvert: env.EXTRACTION_AUTO_CONVERT !== 'false',
|
||||
mergeBatches: env.EXTRACTION_MERGE_BATCHES !== 'false',
|
||||
enableDbPersistence: env.EXTRACTION_ENABLE_DB_PERSISTENCE !== 'false'
|
||||
},
|
||||
validation: {
|
||||
dataSource: env.VALIDATION_DATA_SOURCE || 'database_full',
|
||||
batchSize: parseInt(env.VALIDATION_BATCH_SIZE || '2000', 10),
|
||||
matchMode: env.VALIDATION_MATCH_MODE || 'substring',
|
||||
enableCrud: env.VALIDATION_ENABLE_CRUD === 'true',
|
||||
defaultManager: env.VALIDATION_DEFAULT_MANAGER || ''
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: env.DB_TABLE_NAME || '',
|
||||
productionIdField: env.DB_FIELD_PRODUCTION_ID || '',
|
||||
orderNumberField: env.DB_FIELD_ORDER_NUMBER || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Backup .env
|
||||
if (fs.existsSync(ENV_PATH)) {
|
||||
fs.copyFileSync(ENV_PATH, BACKUP_PATH)
|
||||
console.log('📁 Backed up .env to .env.backup')
|
||||
}
|
||||
|
||||
// Write YAML with header comments
|
||||
const header = `# ================================\n# ERPAuto 配置文件\n# ================================\n# 由 .env 迁移生成\n# 迁移时间:${new Date().toISOString()}\n# 注意:ERP 配置已迁移到数据库 (dbo_BIPUsers 表)\n# ================================\n\n`
|
||||
|
||||
const yamlContent = yaml.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false
|
||||
})
|
||||
|
||||
fs.writeFileSync(YAML_PATH, header + yamlContent, 'utf-8')
|
||||
|
||||
console.log('✅ Migration completed successfully!')
|
||||
console.log(`📁 Config saved to: ${YAML_PATH}`)
|
||||
console.log('\n📋 Next steps:')
|
||||
console.log(' 1. Review config.yaml and verify all values')
|
||||
console.log(' 2. Test the application thoroughly')
|
||||
console.log(' 3. Remove .env file when confident (optional)\n')
|
||||
}
|
||||
|
||||
migrate()
|
||||
193
scripts/prepare-release.js
Normal file
193
scripts/prepare-release.js
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/prepare-release.js --channel <stable|preview> --changelog <file> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--changelog <file> Markdown changelog file. Required.
|
||||
--artifact <file> Portable exe path. Default: dist/erpauto-portable.exe
|
||||
--version <x.y.z> Release version. Default: package.json version
|
||||
--summary <text> Optional short summary for notesSummary
|
||||
--published-at <ISO date> Optional publish time. Default: current time
|
||||
--base-prefix <prefix> Default: updates/win-portable
|
||||
--output <dir> Default: release-output
|
||||
--existing-index <file> Existing index.json to merge with
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
})
|
||||
}
|
||||
|
||||
function loadExistingIndex(existingIndexPath, outputIndexPath) {
|
||||
const candidate = existingIndexPath || (fs.existsSync(outputIndexPath) ? outputIndexPath : null)
|
||||
if (!candidate) {
|
||||
return { releases: [] }
|
||||
}
|
||||
|
||||
const parsed = readJson(candidate)
|
||||
if (!parsed || !Array.isArray(parsed.releases)) {
|
||||
throw new Error(`Invalid index file: ${candidate}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function sortReleases(releases) {
|
||||
return [...releases].sort((left, right) => {
|
||||
const leftParts = String(left.version)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
const rightParts = String(right.version)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
|
||||
const length = Math.max(leftParts.length, rightParts.length)
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const l = leftParts[i] || 0
|
||||
const r = rightParts[i] || 0
|
||||
if (r !== l) {
|
||||
return r - l
|
||||
}
|
||||
}
|
||||
|
||||
const leftTime = new Date(left.publishedAt).getTime()
|
||||
const rightTime = new Date(right.publishedAt).getTime()
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || args.h) {
|
||||
printUsage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||
const version = args.version || packageJson.version
|
||||
const channel = args.channel
|
||||
const changelogPath = args.changelog
|
||||
const artifactPath = path.resolve(process.cwd(), args.artifact || 'dist/erpauto-portable.exe')
|
||||
const basePrefix = args['base-prefix'] || 'updates/win-portable'
|
||||
const outputRoot = path.resolve(process.cwd(), args.output || 'release-output')
|
||||
const publishedAt = args['published-at'] || new Date().toISOString()
|
||||
const summary = args.summary
|
||||
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
assert(changelogPath, 'Missing --changelog')
|
||||
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||
|
||||
const resolvedChangelogPath = path.resolve(process.cwd(), changelogPath)
|
||||
assert(fs.existsSync(resolvedChangelogPath), `Changelog not found: ${resolvedChangelogPath}`)
|
||||
|
||||
const artifactStat = fs.statSync(artifactPath)
|
||||
const sha256 = await sha256File(artifactPath)
|
||||
const fileName = `erpauto-${version}-${channel}-portable.exe`
|
||||
const changelogFileName = `${version}.md`
|
||||
|
||||
const channelDir = path.join(outputRoot, basePrefix, channel)
|
||||
const artifactsDir = path.join(channelDir, 'artifacts')
|
||||
const changelogDir = path.join(channelDir, 'changelogs')
|
||||
const outputIndexPath = path.join(channelDir, 'index.json')
|
||||
|
||||
ensureDir(artifactsDir)
|
||||
ensureDir(changelogDir)
|
||||
|
||||
const targetArtifactPath = path.join(artifactsDir, fileName)
|
||||
const targetChangelogPath = path.join(changelogDir, changelogFileName)
|
||||
|
||||
fs.copyFileSync(artifactPath, targetArtifactPath)
|
||||
fs.copyFileSync(resolvedChangelogPath, targetChangelogPath)
|
||||
|
||||
const releaseEntry = {
|
||||
version,
|
||||
channel,
|
||||
artifactKey: `${basePrefix}/${channel}/artifacts/${fileName}`,
|
||||
sha256,
|
||||
size: artifactStat.size,
|
||||
publishedAt,
|
||||
changelogKey: `${basePrefix}/${channel}/changelogs/${changelogFileName}`,
|
||||
...(summary ? { notesSummary: summary } : {})
|
||||
}
|
||||
|
||||
const existingIndex = loadExistingIndex(
|
||||
args['existing-index'] ? path.resolve(process.cwd(), args['existing-index']) : null,
|
||||
outputIndexPath
|
||||
)
|
||||
|
||||
const mergedReleases = sortReleases([
|
||||
releaseEntry,
|
||||
...existingIndex.releases.filter(
|
||||
(item) => !(item.version === version && item.channel === channel)
|
||||
)
|
||||
])
|
||||
|
||||
fs.writeFileSync(
|
||||
outputIndexPath,
|
||||
`${JSON.stringify({ releases: mergedReleases }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
console.log('Release package prepared successfully.')
|
||||
console.log(`Channel: ${channel}`)
|
||||
console.log(`Version: ${version}`)
|
||||
console.log(`Artifact: ${targetArtifactPath}`)
|
||||
console.log(`Changelog: ${targetChangelogPath}`)
|
||||
console.log(`Index: ${outputIndexPath}`)
|
||||
console.log(`SHA256: ${sha256}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`prepare-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
230
scripts/publish-release.js
Normal file
230
scripts/publish-release.js
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const yaml = require('js-yaml')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/publish-release.js --channel <stable|preview> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--changelog <file> Optional changelog file. Default: auto-resolve from version
|
||||
--config <file> Config file path. Default: config.yaml
|
||||
--help Show this help message
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function readYaml(filePath) {
|
||||
return yaml.load(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function resolveChangelogPath(version, explicitPath) {
|
||||
if (explicitPath) {
|
||||
return path.resolve(process.cwd(), explicitPath)
|
||||
}
|
||||
|
||||
const rebuildPath = path.resolve(process.cwd(), 'docs', 'releases', `${version}-rebuild.md`)
|
||||
if (fs.existsSync(rebuildPath)) {
|
||||
return rebuildPath
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), 'docs', 'releases', `${version}.md`)
|
||||
}
|
||||
|
||||
function validateUpdateConfig(configPath) {
|
||||
assert(fs.existsSync(configPath), `Config file not found: ${configPath}`)
|
||||
|
||||
const parsed = readYaml(configPath)
|
||||
const update = parsed && parsed.update
|
||||
|
||||
assert(update, 'Missing update config in config file')
|
||||
assert(update.enabled, 'update.enabled is false')
|
||||
assert(update.endpoint, 'update.endpoint is required')
|
||||
assert(update.accessKey, 'update.accessKey is required')
|
||||
assert(update.secretKey, 'update.secretKey is required')
|
||||
assert(update.bucket, 'update.bucket is required')
|
||||
assert(update.basePrefix, 'update.basePrefix is required')
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function hasConflictMarker(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
|
||||
}
|
||||
|
||||
function validateVersionFiles(version) {
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||
const packageLockPath = path.resolve(process.cwd(), 'package-lock.json')
|
||||
|
||||
assert(fs.existsSync(packageLockPath), `package-lock.json not found: ${packageLockPath}`)
|
||||
assert(!hasConflictMarker(packageJsonPath), 'package.json contains merge conflict markers')
|
||||
assert(!hasConflictMarker(packageLockPath), 'package-lock.json contains merge conflict markers')
|
||||
|
||||
const packageLock = readJson(packageLockPath)
|
||||
assert(packageLock.version === version, 'package-lock.json version does not match package.json')
|
||||
|
||||
const rootPackage = packageLock.packages && packageLock.packages['']
|
||||
if (rootPackage && rootPackage.version) {
|
||||
assert(
|
||||
rootPackage.version === version,
|
||||
'package-lock root package version does not match package.json'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(name, command, args, envOverrides = {}) {
|
||||
console.log('')
|
||||
console.log(`==> ${name}`)
|
||||
console.log(`$ ${command} ${args.join(' ')}`)
|
||||
|
||||
let result
|
||||
if (process.platform === 'win32') {
|
||||
const shellCommand = [command, ...args]
|
||||
.map((arg) => (/\s|"/.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
|
||||
.join(' ')
|
||||
|
||||
result = spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', shellCommand], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...envOverrides },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
} else {
|
||||
result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...envOverrides },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${name} failed with exit code ${result.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readPreparedIndex(channel, basePrefix) {
|
||||
const indexPath = path.resolve(
|
||||
process.cwd(),
|
||||
'release-output',
|
||||
...basePrefix.split('/'),
|
||||
channel,
|
||||
'index.json'
|
||||
)
|
||||
|
||||
assert(fs.existsSync(indexPath), `Prepared index not found: ${indexPath}`)
|
||||
const parsed = readJson(indexPath)
|
||||
assert(parsed && Array.isArray(parsed.releases), `Invalid prepared index: ${indexPath}`)
|
||||
return { indexPath, parsed }
|
||||
}
|
||||
|
||||
function summarizeRelease(version, channel, releaseEntry, indexPath) {
|
||||
console.log('')
|
||||
console.log('Release published successfully.')
|
||||
console.log(`Version: ${version}`)
|
||||
console.log(`Channel: ${channel}`)
|
||||
console.log(`Artifact: ${releaseEntry.artifactKey}`)
|
||||
console.log(`SHA256: ${releaseEntry.sha256}`)
|
||||
console.log(`Changelog: ${releaseEntry.changelogKey}`)
|
||||
console.log(`Prepared Index: ${indexPath}`)
|
||||
console.log(`Published At: ${releaseEntry.publishedAt}`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || args.h) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const channel = args.channel
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||
assert(fs.existsSync(packageJsonPath), `package.json not found: ${packageJsonPath}`)
|
||||
const packageJson = readJson(packageJsonPath)
|
||||
const version = packageJson.version
|
||||
assert(version, 'package.json version is required')
|
||||
|
||||
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||
const updateConfig = validateUpdateConfig(configPath)
|
||||
validateVersionFiles(version)
|
||||
|
||||
const changelogPath = resolveChangelogPath(version, args.changelog)
|
||||
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||
|
||||
runStep('Build Windows package', 'npm', ['run', 'build:win'], {
|
||||
APP_CHANNEL: channel
|
||||
})
|
||||
|
||||
runStep('Prepare release package', 'npm', [
|
||||
'run',
|
||||
'release:prepare',
|
||||
'--',
|
||||
'--channel',
|
||||
channel,
|
||||
'--changelog',
|
||||
changelogPath
|
||||
])
|
||||
|
||||
const { indexPath, parsed } = readPreparedIndex(channel, updateConfig.basePrefix)
|
||||
const latestRelease = parsed.releases[0]
|
||||
assert(latestRelease, 'Prepared index does not contain any release entry')
|
||||
assert(
|
||||
latestRelease.version === version && latestRelease.channel === channel,
|
||||
`Prepared index latest entry mismatch: expected ${version}/${channel}, got ${latestRelease.version}/${latestRelease.channel}`
|
||||
)
|
||||
|
||||
runStep('Upload release package', 'npm', [
|
||||
'run',
|
||||
'release:upload',
|
||||
'--',
|
||||
'--channel',
|
||||
channel,
|
||||
'--verify'
|
||||
])
|
||||
|
||||
summarizeRelease(version, channel, latestRelease, indexPath)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`publish-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
217
scripts/upload-release.js
Normal file
217
scripts/upload-release.js
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const yaml = require('js-yaml')
|
||||
const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3')
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/upload-release.js --channel <stable|preview> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--source <dir> Local release root. Default: release-output
|
||||
--config <file> Config file path. Default: config.yaml
|
||||
--version <x.y.z> Release version. Default: package.json version
|
||||
--full-sync Upload the whole channel directory instead of current release only
|
||||
--verify Read back remote index.json after upload
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function loadConfig(configPath) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8')
|
||||
const parsed = yaml.load(raw)
|
||||
const update = parsed && parsed.update
|
||||
|
||||
assert(update, 'Missing update config in config.yaml')
|
||||
assert(update.enabled, 'update.enabled is false')
|
||||
assert(update.endpoint, 'update.endpoint is required')
|
||||
assert(update.accessKey, 'update.accessKey is required')
|
||||
assert(update.secretKey, 'update.secretKey is required')
|
||||
assert(update.bucket, 'update.bucket is required')
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function getContentType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext === '.json') return 'application/json; charset=utf-8'
|
||||
if (ext === '.md') return 'text/markdown; charset=utf-8'
|
||||
if (ext === '.exe') return 'application/vnd.microsoft.portable-executable'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function readRemoteText(client, bucket, key) {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of response.Body) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
}
|
||||
|
||||
function buildFileDescriptor(sourceRoot, absolutePath) {
|
||||
return {
|
||||
absolutePath,
|
||||
relativeKey: path.relative(sourceRoot, absolutePath).replace(/\\/g, '/')
|
||||
}
|
||||
}
|
||||
|
||||
function collectUploadFiles(sourceRoot, channelRoot, channel, version, fullSync, basePrefix) {
|
||||
const indexPath = path.join(channelRoot, 'index.json')
|
||||
assert(fs.existsSync(indexPath), `Index not found: ${indexPath}`)
|
||||
|
||||
if (fullSync) {
|
||||
const results = []
|
||||
const entries = fs.readdirSync(channelRoot, { withFileTypes: true })
|
||||
|
||||
function walk(dirPath) {
|
||||
const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
for (const entry of dirEntries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath)
|
||||
} else {
|
||||
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(channelRoot, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath)
|
||||
} else {
|
||||
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||
}
|
||||
}
|
||||
|
||||
return { files: results, indexKey: `${basePrefix}/${channel}/index.json` }
|
||||
}
|
||||
|
||||
const parsedIndex = readJson(indexPath)
|
||||
assert(parsedIndex && Array.isArray(parsedIndex.releases), `Invalid index file: ${indexPath}`)
|
||||
|
||||
const releaseEntry = parsedIndex.releases.find(
|
||||
(release) => release.version === version && release.channel === channel
|
||||
)
|
||||
assert(releaseEntry, `Release entry not found in index for ${channel}/${version}`)
|
||||
|
||||
const artifactPath = path.resolve(sourceRoot, releaseEntry.artifactKey)
|
||||
const changelogPath = path.resolve(sourceRoot, releaseEntry.changelogKey)
|
||||
|
||||
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||
|
||||
return {
|
||||
files: [
|
||||
buildFileDescriptor(sourceRoot, artifactPath),
|
||||
buildFileDescriptor(sourceRoot, changelogPath),
|
||||
buildFileDescriptor(sourceRoot, indexPath)
|
||||
],
|
||||
indexKey: `${basePrefix}/${channel}/index.json`
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help || args.h) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const channel = args.channel
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
|
||||
const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output')
|
||||
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||
const updateConfig = loadConfig(configPath)
|
||||
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||
const version = args.version || packageJson.version
|
||||
assert(version, 'Missing release version')
|
||||
|
||||
const channelRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
|
||||
assert(fs.existsSync(channelRoot), `Upload root not found: ${channelRoot}`)
|
||||
|
||||
const client = new S3Client({
|
||||
region: updateConfig.region || 'us-east-1',
|
||||
endpoint: updateConfig.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: updateConfig.accessKey,
|
||||
secretAccessKey: updateConfig.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
const { files, indexKey } = collectUploadFiles(
|
||||
sourceRoot,
|
||||
channelRoot,
|
||||
channel,
|
||||
version,
|
||||
Boolean(args['full-sync']),
|
||||
updateConfig.basePrefix
|
||||
)
|
||||
assert(files.length > 0, `No files found under ${channelRoot}`)
|
||||
|
||||
for (const file of files) {
|
||||
const body = fs.readFileSync(file.absolutePath)
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: updateConfig.bucket,
|
||||
Key: file.relativeKey,
|
||||
Body: body,
|
||||
ContentType: getContentType(file.absolutePath)
|
||||
})
|
||||
)
|
||||
|
||||
console.log(`Uploaded: ${file.relativeKey}`)
|
||||
}
|
||||
|
||||
if (args.verify) {
|
||||
const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey)
|
||||
console.log('')
|
||||
console.log(`Verified remote index: ${indexKey}`)
|
||||
console.log(remoteIndex)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`upload-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
15
skills-lock.json
Normal file
15
skills-lock.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"electron-best-practices": {
|
||||
"source": "jwynia/agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "744549070132b3bc0602fd7121d42278ba74694b9d0943358093bde3543cbe97"
|
||||
},
|
||||
"vercel-react-best-practices": {
|
||||
"source": "vercel-labs/agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "e218e50fe7057a4db91390e579c7db5aafac2394c31a3d8e5fa9444c8fa00726"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/main/bootstrap/main-window.ts
Normal file
59
src/main/bootstrap/main-window.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
import { join } from 'path'
|
||||
import icon from '../../../resources/icon.png?asset'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
function isDevelopment(): boolean {
|
||||
return Boolean(process.env['ELECTRON_RENDERER_URL']) || process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
||||
export function createMainWindow(): BrowserWindow {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (isDevelopment() && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
export function registerMainWindowLifecycle(): void {
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
}
|
||||
40
src/main/bootstrap/process-guards.ts
Normal file
40
src/main/bootstrap/process-guards.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { app } from 'electron'
|
||||
import logger from '../services/logger/index'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
|
||||
export function setupProcessGuards(): void {
|
||||
process.on('uncaughtException', async (err) => {
|
||||
logger.error('Uncaught exception', { error: err })
|
||||
await 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) => {
|
||||
logger.error('Unhandled Rejection', { reason: String(reason) })
|
||||
await 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)
|
||||
})
|
||||
}
|
||||
92
src/main/bootstrap/runtime.ts
Normal file
92
src/main/bootstrap/runtime.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { join } from 'path'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
|
||||
export function configurePlaywrightBrowsersPath(): string {
|
||||
const browsersPath = join(app.getPath('userData'), 'ms-playwright')
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath
|
||||
return browsersPath
|
||||
}
|
||||
|
||||
export function setupElectronRuntime(): void {
|
||||
app.setAppUserModelId('com.electron')
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const isReloadShortcut = (input.control || input.meta) && input.key.toLowerCase() === 'r'
|
||||
const isToggleDevTools = input.key === 'F12'
|
||||
|
||||
if (!app.isPackaged && isToggleDevTools && input.type === 'keyDown') {
|
||||
window.webContents.toggleDevTools()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (app.isPackaged && isReloadShortcut) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Playwright browsers are installed
|
||||
* @returns true if browsers exist, false otherwise
|
||||
*/
|
||||
export function ensurePlaywrightRuntime(browsersPath: string): boolean {
|
||||
try {
|
||||
fs.mkdirSync(browsersPath, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create browsers directory:', error)
|
||||
}
|
||||
|
||||
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
|
||||
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
|
||||
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||
|
||||
if (fs.existsSync(chromiumPath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let foundRevision = false
|
||||
try {
|
||||
const entries = fs.readdirSync(browsersPath)
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
|
||||
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
|
||||
if (fs.existsSync(revisionPath)) {
|
||||
console.log('Found Chromium revision:', entry)
|
||||
foundRevision = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore browser directory probing failures and fall through to the warning dialog.
|
||||
}
|
||||
|
||||
if (foundRevision) {
|
||||
return true
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'Playwright browser not found. Available:',
|
||||
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function initializeMainProcessServices(): Promise<void> {
|
||||
try {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
await configManager.initialize()
|
||||
UpdateService.getInstance().initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error)
|
||||
}
|
||||
|
||||
const { registerIpcHandlers } = await import('../ipc')
|
||||
registerIpcHandlers()
|
||||
}
|
||||
@@ -1,86 +1,23 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerIpcHandlers } from './ipc'
|
||||
import * as dotenv from 'dotenv'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { createMainWindow, registerMainWindowLifecycle } from './bootstrap/main-window'
|
||||
import {
|
||||
configurePlaywrightBrowsersPath,
|
||||
ensurePlaywrightRuntime,
|
||||
initializeMainProcessServices,
|
||||
setupElectronRuntime
|
||||
} from './bootstrap/runtime'
|
||||
import { setupProcessGuards } from './bootstrap/process-guards'
|
||||
|
||||
// Load environment variables from .env file
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
||||
app.whenReady().then(async () => {
|
||||
setupProcessGuards()
|
||||
registerMainWindowLifecycle()
|
||||
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
||||
const browsersExist = ensurePlaywrightRuntime(playwrightBrowsersPath)
|
||||
console.log('Playwright browsers exist:', browsersExist)
|
||||
await initializeMainProcessServices()
|
||||
setupElectronRuntime()
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
createMainWindow()
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
@@ -11,222 +11,68 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type { UserInfo } from '../types/user.types'
|
||||
|
||||
const log = createLogger('AuthHandler')
|
||||
|
||||
/**
|
||||
* Login request
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Login response
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent login response
|
||||
*/
|
||||
export interface SilentLoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
requiresUserSelection?: boolean // True if admin needs to select a user
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User selection response
|
||||
*/
|
||||
export interface UserSelectionResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Current user response
|
||||
*/
|
||||
export interface CurrentUserResponse {
|
||||
isAuthenticated: boolean
|
||||
userInfo?: UserInfo
|
||||
}
|
||||
import type {
|
||||
CurrentUserResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SilentLoginResponse,
|
||||
UserSelectionResponse
|
||||
} from '../types/auth-ipc.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { AuthApplicationService } from '../services/auth/auth-application-service'
|
||||
|
||||
/**
|
||||
* Register IPC handlers for user authentication
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const authService = new AuthApplicationService()
|
||||
|
||||
/**
|
||||
* Get computer name
|
||||
*/
|
||||
ipcMain.handle('auth:getComputerName', async (): Promise<string> => {
|
||||
const os = await import('os')
|
||||
return os.hostname()
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => authService.getComputerName(), 'auth:getComputerName')
|
||||
})
|
||||
|
||||
/**
|
||||
* Silent login by computer name
|
||||
*/
|
||||
ipcMain.handle('auth:silentLogin', async (): Promise<SilentLoginResponse> => {
|
||||
try {
|
||||
log.info('Attempting silent login')
|
||||
const success = await sessionManager.loginByComputerName()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
// Check if admin needs user selection
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
|
||||
log.info('Silent login successful', {
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
}
|
||||
|
||||
log.warn('Silent login failed - no matching user')
|
||||
return {
|
||||
success: false,
|
||||
requiresUserSelection: false
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Silent login error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `无感登录失败:${message}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
ipcMain.handle('auth:login', async (_event, request: LoginRequest): Promise<LoginResponse> => {
|
||||
try {
|
||||
const { username, password } = request
|
||||
|
||||
if (!username || !password) {
|
||||
log.warn('Login attempt with missing credentials')
|
||||
return {
|
||||
success: false,
|
||||
error: '请输入用户名和密码'
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Login attempt', { username })
|
||||
const success = await sessionManager.login(username, password)
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
}
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
return {
|
||||
success: false,
|
||||
error: '用户名或密码错误'
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Login error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `登录失败:${message}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
ipcMain.handle('auth:logout', async (): Promise<void> => {
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
log.info('User logout', { username: userInfo?.username })
|
||||
sessionManager.logout()
|
||||
})
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
ipcMain.handle('auth:getCurrentUser', async (): Promise<CurrentUserResponse> => {
|
||||
const isAuthenticated = sessionManager.isAuthenticated()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
userInfo: userInfo ?? undefined
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get all users (for admin user selection)
|
||||
*/
|
||||
ipcMain.handle('auth:getAllUsers', async (): Promise<UserInfo[]> => {
|
||||
log.debug('Fetching all users for admin selection')
|
||||
return await sessionManager.getAllUsers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch user (admin only)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'auth:switchUser',
|
||||
async (_event, userInfo: UserInfo): Promise<UserSelectionResponse> => {
|
||||
try {
|
||||
log.info('User switch attempt', { targetUser: userInfo.username })
|
||||
const success = sessionManager.switchUser(userInfo)
|
||||
|
||||
if (success) {
|
||||
const newUser = sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
log.warn('User switch failed')
|
||||
return {
|
||||
success: false,
|
||||
error: '用户切换失败'
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('User switch error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `用户切换失败:${message}`
|
||||
}
|
||||
}
|
||||
IPC_CHANNELS.AUTH_SILENT_LOGIN,
|
||||
async (): Promise<IpcResult<SilentLoginResponse>> => {
|
||||
return withErrorHandling(async () => authService.silentLogin(), 'auth:silentLogin')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
ipcMain.handle('auth:isAdmin', async (): Promise<boolean> => {
|
||||
return sessionManager.isAdmin()
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_LOGIN,
|
||||
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
|
||||
return withErrorHandling(
|
||||
async () => authService.login(request.username, request.password),
|
||||
'auth:login'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => authService.logout(), 'auth:logout')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
|
||||
async (): Promise<IpcResult<CurrentUserResponse>> => {
|
||||
return withErrorHandling(async () => authService.getCurrentUser(), 'auth:getCurrentUser')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
|
||||
return withErrorHandling(async () => authService.getAllUsers(), 'auth:getAllUsers')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_SWITCH_USER,
|
||||
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
|
||||
return withErrorHandling(async () => authService.switchUser(userInfo), 'auth:switchUser')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => authService.isAdmin(), 'auth:isAdmin')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,155 +1,34 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { CleanerService } from '../services/erp/cleaner'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
import type { CleanerInput, CleanerResult } from '../types/cleaner.types'
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerResult,
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} from '../types/cleaner.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
|
||||
|
||||
const log = createLogger('CleanerHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for cleaner service
|
||||
*/
|
||||
export function registerCleanerHandlers(): void {
|
||||
const cleanerService = new CleanerApplicationService()
|
||||
|
||||
ipcMain.handle(
|
||||
'cleaner:run',
|
||||
async (_event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let mysqlService: MySqlService | null = null
|
||||
IPC_CHANNELS.CLEANER_RUN,
|
||||
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||
return withErrorHandling(
|
||||
async () => cleanerService.runCleaner(event.sender, input),
|
||||
'cleaner:run'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
// Check environment variables
|
||||
const erpUrl = process.env.ERP_URL || ''
|
||||
const erpUsername = process.env.ERP_USERNAME || ''
|
||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
||||
|
||||
log.info('Config check', {
|
||||
url: erpUrl ? 'configured' : 'EMPTY',
|
||||
username: erpUsername ? 'configured' : 'EMPTY'
|
||||
})
|
||||
|
||||
if (!erpUrl || !erpUsername || !erpPassword) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请检查 .env 文件中的 ERP_URL, ERP_USERNAME, ERP_PASSWORD',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
||||
const mysqlConfig = {
|
||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||
user: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || ''
|
||||
}
|
||||
|
||||
log.info('Connecting to MySQL for order resolution...')
|
||||
mysqlService = new MySqlService(mysqlConfig)
|
||||
|
||||
try {
|
||||
await mysqlService.connect()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'MySQL 连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const resolver = new OrderNumberResolver(mysqlService)
|
||||
const mappings = await resolver.resolve(input.orderNumbers)
|
||||
|
||||
// Get valid order numbers and warnings
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
// Create auth service and login
|
||||
authService = new ErpAuthService({
|
||||
url: erpUrl,
|
||||
username: erpUsername,
|
||||
password: erpPassword,
|
||||
headless: true
|
||||
})
|
||||
|
||||
log.info('Logging in to ERP...')
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
log.info('Login successful')
|
||||
|
||||
// Create cleaner service and run cleaning with resolved order numbers
|
||||
const cleaner = new CleanerService(authService)
|
||||
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: input.onProgress
|
||||
}
|
||||
|
||||
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
|
||||
const result = await cleaner.clean(modifiedInput)
|
||||
|
||||
// Add warnings to result errors if any
|
||||
if (warnings.length > 0) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
log.info('Cleaning completed', {
|
||||
processedCount: result.ordersProcessed,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
return result
|
||||
} finally {
|
||||
// Clean up: close browser
|
||||
if (authService) {
|
||||
try {
|
||||
await authService.close()
|
||||
log.debug('Browser closed')
|
||||
} catch (closeError) {
|
||||
log.warn('Error closing browser', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up: disconnect MySQL
|
||||
if (mysqlService) {
|
||||
try {
|
||||
await mysqlService.disconnect()
|
||||
log.debug('MySQL disconnected')
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting MySQL', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'cleaner:run')
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_EXPORT_RESULTS,
|
||||
async (_event, items: ExportResultItem[]): Promise<IpcResult<ExportResultResponse>> => {
|
||||
return withErrorHandling(
|
||||
async () => cleanerService.exportResults(items),
|
||||
'cleaner:exportResults'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@ import { ipcMain } from 'electron'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { DatabaseQueryError, ValidationError } from '../types/errors'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import type {
|
||||
MySqlConfig,
|
||||
MySqlQueryResult,
|
||||
SqlServerConfig,
|
||||
SqlServerQueryResult
|
||||
} from '../types/ipc-api.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('DatabaseHandler')
|
||||
|
||||
@@ -17,6 +19,39 @@ const mysqlServices = new Map<string, MySqlService>()
|
||||
|
||||
// Store SQL Server service instances per window/connection
|
||||
const sqlServerServices = new Map<string, SqlServerService>()
|
||||
const cleanupBoundWindows = new Set<string>()
|
||||
|
||||
function bindWindowCleanup(
|
||||
windowId: string,
|
||||
sender: { once: (event: string, listener: () => void) => void }
|
||||
): void {
|
||||
if (cleanupBoundWindows.has(windowId)) {
|
||||
return
|
||||
}
|
||||
|
||||
sender.once('destroyed', () => {
|
||||
const mysql = getMySqlService(windowId)
|
||||
const sqlServer = getSqlServerService(windowId)
|
||||
|
||||
if (mysql) {
|
||||
mysql
|
||||
.disconnect()
|
||||
.catch((error) => log.warn('MySQL disconnect on window destroy failed', { error }))
|
||||
deleteMySqlService(windowId)
|
||||
}
|
||||
|
||||
if (sqlServer) {
|
||||
sqlServer
|
||||
.disconnect()
|
||||
.catch((error) => log.warn('SQL Server disconnect on window destroy failed', { error }))
|
||||
deleteSqlServerService(windowId)
|
||||
}
|
||||
|
||||
cleanupBoundWindows.delete(windowId)
|
||||
})
|
||||
|
||||
cleanupBoundWindows.add(windowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MySQL service for a connection ID
|
||||
@@ -65,59 +100,58 @@ function deleteSqlServerService(connectionId: string): void {
|
||||
*/
|
||||
export function registerDatabaseHandlers(): void {
|
||||
// Connect to MySQL
|
||||
ipcMain.handle('database:mysql:connect', async (event, config: MySqlConfig): Promise<void> => {
|
||||
try {
|
||||
// Use window ID as connection identifier
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
log.info('Connecting to MySQL', { windowId })
|
||||
const service = new MySqlService(config)
|
||||
await service.connect()
|
||||
setMySqlService(windowId, service)
|
||||
log.info('MySQL connected', { windowId })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to connect to MySQL'
|
||||
log.error('MySQL connection failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_CONNECT,
|
||||
async (event, config: MySqlConfig): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
// Use window ID as connection identifier
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
bindWindowCleanup(
|
||||
windowId,
|
||||
event.sender as { once: (event: string, listener: () => void) => void }
|
||||
)
|
||||
log.info('Connecting to MySQL', { windowId })
|
||||
const service = new MySqlService(config)
|
||||
await service.connect()
|
||||
setMySqlService(windowId, service)
|
||||
log.info('MySQL connected', { windowId })
|
||||
}, 'database:mysql:connect')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Disconnect from MySQL
|
||||
ipcMain.handle('database:mysql:disconnect', async (event): Promise<void> => {
|
||||
try {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteMySqlService(windowId)
|
||||
log.info('MySQL disconnected', { windowId })
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to disconnect from MySQL'
|
||||
log.error('MySQL disconnect failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT,
|
||||
async (event): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteMySqlService(windowId)
|
||||
log.info('MySQL disconnected', { windowId })
|
||||
}
|
||||
}, 'database:mysql:disconnect')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Check if MySQL is connected
|
||||
ipcMain.handle('database:mysql:isConnected', async (event): Promise<boolean> => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED,
|
||||
async (event): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
}, 'database:mysql:isConnected')
|
||||
}
|
||||
)
|
||||
|
||||
// Execute MySQL query
|
||||
ipcMain.handle(
|
||||
'database:mysql:query',
|
||||
async (event, sql: string, params?: unknown[]): Promise<MySqlQueryResult> => {
|
||||
try {
|
||||
IPC_CHANNELS.DATABASE_MYSQL_QUERY,
|
||||
async (event, sql: string, params?: unknown[]): Promise<IpcResult<MySqlQueryResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
|
||||
@@ -130,79 +164,66 @@ export function registerDatabaseHandlers(): void {
|
||||
|
||||
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
|
||||
return await service.query(sql, params)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'MySQL query failed'
|
||||
log.error('MySQL query failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_QUERY_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
}, 'database:mysql:query')
|
||||
}
|
||||
)
|
||||
|
||||
// Connect to SQL Server
|
||||
ipcMain.handle(
|
||||
'database:sqlserver:connect',
|
||||
async (event, config: SqlServerConfig): Promise<void> => {
|
||||
try {
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT,
|
||||
async (event, config: SqlServerConfig): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
bindWindowCleanup(
|
||||
windowId,
|
||||
event.sender as { once: (event: string, listener: () => void) => void }
|
||||
)
|
||||
log.info('Connecting to SQL Server', { windowId })
|
||||
const service = new SqlServerService(config)
|
||||
await service.connect()
|
||||
setSqlServerService(windowId, service)
|
||||
log.info('SQL Server connected', { windowId })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to connect to SQL Server'
|
||||
log.error('SQL Server connection failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
}, 'database:sqlserver:connect')
|
||||
}
|
||||
)
|
||||
|
||||
// Disconnect from SQL Server
|
||||
ipcMain.handle('database:sqlserver:disconnect', async (event): Promise<void> => {
|
||||
try {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteSqlServerService(windowId)
|
||||
log.info('SQL Server disconnected', { windowId })
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to disconnect from SQL Server'
|
||||
log.error('SQL Server disconnect failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT,
|
||||
async (event): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteSqlServerService(windowId)
|
||||
log.info('SQL Server disconnected', { windowId })
|
||||
}
|
||||
}, 'database:sqlserver:disconnect')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Check if SQL Server is connected
|
||||
ipcMain.handle('database:sqlserver:isConnected', async (event): Promise<boolean> => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED,
|
||||
async (event): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
}, 'database:sqlserver:isConnected')
|
||||
}
|
||||
)
|
||||
|
||||
// Execute SQL Server query
|
||||
ipcMain.handle(
|
||||
'database:sqlserver:query',
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_QUERY,
|
||||
async (
|
||||
event,
|
||||
sqlString: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<SqlServerQueryResult> => {
|
||||
try {
|
||||
): Promise<IpcResult<SqlServerQueryResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
|
||||
@@ -214,16 +235,19 @@ export function registerDatabaseHandlers(): void {
|
||||
}
|
||||
|
||||
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
|
||||
return await service.query(sqlString, params)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'SQL Server query failed'
|
||||
log.error('SQL Server query failed', { error: message })
|
||||
throw new DatabaseQueryError(
|
||||
message,
|
||||
'DB_QUERY_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Use queryWithParams for named parameters, or query for no params
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
// Convert to the format expected by queryWithParams
|
||||
const typedParams: Record<string, { value: unknown }> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
typedParams[key] = { value }
|
||||
}
|
||||
return await service.queryWithParams(sqlString, typedParams)
|
||||
} else {
|
||||
return await service.query(sqlString)
|
||||
}
|
||||
}, 'database:sqlserver:query')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,133 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ExtractorService } from '../services/erp/extractor'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
import type { ExtractorInput, ExtractorResult } from '../types/extractor.types'
|
||||
import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../types/extractor.types'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('ExtractorHandler')
|
||||
|
||||
function sendProgress(
|
||||
sender: WebContents,
|
||||
message: string,
|
||||
progress: number,
|
||||
extra?: Partial<ExtractionProgress>
|
||||
): void {
|
||||
try {
|
||||
const progressData = { message, progress, ...extra }
|
||||
sender.send(IPC_CHANNELS.EXTRACTOR_PROGRESS, progressData)
|
||||
} catch (error) {
|
||||
log.warn('Failed to send progress event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
function sendLog(sender: WebContents, level: string, message: string): void {
|
||||
try {
|
||||
sender.send(IPC_CHANNELS.EXTRACTOR_LOG, { level, message })
|
||||
} catch (error) {
|
||||
log.warn('Failed to send log event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ERP configuration for current user
|
||||
* URL is from config.yaml (fixed infrastructure)
|
||||
* Username and password are from user's database config
|
||||
*/
|
||||
async function getErpConfig(): Promise<{
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
}> {
|
||||
// Get ERP URL from config.yaml (fixed for all users)
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const erpUrl = globalConfig.erp.url
|
||||
|
||||
// Get username and password from user's database config
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url: erpUrl,
|
||||
username: userConfig.username,
|
||||
password: userConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for extractor service
|
||||
*/
|
||||
export function registerExtractorHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'extractor:run',
|
||||
async (_event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
||||
IPC_CHANNELS.EXTRACTOR_RUN,
|
||||
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
||||
const sender = event.sender
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let mysqlService: MySqlService | null = null
|
||||
let dbService: IDatabaseService | null = null
|
||||
|
||||
try {
|
||||
// Check environment variables
|
||||
const erpUrl = process.env.ERP_URL || ''
|
||||
const erpUsername = process.env.ERP_USERNAME || ''
|
||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
||||
// Get ERP configuration from database for current user
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
const erpConfig = await getErpConfig()
|
||||
|
||||
log.info('Config check', {
|
||||
url: erpUrl ? 'configured' : 'EMPTY',
|
||||
username: erpUsername ? 'configured' : 'EMPTY'
|
||||
log.info('ERP config retrieved', {
|
||||
url: erpConfig.url ? 'configured' : 'EMPTY',
|
||||
username: erpConfig.username ? 'configured' : 'EMPTY'
|
||||
})
|
||||
|
||||
if (!erpUrl || !erpUsername || !erpPassword) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请检查 .env 文件中的 ERP_URL, ERP_USERNAME, ERP_PASSWORD',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
||||
const mysqlConfig = {
|
||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||
user: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || ''
|
||||
}
|
||||
|
||||
log.info('Connecting to MySQL for order resolution...')
|
||||
mysqlService = new MySqlService(mysqlConfig)
|
||||
// Create database service using factory
|
||||
log.info('Connecting to database for order resolution...')
|
||||
sendProgress(sender, '连接数据库...', 3.33, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '连接数据库', current: 1, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'system', '正在连接数据库...')
|
||||
|
||||
try {
|
||||
await mysqlService.connect()
|
||||
dbService = await create()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'MySQL 连接失败',
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const resolver = new OrderNumberResolver(mysqlService)
|
||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
||||
sendProgress(sender, '解析订单号...', 6.67, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '解析订单号', current: 2, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'info', '正在解析订单号...')
|
||||
|
||||
const resolver = new OrderNumberResolver(dbService)
|
||||
const mappings = await resolver.resolve(input.orderNumbers)
|
||||
|
||||
// Get valid order numbers and warnings
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
|
||||
// Get deduplication report for detailed logging
|
||||
const dedupReport = resolver.getDeduplicationReport(mappings)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
@@ -81,18 +141,43 @@ export function registerExtractorHandlers(): void {
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
// Log deduplication summary
|
||||
sendLog(sender, 'info', dedupReport.summary)
|
||||
|
||||
// Log only merged mappings (where multiple productionIDs map to the same order number)
|
||||
if (dedupReport.inputCount > dedupReport.uniqueOrderNumbersCount) {
|
||||
sendLog(sender, 'info', '重复合并详情:')
|
||||
dedupReport.orderNumberGroups.forEach((productionIds, orderNumber) => {
|
||||
if (productionIds.length > 1) {
|
||||
sendLog(
|
||||
sender,
|
||||
'info',
|
||||
` ${orderNumber} ← ${productionIds.join('、')} (共 ${productionIds.length} 个总排号)`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create auth service and login
|
||||
authService = new ErpAuthService({
|
||||
url: erpUrl,
|
||||
username: erpUsername,
|
||||
password: erpPassword,
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: true
|
||||
})
|
||||
|
||||
sendProgress(sender, '登录 ERP 系统...', 9.99, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '登录 ERP 系统', current: 3, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'system', '正在登录 ERP 系统...')
|
||||
|
||||
log.info('Logging in to ERP...')
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '未知错误'
|
||||
sendLog(sender, 'error', `ERP 登录失败:${errorMsg}`)
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
@@ -100,6 +185,7 @@ export function registerExtractorHandlers(): void {
|
||||
)
|
||||
}
|
||||
log.info('Login successful')
|
||||
sendLog(sender, 'success', 'ERP 登录成功')
|
||||
|
||||
// Create extractor service and run extraction with resolved order numbers
|
||||
const extractor = new ExtractorService(authService)
|
||||
@@ -107,7 +193,14 @@ export function registerExtractorHandlers(): void {
|
||||
|
||||
const modifiedInput: ExtractorInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: (message, progress, extra) => {
|
||||
sendProgress(sender, message, progress, extra)
|
||||
sendLog(sender, 'info', message)
|
||||
},
|
||||
onLog: (level, message) => {
|
||||
sendLog(sender, level, message)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await extractor.extract(modifiedInput)
|
||||
@@ -122,6 +215,37 @@ export function registerExtractorHandlers(): void {
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
// Log detailed error information if any errors occurred
|
||||
if (result.errors.length > 0) {
|
||||
log.warn('Extraction errors occurred', { errors: result.errors })
|
||||
result.errors.forEach((err, index) => {
|
||||
log.error(`Error ${index + 1}/${result.errors.length}: ${err}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log: EXTRACT (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
logAudit('EXTRACT', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
// Clean up: close browser
|
||||
@@ -136,13 +260,13 @@ export function registerExtractorHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up: disconnect MySQL
|
||||
if (mysqlService) {
|
||||
// Clean up: disconnect database
|
||||
if (dbService) {
|
||||
try {
|
||||
await mysqlService.disconnect()
|
||||
log.debug('MySQL disconnected')
|
||||
await dbService.disconnect()
|
||||
log.debug('Database disconnected')
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting MySQL', {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,64 +1,99 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain, shell } from 'electron'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('FileHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for file operations
|
||||
*/
|
||||
export function registerFileHandlers(): void {
|
||||
// Read file content
|
||||
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
|
||||
try {
|
||||
log.debug('Reading file', { filePath })
|
||||
return await fs.readFile(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read file'
|
||||
log.error('Failed to read file', { filePath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
})
|
||||
function getAllowedRoots(): string[] {
|
||||
return [path.resolve(app.getAppPath()), path.resolve(app.getPath('userData'))]
|
||||
}
|
||||
|
||||
// Write content to file
|
||||
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
|
||||
try {
|
||||
log.debug('Writing file', { filePath })
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write file'
|
||||
log.error('Failed to write file', { filePath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
})
|
||||
|
||||
// Check if file exists
|
||||
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// List files in directory
|
||||
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
|
||||
try {
|
||||
log.debug('Listing directory', { dirPath })
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list directory'
|
||||
log.error('Failed to list directory', { dirPath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
export function isPathWithinAllowedRoots(inputPath: string, roots: string[]): boolean {
|
||||
const normalized = path.resolve(inputPath)
|
||||
return roots.some((root) => {
|
||||
const rel = path.relative(root, normalized)
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAndValidatePath(inputPath: string): string {
|
||||
const normalized = path.resolve(inputPath)
|
||||
const isAllowed = isPathWithinAllowedRoots(normalized, getAllowedRoots())
|
||||
if (!isAllowed) {
|
||||
throw new ValidationError('Path is outside allowed roots', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function registerFileHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_READ,
|
||||
async (_event, filePath: string): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Reading file', { filePath: safePath })
|
||||
return await fs.readFile(safePath, 'utf-8')
|
||||
}, 'file:read')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_WRITE,
|
||||
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Writing file', { filePath: safePath })
|
||||
const dir = path.dirname(safePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(safePath, content, 'utf-8')
|
||||
}, 'file:write')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_EXISTS,
|
||||
async (_event, filePath: string): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
try {
|
||||
await fs.access(safePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'file:exists')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_LIST,
|
||||
async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(dirPath)
|
||||
log.debug('Listing directory', { dirPath: safePath })
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
}, 'file:list')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_OPEN_PATH,
|
||||
async (_event, filePath: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Opening path in explorer', { filePath: safePath })
|
||||
await fs.access(safePath)
|
||||
await shell.openPath(safePath)
|
||||
}, 'file:openPath')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,23 +11,37 @@ import { registerResolverHandlers } from './resolver-handler'
|
||||
import { registerAuthHandlers } from './auth-handler'
|
||||
import { registerValidationHandlers } from './validation-handler'
|
||||
import { registerSettingsHandlers } from './settings-handler'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||
import { registerLoggerHandlers } from './logger-handler'
|
||||
import { registerReportHandlers } from './report-handler'
|
||||
import { registerUpdateHandlers } from './update-handler'
|
||||
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
import type { IpcResult } from '../types/ipc.types'
|
||||
|
||||
export type { IpcResult } from '../types/ipc.types'
|
||||
|
||||
const log = createLogger('IPC')
|
||||
|
||||
/**
|
||||
* Standard result type for all IPC handlers
|
||||
*/
|
||||
export interface IpcResult<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: string
|
||||
function getErrorCauseMessage(error: { cause?: unknown }): string | undefined {
|
||||
const { cause } = error
|
||||
return cause instanceof Error ? cause.message : undefined
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): IpcResult<T> {
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
export function fail<T = unknown>(error: string, code?: string): IpcResult<T> {
|
||||
return { success: false, error, code }
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to wrap IPC handlers with consistent error handling
|
||||
* Enhanced to capture full error context including stack traces
|
||||
* @param handler - The async handler function to wrap
|
||||
* @param context - The context name for logging
|
||||
* @returns A wrapped handler that returns IpcResult
|
||||
@@ -37,26 +51,40 @@ export function withErrorHandling<T>(
|
||||
context: string
|
||||
): Promise<IpcResult<T>> {
|
||||
return handler()
|
||||
.then((data) => {
|
||||
.then((data): IpcResult<T> => {
|
||||
log.debug(`[${context}] Handler completed successfully`)
|
||||
return { success: true, data }
|
||||
return ok(data)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = getErrorMessage(error)
|
||||
const code = getErrorCode(error)
|
||||
|
||||
if (isBaseError(error)) {
|
||||
log.error(`[${context}] ${error.name}: ${message}`, { code, cause: error.cause?.message })
|
||||
// Serialize error with full details
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sanitizeError(serializeError(error))
|
||||
} else {
|
||||
log.error(`[${context}] Error: ${message}`, { code })
|
||||
serializeError(error)
|
||||
}
|
||||
|
||||
if (isBaseError(error)) {
|
||||
logError(log, `[${context}] ${error.name}`, error, {
|
||||
code,
|
||||
cause: getErrorCauseMessage(error),
|
||||
handler: context
|
||||
})
|
||||
} else {
|
||||
logError(log, `[${context}] Error`, error, {
|
||||
code,
|
||||
handler: context
|
||||
})
|
||||
}
|
||||
|
||||
// Include stack trace in development
|
||||
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
|
||||
log.debug(`[${context}] Stack trace:`, { stack: error.stack })
|
||||
log.debug(`[${context}] Stack trace: ${error.stack}`)
|
||||
}
|
||||
|
||||
return { success: false, error: message, code }
|
||||
return fail<T>(message, code)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,5 +101,11 @@ export function registerIpcHandlers(): void {
|
||||
registerAuthHandlers()
|
||||
registerValidationHandlers()
|
||||
registerSettingsHandlers()
|
||||
registerMaterialTypeHandlers()
|
||||
registerUserErpConfigHandlers()
|
||||
registerLoggerHandlers()
|
||||
registerReportHandlers()
|
||||
registerUpdateHandlers()
|
||||
registerPlaywrightBrowserHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
229
src/main/ipc/logger-handler.ts
Normal file
229
src/main/ipc/logger-handler.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* IPC Logger Handler with Batching
|
||||
* Receives logs from renderer process and forwards to Winston
|
||||
*
|
||||
* Features:
|
||||
* - 100ms debounce for batch processing
|
||||
* - Maximum 50 messages per batch
|
||||
* - Circuit breaker: discards new logs when buffer > 500
|
||||
* - Error-level logs bypass circuit breaker
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('LoggerHandler')
|
||||
|
||||
/**
|
||||
* Log entry from renderer process
|
||||
*/
|
||||
interface LogEntry {
|
||||
level: LogLevel
|
||||
message: string
|
||||
context?: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processing configuration
|
||||
*/
|
||||
const BATCH_CONFIG = {
|
||||
DEBOUNCE_MS: 100,
|
||||
MAX_BATCH_SIZE: 50,
|
||||
CIRCUIT_BREAKER_THRESHOLD: 500
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Logger handler state
|
||||
*/
|
||||
class LoggerHandlerState {
|
||||
private buffer: LogEntry[] = []
|
||||
private debounceTimer: NodeJS.Timeout | null = null
|
||||
private discardedCount = 0
|
||||
|
||||
/**
|
||||
* Add log entry to buffer
|
||||
* @param entry - Log entry to buffer
|
||||
* @returns true if entry was buffered, false if discarded
|
||||
*/
|
||||
addEntry(entry: LogEntry): boolean {
|
||||
// Error-level logs always bypass circuit breaker
|
||||
if (entry.level === 'error') {
|
||||
this.buffer.push(entry)
|
||||
this.flushIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
// Circuit breaker: discard non-error logs when buffer is too large
|
||||
if (this.buffer.length >= BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD) {
|
||||
this.discardedCount++
|
||||
|
||||
// Log warning about discarded logs periodically (every 100 discarded)
|
||||
if (this.discardedCount % 100 === 0) {
|
||||
log.warn('Circuit breaker active: discarded logs', {
|
||||
discardedCount: this.discardedCount,
|
||||
bufferSize: this.buffer.length
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
this.buffer.push(entry)
|
||||
this.flushIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffer if it reaches max batch size
|
||||
*/
|
||||
private flushIfNeeded(): void {
|
||||
if (this.buffer.length >= BATCH_CONFIG.MAX_BATCH_SIZE) {
|
||||
this.flush()
|
||||
} else if (!this.debounceTimer) {
|
||||
// Start debounce timer if not already running
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.flush()
|
||||
}, BATCH_CONFIG.DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all buffered logs to Winston
|
||||
*/
|
||||
flush(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
|
||||
if (this.buffer.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a copy of the buffer and clear it
|
||||
const batch = [...this.buffer]
|
||||
this.buffer = []
|
||||
|
||||
// Process batch asynchronously (non-blocking)
|
||||
setImmediate(() => {
|
||||
this.processBatch(batch)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of log entries
|
||||
* @param batch - Array of log entries to process
|
||||
*/
|
||||
private processBatch(batch: LogEntry[]): void {
|
||||
try {
|
||||
for (const entry of batch) {
|
||||
this.forwardToWinston(entry)
|
||||
}
|
||||
} catch (error) {
|
||||
// If batch processing fails, log the error but don't rethrow
|
||||
// This ensures logging failures don't crash the app
|
||||
log.error('Failed to process log batch', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
batchSize: batch.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 message = entry.context?.message
|
||||
? `[${entry.context.message}] ${entry.message}`
|
||||
: entry.message
|
||||
|
||||
switch (entry.level) {
|
||||
case 'debug':
|
||||
childLogger.debug(message, entry.context)
|
||||
break
|
||||
case 'warn':
|
||||
childLogger.warn(message, entry.context)
|
||||
break
|
||||
case 'error':
|
||||
childLogger.error(message, entry.context)
|
||||
break
|
||||
case 'info':
|
||||
default:
|
||||
childLogger.info(message, entry.context)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current buffer size (for testing/debugging)
|
||||
*/
|
||||
getBufferSize(): number {
|
||||
return this.buffer.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discarded log count (for testing/debugging)
|
||||
*/
|
||||
getDiscardedCount(): number {
|
||||
return this.discardedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
this.buffer = []
|
||||
this.discardedCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton state instance
|
||||
const state = new LoggerHandlerState()
|
||||
|
||||
/**
|
||||
* Register IPC handlers for logger
|
||||
*/
|
||||
export function registerLoggerHandlers(): void {
|
||||
// Use ipcMain.on with send() - fire-and-forget, non-blocking
|
||||
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
|
||||
// Validate entry
|
||||
if (!entry || typeof entry.level !== 'string' || typeof entry.message !== 'string') {
|
||||
log.warn('Received invalid log entry', { entry })
|
||||
return
|
||||
}
|
||||
|
||||
// Add to buffer for batch processing
|
||||
const buffered = state.addEntry(entry)
|
||||
|
||||
if (!buffered && process.env.NODE_ENV !== 'production') {
|
||||
// In development, log when entries are discarded
|
||||
log.debug('Log entry discarded due to circuit breaker', {
|
||||
level: entry.level,
|
||||
message: entry.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Logger IPC handler registered', {
|
||||
channel: IPC_CHANNELS.LOGGER_FORWARD,
|
||||
debounceMs: BATCH_CONFIG.DEBOUNCE_MS,
|
||||
maxBatchSize: BATCH_CONFIG.MAX_BATCH_SIZE,
|
||||
circuitBreakerThreshold: BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { state }
|
||||
126
src/main/ipc/material-type-handler.ts
Normal file
126
src/main/ipc/material-type-handler.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* IPC handlers for material type management operations
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Getting all material type records
|
||||
* - Getting records by manager
|
||||
* - Getting list of managers
|
||||
* - Upserting (insert/update) records
|
||||
* - Deleting records
|
||||
* - Batch operations
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
MaterialsTypeToBeDeletedDAO,
|
||||
type MaterialTypeRecord,
|
||||
type MaterialTypeBatchRequest
|
||||
} from '../services/database/materials-type-to-be-deleted-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('MaterialTypeHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for material type operations
|
||||
*/
|
||||
export function registerMaterialTypeHandlers(): void {
|
||||
const dao = new MaterialsTypeToBeDeletedDAO()
|
||||
|
||||
/**
|
||||
* Get all material type records
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_ALL,
|
||||
async (): Promise<IpcResult<MaterialTypeRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const records = await dao.getAllMaterials()
|
||||
return records
|
||||
}, 'materialType:getAll')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get material types by manager
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_BY_MANAGER,
|
||||
async (_event, managerName: string): Promise<IpcResult<MaterialTypeRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const records = await dao.getMaterialsByManager(managerName)
|
||||
return records
|
||||
}, 'materialType:getByManager')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get list of managers
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_MANAGERS,
|
||||
async (): Promise<IpcResult<string[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const managers = await dao.getManagers()
|
||||
return managers
|
||||
}, 'materialType:getManagers')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Upsert (insert or update) a material type record
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_UPSERT,
|
||||
async (
|
||||
_event,
|
||||
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||
): Promise<IpcResult<{ updated: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await dao.upsertMaterial(materialName, managerName)
|
||||
if (!result) {
|
||||
throw new ValidationError('Failed to upsert material type', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
return { updated: true }
|
||||
}, 'materialType:upsert')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete a material type record
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_DELETE,
|
||||
async (
|
||||
_event,
|
||||
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||
): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await dao.deleteMaterial(materialName, managerName)
|
||||
if (!result) {
|
||||
throw new ValidationError('Failed to delete material type', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
return { deleted: true }
|
||||
}, 'materialType:delete')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Batch operation for material types (insert, update, delete)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_UPSERT_BATCH,
|
||||
async (
|
||||
_event,
|
||||
request: MaterialTypeBatchRequest
|
||||
): Promise<IpcResult<{ stats: { total: number; success: number; failed: number } }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const stats = await dao.upsertBatch(request)
|
||||
return { stats }
|
||||
}, 'materialType:upsertBatch')
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Material type handlers registered')
|
||||
}
|
||||
110
src/main/ipc/playwright-browser.ts
Normal file
110
src/main/ipc/playwright-browser.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Playwright Browser IPC Handlers
|
||||
* Handles browser download requests from renderer process
|
||||
*/
|
||||
|
||||
import { app, ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { DownloadService } from '../services/playwright-browser'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
import type { DownloadProgress } from '../services/playwright-browser'
|
||||
|
||||
/**
|
||||
* Create S3 client from config
|
||||
*/
|
||||
function createS3Client(): S3Client {
|
||||
const config = ConfigManager.getInstance().getConfig().update
|
||||
if (!config) {
|
||||
throw new Error('Update config is not available')
|
||||
}
|
||||
|
||||
return new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Playwright browsers are installed
|
||||
*/
|
||||
async function checkBrowsersExist(): Promise<boolean> {
|
||||
const fs = await import('fs')
|
||||
const browsersPath = join(app.getPath('userData'), 'ms-playwright')
|
||||
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
|
||||
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
|
||||
const chromiumPath = fs.default.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||
|
||||
if (fs.default.existsSync(chromiumPath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let foundRevision = false
|
||||
try {
|
||||
const entries = fs.default.readdirSync(browsersPath)
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
|
||||
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
|
||||
if (fs.default.existsSync(revisionPath)) {
|
||||
foundRevision = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore browser directory probing failures
|
||||
}
|
||||
|
||||
return foundRevision
|
||||
}
|
||||
|
||||
/**
|
||||
* Track active download for cancellation
|
||||
*/
|
||||
let activeDownload: { service: DownloadService; cancelled: boolean } | null = null
|
||||
|
||||
export function registerPlaywrightBrowserHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK, async (): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
return checkBrowsersExist()
|
||||
}, 'playwright-browser:check')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD,
|
||||
async (event: IpcMainInvokeEvent): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const s3Client = createS3Client()
|
||||
const service = new DownloadService({ s3Client })
|
||||
|
||||
activeDownload = { service, cancelled: false }
|
||||
|
||||
await service.downloadAll((progress: DownloadProgress) => {
|
||||
if (activeDownload?.cancelled) {
|
||||
throw new Error('Download cancelled by user')
|
||||
}
|
||||
|
||||
event.sender.send(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, progress)
|
||||
})
|
||||
|
||||
activeDownload = null
|
||||
}, 'playwright-browser:download')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (activeDownload) {
|
||||
activeDownload.cancelled = true
|
||||
activeDownload = null
|
||||
}
|
||||
}, 'playwright-browser:cancel')
|
||||
})
|
||||
}
|
||||
180
src/main/ipc/report-handler.ts
Normal file
180
src/main/ipc/report-handler.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { RustfsService } from '../services/rustfs'
|
||||
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
const log = createLogger('ReportHandler')
|
||||
|
||||
export interface ReportMetadata {
|
||||
key: string
|
||||
filename: string
|
||||
username: string
|
||||
lastModified?: Date
|
||||
size?: number
|
||||
}
|
||||
|
||||
function getRustfsService(): RustfsService | null {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (config.rustfs?.enabled && config.rustfs.endpoint) {
|
||||
return new RustfsService({ config: config.rustfs })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerReportHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.REPORT_LIST_ALL, async (): Promise<IpcResult<ReportMetadata[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
// Create a direct S3Client since RustfsService doesn't expose listObjects natively easily
|
||||
const client = new S3Client({
|
||||
region: config.rustfs?.region || 'us-east-1',
|
||||
endpoint: config.rustfs?.endpoint || '',
|
||||
credentials: {
|
||||
accessKeyId: config.rustfs?.accessKey || '',
|
||||
secretAccessKey: config.rustfs?.secretKey || ''
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
log.info('Fetching all reports from RustFS')
|
||||
const input = {
|
||||
Bucket: config.rustfs?.bucket || '',
|
||||
Prefix: 'reports/cleaner/'
|
||||
}
|
||||
|
||||
const command = new ListObjectsV2Command(input)
|
||||
const response = await client.send(command)
|
||||
|
||||
const reports: ReportMetadata[] = []
|
||||
|
||||
if (response.Contents) {
|
||||
for (const item of response.Contents) {
|
||||
if (item.Key && item.Key.endsWith('.md')) {
|
||||
// reports/cleaner/{username}/{filename}
|
||||
const parts = item.Key.split('/')
|
||||
if (parts.length >= 4) {
|
||||
const username = parts[2]
|
||||
const filename = parts.slice(3).join('/')
|
||||
reports.push({
|
||||
key: item.Key,
|
||||
filename,
|
||||
username,
|
||||
lastModified: item.LastModified,
|
||||
size: item.Size
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
reports.sort((a, b) => {
|
||||
if (a.lastModified && b.lastModified) {
|
||||
return b.lastModified.getTime() - a.lastModified.getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return reports
|
||||
}, 'report:listAll')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_LIST_BY_USER,
|
||||
async (_event, username: string): Promise<IpcResult<ReportMetadata[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
const client = new S3Client({
|
||||
region: config.rustfs?.region || 'us-east-1',
|
||||
endpoint: config.rustfs?.endpoint || '',
|
||||
credentials: {
|
||||
accessKeyId: config.rustfs?.accessKey || '',
|
||||
secretAccessKey: config.rustfs?.secretKey || ''
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
log.info('Fetching reports from RustFS for user', { username })
|
||||
const input = {
|
||||
Bucket: config.rustfs?.bucket || '',
|
||||
Prefix: `reports/cleaner/${username}/`
|
||||
}
|
||||
|
||||
const command = new ListObjectsV2Command(input)
|
||||
const response = await client.send(command)
|
||||
|
||||
const reports: ReportMetadata[] = []
|
||||
|
||||
if (response.Contents) {
|
||||
for (const item of response.Contents) {
|
||||
if (item.Key && item.Key.endsWith('.md')) {
|
||||
const parts = item.Key.split('/')
|
||||
if (parts.length >= 4) {
|
||||
const itemUsername = parts[2]
|
||||
const filename = parts.slice(3).join('/')
|
||||
reports.push({
|
||||
key: item.Key,
|
||||
filename,
|
||||
username: itemUsername,
|
||||
lastModified: item.LastModified,
|
||||
size: item.Size
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
reports.sort((a, b) => {
|
||||
if (a.lastModified && b.lastModified) {
|
||||
return b.lastModified.getTime() - a.lastModified.getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return reports
|
||||
}, 'report:listByUser')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_DOWNLOAD,
|
||||
async (_event, key: string): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
log.info('Downloading report from RustFS', { key })
|
||||
const result = await rustfs.downloadFile(key)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to download report')
|
||||
}
|
||||
|
||||
// Convert buffer to string
|
||||
return result.content.toString('utf-8')
|
||||
}, 'report:download')
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -7,48 +7,15 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { DatabaseQueryError } from '../types/errors'
|
||||
import type { OrderMapping, ResolutionStats } from '../services/erp/order-resolver'
|
||||
import type { ResolverInput, ResolverResponse } from '../types/resolver-ipc.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('ResolverHandler')
|
||||
|
||||
/**
|
||||
* Resolver input from renderer
|
||||
*/
|
||||
export interface ResolverInput {
|
||||
/** List of order numbers/productionIDs to resolve */
|
||||
inputs: string[]
|
||||
/** MySQL configuration (optional, uses default if not provided) */
|
||||
mysqlConfig?: {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
database: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver response to renderer
|
||||
*/
|
||||
export interface ResolverResponse {
|
||||
/** Whether the resolution was successful */
|
||||
success: boolean
|
||||
/** Resolved order mappings */
|
||||
mappings?: OrderMapping[]
|
||||
/** Valid production order numbers ready for use */
|
||||
validOrderNumbers?: string[]
|
||||
/** Warning messages for invalid inputs */
|
||||
warnings?: string[]
|
||||
/** Resolution statistics */
|
||||
stats?: ResolutionStats
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for order number resolver
|
||||
*/
|
||||
@@ -58,27 +25,17 @@ export function registerResolverHandlers(): void {
|
||||
* Converts productionIDs and 生产订单号 to production order numbers
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'resolver:resolve',
|
||||
async (_event, input: ResolverInput): Promise<ResolverResponse> => {
|
||||
let mysqlService: MySqlService | null = null
|
||||
IPC_CHANNELS.RESOLVER_RESOLVE,
|
||||
async (_event, input: ResolverInput): Promise<IpcResult<ResolverResponse>> => {
|
||||
let dbService: IDatabaseService | null = null
|
||||
|
||||
try {
|
||||
// Use provided config or environment variables
|
||||
const mysqlConfig = input.mysqlConfig || {
|
||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||
user: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || ''
|
||||
}
|
||||
|
||||
// Create MySQL service
|
||||
log.info('Connecting to MySQL for resolution', { inputCount: input.inputs.length })
|
||||
mysqlService = new MySqlService(mysqlConfig)
|
||||
await mysqlService.connect()
|
||||
return withErrorHandling(async () => {
|
||||
// Create database service using factory
|
||||
log.info('Connecting to database for resolution', { inputCount: input.inputs.length })
|
||||
dbService = await create()
|
||||
|
||||
// Create resolver and resolve inputs
|
||||
const resolver = new OrderNumberResolver(mysqlService)
|
||||
const resolver = new OrderNumberResolver(dbService)
|
||||
const mappings = await resolver.resolve(input.inputs)
|
||||
|
||||
// Get valid order numbers and warnings
|
||||
@@ -99,26 +56,19 @@ export function registerResolverHandlers(): void {
|
||||
warnings,
|
||||
stats
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Resolution failed', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `解析失败:${message}`
|
||||
}
|
||||
} finally {
|
||||
// Clean up MySQL connection
|
||||
if (mysqlService) {
|
||||
}, 'resolver:resolve').finally(async () => {
|
||||
// Clean up database connection
|
||||
if (dbService) {
|
||||
try {
|
||||
await mysqlService.disconnect()
|
||||
log.debug('MySQL disconnected')
|
||||
await dbService.disconnect()
|
||||
log.debug('Database disconnected')
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting MySQL', {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -126,19 +76,19 @@ export function registerResolverHandlers(): void {
|
||||
* Validate input format only (without database lookup)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'resolver:validateFormat',
|
||||
IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT,
|
||||
async (
|
||||
_event,
|
||||
inputs: string[]
|
||||
): Promise<{
|
||||
success: boolean
|
||||
results?: Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>
|
||||
error?: string
|
||||
}> => {
|
||||
try {
|
||||
): Promise<
|
||||
IpcResult<Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>>
|
||||
> => {
|
||||
return withErrorHandling(async () => {
|
||||
// Create a mock resolver without database connection
|
||||
const resolver = new OrderNumberResolver({
|
||||
isConnected: () => false
|
||||
} as MySqlService)
|
||||
isConnected: () => false,
|
||||
type: 'mysql'
|
||||
} as IDatabaseService)
|
||||
|
||||
const results = inputs.map((input) => ({
|
||||
input,
|
||||
@@ -147,15 +97,8 @@ export function registerResolverHandlers(): void {
|
||||
|
||||
log.debug('Format validation completed', { inputCount: inputs.length })
|
||||
|
||||
return { success: true, results }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Format validation failed', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `验证失败:${message}`
|
||||
}
|
||||
}
|
||||
return results
|
||||
}, 'resolver:validateFormat')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,228 +1,146 @@
|
||||
/**
|
||||
* Settings IPC Handler
|
||||
*
|
||||
* Provides IPC handlers for settings management:
|
||||
* - Get/set settings
|
||||
* - Reset to defaults
|
||||
* - Test ERP connection
|
||||
* - Test database connection
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type {
|
||||
SettingsData,
|
||||
UserType,
|
||||
ConnectionTestResult,
|
||||
SaveSettingsResult
|
||||
} from '../types/settings.types'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import type { CleanerConfig } from '../types/config.schema'
|
||||
|
||||
const log = createLogger('SettingsHandler')
|
||||
|
||||
/**
|
||||
* Filter settings by user type
|
||||
* Admin users get all settings, User users get limited settings
|
||||
*/
|
||||
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
|
||||
if (userType === 'Admin') {
|
||||
return settings // Return all settings for Admin
|
||||
}
|
||||
|
||||
// User users get limited settings
|
||||
return {
|
||||
erp: {
|
||||
username: settings.erp.username,
|
||||
password: settings.erp.password,
|
||||
headless: settings.erp.headless,
|
||||
url: settings.erp.url,
|
||||
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
|
||||
autoCloseBrowser: settings.erp.autoCloseBrowser
|
||||
},
|
||||
paths: settings.paths,
|
||||
execution: settings.execution,
|
||||
// Include minimal required fields for other sections
|
||||
database: settings.database,
|
||||
extraction: settings.extraction,
|
||||
validation: settings.validation,
|
||||
ui: settings.ui
|
||||
type ErpSettingsPayload = {
|
||||
erp?: {
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for settings management
|
||||
*/
|
||||
export function registerSettingsHandlers(): void {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
|
||||
/**
|
||||
* Get current user type
|
||||
*/
|
||||
ipcMain.handle('settings:getUserType', async (): Promise<UserType> => {
|
||||
return (sessionManager.getUserType() as UserType) || 'Guest'
|
||||
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
||||
return withErrorHandling(
|
||||
async () => (sessionManager.getUserType() as UserType) || 'Guest',
|
||||
'settings:getUserType'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get settings (filtered by user type)
|
||||
*/
|
||||
ipcMain.handle('settings:getSettings', async (): Promise<SettingsData> => {
|
||||
const userType = (sessionManager.getUserType() as UserType) || 'Guest'
|
||||
log.debug('Getting settings', { userType })
|
||||
const settings = configManager.getAllSettings()
|
||||
return filterSettingsByUserType(settings, userType)
|
||||
})
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings')
|
||||
const success = await configManager.saveAllSettings(settings)
|
||||
if (success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings')
|
||||
return { success: false, error: '保存设置失败' }
|
||||
IPC_CHANNELS.SETTINGS_GET_SETTINGS,
|
||||
async (): Promise<IpcResult<{ erp: { username: string; password: string } }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userErpConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
return {
|
||||
erp: {
|
||||
username: userErpConfig?.username || '',
|
||||
password: userErpConfig?.password || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return { success: false, error: `保存设置失败:${message}` }
|
||||
}
|
||||
}, 'settings:getSettings')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset to default settings (Admin only)
|
||||
*/
|
||||
ipcMain.handle('settings:resetDefaults', async (): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
const userType = sessionManager.getUserType()
|
||||
if (userType !== 'Admin') {
|
||||
log.warn('Non-admin user attempted to reset defaults', { userType })
|
||||
return { success: false, error: '只有管理员可以恢复默认设置' }
|
||||
}
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_SAVE_SETTINGS,
|
||||
async (_event, settings: ErpSettingsPayload): Promise<IpcResult<SaveSettingsResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (settings.erp) {
|
||||
const currentUser = sessionManager.getUserInfo()
|
||||
if (!currentUser) {
|
||||
throw new ValidationError('未找到当前用户', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
await erpConfigService.updateCurrentUserErpConfig({
|
||||
username: settings.erp.username || '',
|
||||
password: settings.erp.password || ''
|
||||
})
|
||||
|
||||
// Audit log: SETTINGS_CHANGE (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_CONFIG',
|
||||
status: 'success',
|
||||
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
log.info('Resetting settings to defaults')
|
||||
configManager.resetToDefaults()
|
||||
const success = await configManager.save()
|
||||
if (success) {
|
||||
log.info('Settings reset to defaults successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to reset settings to defaults')
|
||||
return { success: false, error: '恢复默认设置失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error resetting settings', { error: message })
|
||||
return { success: false, error: `恢复默认设置失败:${message}` }
|
||||
}, 'settings:saveSettings')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Test ERP connection
|
||||
*/
|
||||
ipcMain.handle('settings:testErpConnection', async (): Promise<ConnectionTestResult> => {
|
||||
try {
|
||||
log.info('Testing ERP connection')
|
||||
const settings = configManager.getAllSettings()
|
||||
const erpConfig = settings.erp
|
||||
|
||||
if (!erpConfig.url || !erpConfig.username || !erpConfig.password) {
|
||||
log.warn('ERP connection test failed - missing configuration')
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 ERP URL、用户名和密码'
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_RESET_DEFAULTS,
|
||||
async (): Promise<IpcResult<SaveSettingsResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userType = sessionManager.getUserType()
|
||||
if (userType !== 'Admin') {
|
||||
throw new ValidationError('只有管理员可以恢复默认设置', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
}
|
||||
|
||||
// Create ERP auth service and try to login
|
||||
const erpAuthService = new ErpAuthService(erpConfig)
|
||||
const success = await configManager.resetToDefaults()
|
||||
if (!success) {
|
||||
throw new ValidationError('恢复默认设置失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
try {
|
||||
await erpAuthService.login()
|
||||
// Login successful, close browser
|
||||
await erpAuthService.close()
|
||||
log.info('ERP connection test successful')
|
||||
return {
|
||||
success: true,
|
||||
message: 'ERP 连接测试成功!'
|
||||
}
|
||||
} catch (loginError) {
|
||||
const errorMessage = loginError instanceof Error ? loginError.message : '登录失败'
|
||||
log.error('ERP login failed', { error: errorMessage })
|
||||
return {
|
||||
success: false,
|
||||
message: `ERP 连接测试失败:${errorMessage}`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('ERP connection test error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
message: `ERP 连接测试失败:${message}`
|
||||
}
|
||||
return { success: true }
|
||||
}, 'settings:resetDefaults')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
ipcMain.handle('settings:testDbConnection', async (): Promise<ConnectionTestResult> => {
|
||||
try {
|
||||
log.info('Testing database connection')
|
||||
const settings = configManager.getAllSettings()
|
||||
const dbConfig = settings.database
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION,
|
||||
async (): Promise<IpcResult<ConnectionTestResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Testing database connection')
|
||||
const config = configManager.getConfig()
|
||||
const dbType = config.database.activeType
|
||||
|
||||
if (dbConfig.dbType === 'mysql') {
|
||||
// Test MySQL connection
|
||||
if (!dbConfig.mysqlHost || !dbConfig.database || !dbConfig.username) {
|
||||
log.warn('MySQL connection test failed - missing configuration')
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 MySQL 主机、数据库名和用户名'
|
||||
if (dbType === 'mysql') {
|
||||
const dbConfig = config.database.mysql
|
||||
if (!dbConfig.host || !dbConfig.database || !dbConfig.username) {
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 MySQL 主机、数据库名和用户名'
|
||||
}
|
||||
}
|
||||
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
|
||||
try {
|
||||
await mysqlService.connect()
|
||||
await mysqlService.disconnect()
|
||||
return {
|
||||
success: true,
|
||||
message: 'MySQL 数据库连接测试成功!'
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return {
|
||||
success: false,
|
||||
message: `MySQL 数据库连接测试失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.mysqlHost,
|
||||
port: dbConfig.mysqlPort,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
|
||||
try {
|
||||
await mysqlService.connect()
|
||||
await mysqlService.disconnect()
|
||||
log.info('MySQL connection test successful')
|
||||
return {
|
||||
success: true,
|
||||
message: 'MySQL 数据库连接测试成功!'
|
||||
}
|
||||
} catch (connError) {
|
||||
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
|
||||
log.error('MySQL connection failed', { error: errorMessage })
|
||||
return {
|
||||
success: false,
|
||||
message: `MySQL 数据库连接测试失败:${errorMessage}`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Test SQL Server connection
|
||||
const dbConfig = config.database.sqlserver
|
||||
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
|
||||
log.warn('SQL Server connection test failed - missing configuration')
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 SQL Server 服务器、数据库名和用户名'
|
||||
@@ -231,39 +149,52 @@ export function registerSettingsHandlers(): void {
|
||||
|
||||
const sqlServerService = new SqlServerService({
|
||||
server: dbConfig.server,
|
||||
port: 1433, // Default SQL Server port
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
trustServerCertificate: true
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await sqlServerService.connect()
|
||||
await sqlServerService.disconnect()
|
||||
log.info('SQL Server connection test successful')
|
||||
return {
|
||||
success: true,
|
||||
message: 'SQL Server 数据库连接测试成功!'
|
||||
}
|
||||
} catch (connError) {
|
||||
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
|
||||
log.error('SQL Server connection failed', { error: errorMessage })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return {
|
||||
success: false,
|
||||
message: `SQL Server 数据库连接测试失败:${errorMessage}`
|
||||
message: `SQL Server 数据库连接测试失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Database connection test error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
message: `数据库连接测试失败:${message}`
|
||||
}
|
||||
}, 'settings:testDbConnection')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
return config.cleaner
|
||||
}, 'config:getCleaner')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
|
||||
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
return configManager.getConfig().cleaner
|
||||
}, 'config:updateCleaner')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
55
src/main/ipc/update-handler.ts
Normal file
55
src/main/ipc/update-handler.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../types/update.types'
|
||||
|
||||
export function registerUpdateHandlers(): void {
|
||||
const updateService = UpdateService.getInstance()
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_GET_STATUS, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus')
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CATALOG,
|
||||
async (): Promise<IpcResult<UpdateDialogCatalog>> => {
|
||||
return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CHANGELOG,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.getChangelog(request),
|
||||
'update:getChangelog'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.downloadRelease(request),
|
||||
'update:downloadRelease'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.installDownloadedRelease(),
|
||||
'update:installDownloaded'
|
||||
)
|
||||
})
|
||||
}
|
||||
147
src/main/ipc/user-erp-config-handler.ts
Normal file
147
src/main/ipc/user-erp-config-handler.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('UserErpConfigHandler')
|
||||
|
||||
export interface ErpCredentialsRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ErpConfigResponse {
|
||||
success: boolean
|
||||
config?: {
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function registerUserErpConfigHandlers(): void {
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT,
|
||||
async (): Promise<IpcResult<ErpConfigResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Fetching current user ERP credentials')
|
||||
const credentials = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!credentials) {
|
||||
throw new ValidationError(
|
||||
'未找到 ERP 配置。请先配置 ERP 账号和密码。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
}
|
||||
}
|
||||
}, 'user-erp-config:getCurrent')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_UPDATE,
|
||||
async (_event, credentials: ErpCredentialsRequest): Promise<IpcResult<ErpConfigResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const updated = await erpConfigService.updateCurrentUserErpConfig(credentials)
|
||||
if (!updated) {
|
||||
throw new ValidationError('更新 ERP 配置失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
}
|
||||
}
|
||||
}, 'user-erp-config:update')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION,
|
||||
async (
|
||||
_event,
|
||||
credentials: ErpCredentialsRequest
|
||||
): Promise<IpcResult<ConnectionTestResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (!credentials.username || !credentials.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整,请确保用户名和密码都已填写',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const authService = new ErpAuthService({
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
headless: true
|
||||
})
|
||||
|
||||
try {
|
||||
await authService.login()
|
||||
return {
|
||||
success: true,
|
||||
message: 'ERP 连接测试成功'
|
||||
}
|
||||
} finally {
|
||||
await authService.close().catch(() => {})
|
||||
}
|
||||
}, 'user-erp-config:testConnection')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL,
|
||||
async (): Promise<
|
||||
IpcResult<
|
||||
Array<{
|
||||
username: string
|
||||
erpUrl: string
|
||||
erpUsername: string
|
||||
}>
|
||||
>
|
||||
> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (!sessionManager.isAdmin()) {
|
||||
throw new ValidationError('只有管理员可以查看全部用户 ERP 配置', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const configs = await erpConfigService.getAllUsersErpConfig()
|
||||
return configs
|
||||
}, 'user-erp-config:getAll')
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,392 +1,52 @@
|
||||
/**
|
||||
* IPC handlers for material validation operations
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Running material validation from database
|
||||
* - Getting/setting materials to be deleted
|
||||
* - Manager-based filtering
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
|
||||
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type { MaterialStats } from '../services/database/materials-to-be-deleted-dao'
|
||||
import type {
|
||||
ValidationRequest,
|
||||
ValidationResponse,
|
||||
MaterialUpsertBatchRequest,
|
||||
MaterialDeleteRequest,
|
||||
MaterialOperationResponse,
|
||||
ValidationResult,
|
||||
MaterialRecordSummary
|
||||
MaterialRecordSummary,
|
||||
MaterialUpsertBatchRequest,
|
||||
ValidationRequest,
|
||||
ValidationResponse
|
||||
} from '../types/validation.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { sharedProductionIdsStore } from '../services/validation/shared-production-ids-store'
|
||||
import { validationApplicationService } from '../services/validation/validation-application-service'
|
||||
|
||||
const log = createLogger('ValidationHandler')
|
||||
|
||||
/**
|
||||
* Shared state for Production IDs from extractor page
|
||||
* This is a simple in-memory store for sharing Production IDs between pages
|
||||
*/
|
||||
const sharedProductionIds = new Set<string>()
|
||||
|
||||
/**
|
||||
* Set shared Production IDs
|
||||
*/
|
||||
export function setSharedProductionIds(ids: string[]): void {
|
||||
ids.forEach((id) => sharedProductionIds.add(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
export function getSharedProductionIds(): string[] {
|
||||
return [...sharedProductionIds]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear shared Production IDs
|
||||
*/
|
||||
export function clearSharedProductionIds(): void {
|
||||
sharedProductionIds.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service for validation operations (MySQL or SQL Server)
|
||||
*/
|
||||
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
|
||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
||||
const sqlServerService = new SqlServerService({
|
||||
server: process.env.DB_SERVER || 'localhost',
|
||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
||||
user: process.env.DB_USERNAME || 'sa',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || '',
|
||||
options: {
|
||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||
}
|
||||
})
|
||||
await sqlServerService.connect()
|
||||
return sqlServerService
|
||||
} else {
|
||||
const mysqlService = new MySqlService({
|
||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||
user: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || ''
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name based on database type
|
||||
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||
*/
|
||||
function getTableName(mysqlTableName: string): string {
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
||||
// Find the FIRST underscore to split schema and table name
|
||||
// This handles patterns like: schema_tablename
|
||||
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
|
||||
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${tableName}]`
|
||||
}
|
||||
// If no underscore found, default to dbo schema
|
||||
return `[dbo].[${mysqlTableName}]`
|
||||
}
|
||||
return mysqlTableName
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Production IDs from file
|
||||
*/
|
||||
function readProductionIds(filePath: string): string[] {
|
||||
const fs = require('fs')
|
||||
const content = fs.readFileSync(filePath, 'utf-8') as string
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify input type (production ID or order number)
|
||||
*/
|
||||
function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
|
||||
// Order number: SC + 14 digits
|
||||
if (/^SC\d{14}$/.test(input)) {
|
||||
return 'order_number'
|
||||
}
|
||||
// Production ID: 2 digits + 1 letter + 1-6 digits
|
||||
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
|
||||
return 'production_id'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source numbers from inputs
|
||||
*/
|
||||
async function getSourceNumbersFromInputs(
|
||||
inputs: string[],
|
||||
dbService: MySqlService | SqlServerService
|
||||
): Promise<string[]> {
|
||||
const productionIds: string[] = []
|
||||
const orderNumbers: string[] = []
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
||||
|
||||
for (const item of inputs) {
|
||||
const type = identifyInputType(item)
|
||||
if (type === 'order_number') {
|
||||
orderNumbers.push(item)
|
||||
} else if (type === 'production_id') {
|
||||
productionIds.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Query production contract data for production IDs
|
||||
// Table name in MySQL: productionContractData_26年压力表合同数据
|
||||
// Column name: 生产订单号 (SourceNumber)
|
||||
if (productionIds.length > 0) {
|
||||
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const placeholders = productionIds.map((_, idx) => `@p${idx}`).join(',')
|
||||
const params: Record<string, { value: string; type: any }> = {}
|
||||
|
||||
productionIds.forEach((id, idx) => {
|
||||
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
||||
})
|
||||
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as SqlServerService).queryWithParams(
|
||||
contractSql,
|
||||
params
|
||||
)
|
||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||
orderNumbers.push(...dbOrderNumbers)
|
||||
} else {
|
||||
const placeholders = productionIds.map(() => '?').join(',')
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as MySqlService).query(contractSql, productionIds)
|
||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||
orderNumbers.push(...dbOrderNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
return [...new Set(orderNumbers)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for validation operations
|
||||
*/
|
||||
export function registerValidationHandlers(): void {
|
||||
// ==================== VALIDATION ====================
|
||||
|
||||
/**
|
||||
* Run material validation from database
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'validation:validate',
|
||||
async (_event, request: ValidationRequest): Promise<ValidationResponse> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
IPC_CHANNELS.VALIDATION_VALIDATE,
|
||||
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
try {
|
||||
log.info('Starting validation', { mode: request.mode })
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
||||
|
||||
let sourceNumbers: string[] | null = null
|
||||
|
||||
// Get source numbers based on mode
|
||||
if (request.mode === 'database_filtered') {
|
||||
if (request.useSharedProductionIds) {
|
||||
// Use shared Production IDs from extractor page
|
||||
const sharedIds = getSharedProductionIds()
|
||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||
|
||||
if (sharedIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
|
||||
} else if (request.productionIdFile) {
|
||||
// Read from file
|
||||
const inputs = readProductionIds(request.productionIdFile)
|
||||
log.info(`Read ${inputs.length} inputs from file`)
|
||||
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
|
||||
log.info(`Got ${sourceNumbers.length} source numbers`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get material records from DiscreteMaterialPlanData
|
||||
const materialDao = new DiscreteMaterialPlanDAO()
|
||||
|
||||
let materialRecords: any[] = []
|
||||
|
||||
if (request.mode === 'database_full') {
|
||||
// Full table query with deduplication by MaterialCode
|
||||
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
|
||||
} else if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
// Filtered query by source numbers
|
||||
materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers)
|
||||
}
|
||||
|
||||
if (materialRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No material records found',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get type keywords from MaterialsTypeToBeDeleted
|
||||
const typeKeywordTableName = getTableName('dbo_MaterialsTypeToBeDeleted')
|
||||
const typeKeywordSql = `
|
||||
SELECT MaterialName, ManagerName
|
||||
FROM ${typeKeywordTableName}
|
||||
WHERE MaterialName IS NOT NULL
|
||||
`
|
||||
const typeKeywordResult = isSqlServer
|
||||
? await (dbService as SqlServerService).query(typeKeywordSql)
|
||||
: await (dbService as MySqlService).query(typeKeywordSql)
|
||||
|
||||
const typeKeywords = typeKeywordResult.rows.map((row) => ({
|
||||
materialName: row.MaterialName as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
|
||||
// Get marked material codes from MaterialsToBeDeleted
|
||||
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
|
||||
const markedSql = `
|
||||
SELECT MaterialCode, ManagerName
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
|
||||
`
|
||||
const markedResult = isSqlServer
|
||||
? await (dbService as SqlServerService).query(markedSql)
|
||||
: await (dbService as MySqlService).query(markedSql)
|
||||
|
||||
const markedCodesDict = new Map<string, string>()
|
||||
for (const row of markedResult.rows) {
|
||||
markedCodesDict.set(row.MaterialCode as string, row.ManagerName as string)
|
||||
}
|
||||
|
||||
// Match materials
|
||||
const results: ValidationResult[] = []
|
||||
for (const record of materialRecords) {
|
||||
const materialName = (record.MaterialName as string) || ''
|
||||
const materialCode = (record.MaterialCode as string) || ''
|
||||
const specification = (record.Specification as string) || ''
|
||||
const model = (record.Model as string) || ''
|
||||
|
||||
// Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
|
||||
let managerName = markedCodesDict.get(materialCode) || null
|
||||
const isMarkedForDeletion = managerName !== null
|
||||
let matchedTypeKeyword: string | undefined = undefined
|
||||
|
||||
// Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains)
|
||||
if (!managerName) {
|
||||
for (const typeKeyword of typeKeywords) {
|
||||
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
|
||||
matchedTypeKeyword = typeKeyword.materialName
|
||||
managerName = typeKeyword.managerName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
materialName,
|
||||
materialCode,
|
||||
specification,
|
||||
model,
|
||||
managerName: managerName || '',
|
||||
isMarkedForDeletion,
|
||||
matchedTypeKeyword
|
||||
})
|
||||
}
|
||||
|
||||
const markedCount = results.filter((r) => r.isMarkedForDeletion).length
|
||||
const matchedCount = results.filter((r) => r.managerName).length
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results,
|
||||
stats: {
|
||||
totalRecords: results.length,
|
||||
matchedCount,
|
||||
markedCount
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Validation error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Validation failed: ${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validationApplicationService.validate(request, userInfo, event.sender.id)
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== MATERIAL OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Upsert batch materials to MaterialsToBeDeleted
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'materials:upsertBatch',
|
||||
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
|
||||
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
@@ -398,9 +58,7 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Upsert batch error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
log.error('Upsert batch error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `Upsert failed: ${message}`
|
||||
@@ -409,11 +67,8 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete materials by material codes
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'materials:delete',
|
||||
IPC_CHANNELS.MATERIALS_DELETE,
|
||||
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
@@ -425,7 +80,7 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Delete error', { error: error instanceof Error ? error.message : String(error) })
|
||||
log.error('Delete error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `Delete failed: ${message}`
|
||||
@@ -434,10 +89,7 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get unique manager names
|
||||
*/
|
||||
ipcMain.handle('materials:getManagers', async (_event): Promise<{ managers: string[] }> => {
|
||||
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_MANAGERS, async (): Promise<{ managers: string[] }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const managers = await dao.getManagers()
|
||||
@@ -450,312 +102,118 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get materials by manager
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'materials:getByManager',
|
||||
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
|
||||
async (
|
||||
_event,
|
||||
request: { materialCode: string; managerName: string }
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
||||
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getMaterialsByManager(managerName)
|
||||
|
||||
// Get material codes set for quick lookup
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
|
||||
// Enrich with material details from DiscreteMaterialPlanData
|
||||
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
|
||||
|
||||
for (const mat of materials) {
|
||||
let detailResult: any
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const detailSql = `
|
||||
SELECT TOP 1 MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = @materialCode
|
||||
`
|
||||
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
|
||||
materialCode: { value: mat.materialCode, type: sql.NVarChar }
|
||||
})
|
||||
} else {
|
||||
const detailSql = `
|
||||
SELECT MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = ?
|
||||
LIMIT 1
|
||||
`
|
||||
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
|
||||
}
|
||||
|
||||
enrichedMaterials.push({
|
||||
materialCode: mat.materialCode,
|
||||
materialName:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
|
||||
specification:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
|
||||
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
|
||||
managerName: mat.managerName,
|
||||
isMarked: markedCodes.has(mat.materialCode)
|
||||
})
|
||||
return await dao.updateManager(request.materialCode, request.managerName)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error('Update manager error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: message
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { materials: enrichedMaterials }
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
|
||||
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
try {
|
||||
const materials = await validationApplicationService.getMaterialsByManager(managerName)
|
||||
return { materials }
|
||||
} catch (error) {
|
||||
log.error('Get by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { materials: [] }
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all material records
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'materials:getAll',
|
||||
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
IPC_CHANNELS.MATERIALS_GET_ALL,
|
||||
async (): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
||||
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getAllRecords()
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
|
||||
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
|
||||
|
||||
for (const mat of materials) {
|
||||
let detailResult: any
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const detailSql = `
|
||||
SELECT TOP 1 MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = @materialCode
|
||||
`
|
||||
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
|
||||
materialCode: { value: mat.materialCode, type: sql.NVarChar }
|
||||
})
|
||||
} else {
|
||||
const detailSql = `
|
||||
SELECT MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = ?
|
||||
LIMIT 1
|
||||
`
|
||||
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
|
||||
}
|
||||
|
||||
enrichedMaterials.push({
|
||||
materialCode: mat.materialCode,
|
||||
materialName:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
|
||||
specification:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
|
||||
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
|
||||
managerName: mat.managerName,
|
||||
isMarked: markedCodes.has(mat.materialCode)
|
||||
})
|
||||
}
|
||||
|
||||
return { materials: enrichedMaterials }
|
||||
const materials = await validationApplicationService.getAllMaterials()
|
||||
return { materials }
|
||||
} catch (error) {
|
||||
log.error('Get all error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { materials: [] }
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
ipcMain.handle('materials:getStatistics', async (_event): Promise<{ stats: any }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const stats = await dao.getStatistics()
|
||||
return { stats }
|
||||
} catch (error) {
|
||||
log.error('Get statistics error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { stats: null }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Set shared Production IDs from extractor page
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'validation:setSharedProductionIds',
|
||||
async (_event, productionIds: string[]): Promise<void> => {
|
||||
IPC_CHANNELS.MATERIALS_GET_STATISTICS,
|
||||
async (): Promise<{ stats: MaterialStats | null }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const stats = await dao.getStatistics()
|
||||
return { stats }
|
||||
} catch (error) {
|
||||
log.error('Get statistics error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { stats: null }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
|
||||
async (event, productionIds: string[]): Promise<void> => {
|
||||
log.info(`Received ${productionIds.length} shared Production IDs`)
|
||||
setSharedProductionIds(productionIds)
|
||||
sharedProductionIdsStore.set(event.sender.id, productionIds)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'validation:getSharedProductionIds',
|
||||
async (): Promise<{ productionIds: string[] }> => {
|
||||
return { productionIds: getSharedProductionIds() }
|
||||
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<{ productionIds: string[] }> => {
|
||||
return { productionIds: sharedProductionIdsStore.get(event.sender.id) }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
|
||||
* Filters materials by current user (admin sees all, regular users see only their own)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'validation:getCleanerData',
|
||||
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<void> => {
|
||||
log.info('Clearing shared Production IDs')
|
||||
sharedProductionIdsStore.clear(event.sender.id)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
|
||||
async (
|
||||
_event
|
||||
event
|
||||
): Promise<{
|
||||
success: boolean
|
||||
orderNumbers?: string[]
|
||||
materialCodes?: string[]
|
||||
error?: string
|
||||
}> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
try {
|
||||
// Get current user
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录'
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
||||
|
||||
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
|
||||
// 1. Get order numbers from shared Production IDs
|
||||
const sharedIds = getSharedProductionIds()
|
||||
let orderNumbers: string[] = []
|
||||
|
||||
if (sharedIds.length > 0) {
|
||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||
orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||
log.info(`Got ${orderNumbers.length} order numbers`)
|
||||
}
|
||||
|
||||
// 2. Get material codes from MaterialsToBeDeleted table
|
||||
let materialCodes: string[] = []
|
||||
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
|
||||
|
||||
if (isAdmin) {
|
||||
// Admin sees all materials
|
||||
const allCodesSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = isSqlServer
|
||||
? await (dbService as SqlServerService).query(allCodesSql)
|
||||
: await (dbService as MySqlService).query(allCodesSql)
|
||||
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Admin user: got ${materialCodes.length} materials`)
|
||||
} else {
|
||||
// Regular users only see their own materials
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const userMaterialsSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = @username AND MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = await (dbService as SqlServerService).queryWithParams(userMaterialsSql, {
|
||||
username: { value: username, type: sql.NVarChar }
|
||||
})
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
} else {
|
||||
const userMaterialsSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = await (dbService as MySqlService).query(userMaterialsSql, [username])
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
}
|
||||
log.info(`Regular user: got ${materialCodes.length} materials`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderNumbers,
|
||||
materialCodes
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('CleanerData error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: `获取清理数据失败:${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
error: '用户未登录'
|
||||
}
|
||||
}
|
||||
|
||||
return validationApplicationService.getCleanerData(userInfo, event.sender.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export const CleanerInputSchema = z.object({
|
||||
.array(z.string().min(1, 'Order number cannot be empty'))
|
||||
.min(1, 'At least one order number is required'),
|
||||
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
||||
dryRun: z.boolean()
|
||||
dryRun: z.boolean(),
|
||||
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
|
||||
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
|
||||
// Note: onProgress is a function, not validated via Zod
|
||||
})
|
||||
|
||||
|
||||
183
src/main/services/auth/auth-application-service.ts
Normal file
183
src/main/services/auth/auth-application-service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { hostname } from 'os'
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import { UpdateService } from '../update/update-service'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAudit } from '../logger/audit-logger'
|
||||
import { ValidationError } from '../../types/errors'
|
||||
import type { UserInfo } from '../../types/user.types'
|
||||
import type {
|
||||
CurrentUserResponse,
|
||||
LoginResponse,
|
||||
SilentLoginResponse,
|
||||
UserSelectionResponse
|
||||
} from '../../types/auth-ipc.types'
|
||||
|
||||
const log = createLogger('AuthApplicationService')
|
||||
|
||||
export class AuthApplicationService {
|
||||
private silentLoginPromise: Promise<SilentLoginResponse> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly sessionManager: SessionManager = SessionManager.getInstance(),
|
||||
private readonly updateService: UpdateService = UpdateService.getInstance()
|
||||
) {}
|
||||
|
||||
async getComputerName(): Promise<string> {
|
||||
return hostname()
|
||||
}
|
||||
|
||||
async silentLogin(): Promise<SilentLoginResponse> {
|
||||
if (this.silentLoginPromise) {
|
||||
log.debug('Reusing in-flight silent login request')
|
||||
return this.silentLoginPromise
|
||||
}
|
||||
|
||||
this.silentLoginPromise = this.performSilentLogin()
|
||||
try {
|
||||
return await this.silentLoginPromise
|
||||
} finally {
|
||||
this.silentLoginPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
private async performSilentLogin(): Promise<SilentLoginResponse> {
|
||||
log.info('Attempting silent login')
|
||||
const success = await this.sessionManager.loginByComputerName()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (!success || !userInfo) {
|
||||
await this.updateService.setUserContext(null)
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
log.info('Silent login successful', {
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection
|
||||
})
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
if (!username || !password) {
|
||||
log.warn('Login attempt with missing credentials')
|
||||
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
|
||||
}
|
||||
|
||||
log.info('Login attempt', { username })
|
||||
const success = await this.sessionManager.login(username, password)
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (!success || !userInfo) {
|
||||
this.writeAuditLog('LOGIN', '0', {
|
||||
username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
})
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
await this.updateService.setUserContext(null)
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
log.info('User logout', { username: userInfo?.username })
|
||||
|
||||
if (userInfo) {
|
||||
this.writeAuditLog('LOGOUT', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { userType: userInfo.userType }
|
||||
})
|
||||
}
|
||||
|
||||
this.sessionManager.logout()
|
||||
await this.updateService.setUserContext(null)
|
||||
}
|
||||
|
||||
getCurrentUser(): CurrentUserResponse {
|
||||
const isAuthenticated = this.sessionManager.isAuthenticated()
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
userInfo: userInfo ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserInfo[]> {
|
||||
log.debug('Fetching all users for admin selection')
|
||||
return this.sessionManager.getAllUsers()
|
||||
}
|
||||
|
||||
async switchUser(userInfo: UserInfo): Promise<UserSelectionResponse> {
|
||||
log.info('User switch attempt', { targetUser: userInfo.username })
|
||||
const success = this.sessionManager.switchUser(userInfo)
|
||||
|
||||
if (!success) {
|
||||
log.warn('User switch failed')
|
||||
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const newUser = this.sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
await this.updateService.setUserContext(newUser?.userType ?? null)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return this.sessionManager.isAdmin()
|
||||
}
|
||||
|
||||
private writeAuditLog(
|
||||
action: 'LOGIN' | 'LOGOUT',
|
||||
actorId: string,
|
||||
payload: Parameters<typeof logAudit>[2]
|
||||
): void {
|
||||
logAudit(action, actorId, payload).catch((err) =>
|
||||
log.warn('Failed to write audit log', { err })
|
||||
)
|
||||
}
|
||||
}
|
||||
359
src/main/services/cleaner/cleaner-application-service.ts
Normal file
359
src/main/services/cleaner/cleaner-application-service.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { MySqlService } from '../database/mysql'
|
||||
import type { SqlServerService } from '../database/sql-server'
|
||||
import { ErpAuthService } from '../erp/erp-auth'
|
||||
import { CleanerService } from '../erp/cleaner'
|
||||
import { OrderNumberResolver } from '../erp/order-resolver'
|
||||
import { MySqlService as MySqlServiceImpl } from '../database/mysql'
|
||||
import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { ResultExporter } from '../excel/result-exporter'
|
||||
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
|
||||
import { RustfsService } from '../rustfs'
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import { UserErpConfigService } from '../user/user-erp-config-service'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAudit } from '../logger/audit-logger'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerProgress,
|
||||
CleanerResult,
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} from '../../types/cleaner.types'
|
||||
|
||||
const log = createLogger('CleanerApplicationService')
|
||||
|
||||
type DatabaseService = MySqlService | SqlServerService
|
||||
|
||||
export class CleanerApplicationService {
|
||||
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
|
||||
const startTime = Date.now()
|
||||
let authService: ErpAuthService | null = null
|
||||
let dbService: DatabaseService | null = null
|
||||
|
||||
try {
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
const erpConfig = await this.getErpConfig()
|
||||
|
||||
log.info('ERP config retrieved', {
|
||||
url: erpConfig.url ? 'configured' : 'EMPTY',
|
||||
username: erpConfig.username ? 'configured' : 'EMPTY'
|
||||
})
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
log.info(
|
||||
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
|
||||
)
|
||||
|
||||
try {
|
||||
dbService = await this.getDatabaseService()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const resolver = new OrderNumberResolver(dbService)
|
||||
const mappings = await resolver.resolve(input.orderNumbers)
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
authService = new ErpAuthService({
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: input.headless ?? true
|
||||
})
|
||||
|
||||
log.info('Logging in to ERP...')
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
log.info('Login successful')
|
||||
|
||||
const totalOrders = validOrderNumbers.length
|
||||
this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, {
|
||||
phase: 'login',
|
||||
currentOrderIndex: 0,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
const cleaner = new CleanerService(authService)
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: (message, progress, extra) => {
|
||||
this.sendProgress(eventSender, message, progress ?? 0, extra)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Starting cleaning', {
|
||||
orderCount: validOrderNumbers.length,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1
|
||||
})
|
||||
|
||||
const result = await cleaner.clean(modifiedInput)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
this.sendProgress(eventSender, '清理完成', 100, {
|
||||
phase: 'complete',
|
||||
currentOrderIndex: totalOrders,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
log.info('Cleaning completed', {
|
||||
processedCount: result.ordersProcessed,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
||||
await this.generateAndUploadReport(input, result, startTime)
|
||||
|
||||
return result
|
||||
} finally {
|
||||
if (authService) {
|
||||
try {
|
||||
await authService.close()
|
||||
log.debug('Browser closed')
|
||||
} catch (closeError) {
|
||||
log.warn('Error closing browser', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
log.debug('Database disconnected')
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exportResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
|
||||
log.info('Exporting validation results', { count: items.length })
|
||||
if (!items || items.length === 0) {
|
||||
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const exporter = new ResultExporter()
|
||||
return exporter.exportValidationResults(items)
|
||||
}
|
||||
|
||||
private sendProgress(
|
||||
sender: WebContents,
|
||||
message: string,
|
||||
progress: number,
|
||||
extra?: Partial<CleanerProgress>
|
||||
): void {
|
||||
try {
|
||||
const progressData: CleanerProgress = {
|
||||
message,
|
||||
progress,
|
||||
currentOrderIndex: extra?.currentOrderIndex ?? 0,
|
||||
totalOrders: extra?.totalOrders ?? 0,
|
||||
currentMaterialIndex: extra?.currentMaterialIndex ?? 0,
|
||||
totalMaterialsInOrder: extra?.totalMaterialsInOrder ?? 0,
|
||||
currentOrderNumber: extra?.currentOrderNumber,
|
||||
phase: extra?.phase ?? 'processing'
|
||||
}
|
||||
sender.send(IPC_CHANNELS.CLEANER_PROGRESS, progressData)
|
||||
} catch (error) {
|
||||
log.warn('Failed to send progress event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
private async getDatabaseService(): Promise<DatabaseService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerServiceImpl({
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
await sqlServerService.connect()
|
||||
return sqlServerService
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlServiceImpl({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
|
||||
private async getErpConfig(): Promise<{ url: string; username: string; password: string }> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const erpUrl = globalConfig.erp.url
|
||||
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url: erpUrl,
|
||||
username: userConfig.username,
|
||||
password: userConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
private async recordCleanupAudit(
|
||||
orderCount: number,
|
||||
input: CleanerInput,
|
||||
result: CleanerResult
|
||||
): Promise<void> {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (!currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.materialsDeleted > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
|
||||
await logAudit('CLEAN', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: (await import('os')).hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount,
|
||||
dryRun: input.dryRun ?? false,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
private async generateAndUploadReport(
|
||||
input: CleanerInput,
|
||||
result: CleanerResult,
|
||||
startTime: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const endTime = Date.now()
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
const username = currentUser?.username ?? 'unknown'
|
||||
|
||||
const reportGenerator = new CleanerReportGenerator()
|
||||
const reportPath = await reportGenerator.generateReport(result, {
|
||||
dryRun: input.dryRun ?? false,
|
||||
username,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
log.info('Report generated', { path: reportPath })
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (!config.rustfs?.enabled || !config.rustfs.endpoint) {
|
||||
log.debug('RustFS is not enabled, skipping upload')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rustfs = new RustfsService({ config: config.rustfs })
|
||||
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
|
||||
const storageKey = rustfs.generateReportKey(reportFileName, username)
|
||||
|
||||
log.info('Uploading report to RustFS', {
|
||||
localPath: reportPath,
|
||||
storageKey
|
||||
})
|
||||
|
||||
const uploadResult = await rustfs.uploadFile(
|
||||
reportPath,
|
||||
storageKey,
|
||||
'text/markdown; charset=utf-8'
|
||||
)
|
||||
|
||||
if (uploadResult.success) {
|
||||
log.info('Report uploaded to RustFS successfully', {
|
||||
key: storageKey,
|
||||
etag: uploadResult.etag
|
||||
})
|
||||
} else {
|
||||
log.warn('Failed to upload report to RustFS', {
|
||||
error: uploadResult.error,
|
||||
key: storageKey
|
||||
})
|
||||
}
|
||||
} catch (rustfsError) {
|
||||
log.error('RustFS upload failed', {
|
||||
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
|
||||
})
|
||||
}
|
||||
} catch (reportError) {
|
||||
log.warn('Failed to generate report', {
|
||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user