Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6eb60ada7 | ||
|
|
571ec2325f | ||
|
|
557ed174c3 | ||
|
|
dd2cf1c576 | ||
|
|
b7e9e5e472 | ||
|
|
491f2afe3f | ||
|
|
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 | ||
|
|
7644b8d4ea |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ out
|
||||
|
||||
# AI Agent
|
||||
.claude
|
||||
.agents
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
10
README.md
10
README.md
@@ -76,16 +76,12 @@ npm run dev
|
||||
### 构建应用
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
# Windows 安装版 + 便携版
|
||||
npm run build:win
|
||||
|
||||
# macOS
|
||||
npm run build:mac
|
||||
|
||||
# Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
当前项目的正式构建与发布链路仅维护 Windows 目标。
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 数据提取
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
根据 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json),项目当前依赖为:
|
||||
|
||||
| 组件 | 当前版本 |
|
||||
| --- | --- |
|
||||
| `playwright` | `^1.58.2` |
|
||||
| `playwright-core` | `^1.58.2` |
|
||||
| 组件 | 当前版本 |
|
||||
| ------------------ | --------- |
|
||||
| `playwright` | `^1.58.2` |
|
||||
| `playwright-core` | `^1.58.2` |
|
||||
| `@playwright/test` | `^1.58.2` |
|
||||
|
||||
## 当前代码中的目录约定
|
||||
|
||||
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
|
||||
- 跨模块共享数据要保持单向来源清晰
|
||||
@@ -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个阶段,每个阶段都可以独立验证
|
||||
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
|
||||
- 新增工具函数:数据解析器和聚合器
|
||||
|
||||
## 破坏性变更
|
||||
|
||||
无破坏性变更,所有现有功能保持完全兼容。
|
||||
6
docs/releases/1.6.2.md
Normal file
6
docs/releases/1.6.2.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 1.6.2
|
||||
|
||||
## 系统优化
|
||||
|
||||
- 简化用户角色体系,移除未使用的 Guest 角色。
|
||||
- 优化类型安全性,加强用户认证流程健壮性。
|
||||
18
docs/releases/1.7.0.md
Normal file
18
docs/releases/1.7.0.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 1.7.0
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
|
||||
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
|
||||
- 批次记录可展开查看,显示总排号与订单号的对应关系。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
|
||||
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
|
||||
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
|
||||
|
||||
## 数据存储
|
||||
|
||||
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
|
||||
- 需执行数据库脚本创建表结构(详见项目文档)。
|
||||
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 由发布脚本自动生成,不手动维护
|
||||
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 模块开始从“页面驱动逻辑”向“模块化前端能力”转变。
|
||||
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。
|
||||
@@ -38,26 +38,4 @@ nsis:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
648
package-lock.json
generated
648
package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erpauto",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"hasInstallScript": true,
|
||||
"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",
|
||||
@@ -26,6 +27,7 @@
|
||||
"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",
|
||||
@@ -968,7 +970,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
|
||||
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-auth": "^1.10.0",
|
||||
@@ -1030,7 +1031,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
|
||||
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-auth": "^1.10.0",
|
||||
@@ -1222,7 +1222,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2000,6 +1999,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -2021,6 +2021,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2037,6 +2038,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -2051,6 +2053,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2757,6 +2760,79 @@
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
|
||||
"integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -3136,6 +3212,139 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz",
|
||||
"integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.27.1",
|
||||
"@react-aria/utils": "^3.33.1",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz",
|
||||
"integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.33.1",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz",
|
||||
"integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
|
||||
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz",
|
||||
"integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -4233,9 +4442,23 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -4505,6 +4728,33 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tediousjs/connection-string": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.6.0.tgz",
|
||||
@@ -4579,6 +4829,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -4689,7 +5002,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4711,7 +5023,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4756,6 +5067,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -4826,7 +5143,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -5266,7 +5582,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5299,7 +5614,6 @@
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6062,7 +6376,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6657,6 +6970,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
@@ -6849,7 +7171,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6892,6 +7215,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
@@ -6979,6 +7423,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -7255,7 +7705,6 @@
|
||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -7470,7 +7919,6 @@
|
||||
"integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
@@ -7659,6 +8107,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7679,6 +8128,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7930,6 +8380,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
@@ -8007,7 +8467,6 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8068,7 +8527,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -8358,6 +8816,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -9536,6 +10000,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -9601,6 +10075,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -13031,7 +13514,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13112,7 +13594,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13136,6 +13617,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13153,6 +13635,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -13173,7 +13656,6 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -13315,7 +13797,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13336,7 +13817,6 @@
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
@@ -13401,6 +13881,29 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -13474,6 +13977,51 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
@@ -13669,6 +14217,12 @@
|
||||
"url": "https://github.com/sponsors/jet2jet"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.6",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||
@@ -13968,7 +14522,6 @@
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@@ -14680,6 +15233,12 @@
|
||||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
@@ -14846,6 +15405,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -14929,6 +15489,12 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -15875,7 +16441,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16207,6 +16772,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8-byte-length": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||
@@ -16277,12 +16851,33 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16829,7 +17424,6 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -17038,7 +17632,6 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -17276,7 +17869,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
@@ -18,8 +18,6 @@
|
||||
"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",
|
||||
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
||||
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
|
||||
"release:prepare": "node scripts/prepare-release.js",
|
||||
"release:publish": "node scripts/publish-release.js",
|
||||
"release:upload": "node scripts/upload-release.js",
|
||||
@@ -38,6 +36,7 @@
|
||||
"@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",
|
||||
@@ -52,6 +51,7 @@
|
||||
"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",
|
||||
|
||||
@@ -81,9 +81,7 @@ function validateUpdateConfig(configPath) {
|
||||
|
||||
function hasConflictMarker(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return (
|
||||
content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
|
||||
)
|
||||
return content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
|
||||
}
|
||||
|
||||
function validateVersionFiles(version) {
|
||||
@@ -99,7 +97,10 @@ function validateVersionFiles(version) {
|
||||
|
||||
const rootPackage = packageLock.packages && packageLock.packages['']
|
||||
if (rootPackage && rootPackage.version) {
|
||||
assert(rootPackage.version === version, 'package-lock root package version does not match package.json')
|
||||
assert(
|
||||
rootPackage.version === version,
|
||||
'package-lock root package version does not match package.json'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,191 +1,23 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog } 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 { ConfigManager } from './services/config/config-manager'
|
||||
import logger from './services/logger/index'
|
||||
import { logAudit } from './services/logger/audit-logger'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import fs from 'fs'
|
||||
import { UpdateService } from './services/update/update-service'
|
||||
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'
|
||||
|
||||
// Set Playwright browsers path BEFORE any playwright import
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
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(async () => {
|
||||
// Validate Playwright browser path
|
||||
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH!
|
||||
setupProcessGuards()
|
||||
registerMainWindowLifecycle()
|
||||
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
||||
const browsersExist = ensurePlaywrightRuntime(playwrightBrowsersPath)
|
||||
console.log('Playwright browsers exist:', browsersExist)
|
||||
await initializeMainProcessServices()
|
||||
setupElectronRuntime()
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
fs.mkdirSync(browsersPath, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create browsers directory:', error)
|
||||
}
|
||||
|
||||
// Check if chromium browser exists (supports both old and new Playwright directory structures)
|
||||
// New format (v1.48+): chromium-1208/chrome-win64/chrome.exe
|
||||
// Old format: chromium-win32/chrome.exe
|
||||
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)) {
|
||||
// Try to find any chromium revision
|
||||
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 (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (!foundRevision) {
|
||||
dialog.showErrorBox(
|
||||
'浏览器文件未找到',
|
||||
`Playwright 浏览器文件不存在。\n\n` +
|
||||
`期望路径:${newChromiumPath}\n` +
|
||||
`或:${oldChromiumPath}\n\n` +
|
||||
`当前目录内容:${fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath).join(', ') : '目录不存在'}\n\n` +
|
||||
`请运行以下命令安装浏览器:\n` +
|
||||
`npx playwright install chromium`
|
||||
)
|
||||
console.warn(
|
||||
'Playwright browser not found. Available:',
|
||||
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ConfigManager BEFORE registering IPC handlers
|
||||
// This ensures config is loaded before any service tries to use it
|
||||
try {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
await configManager.initialize()
|
||||
UpdateService.getInstance().initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error)
|
||||
// Continue anyway - default config will be created
|
||||
}
|
||||
|
||||
// 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 (after ConfigManager is initialized)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// Global exception handlers to prevent crashes without logging
|
||||
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, promise) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// 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,261 +11,68 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import type { UserInfo } from '../types/user.types'
|
||||
import type {
|
||||
CurrentUserResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SilentLoginResponse,
|
||||
UserSelectionResponse
|
||||
} from '../types/auth-ipc.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
|
||||
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 { AuthApplicationService } from '../services/auth/auth-application-service'
|
||||
|
||||
/**
|
||||
* Register IPC handlers for user authentication
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const updateService = UpdateService.getInstance()
|
||||
const authService = new AuthApplicationService()
|
||||
|
||||
/**
|
||||
* Get computer name
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const os = await import('os')
|
||||
return os.hostname()
|
||||
}, 'auth:getComputerName')
|
||||
return withErrorHandling(async () => authService.getComputerName(), 'auth:getComputerName')
|
||||
})
|
||||
|
||||
/**
|
||||
* Silent login by computer name
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_SILENT_LOGIN,
|
||||
async (): Promise<IpcResult<SilentLoginResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Attempting silent login')
|
||||
const success = await sessionManager.loginByComputerName()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Check if admin needs user selection
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
|
||||
log.info('Silent login successful', {
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection
|
||||
})
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
}
|
||||
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:silentLogin')
|
||||
return withErrorHandling(async () => authService.silentLogin(), 'auth:silentLogin')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_LOGIN,
|
||||
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const { username, password } = request
|
||||
|
||||
if (!username || !password) {
|
||||
log.warn('Login attempt with missing credentials')
|
||||
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
|
||||
}
|
||||
|
||||
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 })
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log: LOGIN failure (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', '0', {
|
||||
username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:login')
|
||||
return withErrorHandling(
|
||||
async () => authService.login(request.username, request.password),
|
||||
'auth:login'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
log.info('User logout', { username: userInfo?.username })
|
||||
|
||||
// Audit log: LOGOUT (non-blocking)
|
||||
if (userInfo) {
|
||||
const os = await import('os')
|
||||
logAudit('LOGOUT', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
sessionManager.logout()
|
||||
await updateService.setUserContext(null)
|
||||
}, 'auth:logout')
|
||||
return withErrorHandling(async () => authService.logout(), 'auth:logout')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
|
||||
async (): Promise<IpcResult<CurrentUserResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const isAuthenticated = sessionManager.isAuthenticated()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
return {
|
||||
isAuthenticated,
|
||||
userInfo: userInfo ?? undefined
|
||||
}
|
||||
}, 'auth:getCurrentUser')
|
||||
return withErrorHandling(async () => authService.getCurrentUser(), 'auth:getCurrentUser')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all users (for admin user selection)
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.debug('Fetching all users for admin selection')
|
||||
return await sessionManager.getAllUsers()
|
||||
}, 'auth:getAllUsers')
|
||||
return withErrorHandling(async () => authService.getAllUsers(), 'auth:getAllUsers')
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch user (admin only)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_SWITCH_USER,
|
||||
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
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 })
|
||||
await updateService.setUserContext(newUser?.userType ?? null)
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
log.warn('User switch failed')
|
||||
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:switchUser')
|
||||
return withErrorHandling(async () => authService.switchUser(userInfo), 'auth:switchUser')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => sessionManager.isAdmin(), 'auth:isAdmin')
|
||||
return withErrorHandling(async () => authService.isAdmin(), 'auth:isAdmin')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,368 +1,34 @@
|
||||
import { ipcMain, type WebContents } 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 { SqlServerService } from '../services/database/sql-server'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { ResultExporter } from '../services/excel/result-exporter'
|
||||
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
|
||||
import { RustfsService } from '../services/rustfs'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { ipcMain } from 'electron'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerResult,
|
||||
CleanerProgress,
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} from '../types/cleaner.types'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('CleanerHandler')
|
||||
|
||||
function 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 })
|
||||
}
|
||||
}
|
||||
|
||||
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerService({
|
||||
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
|
||||
} else {
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
|
||||
|
||||
export function registerCleanerHandlers(): void {
|
||||
const cleanerService = new CleanerApplicationService()
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_RUN,
|
||||
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||
const sender = event.sender
|
||||
const startTime = Date.now()
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
// Get ERP configuration from database for current user
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
const erpConfig = await 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 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')
|
||||
|
||||
// Send login complete progress
|
||||
const totalOrders = validOrderNumbers.length
|
||||
const loginProgress = (1 / (1 + totalOrders)) * 100
|
||||
sendProgress(sender, 'ERP 登录成功', loginProgress, {
|
||||
phase: 'login',
|
||||
currentOrderIndex: 0,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
const cleaner = new CleanerService(authService)
|
||||
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: (message, progress, extra) => {
|
||||
sendProgress(sender, 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]
|
||||
}
|
||||
|
||||
// Send completion progress
|
||||
sendProgress(sender, '清理完成', 100, {
|
||||
phase: 'complete',
|
||||
currentOrderIndex: totalOrders,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
log.info('Cleaning completed', {
|
||||
processedCount: result.ordersProcessed,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
// Audit log: CLEAN (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.materialsDeleted > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
logAudit('CLEAN', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
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 }))
|
||||
}
|
||||
|
||||
// Generate report and upload to RustFS (silent, user unaware)
|
||||
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 })
|
||||
|
||||
// Upload to RustFS if enabled
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (config.rustfs?.enabled && config.rustfs.endpoint) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log.debug('RustFS is not enabled, skipping upload')
|
||||
}
|
||||
} catch (reportError) {
|
||||
log.warn('Failed to generate report', {
|
||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'cleaner:run')
|
||||
return withErrorHandling(
|
||||
async () => cleanerService.runCleaner(event.sender, input),
|
||||
'cleaner:run'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_EXPORT_RESULTS,
|
||||
async (_event, items: ExportResultItem[]): Promise<IpcResult<ExportResultResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Exporting validation results', { count: items.length })
|
||||
if (!items || items.length === 0) {
|
||||
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
const exporter = new ResultExporter()
|
||||
return await exporter.exportValidationResults(items)
|
||||
}, 'cleaner:exportResults')
|
||||
return withErrorHandling(
|
||||
async () => cleanerService.exportResults(items),
|
||||
'cleaner:exportResults'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ExtractorService } from '../services/erp/extractor'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const log = createLogger('ExtractorHandler')
|
||||
|
||||
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
// Initialize operation history recording
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
const historyDao = new ExtractorOperationHistoryDAO()
|
||||
const batchId = randomUUID()
|
||||
|
||||
// Save order records to history (preserve productionId -> orderNumber mapping)
|
||||
if (currentUser) {
|
||||
const orderRecords = mappings.map((m) => ({
|
||||
productionId: m.productionId || null,
|
||||
orderNumber: m.orderNumber || m.input
|
||||
}))
|
||||
await historyDao.insertBatchRecords(
|
||||
batchId,
|
||||
currentUser.id,
|
||||
currentUser.username,
|
||||
orderRecords
|
||||
)
|
||||
log.info('Operation history batch created', {
|
||||
batchId,
|
||||
recordCount: orderRecords.length
|
||||
})
|
||||
}
|
||||
|
||||
// Log deduplication summary
|
||||
sendLog(sender, 'info', dedupReport.summary)
|
||||
|
||||
@@ -223,11 +248,22 @@ export function registerExtractorHandlers(): void {
|
||||
})
|
||||
}
|
||||
|
||||
// Update operation history batch status
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failed' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failed'
|
||||
: 'success'
|
||||
await historyDao.updateBatchStatus(batchId, status, result.recordCount)
|
||||
log.info('Operation history batch status updated', { batchId, status })
|
||||
}
|
||||
|
||||
// Audit log: EXTRACT (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
const auditStatus: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
@@ -237,7 +273,7 @@ export function registerExtractorHandlers(): void {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
status: auditStatus,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
|
||||
@@ -16,20 +16,20 @@ 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 { registerOperationHistoryHandlers } from './operation-history-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
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> {
|
||||
@@ -70,7 +70,7 @@ export function withErrorHandling<T>(
|
||||
if (isBaseError(error)) {
|
||||
logError(log, `[${context}] ${error.name}`, error, {
|
||||
code,
|
||||
cause: (error as any).cause?.message,
|
||||
cause: getErrorCauseMessage(error),
|
||||
handler: context
|
||||
})
|
||||
} else {
|
||||
@@ -107,5 +107,7 @@ export function registerIpcHandlers(): void {
|
||||
registerLoggerHandlers()
|
||||
registerReportHandlers()
|
||||
registerUpdateHandlers()
|
||||
registerPlaywrightBrowserHandlers()
|
||||
registerOperationHistoryHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
124
src/main/ipc/operation-history-handler.ts
Normal file
124
src/main/ipc/operation-history-handler.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* IPC Handler for Extractor Operation History
|
||||
*
|
||||
* Handles IPC requests for operation history management:
|
||||
* - Get batch list (filtered by user for non-admin users)
|
||||
* - Get batch details
|
||||
* - Delete batches
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type {
|
||||
BatchStats,
|
||||
OperationHistoryRecord,
|
||||
GetBatchesOptions
|
||||
} from '../types/operation-history.types'
|
||||
|
||||
const log = createLogger('OperationHistoryHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for operation history
|
||||
*/
|
||||
export function registerOperationHistoryHandlers(): void {
|
||||
const dao = new ExtractorOperationHistoryDAO()
|
||||
|
||||
/**
|
||||
* Get batches list
|
||||
* Admin users get all batches, regular users get only their own
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
|
||||
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
// Admin gets all batches, User gets only their own
|
||||
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||
|
||||
log.info('Getting operation history batches', {
|
||||
userId: currentUser.id,
|
||||
userType: currentUser.userType,
|
||||
filtered: userId !== undefined
|
||||
})
|
||||
|
||||
const batches = await dao.getBatches(userId, options)
|
||||
return batches
|
||||
}, 'operationHistory:getBatches')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get batch details
|
||||
* Users can only view their own batch details, admins can view all
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
|
||||
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
log.info('Getting batch details', { batchId, userId: currentUser.id })
|
||||
|
||||
const details = await dao.getBatchDetails(batchId)
|
||||
|
||||
// For non-admin users, verify they own this batch
|
||||
if (currentUser.userType !== 'Admin' && details.length > 0) {
|
||||
const batchOwnerId = details[0].userId
|
||||
if (batchOwnerId !== currentUser.id) {
|
||||
throw new Error('没有权限查看此批次详情')
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}, 'operationHistory:getBatchDetails')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete a batch
|
||||
* Users can only delete their own batches, admins can delete any
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
|
||||
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
const isAdmin = currentUser.userType === 'Admin'
|
||||
|
||||
log.info('Deleting batch', {
|
||||
batchId,
|
||||
userId: currentUser.id,
|
||||
isAdmin
|
||||
})
|
||||
|
||||
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '删除批次失败')
|
||||
}
|
||||
|
||||
return { deleted: true }
|
||||
}, 'operationHistory:deleteBatch')
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Operation history IPC handlers registered')
|
||||
}
|
||||
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')
|
||||
})
|
||||
}
|
||||
@@ -27,72 +27,69 @@ function getRustfsService(): RustfsService | 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')
|
||||
}
|
||||
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()
|
||||
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
|
||||
})
|
||||
// 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/'
|
||||
}
|
||||
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 command = new ListObjectsV2Command(input)
|
||||
const response = await client.send(command)
|
||||
|
||||
const reports: ReportMetadata[] = []
|
||||
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
|
||||
})
|
||||
}
|
||||
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
|
||||
})
|
||||
// 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')
|
||||
}
|
||||
)
|
||||
return reports
|
||||
}, 'report:listAll')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_LIST_BY_USER,
|
||||
|
||||
@@ -10,38 +10,12 @@ import { ipcMain } from 'electron'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { createLogger } from '../services/logger'
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,13 @@ export function registerSettingsHandlers(): void {
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
||||
return withErrorHandling(
|
||||
async () => (sessionManager.getUserType() as UserType) || 'Guest',
|
||||
async () => {
|
||||
const userType = sessionManager.getUserType()
|
||||
if (!userType) {
|
||||
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
return userType as UserType
|
||||
},
|
||||
'settings:getUserType'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,478 +1,50 @@
|
||||
/**
|
||||
* 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 { ConfigManager } from '../services/config/config-manager'
|
||||
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 sharedProductionIdsBySender = new Map<number, Set<string>>()
|
||||
|
||||
/**
|
||||
* Set shared Production IDs
|
||||
*/
|
||||
export function setSharedProductionIds(senderId: number, ids: string[]): void {
|
||||
sharedProductionIdsBySender.set(senderId, new Set(ids))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
export function getSharedProductionIds(senderId: number): string[] {
|
||||
const senderSet = sharedProductionIdsBySender.get(senderId)
|
||||
return senderSet ? [...senderSet] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear shared Production IDs
|
||||
*/
|
||||
export function clearSharedProductionIds(senderId: number): void {
|
||||
sharedProductionIdsBySender.delete(senderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service for validation operations (MySQL or SQL Server)
|
||||
*/
|
||||
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerService({
|
||||
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
|
||||
} else {
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
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 configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
// 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 configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
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年压力表合同数据')
|
||||
const batchSize = 2000
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
||||
const params: Record<string, { value: string; type: any }> = {}
|
||||
|
||||
batch.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)
|
||||
allOrderNumbers.push(...dbOrderNumbers)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
} else {
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
|
||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||
allOrderNumbers.push(...dbOrderNumbers)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
return [...new Set(orderNumbers)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for validation operations
|
||||
*/
|
||||
export function registerValidationHandlers(): void {
|
||||
// ==================== VALIDATION ====================
|
||||
|
||||
/**
|
||||
* Run material validation from database
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_VALIDATE,
|
||||
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
try {
|
||||
// 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 })
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
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(event.sender.id)
|
||||
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`)
|
||||
|
||||
// Check if we got any order numbers from the shared Production IDs
|
||||
if (sourceNumbers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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`)
|
||||
|
||||
// Check if we got any order numbers from the file
|
||||
if (sourceNumbers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
|
||||
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
|
||||
@@ -486,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}`
|
||||
@@ -497,9 +67,6 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete materials by material codes
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_DELETE,
|
||||
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
|
||||
@@ -513,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}`
|
||||
@@ -522,28 +89,19 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get unique manager names
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
|
||||
async (_event): Promise<{ managers: string[] }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const managers = await dao.getManagers()
|
||||
return { managers }
|
||||
} catch (error) {
|
||||
log.error('Get managers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { managers: [] }
|
||||
}
|
||||
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_MANAGERS, async (): Promise<{ managers: string[] }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const managers = await dao.getManagers()
|
||||
return { managers }
|
||||
} catch (error) {
|
||||
log.error('Get managers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { managers: [] }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Update manager for a single material
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
|
||||
async (
|
||||
@@ -554,337 +112,108 @@ export function registerValidationHandlers(): void {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
return await dao.updateManager(request.materialCode, request.managerName)
|
||||
} catch (error) {
|
||||
log.error('Update manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error('Update manager error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: message
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get materials by manager
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
|
||||
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
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 { materials: enrichedMaterials }
|
||||
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(
|
||||
IPC_CHANNELS.MATERIALS_GET_ALL,
|
||||
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
async (): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
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(IPC_CHANNELS.MATERIALS_GET_STATISTICS, 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 }
|
||||
ipcMain.handle(
|
||||
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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Set shared Production IDs from extractor page
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
|
||||
async (event, productionIds: string[]): Promise<void> => {
|
||||
log.info(`Received ${productionIds.length} shared Production IDs`)
|
||||
setSharedProductionIds(event.sender.id, productionIds)
|
||||
sharedProductionIdsStore.set(event.sender.id, productionIds)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<{ productionIds: string[] }> => {
|
||||
return { productionIds: getSharedProductionIds(event.sender.id) }
|
||||
return { productionIds: sharedProductionIdsStore.get(event.sender.id) }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Clear shared Production IDs
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<void> => {
|
||||
log.info('Clearing shared Production IDs')
|
||||
clearSharedProductionIds(event.sender.id)
|
||||
sharedProductionIdsStore.clear(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(
|
||||
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 configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
|
||||
// 1. Get order numbers from shared Production IDs
|
||||
const sharedIds = getSharedProductionIds(_event.sender.id)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
|
||||
export const UserInfoSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
username: z.string().min(1),
|
||||
userType: z.enum(['Admin', 'User', 'Guest']),
|
||||
userType: z.enum(['Admin', 'User']),
|
||||
computerName: z.string().optional()
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
} from '../../types/config.schema'
|
||||
|
||||
const log = createLogger('ConfigManager')
|
||||
type DeepPartialRecord = Record<string, unknown>
|
||||
|
||||
function formatZodIssue(issue: { path: PropertyKey[]; message: string }): string {
|
||||
return `${issue.path.map((segment) => String(segment)).join('.')}: ${issue.message}`
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -128,7 +133,7 @@ export class ConfigManager {
|
||||
if (this.initialized) return
|
||||
|
||||
// 检测是否为开发环境
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
const isDev = process.env.NODE_ENV === 'development' || !(app?.isPackaged ?? false)
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:配置文件放在项目根目录,方便编辑和调试
|
||||
@@ -190,7 +195,7 @@ export class ConfigManager {
|
||||
log.info('Configuration loaded and validated successfully')
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
|
||||
const messages = error.issues.map(formatZodIssue)
|
||||
log.error('Configuration validation failed', { errors: messages })
|
||||
throw new Error(`配置文件验证失败:\n${messages.join('\n')}`)
|
||||
}
|
||||
@@ -300,7 +305,7 @@ export class ConfigManager {
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
|
||||
const messages = error.issues.map(formatZodIssue)
|
||||
return { success: false, error: `配置验证失败:\n${messages.join('\n')}` }
|
||||
}
|
||||
return { success: false, error: error instanceof Error ? error.message : '未知错误' }
|
||||
@@ -310,18 +315,27 @@ export class ConfigManager {
|
||||
/**
|
||||
* 深合并工具函数
|
||||
*/
|
||||
private deepMerge<T extends Record<string, any>>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
private deepMerge<T extends DeepPartialRecord>(source: T, target: Partial<T>): T {
|
||||
const result: T = { ...source }
|
||||
for (const key in target) {
|
||||
if (target[key] !== undefined) {
|
||||
const sourceValue = result[key]
|
||||
const targetValue = target[key]
|
||||
|
||||
if (targetValue !== undefined) {
|
||||
if (
|
||||
typeof target[key] === 'object' &&
|
||||
target[key] !== null &&
|
||||
!Array.isArray(target[key])
|
||||
typeof sourceValue === 'object' &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
typeof targetValue === 'object' &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = this.deepMerge(result[key] as any, target[key] as any)
|
||||
result[key] = this.deepMerge(
|
||||
sourceValue as DeepPartialRecord,
|
||||
targetValue as Partial<DeepPartialRecord>
|
||||
) as T[Extract<keyof T, string>]
|
||||
} else {
|
||||
result[key] = target[key] as any
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
607
src/main/services/database/extractor-operation-history-dao.ts
Normal file
607
src/main/services/database/extractor-operation-history-dao.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Data Access Object for ExtractorOperationHistory table
|
||||
*
|
||||
* Handles database operations for tracking extraction operation history:
|
||||
* - Batch record insertion
|
||||
* - Batch status updates
|
||||
* - Querying batches (with user filtering for non-admin users)
|
||||
* - Getting batch details
|
||||
* - Deleting batches
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger } from '../logger'
|
||||
import type {
|
||||
OperationHistoryRecord,
|
||||
BatchStats,
|
||||
InsertBatchRecordInput,
|
||||
UpdateBatchStatusResult,
|
||||
GetBatchesOptions
|
||||
} from '../../types/operation-history.types'
|
||||
|
||||
const log = createLogger('ExtractorOperationHistoryDAO')
|
||||
|
||||
/**
|
||||
* Configuration for ExtractorOperationHistory table
|
||||
*/
|
||||
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
|
||||
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
BATCH_ID: 'BatchId',
|
||||
USER_ID: 'UserId',
|
||||
USERNAME: 'Username',
|
||||
PRODUCTION_ID: 'ProductionId',
|
||||
ORDER_NUMBER: 'OrderNumber',
|
||||
OPERATION_TIME: 'OperationTime',
|
||||
STATUS: 'Status',
|
||||
RECORD_COUNT: 'RecordCount',
|
||||
ERROR_MESSAGE: 'ErrorMessage'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* ExtractorOperationHistory DAO Class
|
||||
*/
|
||||
export class ExtractorOperationHistoryDAO {
|
||||
private dbService: IDatabaseService | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== INSERT ====================
|
||||
|
||||
/**
|
||||
* Insert batch records for a single extraction operation
|
||||
* @param batchId - Unique batch identifier
|
||||
* @param userId - User ID performing the operation
|
||||
* @param username - Username performing the operation
|
||||
* @param records - Array of order records to insert
|
||||
* @returns True if successful
|
||||
*/
|
||||
async insertBatchRecords(
|
||||
batchId: string,
|
||||
userId: number,
|
||||
username: string,
|
||||
records: InsertBatchRecordInput[]
|
||||
): Promise<boolean> {
|
||||
if (!records || records.length === 0) {
|
||||
log.warn('No records to insert')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
|
||||
`
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
])
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, NOW(), 'pending')
|
||||
`
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error inserting individual record', {
|
||||
batchId,
|
||||
orderNumber: record.orderNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Batch records inserted', { batchId, count: records.length })
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Insert batch records error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UPDATE ====================
|
||||
|
||||
/**
|
||||
* Update the status of all records in a batch
|
||||
* @param batchId - Batch identifier
|
||||
* @param status - New status (success, failed, partial)
|
||||
* @param recordCount - Total record count for the batch
|
||||
* @returns Update result
|
||||
*/
|
||||
async updateBatchStatus(
|
||||
batchId: string,
|
||||
status: string,
|
||||
recordCount: number | null
|
||||
): Promise<UpdateBatchStatusResult> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
let sqlString: string
|
||||
let params: (string | number | null)[]
|
||||
|
||||
if (recordCount !== null) {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
RecordCount = ${isSqlServer ? '@p1' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||
`
|
||||
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
|
||||
} else {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${placeholder}
|
||||
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
||||
`
|
||||
params = isSqlServer ? [status, batchId] : [status, batchId]
|
||||
}
|
||||
|
||||
await dbService.query(sqlString, params)
|
||||
|
||||
log.info('Batch status updated', { batchId, status, recordCount })
|
||||
return { success: true, updatedCount: 1 }
|
||||
} catch (error) {
|
||||
log.error('Update batch status error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { success: false, updatedCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single record's status and error message
|
||||
* @param batchId - Batch identifier
|
||||
* @param orderNumber - Order number
|
||||
* @param status - New status
|
||||
* @param errorMessage - Optional error message
|
||||
* @returns True if successful
|
||||
*/
|
||||
async updateRecordStatus(
|
||||
batchId: string,
|
||||
orderNumber: string,
|
||||
status: string,
|
||||
errorMessage?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber])
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Update record status error', {
|
||||
batchId,
|
||||
orderNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== READ ====================
|
||||
|
||||
/**
|
||||
* Get batch statistics with optional user filtering
|
||||
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
|
||||
* @param options - Query options (limit, offset)
|
||||
* @returns Array of batch statistics
|
||||
*/
|
||||
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
let sqlString = `
|
||||
SELECT
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
MIN(OperationTime) as OperationTime,
|
||||
MAX(Status) as Status,
|
||||
COUNT(*) as TotalOrders,
|
||||
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||
FROM ${tableName}
|
||||
`
|
||||
|
||||
const params: (number | string)[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
sqlString += `
|
||||
GROUP BY BatchId, UserId, Username
|
||||
ORDER BY OperationTime DESC
|
||||
`
|
||||
|
||||
if (options?.limit) {
|
||||
// Add pagination - track current param count before adding new params
|
||||
const offsetIndex = params.length
|
||||
const limitIndex = params.length + 1
|
||||
|
||||
if (options.offset !== undefined) {
|
||||
params.push(options.offset)
|
||||
}
|
||||
params.push(options.limit)
|
||||
|
||||
if (isSqlServer) {
|
||||
if (options.offset !== undefined) {
|
||||
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY`
|
||||
} else {
|
||||
// When no offset, use 0 for offset and next index for limit
|
||||
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
||||
}
|
||||
} else {
|
||||
if (options.offset !== undefined) {
|
||||
sqlString += ` LIMIT ?`
|
||||
// For MySQL with offset, we need to modify the query
|
||||
// Replace LIMIT with OFFSET LIMIT
|
||||
const parts = sqlString.split(' LIMIT ?')
|
||||
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
|
||||
} else {
|
||||
sqlString += ` LIMIT ?`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dbService.query(sqlString, params)
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
operationTime: row.OperationTime
|
||||
? new Date(row.OperationTime as string).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: row.Status as string,
|
||||
totalOrders: row.TotalOrders as number,
|
||||
totalRecords: (row.TotalRecords as number) || 0,
|
||||
successCount: (row.SuccessCount as number) || 0,
|
||||
failedCount: (row.FailedCount as number) || 0
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get batches error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed records for a specific batch
|
||||
* @param batchId - Batch identifier
|
||||
* @returns Array of operation records
|
||||
*/
|
||||
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT
|
||||
ID,
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
ProductionId,
|
||||
OrderNumber,
|
||||
OperationTime,
|
||||
Status,
|
||||
RecordCount,
|
||||
ErrorMessage
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
ORDER BY ID
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
productionId: row.ProductionId as string | null,
|
||||
orderNumber: row.OrderNumber as string,
|
||||
operationTime: new Date(row.OperationTime as string),
|
||||
status: row.Status as string,
|
||||
recordCount: row.RecordCount as number | null,
|
||||
errorMessage: row.ErrorMessage as string | null
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get batch details error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single batch's statistics
|
||||
* @param batchId - Batch identifier
|
||||
* @returns Batch statistics or null
|
||||
*/
|
||||
async getBatchStats(batchId: string): Promise<BatchStats | null> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
MIN(OperationTime) as OperationTime,
|
||||
MAX(Status) as Status,
|
||||
COUNT(*) as TotalOrders,
|
||||
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
GROUP BY BatchId, UserId, Username
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
operationTime: row.OperationTime
|
||||
? new Date(row.OperationTime as string).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: row.Status as string,
|
||||
totalOrders: row.TotalOrders as number,
|
||||
totalRecords: (row.TotalRecords as number) || 0,
|
||||
successCount: (row.SuccessCount as number) || 0,
|
||||
failedCount: (row.FailedCount as number) || 0
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get batch stats error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DELETE ====================
|
||||
|
||||
/**
|
||||
* Delete a batch with permission checking
|
||||
* @param batchId - Batch identifier
|
||||
* @param requestingUserId - User ID requesting the deletion
|
||||
* @param isAdmin - Whether the requesting user is an admin
|
||||
* @returns True if successful
|
||||
*/
|
||||
async deleteBatch(
|
||||
batchId: string,
|
||||
requestingUserId: number,
|
||||
isAdmin: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
// First check if the batch exists and if the user has permission
|
||||
const batchStats = await this.getBatchStats(batchId)
|
||||
|
||||
if (!batchStats) {
|
||||
return { success: false, error: '批次不存在' }
|
||||
}
|
||||
|
||||
// Non-admin users can only delete their own batches
|
||||
if (!isAdmin && batchStats.userId !== requestingUserId) {
|
||||
return { success: false, error: '没有权限删除此批次' }
|
||||
}
|
||||
|
||||
// Delete the batch
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
log.error('Delete batch error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all batches for a specific user
|
||||
* @param userId - User ID
|
||||
* @returns Number of batches deleted
|
||||
*/
|
||||
async deleteByUser(userId: number): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [userId])
|
||||
return result.rowCount
|
||||
} catch (error) {
|
||||
log.error('Delete by user error', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UTILITIES ====================
|
||||
|
||||
/**
|
||||
* Check if a batch exists
|
||||
* @param batchId - Batch identifier
|
||||
* @returns True if batch exists
|
||||
*/
|
||||
async batchExists(batchId: string): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
} catch (error) {
|
||||
log.error('Batch exists error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total batches with optional user filtering
|
||||
* @param userId - Optional user ID for filtering
|
||||
* @returns Total number of batches
|
||||
*/
|
||||
async countBatches(userId?: number): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
let sqlString = `
|
||||
SELECT COUNT(DISTINCT BatchId) as count
|
||||
FROM ${tableName}
|
||||
`
|
||||
|
||||
const params: number[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
const result = await dbService.query(sqlString, params)
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count batches error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, In } from 'typeorm'
|
||||
import { DiscreteMaterialPlan, MaterialPlanRecordData } from '../entities/DiscreteMaterialPlan'
|
||||
import { DiscreteMaterialPlan } from '../entities/DiscreteMaterialPlan'
|
||||
import { getDataSource } from '../data-source'
|
||||
import { createLogger } from '../../logger'
|
||||
|
||||
|
||||
@@ -124,12 +124,6 @@ export class MaterialsToBeDeletedRepository {
|
||||
async getAllMaterialCodes(): Promise<Set<string>> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const records = await repo.find({
|
||||
select: ['materialCode'],
|
||||
where: { materialCode: In([]) } // This will be overridden
|
||||
})
|
||||
|
||||
// Use query builder for better performance
|
||||
const result = await repo
|
||||
.createQueryBuilder('m')
|
||||
.select('m.materialCode')
|
||||
|
||||
@@ -108,7 +108,6 @@ export class ErpAuthService {
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mainFrame: mainFrame as any, // Store forwardFrame content frame for subsequent operations
|
||||
isLoggedIn: true
|
||||
}
|
||||
|
||||
@@ -13,42 +13,14 @@
|
||||
import type { IDatabaseService } from '../database'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { createLogger } from '../logger'
|
||||
import type {
|
||||
OrderMapping,
|
||||
OrderNumberType,
|
||||
ResolutionStats
|
||||
} from '../../types/order-resolver.types'
|
||||
|
||||
const log = createLogger('OrderResolver')
|
||||
|
||||
/**
|
||||
* Order mapping result
|
||||
*/
|
||||
export interface OrderMapping {
|
||||
/** Original input from user */
|
||||
input: string
|
||||
/** Recognized productionID (if input matches productionID pattern) */
|
||||
productionId?: string
|
||||
/** Final production order number to use */
|
||||
orderNumber?: string
|
||||
/** Whether the order number was successfully resolved */
|
||||
resolved: boolean
|
||||
/** Error message if resolution failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Order number type recognition result
|
||||
*/
|
||||
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
|
||||
|
||||
/**
|
||||
* Resolution statistics
|
||||
*/
|
||||
export interface ResolutionStats {
|
||||
totalInputs: number
|
||||
validOrderNumbers: number
|
||||
validProductionIds: number
|
||||
resolvedCount: number
|
||||
failedCount: number
|
||||
unknownFormat: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
|
||||
* Examples: 22A1, 22A123, 26B10617
|
||||
|
||||
@@ -70,7 +70,7 @@ export class ExcelParser {
|
||||
const allRows: any[][] = []
|
||||
|
||||
// Read all rows into memory
|
||||
worksheet.eachRow((row, _rowNumber) => {
|
||||
worksheet.eachRow((row) => {
|
||||
allRows.push(row.values as any[])
|
||||
})
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export function setLogLevel(level: string): void {
|
||||
}
|
||||
|
||||
// Add error-specific file transport in production
|
||||
if (app.isPackaged) {
|
||||
if (app?.isPackaged) {
|
||||
logger.add(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(getLogDir(), 'error-%DATE%.log'),
|
||||
|
||||
407
src/main/services/playwright-browser/download-service.ts
Normal file
407
src/main/services/playwright-browser/download-service.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Playwright Browser Download Service
|
||||
*
|
||||
* Downloads Playwright browser files from S3 to local directory
|
||||
* with progress tracking and basic validation.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('PlaywrightDownloadService')
|
||||
|
||||
/**
|
||||
* Download progress event
|
||||
*/
|
||||
export interface DownloadProgress {
|
||||
percent: number
|
||||
downloadedBytes: number
|
||||
totalBytes: number
|
||||
currentFile: string
|
||||
speed: number
|
||||
eta?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 Object information
|
||||
*/
|
||||
export interface S3Object {
|
||||
key: string
|
||||
size?: number
|
||||
lastModified?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
success: boolean
|
||||
message: string
|
||||
fileCount?: number
|
||||
chromeExeExists?: boolean
|
||||
expectedChromePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Download configuration
|
||||
*/
|
||||
export interface DownloadConfig {
|
||||
s3Client?: S3Client
|
||||
bucket?: string
|
||||
prefix?: string
|
||||
destDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<DownloadConfig> = {
|
||||
s3Client: null as unknown as S3Client,
|
||||
bucket: 'erpauto',
|
||||
prefix: 'resources/ms-playwright/', // ✅ Fixed: removed 'erpauto/' prefix
|
||||
destDir: path.join(process.env.APPDATA || '', 'erpauto', 'ms-playwright')
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadService class
|
||||
* Handles downloading Playwright browser files from S3
|
||||
*/
|
||||
export class DownloadService {
|
||||
private config: Required<DownloadConfig>
|
||||
private s3Client: S3Client
|
||||
|
||||
constructor(config?: DownloadConfig) {
|
||||
if (!config?.s3Client) {
|
||||
throw new Error('S3Client is required')
|
||||
}
|
||||
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
bucket: config?.bucket || DEFAULT_CONFIG.bucket,
|
||||
prefix: config?.prefix || DEFAULT_CONFIG.prefix,
|
||||
destDir: config?.destDir || DEFAULT_CONFIG.destDir
|
||||
}
|
||||
|
||||
this.s3Client = this.config.s3Client
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with retry logic using exponential backoff
|
||||
* @param operation - The async operation to execute
|
||||
* @param context - Description of the operation for logging
|
||||
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
||||
* @returns The result of the operation
|
||||
*/
|
||||
private async withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
context: string,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
// Check if error is retryable
|
||||
const isRetryable = this.isRetryableError(lastError)
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
break
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delay = Math.pow(2, attempt - 1) * 1000
|
||||
log.warn(`${context} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`, {
|
||||
error: lastError.message
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is retryable based on error type and message
|
||||
* @param error - The error to classify
|
||||
* @returns true if the error should trigger a retry
|
||||
*/
|
||||
private isRetryableError(error: Error): boolean {
|
||||
const message = error.message.toLowerCase()
|
||||
const code = (error as any).code?.toLowerCase() || ''
|
||||
|
||||
// Retryable: network errors, timeouts
|
||||
const retryablePatterns = [
|
||||
'etimedout',
|
||||
'econnreset',
|
||||
'timeout',
|
||||
'network',
|
||||
'socket hang up',
|
||||
'connection reset'
|
||||
]
|
||||
|
||||
// Not retryable: S3 errors, validation errors
|
||||
const nonRetryablePatterns = [
|
||||
'404',
|
||||
'403',
|
||||
'not found',
|
||||
'access denied',
|
||||
'invalid',
|
||||
'validation'
|
||||
]
|
||||
|
||||
// Check non-retryable first
|
||||
for (const pattern of nonRetryablePatterns) {
|
||||
if (message.includes(pattern) || code.includes(pattern)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check retryable
|
||||
for (const pattern of retryablePatterns) {
|
||||
if (message.includes(pattern) || code.includes(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Default: don't retry unknown errors
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* List all S3 objects under the configured prefix
|
||||
* Filters to include ALL Chromium files (regular + headless)
|
||||
* Simple filter: includes 'chromium' keyword
|
||||
*/
|
||||
async listObjects(): Promise<S3Object[]> {
|
||||
log.info('Listing S3 objects', { bucket: this.config.bucket, prefix: this.config.prefix })
|
||||
|
||||
const objects: S3Object[] = []
|
||||
let continuationToken: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.config.bucket,
|
||||
Prefix: this.config.prefix,
|
||||
ContinuationToken: continuationToken
|
||||
})
|
||||
)
|
||||
|
||||
for (const obj of response.Contents || []) {
|
||||
if (!obj.Key) continue
|
||||
|
||||
// Simple filter: only download Chromium-related files
|
||||
// This includes: chromium-1200, chromium-1208, chromium_headless_shell-1200, chromium_headless_shell-1208
|
||||
// Excludes: firefox, webkit, ffmpeg, winldd, .links, .settings
|
||||
if (!obj.Key.includes('chromium')) {
|
||||
continue
|
||||
}
|
||||
|
||||
objects.push({
|
||||
key: obj.Key,
|
||||
size: obj.Size,
|
||||
lastModified: obj.LastModified
|
||||
})
|
||||
}
|
||||
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
|
||||
log.info(`Found ${objects.length} Chromium objects`)
|
||||
return objects
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file from S3 with retry logic
|
||||
*/
|
||||
async downloadFile(key: string, destPath: string): Promise<void> {
|
||||
await this.withRetry(async () => {
|
||||
log.debug('Downloading file', { key, destPath })
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
|
||||
|
||||
const response = await this.s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const output = fs.createWriteStream(destPath)
|
||||
const body = response.Body as NodeJS.ReadableStream
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
body.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
body.pipe(output)
|
||||
})
|
||||
|
||||
log.debug('File downloaded', { key, destPath })
|
||||
}, `Download ${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all Chromium browser files from S3
|
||||
* Emits progress events during download
|
||||
*/
|
||||
async downloadAll(onProgress: (progress: DownloadProgress) => void): Promise<void> {
|
||||
log.info('Starting download of all browser files', { destDir: this.config.destDir })
|
||||
|
||||
const objects = await this.listObjects()
|
||||
if (objects.length === 0) {
|
||||
throw new Error('No Chromium browser files found in S3')
|
||||
}
|
||||
|
||||
// Calculate total bytes
|
||||
const totalBytes = objects.reduce((sum, obj) => sum + (obj.size || 0), 0)
|
||||
log.info(`Total files: ${objects.length}, Total bytes: ${totalBytes}`)
|
||||
|
||||
let downloadedBytes = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
for (const obj of objects) {
|
||||
const relativePath = obj.key.replace(this.config.prefix, '')
|
||||
const destPath = path.join(this.config.destDir, relativePath)
|
||||
|
||||
// Emit progress for current file
|
||||
onProgress({
|
||||
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
currentFile: relativePath,
|
||||
speed: 0,
|
||||
eta: undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await this.downloadFile(obj.key, destPath)
|
||||
|
||||
const fileSize = obj.size || 0
|
||||
downloadedBytes += fileSize
|
||||
|
||||
// Calculate speed and ETA
|
||||
const elapsedSeconds = (Date.now() - startTime) / 1000
|
||||
const speed = Math.round(downloadedBytes / elapsedSeconds)
|
||||
const remainingBytes = totalBytes - downloadedBytes
|
||||
const eta = speed > 0 ? Math.round(remainingBytes / speed) : undefined
|
||||
|
||||
onProgress({
|
||||
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
currentFile: relativePath,
|
||||
speed,
|
||||
eta
|
||||
})
|
||||
|
||||
log.debug('Downloaded file', {
|
||||
key: obj.key,
|
||||
size: fileSize,
|
||||
speed: `${speed} bytes/s`
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Failed to download file', {
|
||||
key: obj.key,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw new Error(
|
||||
`Failed to download ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const totalElapsed = (Date.now() - startTime) / 1000
|
||||
log.info('Download completed', {
|
||||
totalFiles: objects.length,
|
||||
totalBytes,
|
||||
duration: `${totalElapsed.toFixed(1)}s`,
|
||||
avgSpeed: `${Math.round(downloadedBytes / totalElapsed)} bytes/s`
|
||||
})
|
||||
|
||||
// Final progress event
|
||||
onProgress({
|
||||
percent: 100,
|
||||
downloadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
currentFile: 'Complete',
|
||||
speed: Math.round(downloadedBytes / totalElapsed),
|
||||
eta: 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the downloaded files
|
||||
* Checks if the destination directory exists and chrome.exe is present
|
||||
*/
|
||||
async validateDownload(): Promise<ValidationResult> {
|
||||
log.info('Validating download', { destDir: this.config.destDir })
|
||||
|
||||
// Check if destination directory exists
|
||||
try {
|
||||
await fs.promises.access(this.config.destDir)
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Download directory does not exist',
|
||||
fileCount: 0,
|
||||
chromeExeExists: false
|
||||
}
|
||||
}
|
||||
|
||||
// Find chrome.exe in chromium-* folders
|
||||
const chromeExePattern = /chromium-\d+[/\\]chrome-win64[/\\]chrome\.exe$/
|
||||
let chromeExeExists = false
|
||||
let expectedChromePath: string | undefined
|
||||
let fileCount = 0
|
||||
|
||||
const findChromeExe = async (dir: string): Promise<void> => {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await findChromeExe(fullPath)
|
||||
} else if (entry.name.toLowerCase() === 'chrome.exe') {
|
||||
// Check if path matches chromium-*/chrome-win64/chrome.exe pattern
|
||||
const relativePath = path.relative(this.config.destDir, fullPath)
|
||||
if (chromeExePattern.test(relativePath.replace(/\\/g, '/'))) {
|
||||
chromeExeExists = true
|
||||
expectedChromePath = fullPath
|
||||
}
|
||||
}
|
||||
|
||||
fileCount++
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await findChromeExe(this.config.destDir)
|
||||
} catch (error) {
|
||||
log.error('Error scanning download directory', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
const result: ValidationResult = {
|
||||
success: chromeExeExists,
|
||||
message: chromeExeExists ? 'Validation passed' : 'chrome.exe not found in expected location',
|
||||
fileCount,
|
||||
chromeExeExists,
|
||||
expectedChromePath
|
||||
}
|
||||
|
||||
log.info('Validation result', result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export default DownloadService
|
||||
11
src/main/services/playwright-browser/index.ts
Normal file
11
src/main/services/playwright-browser/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Playwright Browser Module Exports
|
||||
*/
|
||||
|
||||
export { DownloadService, default } from './download-service'
|
||||
export type {
|
||||
DownloadProgress,
|
||||
S3Object,
|
||||
ValidationResult,
|
||||
DownloadConfig
|
||||
} from './download-service'
|
||||
166
src/main/services/update/update-catalog-service.ts
Normal file
166
src/main/services/update/update-catalog-service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createLogger } from '../logger'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
import type { UserType } from '../../types/user.types'
|
||||
import type {
|
||||
UpdateCatalog,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease,
|
||||
UpdateStatus
|
||||
} from '../../types/update.types'
|
||||
import type { UpdateInstaller } from './update-installer'
|
||||
import type { UpdateStorageClient } from './update-storage-client'
|
||||
import { isMissingObjectError } from './update-support'
|
||||
import {
|
||||
compareVersions,
|
||||
limitCatalogHistory,
|
||||
normalizeReleases,
|
||||
resolveAdminDecision,
|
||||
resolveUserDecision
|
||||
} from './update-utils'
|
||||
|
||||
const log = createLogger('UpdateCatalogService')
|
||||
|
||||
export class UpdateCatalogService {
|
||||
constructor(
|
||||
private readonly config: UpdateConfig,
|
||||
private readonly storageClient: UpdateStorageClient,
|
||||
private readonly installer: UpdateInstaller
|
||||
) {}
|
||||
|
||||
public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog {
|
||||
const currentUserType = status.currentUserType
|
||||
if (!status.enabled || !currentUserType) {
|
||||
return { mode: 'disabled' }
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
return {
|
||||
mode: 'user',
|
||||
recommendedRelease: status.recommendedRelease
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'admin',
|
||||
recommendedRelease: status.recommendedRelease,
|
||||
channels: limitCatalogHistory(catalog, this.config.maxAdminHistoryPerChannel ?? 10)
|
||||
}
|
||||
}
|
||||
|
||||
public async loadCatalog(currentUserType: UserType | null): Promise<UpdateCatalog> {
|
||||
return {
|
||||
stable: await this.fetchChannelIndex('stable'),
|
||||
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||
}
|
||||
}
|
||||
|
||||
public async resolveUserStatus(
|
||||
status: UpdateStatus,
|
||||
catalog: UpdateCatalog
|
||||
): Promise<Partial<UpdateStatus>> {
|
||||
const decision = resolveUserDecision({
|
||||
currentVersion: status.currentVersion,
|
||||
currentChannel: status.currentChannel,
|
||||
catalog
|
||||
})
|
||||
|
||||
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
||||
return {
|
||||
phase: 'idle',
|
||||
recommendedRelease: undefined,
|
||||
latestVersion: undefined,
|
||||
latestChannel: undefined,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const recommended = decision.recommendedRelease
|
||||
const existing = await this.installer.getValidDownloadedRelease(recommended)
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
phase: 'downloaded',
|
||||
recommendedRelease: recommended,
|
||||
latestVersion: recommended.version,
|
||||
latestChannel: recommended.channel,
|
||||
progress: 100,
|
||||
downloadedRelease: {
|
||||
version: existing.version,
|
||||
channel: existing.channel,
|
||||
localPath: existing.localPath
|
||||
},
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message:
|
||||
status.currentChannel === 'preview'
|
||||
? `稳定版 ${recommended.version} 已准备安装`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'available',
|
||||
recommendedRelease: recommended,
|
||||
latestVersion: recommended.version,
|
||||
latestChannel: recommended.channel,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message:
|
||||
status.currentChannel === 'preview'
|
||||
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
}
|
||||
}
|
||||
|
||||
public resolveAdminStatus(status: UpdateStatus, catalog: UpdateCatalog): Partial<UpdateStatus> {
|
||||
const decision = resolveAdminDecision({
|
||||
currentVersion: status.currentVersion,
|
||||
currentChannel: status.currentChannel,
|
||||
catalog,
|
||||
maxHistoryPerChannel: this.config.maxAdminHistoryPerChannel ?? 10
|
||||
})
|
||||
|
||||
const hasHigherVersion = [...catalog.stable, ...catalog.preview].some(
|
||||
(release) => compareVersions(release.version, status.currentVersion) > 0
|
||||
)
|
||||
const hasCrossChannelOption = [...catalog.stable, ...catalog.preview].some(
|
||||
(release) => release.channel !== status.currentChannel
|
||||
)
|
||||
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
||||
|
||||
return {
|
||||
phase: shouldOffer ? 'available' : 'idle',
|
||||
recommendedRelease: decision.recommendedRelease,
|
||||
latestVersion: decision.recommendedRelease?.version,
|
||||
latestChannel: decision.recommendedRelease?.channel,
|
||||
adminHasAnyRelease: shouldOffer,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
message: shouldOffer ? '发现可用版本' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchChannelIndex(channel: 'stable' | 'preview'): Promise<UpdateRelease[]> {
|
||||
const key = `${this.config.basePrefix}/${channel}/index.json`
|
||||
|
||||
try {
|
||||
const raw = await this.storageClient.readText(key)
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return normalizeReleases(parsed, channel)
|
||||
} catch (error) {
|
||||
if (isMissingObjectError(error)) {
|
||||
log.warn('Update channel index not found, treating as empty', { channel, key })
|
||||
return []
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/main/services/update/update-installer.ts
Normal file
146
src/main/services/update/update-installer.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import { createLogger } from '../logger'
|
||||
import type { DownloadedRelease, UpdateRelease } from '../../types/update.types'
|
||||
import { appendPortableLaunchLog } from './update-support'
|
||||
|
||||
const log = createLogger('UpdateInstaller')
|
||||
|
||||
export class UpdateInstaller {
|
||||
public async getValidDownloadedRelease(
|
||||
release: UpdateRelease
|
||||
): Promise<DownloadedRelease | null> {
|
||||
const downloadPath = this.getDownloadPath(release)
|
||||
if (!fs.existsSync(downloadPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hash = await this.calculateSha256(downloadPath)
|
||||
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...release,
|
||||
localPath: downloadPath
|
||||
}
|
||||
}
|
||||
|
||||
public getDownloadPath(release: Pick<UpdateRelease, 'channel' | 'version'>): string {
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'pending-update',
|
||||
`${release.channel}-${release.version}.exe`
|
||||
)
|
||||
}
|
||||
|
||||
public async calculateSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
const input = fs.createReadStream(filePath)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
input.on('data', (chunk) => hash.update(chunk))
|
||||
input.on('error', reject)
|
||||
input.on('end', resolve)
|
||||
})
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
public async installDownloadedRelease(
|
||||
downloaded: Pick<DownloadedRelease, 'version' | 'channel' | 'localPath'>
|
||||
): Promise<void> {
|
||||
const updaterSourcePath = this.resolveUpdaterBinaryPath()
|
||||
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
|
||||
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
|
||||
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
|
||||
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
|
||||
const appArgs = process.argv.slice(1)
|
||||
const argsBase64 =
|
||||
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
|
||||
|
||||
const spawnArgs = [
|
||||
'--targetExe',
|
||||
targetExe,
|
||||
'--downloadedExe',
|
||||
downloaded.localPath,
|
||||
'--parentPid',
|
||||
String(process.pid),
|
||||
'--logPath',
|
||||
logPath
|
||||
]
|
||||
|
||||
if (argsBase64) {
|
||||
spawnArgs.push('--argsBase64', argsBase64)
|
||||
}
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
|
||||
updaterSourcePath,
|
||||
updaterPath,
|
||||
targetExe,
|
||||
downloadedExe: downloaded.localPath,
|
||||
parentPid: process.pid,
|
||||
logPath,
|
||||
appArgs,
|
||||
updaterExists: fs.existsSync(updaterPath),
|
||||
spawnArgs
|
||||
})
|
||||
|
||||
const child = spawn(updaterPath, spawnArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
log.error('Failed to launch portable updater executable', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
|
||||
child.once('spawn', () => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
})
|
||||
|
||||
child.unref()
|
||||
app.quit()
|
||||
}
|
||||
|
||||
private resolveUpdaterBinaryPath(): string {
|
||||
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
|
||||
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
|
||||
|
||||
if (fs.existsSync(packagedPath)) {
|
||||
return packagedPath
|
||||
}
|
||||
|
||||
if (fs.existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
throw new Error('未找到便携版更新器')
|
||||
}
|
||||
|
||||
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
|
||||
const updatesDir = path.join(app.getPath('userData'), 'updates')
|
||||
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
|
||||
|
||||
await fs.promises.mkdir(updatesDir, { recursive: true })
|
||||
await fs.promises.copyFile(sourcePath, stagedPath)
|
||||
|
||||
return stagedPath
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,29 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { createLogger } from '../logger'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
import type { UserType } from '../../types/user.types'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
DownloadedRelease,
|
||||
ReleaseChannel,
|
||||
UpdateCatalog,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease,
|
||||
UpdateStatus
|
||||
} from '../../types/update.types'
|
||||
import {
|
||||
compareVersions,
|
||||
limitCatalogHistory,
|
||||
normalizeReleases,
|
||||
resolveAdminDecision,
|
||||
resolveUserDecision
|
||||
} from './update-utils'
|
||||
import { UpdateCatalogService } from './update-catalog-service'
|
||||
import { UpdateInstaller } from './update-installer'
|
||||
import { UpdateStorageClient } from './update-storage-client'
|
||||
import { publishUpdateStatus } from './update-status-publisher'
|
||||
import { DEFAULT_STATUS, getSupportState } from './update-support'
|
||||
|
||||
const log = createLogger('UpdateService')
|
||||
|
||||
function appendPortableLaunchLog(logPath: string, message: string, meta?: Record<string, unknown>): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||
const timestamp = new Date().toISOString()
|
||||
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
|
||||
} catch {
|
||||
// Best-effort debug log only.
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
return typeof app.getVersion === 'function' ? app.getVersion() : '0.0.0'
|
||||
}
|
||||
|
||||
function getCurrentChannel(): ReleaseChannel {
|
||||
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
|
||||
}
|
||||
|
||||
function isMissingObjectError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = error as {
|
||||
name?: string
|
||||
Code?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.name === 'NoSuchKey' ||
|
||||
candidate.Code === 'NoSuchKey' ||
|
||||
candidate.code === 'NoSuchKey' ||
|
||||
candidate.message?.includes('The specified key does not exist') === true
|
||||
)
|
||||
}
|
||||
|
||||
function getSupportState(config: UpdateConfig | null): {
|
||||
supported: boolean
|
||||
reason?: string
|
||||
} {
|
||||
if (process.platform !== 'win32') {
|
||||
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
return { supported: true }
|
||||
}
|
||||
|
||||
if (config?.allowDevMode) {
|
||||
return { supported: true, reason: '开发模式调试已启用更新检查' }
|
||||
}
|
||||
|
||||
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: UpdateStatus = {
|
||||
enabled: false,
|
||||
supported: false,
|
||||
phase: 'idle',
|
||||
currentVersion: getCurrentAppVersion(),
|
||||
currentChannel: getCurrentChannel(),
|
||||
currentUserType: null
|
||||
}
|
||||
|
||||
class UpdateStorageClient {
|
||||
private client: S3Client
|
||||
private bucket: string
|
||||
|
||||
constructor(config: UpdateConfig) {
|
||||
this.bucket = config.bucket
|
||||
this.client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
}
|
||||
|
||||
async readText(key: string): Promise<string> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
}
|
||||
|
||||
async downloadToFile(key: string, destination: string): Promise<void> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
|
||||
const output = fs.createWriteStream(destination)
|
||||
const body = response.Body as NodeJS.ReadableStream
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
body.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
body.pipe(output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private static instance: UpdateService | null = null
|
||||
|
||||
private config: UpdateConfig | null = null
|
||||
private storageClient: UpdateStorageClient | null = null
|
||||
private catalogService: UpdateCatalogService | null = null
|
||||
private installer = new UpdateInstaller()
|
||||
private status: UpdateStatus = { ...DEFAULT_STATUS }
|
||||
private catalog: UpdateCatalog = { stable: [], preview: [] }
|
||||
private changelogCache = new Map<string, string>()
|
||||
@@ -185,6 +56,11 @@ export class UpdateService {
|
||||
|
||||
if (enabled && this.config) {
|
||||
this.storageClient = new UpdateStorageClient(this.config)
|
||||
this.catalogService = new UpdateCatalogService(
|
||||
this.config,
|
||||
this.storageClient,
|
||||
this.installer
|
||||
)
|
||||
}
|
||||
|
||||
log.info('Update service initialized', {
|
||||
@@ -202,26 +78,11 @@ export class UpdateService {
|
||||
}
|
||||
|
||||
public getCatalog(): UpdateDialogCatalog {
|
||||
const currentUserType = this.status.currentUserType
|
||||
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
|
||||
if (!this.catalogService) {
|
||||
return { mode: 'disabled' }
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
return {
|
||||
mode: 'user',
|
||||
recommendedRelease: this.status.recommendedRelease
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'admin',
|
||||
recommendedRelease: this.status.recommendedRelease,
|
||||
channels: limitCatalogHistory(
|
||||
this.catalog,
|
||||
this.config?.maxAdminHistoryPerChannel ?? 10
|
||||
)
|
||||
}
|
||||
return this.catalogService.getDialogCatalog(this.status, this.catalog)
|
||||
}
|
||||
|
||||
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
|
||||
@@ -245,7 +106,7 @@ export class UpdateService {
|
||||
this.ensureInitialized()
|
||||
this.status.currentUserType = userType
|
||||
|
||||
if (!this.status.enabled || !userType || userType === 'Guest') {
|
||||
if (!this.status.enabled || !userType) {
|
||||
this.clearPolling()
|
||||
this.catalog = { stable: [], preview: [] }
|
||||
this.publishStatus({
|
||||
@@ -263,13 +124,18 @@ export class UpdateService {
|
||||
return
|
||||
}
|
||||
|
||||
await this.checkForUpdates()
|
||||
// 启动异步更新检查,不阻塞登录流程
|
||||
void this.checkForUpdates().catch((error) => {
|
||||
log.warn('Async update check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
this.startPolling()
|
||||
}
|
||||
|
||||
public async checkForUpdates(): Promise<UpdateStatus> {
|
||||
this.ensureInitialized()
|
||||
if (!this.status.enabled || !this.storageClient || !this.config) {
|
||||
if (!this.status.enabled || !this.storageClient || !this.catalogService) {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
@@ -281,15 +147,26 @@ export class UpdateService {
|
||||
|
||||
try {
|
||||
const currentUserType = this.status.currentUserType
|
||||
this.catalog = {
|
||||
stable: await this.fetchChannelIndex('stable'),
|
||||
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||
}
|
||||
this.catalog = await this.catalogService.loadCatalog(currentUserType)
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
await this.processUserCatalog()
|
||||
const nextStatus = await this.catalogService.resolveUserStatus(this.status, this.catalog)
|
||||
this.publishStatus(nextStatus)
|
||||
|
||||
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
|
||||
// 异步下载,不阻塞更新检查流程
|
||||
void this.downloadRelease(nextStatus.recommendedRelease).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||
log.warn('Async update download failed', { error: message })
|
||||
this.publishStatus({
|
||||
phase: 'error',
|
||||
error: message,
|
||||
message
|
||||
})
|
||||
})
|
||||
}
|
||||
} else if (currentUserType === 'Admin') {
|
||||
this.processAdminCatalog()
|
||||
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))
|
||||
} else {
|
||||
this.publishStatus({
|
||||
phase: 'idle',
|
||||
@@ -326,9 +203,9 @@ export class UpdateService {
|
||||
error: undefined
|
||||
})
|
||||
|
||||
const downloadPath = this.getDownloadPath(request)
|
||||
const downloadPath = this.installer.getDownloadPath(request)
|
||||
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
|
||||
const hash = await this.calculateSha256(downloadPath)
|
||||
const hash = await this.installer.calculateSha256(downloadPath)
|
||||
|
||||
if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
@@ -359,30 +236,6 @@ export class UpdateService {
|
||||
throw new Error('没有可安装的更新包')
|
||||
}
|
||||
|
||||
const updaterSourcePath = this.resolveUpdaterBinaryPath()
|
||||
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
|
||||
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
|
||||
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
|
||||
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
|
||||
const appArgs = process.argv.slice(1)
|
||||
const argsBase64 =
|
||||
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
|
||||
|
||||
const spawnArgs = [
|
||||
'--targetExe',
|
||||
targetExe,
|
||||
'--downloadedExe',
|
||||
downloaded.localPath,
|
||||
'--parentPid',
|
||||
String(process.pid),
|
||||
'--logPath',
|
||||
logPath
|
||||
]
|
||||
|
||||
if (argsBase64) {
|
||||
spawnArgs.push('--argsBase64', argsBase64)
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'installing',
|
||||
latestVersion: downloaded.version,
|
||||
@@ -391,45 +244,7 @@ export class UpdateService {
|
||||
error: undefined
|
||||
})
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
|
||||
updaterSourcePath,
|
||||
updaterPath,
|
||||
targetExe,
|
||||
downloadedExe: downloaded.localPath,
|
||||
parentPid: process.pid,
|
||||
logPath,
|
||||
appArgs,
|
||||
updaterExists: fs.existsSync(updaterPath),
|
||||
spawnArgs
|
||||
})
|
||||
|
||||
const child = spawn(updaterPath, spawnArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
log.error('Failed to launch portable updater executable', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
|
||||
child.once('spawn', () => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
})
|
||||
|
||||
child.unref()
|
||||
app.quit()
|
||||
await this.installer.installDownloadedRelease(downloaded)
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
@@ -444,139 +259,24 @@ export class UpdateService {
|
||||
...next
|
||||
}
|
||||
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status)
|
||||
})
|
||||
publishUpdateStatus(this.status)
|
||||
}
|
||||
|
||||
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
|
||||
if (!this.storageClient || !this.config) {
|
||||
return []
|
||||
}
|
||||
|
||||
const key = `${this.config.basePrefix}/${channel}/index.json`
|
||||
|
||||
try {
|
||||
const raw = await this.storageClient.readText(key)
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return normalizeReleases(parsed, channel)
|
||||
} catch (error) {
|
||||
if (isMissingObjectError(error)) {
|
||||
log.warn('Update channel index not found, treating as empty', { channel, key })
|
||||
return []
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async processUserCatalog(): Promise<void> {
|
||||
const decision = resolveUserDecision({
|
||||
currentVersion: this.status.currentVersion,
|
||||
currentChannel: this.status.currentChannel,
|
||||
catalog: this.catalog
|
||||
})
|
||||
|
||||
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
||||
this.publishStatus({
|
||||
phase: 'idle',
|
||||
recommendedRelease: undefined,
|
||||
latestVersion: undefined,
|
||||
latestChannel: undefined,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const recommended = decision.recommendedRelease
|
||||
this.publishStatus({
|
||||
phase: 'available',
|
||||
recommendedRelease: recommended,
|
||||
latestVersion: recommended.version,
|
||||
latestChannel: recommended.channel,
|
||||
adminHasAnyRelease: false,
|
||||
message:
|
||||
this.status.currentChannel === 'preview'
|
||||
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
})
|
||||
|
||||
const existing = await this.getValidDownloadedRelease(recommended)
|
||||
if (existing) {
|
||||
this.publishStatus({
|
||||
phase: 'downloaded',
|
||||
progress: 100,
|
||||
downloadedRelease: {
|
||||
version: existing.version,
|
||||
channel: existing.channel,
|
||||
localPath: existing.localPath
|
||||
},
|
||||
message:
|
||||
this.status.currentChannel === 'preview'
|
||||
? `稳定版 ${recommended.version} 已准备安装`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.downloadRelease(recommended)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||
this.publishStatus({
|
||||
phase: 'error',
|
||||
error: message,
|
||||
message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private processAdminCatalog(): void {
|
||||
const decision = resolveAdminDecision({
|
||||
currentVersion: this.status.currentVersion,
|
||||
currentChannel: this.status.currentChannel,
|
||||
catalog: this.catalog,
|
||||
maxHistoryPerChannel: this.config?.maxAdminHistoryPerChannel ?? 10
|
||||
})
|
||||
|
||||
const hasHigherVersion = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||
(release) => compareVersions(release.version, this.status.currentVersion) > 0
|
||||
)
|
||||
const hasCrossChannelOption = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||
(release) => release.channel !== this.status.currentChannel
|
||||
)
|
||||
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
||||
|
||||
this.publishStatus({
|
||||
phase: shouldOffer ? 'available' : 'idle',
|
||||
recommendedRelease: decision.recommendedRelease,
|
||||
latestVersion: decision.recommendedRelease?.version,
|
||||
latestChannel: decision.recommendedRelease?.channel,
|
||||
adminHasAnyRelease: shouldOffer,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
message: shouldOffer ? '发现可用版本' : undefined
|
||||
})
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.clearPolling()
|
||||
if (!this.config) {
|
||||
return
|
||||
}
|
||||
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkForUpdates().catch((error) => {
|
||||
log.warn('Periodic update check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
this.intervalHandle = setInterval(
|
||||
() => {
|
||||
this.checkForUpdates().catch((error) => {
|
||||
log.warn('Periodic update check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
}, this.config.checkIntervalMinutes * 60 * 1000)
|
||||
},
|
||||
this.config.checkIntervalMinutes * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
private clearPolling(): void {
|
||||
@@ -585,66 +285,4 @@ export class UpdateService {
|
||||
this.intervalHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
private async getValidDownloadedRelease(
|
||||
release: UpdateRelease
|
||||
): Promise<DownloadedRelease | null> {
|
||||
const downloadPath = this.getDownloadPath(release)
|
||||
if (!fs.existsSync(downloadPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hash = await this.calculateSha256(downloadPath)
|
||||
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...release,
|
||||
localPath: downloadPath
|
||||
}
|
||||
}
|
||||
|
||||
private getDownloadPath(release: UpdateRelease): string {
|
||||
return path.join(app.getPath('userData'), 'pending-update', `${release.channel}-${release.version}.exe`)
|
||||
}
|
||||
|
||||
private async calculateSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
const input = fs.createReadStream(filePath)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
input.on('data', (chunk) => hash.update(chunk))
|
||||
input.on('error', reject)
|
||||
input.on('end', resolve)
|
||||
})
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
private resolveUpdaterBinaryPath(): string {
|
||||
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
|
||||
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
|
||||
|
||||
if (fs.existsSync(packagedPath)) {
|
||||
return packagedPath
|
||||
}
|
||||
|
||||
if (fs.existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
throw new Error('未找到便携版更新器')
|
||||
}
|
||||
|
||||
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
|
||||
const updatesDir = path.join(app.getPath('userData'), 'updates')
|
||||
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
|
||||
|
||||
await fs.promises.mkdir(updatesDir, { recursive: true })
|
||||
await fs.promises.copyFile(sourcePath, stagedPath)
|
||||
|
||||
return stagedPath
|
||||
}
|
||||
}
|
||||
|
||||
9
src/main/services/update/update-status-publisher.ts
Normal file
9
src/main/services/update/update-status-publisher.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import type { UpdateStatus } from '../../types/update.types'
|
||||
|
||||
export function publishUpdateStatus(status: UpdateStatus): void {
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, status)
|
||||
})
|
||||
}
|
||||
57
src/main/services/update/update-storage-client.ts
Normal file
57
src/main/services/update/update-storage-client.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
|
||||
export class UpdateStorageClient {
|
||||
private client: S3Client
|
||||
private bucket: string
|
||||
|
||||
constructor(config: UpdateConfig) {
|
||||
this.bucket = config.bucket
|
||||
this.client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
}
|
||||
|
||||
async readText(key: string): Promise<string> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
}
|
||||
|
||||
async downloadToFile(key: string, destination: string): Promise<void> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
|
||||
const output = fs.createWriteStream(destination)
|
||||
const body = response.Body as NodeJS.ReadableStream
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
body.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
body.pipe(output)
|
||||
})
|
||||
}
|
||||
}
|
||||
76
src/main/services/update/update-support.ts
Normal file
76
src/main/services/update/update-support.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { app } from 'electron'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
import type { ReleaseChannel, UpdateStatus } from '../../types/update.types'
|
||||
|
||||
export function appendPortableLaunchLog(
|
||||
logPath: string,
|
||||
message: string,
|
||||
meta?: Record<string, unknown>
|
||||
): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||
const timestamp = new Date().toISOString()
|
||||
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
|
||||
} catch {
|
||||
// Best-effort debug log only.
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentAppVersion(): string {
|
||||
return typeof app?.getVersion === 'function' ? app.getVersion() : '0.0.0'
|
||||
}
|
||||
|
||||
export function getCurrentChannel(): ReleaseChannel {
|
||||
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
|
||||
}
|
||||
|
||||
export function isMissingObjectError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = error as {
|
||||
name?: string
|
||||
Code?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.name === 'NoSuchKey' ||
|
||||
candidate.Code === 'NoSuchKey' ||
|
||||
candidate.code === 'NoSuchKey' ||
|
||||
candidate.message?.includes('The specified key does not exist') === true
|
||||
)
|
||||
}
|
||||
|
||||
export function getSupportState(config: UpdateConfig | null): {
|
||||
supported: boolean
|
||||
reason?: string
|
||||
} {
|
||||
if (process.platform !== 'win32') {
|
||||
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
|
||||
}
|
||||
|
||||
if (app?.isPackaged) {
|
||||
return { supported: true }
|
||||
}
|
||||
|
||||
if (config?.allowDevMode) {
|
||||
return { supported: true, reason: '开发模式调试已启用更新检查' }
|
||||
}
|
||||
|
||||
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
|
||||
}
|
||||
|
||||
export const DEFAULT_STATUS: UpdateStatus = {
|
||||
enabled: false,
|
||||
supported: false,
|
||||
phase: 'idle',
|
||||
currentVersion: getCurrentAppVersion(),
|
||||
currentChannel: getCurrentChannel(),
|
||||
currentUserType: null
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export function compareVersions(left: string, right: string): number {
|
||||
export function normalizeReleases(input: unknown, channel: ReleaseChannel): UpdateRelease[] {
|
||||
const list = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === 'object' && Array.isArray((input as { releases?: unknown[] }).releases)
|
||||
: input &&
|
||||
typeof input === 'object' &&
|
||||
Array.isArray((input as { releases?: unknown[] }).releases)
|
||||
? (input as { releases: unknown[] }).releases
|
||||
: []
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export class BIPUsersDAO {
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -157,7 +157,7 @@ export class BIPUsersDAO {
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -198,7 +198,7 @@ export class BIPUsersDAO {
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -216,7 +216,7 @@ export class BIPUsersDAO {
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -254,7 +254,7 @@ export class BIPUsersDAO {
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User' | 'Guest',
|
||||
userType: row.UserType as 'Admin' | 'User',
|
||||
createTime: row.CreateTime as Date | undefined
|
||||
}))
|
||||
} catch (error) {
|
||||
@@ -270,7 +270,7 @@ export class BIPUsersDAO {
|
||||
* Create a new user
|
||||
* @param username - The username (must be unique)
|
||||
* @param password - The password
|
||||
* @param userType - User type ('Admin', 'User', or 'Guest')
|
||||
* @param userType - User type ('Admin' or 'User')
|
||||
* @param computerName - Optional computer name for silent login
|
||||
* @returns True if successful
|
||||
*/
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
* npx tsx src/main/services/user/migration/add-erp-params-migration.ts
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import { ConfigManager } from '../../config/config-manager'
|
||||
import { MySqlService } from '../../database/mysql'
|
||||
import { SqlServerService } from '../../database/sql-server'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -92,62 +90,6 @@ async function addColumnSqlServer(
|
||||
console.log(` ✓ Added column ${columnName} (${columnType})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ERP credentials for all users in MySQL
|
||||
*/
|
||||
async function initializeErpCredentialsMySQL(
|
||||
mysqlService: MySqlService,
|
||||
tableName: string,
|
||||
erpUrl: string,
|
||||
erpUsername: string,
|
||||
erpPassword: string
|
||||
): Promise<void> {
|
||||
const result = await mysqlService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
|
||||
const userCount = result.rows[0]?.count as number
|
||||
|
||||
if (userCount === 0) {
|
||||
console.log('No users found in BIPUsers table')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
|
||||
|
||||
await mysqlService.query(
|
||||
`UPDATE ${tableName} SET ERP_URL = ?, ERP_Username = ?, ERP_Password = ?`,
|
||||
[erpUrl, erpUsername, erpPassword]
|
||||
)
|
||||
|
||||
console.log('✓ ERP credentials initialized for all users')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ERP credentials for all users in SQL Server
|
||||
*/
|
||||
async function initializeErpCredentialsSqlServer(
|
||||
sqlServerService: SqlServerService,
|
||||
tableName: string,
|
||||
erpUrl: string,
|
||||
erpUsername: string,
|
||||
erpPassword: string
|
||||
): Promise<void> {
|
||||
const result = await sqlServerService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
|
||||
const userCount = result.rows[0]?.count as number
|
||||
|
||||
if (userCount === 0) {
|
||||
console.log('No users found in BIPUsers table')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
|
||||
|
||||
await sqlServerService.query(
|
||||
`UPDATE ${tableName} SET ERP_URL = @p0, ERP_Username = @p1, ERP_Password = @p2`,
|
||||
[erpUrl, erpUsername, erpPassword]
|
||||
)
|
||||
|
||||
console.log('✓ ERP credentials initialized for all users')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migration for MySQL
|
||||
*/
|
||||
|
||||
@@ -11,13 +11,9 @@
|
||||
import * as mysql from 'mysql2/promise'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import yaml from 'js-yaml'
|
||||
import { z } from 'zod'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
|
||||
/**
|
||||
* MySQL configuration schema
|
||||
*/
|
||||
|
||||
@@ -43,9 +43,10 @@ export class SessionManager {
|
||||
* @returns True if login successful, false otherwise
|
||||
*/
|
||||
public async login(username: string, password: string): Promise<boolean> {
|
||||
let dao: InstanceType<(typeof import('./bip-users-dao'))['BIPUsersDAO']> | null = null
|
||||
try {
|
||||
const { BIPUsersDAO } = await import('./bip-users-dao')
|
||||
const dao = new BIPUsersDAO()
|
||||
dao = new BIPUsersDAO()
|
||||
const userInfo = await dao.authenticate(username, password)
|
||||
|
||||
if (userInfo) {
|
||||
@@ -60,6 +61,12 @@ export class SessionManager {
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Login error:', error)
|
||||
return false
|
||||
} finally {
|
||||
if (dao) {
|
||||
await dao.disconnect().catch((error) => {
|
||||
console.error('[SessionManager] Login disconnect error:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +75,12 @@ export class SessionManager {
|
||||
* @returns True if login successful, false otherwise
|
||||
*/
|
||||
public async loginByComputerName(): Promise<boolean> {
|
||||
let dao: InstanceType<(typeof import('./bip-users-dao'))['BIPUsersDAO']> | null = null
|
||||
try {
|
||||
const { BIPUsersDAO } = await import('./bip-users-dao')
|
||||
const dao = new BIPUsersDAO()
|
||||
const computerName = require('os').hostname()
|
||||
const { hostname } = await import('os')
|
||||
dao = new BIPUsersDAO()
|
||||
const computerName = hostname()
|
||||
const userInfo = await dao.authenticateByComputerName(computerName)
|
||||
|
||||
if (userInfo) {
|
||||
@@ -86,6 +95,12 @@ export class SessionManager {
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Silent login error:', error)
|
||||
return false
|
||||
} finally {
|
||||
if (dao) {
|
||||
await dao.disconnect().catch((error) => {
|
||||
console.error('[SessionManager] Silent login disconnect error:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,13 +126,6 @@ export class SessionManager {
|
||||
return this.currentUser?.userType === 'Admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a guest
|
||||
*/
|
||||
public isGuest(): boolean {
|
||||
return this.currentUser?.userType === 'Guest'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current username
|
||||
*/
|
||||
@@ -173,13 +181,20 @@ export class SessionManager {
|
||||
* Get all users from database (for Admin user selection)
|
||||
*/
|
||||
public async getAllUsers(): Promise<UserInfo[]> {
|
||||
let dao: InstanceType<(typeof import('./bip-users-dao'))['BIPUsersDAO']> | null = null
|
||||
try {
|
||||
const { BIPUsersDAO } = await import('./bip-users-dao')
|
||||
const dao = new BIPUsersDAO()
|
||||
dao = new BIPUsersDAO()
|
||||
return await dao.getAllUsers()
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Get all users error:', error)
|
||||
return []
|
||||
} finally {
|
||||
if (dao) {
|
||||
await dao.disconnect().catch((error) => {
|
||||
console.error('[SessionManager] Get all users disconnect error:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
src/main/services/validation/production-input-service.ts
Normal file
95
src/main/services/validation/production-input-service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import fs from 'fs'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { SqlServerService } from '../database/sql-server'
|
||||
import type { ValidationDatabaseService } from './validation-database'
|
||||
import { getValidationTableName } from './validation-database'
|
||||
|
||||
export function readProductionIds(filePath: string): string[] {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
export function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
|
||||
if (/^SC\d{14}$/.test(input)) {
|
||||
return 'order_number'
|
||||
}
|
||||
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
|
||||
return 'production_id'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function getSourceNumbersFromInputs(
|
||||
inputs: string[],
|
||||
dbService: ValidationDatabaseService
|
||||
): Promise<string[]> {
|
||||
const productionIds: string[] = []
|
||||
const orderNumbers: string[] = []
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const isSqlServer = configManager.getDatabaseType() === 'sqlserver'
|
||||
|
||||
for (const item of inputs) {
|
||||
const type = identifyInputType(item)
|
||||
if (type === 'order_number') {
|
||||
orderNumbers.push(item)
|
||||
} else if (type === 'production_id') {
|
||||
productionIds.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (productionIds.length > 0) {
|
||||
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据')
|
||||
const batchSize = 2000
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = await import('mssql')
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
||||
const params: Record<string, { value: string; type: typeof sql.default.NVarChar }> = {}
|
||||
|
||||
batch.forEach((id, idx) => {
|
||||
params[`p${idx}`] = { value: id, type: sql.default.NVarChar }
|
||||
})
|
||||
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as SqlServerService).queryWithParams(
|
||||
contractSql,
|
||||
params
|
||||
)
|
||||
allOrderNumbers.push(
|
||||
...contractResult.rows.map((row: Record<string, unknown>) => row.生产订单号 as string)
|
||||
)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
} else {
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await dbService.query(contractSql, batch)
|
||||
allOrderNumbers.push(...contractResult.rows.map((row) => row.生产订单号 as string))
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(orderNumbers)]
|
||||
}
|
||||
18
src/main/services/validation/shared-production-ids-store.ts
Normal file
18
src/main/services/validation/shared-production-ids-store.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export class SharedProductionIdsStore {
|
||||
private sharedProductionIdsBySender = new Map<number, Set<string>>()
|
||||
|
||||
set(senderId: number, ids: string[]): void {
|
||||
this.sharedProductionIdsBySender.set(senderId, new Set(ids))
|
||||
}
|
||||
|
||||
get(senderId: number): string[] {
|
||||
const senderSet = this.sharedProductionIdsBySender.get(senderId)
|
||||
return senderSet ? [...senderSet] : []
|
||||
}
|
||||
|
||||
clear(senderId: number): void {
|
||||
this.sharedProductionIdsBySender.delete(senderId)
|
||||
}
|
||||
}
|
||||
|
||||
export const sharedProductionIdsStore = new SharedProductionIdsStore()
|
||||
413
src/main/services/validation/validation-application-service.ts
Normal file
413
src/main/services/validation/validation-application-service.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { DiscreteMaterialPlanDAO } from '../database/discrete-material-plan-dao'
|
||||
import { MaterialsToBeDeletedDAO } from '../database/materials-to-be-deleted-dao'
|
||||
import { SqlServerService } from '../database/sql-server'
|
||||
import { createLogger } from '../logger'
|
||||
import type {
|
||||
MaterialRecordSummary,
|
||||
ValidationRequest,
|
||||
ValidationResponse,
|
||||
ValidationResult
|
||||
} from '../../types/validation.types'
|
||||
import type { UserInfo } from '../../types/user.types'
|
||||
import { getSourceNumbersFromInputs, readProductionIds } from './production-input-service'
|
||||
import { sharedProductionIdsStore } from './shared-production-ids-store'
|
||||
import {
|
||||
createValidationDatabaseService,
|
||||
getValidationTableName,
|
||||
type ValidationDatabaseService
|
||||
} from './validation-database'
|
||||
|
||||
const log = createLogger('ValidationApplicationService')
|
||||
|
||||
type TypeKeyword = {
|
||||
materialName: string
|
||||
managerName: string
|
||||
}
|
||||
|
||||
export class ValidationApplicationService {
|
||||
async validate(
|
||||
request: ValidationRequest,
|
||||
userInfo: UserInfo,
|
||||
senderId: number
|
||||
): Promise<ValidationResponse> {
|
||||
let dbService: ValidationDatabaseService | null = null
|
||||
|
||||
try {
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
|
||||
dbService = await createValidationDatabaseService()
|
||||
|
||||
let sourceNumbers: string[] | null = null
|
||||
|
||||
if (request.mode === 'database_filtered') {
|
||||
if (request.useSharedProductionIds) {
|
||||
const sharedIds = sharedProductionIdsStore.get(senderId)
|
||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||
|
||||
if (sharedIds.length === 0) {
|
||||
return this.emptyFailure(
|
||||
'没有可用的共享 Production ID。请在数据提取页面输入 Production ID。'
|
||||
)
|
||||
}
|
||||
|
||||
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
|
||||
|
||||
if (sourceNumbers.length === 0) {
|
||||
return this.emptyFailure(
|
||||
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。'
|
||||
)
|
||||
}
|
||||
} else if (request.productionIdFile) {
|
||||
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`)
|
||||
|
||||
if (sourceNumbers.length === 0) {
|
||||
return this.emptyFailure(
|
||||
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const materialDao = new DiscreteMaterialPlanDAO()
|
||||
let materialRecords: any[] = []
|
||||
|
||||
if (request.mode === 'database_full') {
|
||||
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
|
||||
} else if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers)
|
||||
}
|
||||
|
||||
if (materialRecords.length === 0) {
|
||||
return this.emptyFailure('未找到物料记录。请检查数据库中是否有对应订单的物料数据。')
|
||||
}
|
||||
|
||||
const typeKeywords = await this.loadTypeKeywords(dbService)
|
||||
const markedCodes = await this.loadMarkedCodes(dbService)
|
||||
const results = this.buildValidationResults(materialRecords, typeKeywords, markedCodes, {
|
||||
isAdmin,
|
||||
username
|
||||
})
|
||||
|
||||
const markedCount = results.filter((result) => result.isMarkedForDeletion).length
|
||||
const matchedCount = results.filter((result) => result.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: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `Validation failed: ${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
await this.disconnectQuietly(dbService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getMaterialsByManager(managerName: string): Promise<MaterialRecordSummary[]> {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getMaterialsByManager(managerName)
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
return this.enrichMaterials(materials, markedCodes)
|
||||
}
|
||||
|
||||
async getAllMaterials(): Promise<MaterialRecordSummary[]> {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getAllRecords()
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
return this.enrichMaterials(materials, markedCodes)
|
||||
}
|
||||
|
||||
async getCleanerData(
|
||||
userInfo: UserInfo,
|
||||
senderId: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
orderNumbers?: string[]
|
||||
materialCodes?: string[]
|
||||
error?: string
|
||||
}> {
|
||||
let dbService: ValidationDatabaseService | null = null
|
||||
|
||||
try {
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
||||
|
||||
dbService = await createValidationDatabaseService()
|
||||
|
||||
const sharedIds = sharedProductionIdsStore.get(senderId)
|
||||
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`)
|
||||
}
|
||||
|
||||
const materialCodes = await this.loadMaterialCodesForCleaner(dbService, username, isAdmin)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderNumbers,
|
||||
materialCodes
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('CleanerData error', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `获取清理数据失败:${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
await this.disconnectQuietly(dbService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emptyFailure(error: string): ValidationResponse {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTypeKeywords(dbService: ValidationDatabaseService): Promise<TypeKeyword[]> {
|
||||
const typeKeywordTableName = getValidationTableName('dbo_MaterialsTypeToBeDeleted')
|
||||
const sql = `
|
||||
SELECT MaterialName, ManagerName
|
||||
FROM ${typeKeywordTableName}
|
||||
WHERE MaterialName IS NOT NULL
|
||||
`
|
||||
const result = await dbService.query(sql)
|
||||
return result.rows.map((row) => ({
|
||||
materialName: row.MaterialName as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
}
|
||||
|
||||
private async loadMarkedCodes(
|
||||
dbService: ValidationDatabaseService
|
||||
): Promise<Map<string, string>> {
|
||||
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted')
|
||||
const sql = `
|
||||
SELECT MaterialCode, ManagerName
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
|
||||
`
|
||||
const result = await dbService.query(sql)
|
||||
const markedCodes = new Map<string, string>()
|
||||
|
||||
for (const row of result.rows) {
|
||||
markedCodes.set(row.MaterialCode as string, row.ManagerName as string)
|
||||
}
|
||||
|
||||
return markedCodes
|
||||
}
|
||||
|
||||
private buildValidationResults(
|
||||
materialRecords: any[],
|
||||
typeKeywords: TypeKeyword[],
|
||||
markedCodes: Map<string, string>,
|
||||
context: { isAdmin: boolean; username: string }
|
||||
): ValidationResult[] {
|
||||
return materialRecords.map((record) => {
|
||||
const materialName = (record.MaterialName as string) || ''
|
||||
const materialCode = (record.MaterialCode as string) || ''
|
||||
const specification = (record.Specification as string) || ''
|
||||
const model = (record.Model as string) || ''
|
||||
|
||||
let managerName = markedCodes.get(materialCode) || null
|
||||
const isMarkedForDeletion = managerName !== null
|
||||
let matchedTypeKeyword: string | undefined
|
||||
|
||||
if (!managerName) {
|
||||
for (const typeKeyword of typeKeywords) {
|
||||
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
|
||||
matchedTypeKeyword = typeKeyword.materialName
|
||||
managerName = typeKeyword.managerName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.isAdmin && context.username) {
|
||||
const userKeywords = typeKeywords.filter(
|
||||
(keyword) => keyword.managerName === context.username
|
||||
)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
materialName,
|
||||
materialCode,
|
||||
specification,
|
||||
model,
|
||||
managerName: managerName || '',
|
||||
isMarkedForDeletion,
|
||||
matchedTypeKeyword
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async enrichMaterials(
|
||||
materials: Array<{ materialCode: string; managerName: string }>,
|
||||
markedCodes: Set<string>
|
||||
): Promise<MaterialRecordSummary[]> {
|
||||
let dbService: ValidationDatabaseService | null = null
|
||||
|
||||
try {
|
||||
dbService = await createValidationDatabaseService()
|
||||
const detailTableName = getValidationTableName('dbo_DiscreteMaterialPlanData')
|
||||
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||
|
||||
for (const material of materials) {
|
||||
const detailResult = await this.queryMaterialDetail(
|
||||
dbService,
|
||||
detailTableName,
|
||||
material.materialCode
|
||||
)
|
||||
const firstRow = detailResult.rows[0]
|
||||
|
||||
enrichedMaterials.push({
|
||||
materialCode: material.materialCode,
|
||||
materialName: firstRow ? (firstRow.MaterialName as string) : '',
|
||||
specification: firstRow ? (firstRow.Specification as string) : '',
|
||||
model: firstRow ? (firstRow.Model as string) : '',
|
||||
managerName: material.managerName,
|
||||
isMarked: markedCodes.has(material.materialCode)
|
||||
})
|
||||
}
|
||||
|
||||
return enrichedMaterials
|
||||
} finally {
|
||||
if (dbService) {
|
||||
await this.disconnectQuietly(dbService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async queryMaterialDetail(
|
||||
dbService: ValidationDatabaseService,
|
||||
detailTableName: string,
|
||||
materialCode: string
|
||||
) {
|
||||
if (dbService.type === 'sqlserver') {
|
||||
const sql = await import('mssql')
|
||||
return (dbService as SqlServerService).queryWithParams(
|
||||
`
|
||||
SELECT TOP 1 MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = @materialCode
|
||||
`,
|
||||
{
|
||||
materialCode: { value: materialCode, type: sql.default.NVarChar }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return dbService.query(
|
||||
`
|
||||
SELECT MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[materialCode]
|
||||
)
|
||||
}
|
||||
|
||||
private async loadMaterialCodesForCleaner(
|
||||
dbService: ValidationDatabaseService,
|
||||
username: string,
|
||||
isAdmin: boolean
|
||||
): Promise<string[]> {
|
||||
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted')
|
||||
|
||||
if (isAdmin) {
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
)
|
||||
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Admin user: got ${materialCodes.length} materials`)
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
if (dbService.type === 'sqlserver') {
|
||||
const sql = await import('mssql')
|
||||
const result = await (dbService as SqlServerService).queryWithParams(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = @username AND MaterialCode IS NOT NULL
|
||||
`,
|
||||
{
|
||||
username: { value: username, type: sql.default.NVarChar }
|
||||
}
|
||||
)
|
||||
const materialCodes = result.rows
|
||||
.map((row: Record<string, unknown>) => row.MaterialCode as string)
|
||||
.filter(Boolean)
|
||||
log.info(`Regular user: got ${materialCodes.length} materials`)
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
|
||||
`,
|
||||
[username]
|
||||
)
|
||||
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Regular user: got ${materialCodes.length} materials`)
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
private async disconnectQuietly(dbService: ValidationDatabaseService): Promise<void> {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const validationApplicationService = new ValidationApplicationService()
|
||||
56
src/main/services/validation/validation-database.ts
Normal file
56
src/main/services/validation/validation-database.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { MySqlService } from '../database/mysql'
|
||||
import { SqlServerService } from '../database/sql-server'
|
||||
|
||||
export type ValidationDatabaseService = MySqlService | SqlServerService
|
||||
|
||||
export async function createValidationDatabaseService(): Promise<ValidationDatabaseService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerService({
|
||||
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 MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
|
||||
export function getValidationTableName(mysqlTableName: string): string {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
|
||||
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${tableName}]`
|
||||
}
|
||||
return `[dbo].[${mysqlTableName}]`
|
||||
}
|
||||
|
||||
return mysqlTableName
|
||||
}
|
||||
30
src/main/types/auth-ipc.types.ts
Normal file
30
src/main/types/auth-ipc.types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { UserInfo } from './user.types'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SilentLoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
requiresUserSelection?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface UserSelectionResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CurrentUserResponse {
|
||||
isAuthenticated: boolean
|
||||
userInfo?: UserInfo
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} from './cleaner.types'
|
||||
import type { IpcResult } from '../ipc'
|
||||
import type { IpcResult } from './ipc.types'
|
||||
|
||||
/**
|
||||
* MySQL connection configuration
|
||||
@@ -176,13 +176,23 @@ export interface ReportAPI {
|
||||
/**
|
||||
* List all reports across all users (Admin only typically)
|
||||
*/
|
||||
listAll: () => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
|
||||
listAll: () => Promise<
|
||||
IpcResult<
|
||||
{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* List reports for a specific user
|
||||
* @param username - Username to list reports for
|
||||
*/
|
||||
listByUser: (username: string) => Promise<IpcResult<{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]>>
|
||||
listByUser: (
|
||||
username: string
|
||||
) => Promise<
|
||||
IpcResult<
|
||||
{ key: string; filename: string; username: string; lastModified?: Date; size?: number }[]
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Download a specific report by key
|
||||
|
||||
6
src/main/types/ipc.types.ts
Normal file
6
src/main/types/ipc.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface IpcResult<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
86
src/main/types/operation-history.types.ts
Normal file
86
src/main/types/operation-history.types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Operation History Type Definitions
|
||||
*
|
||||
* Type definitions for the Extractor Operation History feature.
|
||||
* Tracks extraction operations with batch and individual order record details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Individual operation history record
|
||||
*/
|
||||
export interface OperationHistoryRecord {
|
||||
/** Auto-increment ID */
|
||||
id?: number
|
||||
/** Batch ID - shared among all orders in a single extraction operation */
|
||||
batchId: string
|
||||
/** User ID who performed the operation */
|
||||
userId: number
|
||||
/** Username who performed the operation */
|
||||
username: string
|
||||
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
|
||||
productionId: string | null
|
||||
/** Resolved order number (e.g., "SC70202602120085") */
|
||||
orderNumber: string
|
||||
/** When the operation was performed */
|
||||
operationTime: Date
|
||||
/** Operation status: pending, success, failed, partial */
|
||||
status: string
|
||||
/** Number of records extracted for this order */
|
||||
recordCount: number | null
|
||||
/** Error message if operation failed */
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch statistics - aggregated view of a batch operation
|
||||
*/
|
||||
export interface BatchStats {
|
||||
/** Unique batch identifier */
|
||||
batchId: string
|
||||
/** User ID who performed the operation */
|
||||
userId: number
|
||||
/** Username who performed the operation */
|
||||
username: string
|
||||
/** When the operation started */
|
||||
operationTime: string
|
||||
/** Overall batch status: pending, success, failed, partial */
|
||||
status: string
|
||||
/** Total number of orders in the batch */
|
||||
totalOrders: number
|
||||
/** Total records extracted across all orders */
|
||||
totalRecords: number
|
||||
/** Number of orders that succeeded */
|
||||
successCount: number
|
||||
/** Number of orders that failed */
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for inserting batch records
|
||||
*/
|
||||
export interface InsertBatchRecordInput {
|
||||
/** Original input production ID (e.g., "22A1") */
|
||||
productionId: string | null
|
||||
/** Resolved order number (e.g., "SC70202602120085") */
|
||||
orderNumber: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for batch status update
|
||||
*/
|
||||
export interface UpdateBatchStatusResult {
|
||||
/** Whether the update was successful */
|
||||
success: boolean
|
||||
/** Number of records updated */
|
||||
updatedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for querying batches
|
||||
*/
|
||||
export interface GetBatchesOptions {
|
||||
/** Maximum number of batches to return */
|
||||
limit?: number
|
||||
/** Number of batches to skip (for pagination) */
|
||||
offset?: number
|
||||
}
|
||||
18
src/main/types/order-resolver.types.ts
Normal file
18
src/main/types/order-resolver.types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface OrderMapping {
|
||||
input: string
|
||||
productionId?: string
|
||||
orderNumber?: string
|
||||
resolved: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
|
||||
|
||||
export interface ResolutionStats {
|
||||
totalInputs: number
|
||||
validOrderNumbers: number
|
||||
validProductionIds: number
|
||||
resolvedCount: number
|
||||
failedCount: number
|
||||
unknownFormat: number
|
||||
}
|
||||
14
src/main/types/resolver-ipc.types.ts
Normal file
14
src/main/types/resolver-ipc.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { OrderMapping, ResolutionStats } from './order-resolver.types'
|
||||
|
||||
export interface ResolverInput {
|
||||
inputs: string[]
|
||||
}
|
||||
|
||||
export interface ResolverResponse {
|
||||
success: boolean
|
||||
mappings?: OrderMapping[]
|
||||
validOrderNumbers?: string[]
|
||||
warnings?: string[]
|
||||
stats?: ResolutionStats
|
||||
error?: string
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
/**
|
||||
* User type for settings permission control
|
||||
*/
|
||||
export type UserType = 'Admin' | 'User' | 'Guest'
|
||||
export type UserType = 'Admin' | 'User'
|
||||
|
||||
/**
|
||||
* Database type selection
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* User type enumeration
|
||||
*/
|
||||
export type UserType = 'Admin' | 'User' | 'Guest'
|
||||
export type UserType = 'Admin' | 'User'
|
||||
|
||||
/**
|
||||
* User information interface
|
||||
|
||||
15
src/preload/api/auth.ts
Normal file
15
src/preload/api/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LoginRequest } from '../../main/types/auth-ipc.types'
|
||||
import type { UserInfo } from '../../main/types/user.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc } from '../lib/ipc'
|
||||
|
||||
export const authApi = {
|
||||
getComputerName: () => invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
|
||||
silentLogin: () => invokeIpc(IPC_CHANNELS.AUTH_SILENT_LOGIN),
|
||||
login: (request: LoginRequest) => invokeIpc(IPC_CHANNELS.AUTH_LOGIN, request),
|
||||
logout: () => invokeIpc(IPC_CHANNELS.AUTH_LOGOUT),
|
||||
getCurrentUser: () => invokeIpc(IPC_CHANNELS.AUTH_GET_CURRENT_USER),
|
||||
getAllUsers: () => invokeIpc(IPC_CHANNELS.AUTH_GET_ALL_USERS),
|
||||
switchUser: (userInfo: UserInfo) => invokeIpc(IPC_CHANNELS.AUTH_SWITCH_USER, userInfo),
|
||||
isAdmin: () => invokeIpc(IPC_CHANNELS.AUTH_IS_ADMIN)
|
||||
} as const
|
||||
24
src/preload/api/browser-download.ts
Normal file
24
src/preload/api/browser-download.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import type { IpcResult } from '../../main/types/ipc.types'
|
||||
import type { DownloadProgress } from '../index.d'
|
||||
|
||||
export const playwrightBrowserApi = {
|
||||
check: async (): Promise<IpcResult<boolean>> => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK)
|
||||
},
|
||||
|
||||
download: async (): Promise<IpcResult<void>> => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD)
|
||||
},
|
||||
|
||||
cancel: async (): Promise<IpcResult<void>> => {
|
||||
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL)
|
||||
},
|
||||
|
||||
onProgress: (callback: (data: DownloadProgress) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: DownloadProgress) => callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||
}
|
||||
} as const
|
||||
19
src/preload/api/cleaner.ts
Normal file
19
src/preload/api/cleaner.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerProgress,
|
||||
ExportResultItem
|
||||
} from '../../main/types/cleaner.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc, ipcRenderer } from '../lib/ipc'
|
||||
|
||||
export const cleanerApi = {
|
||||
runCleaner: (input: CleanerInput) => invokeIpc(IPC_CHANNELS.CLEANER_RUN, input),
|
||||
exportResults: (items: ExportResultItem[]) =>
|
||||
invokeIpc(IPC_CHANNELS.CLEANER_EXPORT_RESULTS, items),
|
||||
onProgress: (callback: (data: CleanerProgress) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: CleanerProgress) =>
|
||||
callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
||||
}
|
||||
} as const
|
||||
17
src/preload/api/database.ts
Normal file
17
src/preload/api/database.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { MySqlConfig, SqlServerConfig } from '../../main/types/ipc-api.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc } from '../lib/ipc'
|
||||
|
||||
export const databaseApi = {
|
||||
connectMySql: (config: MySqlConfig) => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_CONNECT, config),
|
||||
disconnectMySql: () => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT),
|
||||
isMySqlConnected: () => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
|
||||
queryMySql: (sql: string, params?: unknown[]) =>
|
||||
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_QUERY, sql, params),
|
||||
connectSqlServer: (config: SqlServerConfig) =>
|
||||
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT, config),
|
||||
disconnectSqlServer: () => invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT),
|
||||
isSqlServerConnected: () => invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED),
|
||||
querySqlServer: (sql: string, params?: Record<string, unknown>) =>
|
||||
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_QUERY, sql, params)
|
||||
} as const
|
||||
21
src/preload/api/extractor.ts
Normal file
21
src/preload/api/extractor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ExtractorInput, ExtractionProgress } from '../../main/types/extractor.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc, ipcRenderer } from '../lib/ipc'
|
||||
|
||||
export const extractorApi = {
|
||||
runExtractor: (input: ExtractorInput) => invokeIpc(IPC_CHANNELS.EXTRACTOR_RUN, input),
|
||||
onProgress: (callback: (data: ExtractionProgress) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: ExtractionProgress) =>
|
||||
callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.EXTRACTOR_PROGRESS, subscription)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.EXTRACTOR_PROGRESS, subscription)
|
||||
},
|
||||
onLog: (callback: (data: { level: string; message: string }) => void) => {
|
||||
const subscription = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { level: string; message: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.EXTRACTOR_LOG, subscription)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.EXTRACTOR_LOG, subscription)
|
||||
}
|
||||
} as const
|
||||
11
src/preload/api/file.ts
Normal file
11
src/preload/api/file.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc } from '../lib/ipc'
|
||||
|
||||
export const fileApi = {
|
||||
readFile: (filePath: string) => invokeIpc(IPC_CHANNELS.FILE_READ, filePath),
|
||||
writeFile: (filePath: string, content: string) =>
|
||||
invokeIpc(IPC_CHANNELS.FILE_WRITE, filePath, content),
|
||||
fileExists: (filePath: string) => invokeIpc(IPC_CHANNELS.FILE_EXISTS, filePath),
|
||||
listFiles: (dirPath: string) => invokeIpc(IPC_CHANNELS.FILE_LIST, dirPath),
|
||||
openPath: (filePath: string) => invokeIpc(IPC_CHANNELS.FILE_OPEN_PATH, filePath)
|
||||
} as const
|
||||
43
src/preload/api/index.ts
Normal file
43
src/preload/api/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { processApi } from './process'
|
||||
import { fileApi } from './file'
|
||||
import { extractorApi } from './extractor'
|
||||
import { cleanerApi } from './cleaner'
|
||||
import { resolverApi } from './resolver'
|
||||
import { authApi } from './auth'
|
||||
import { databaseApi } from './database'
|
||||
import { validationApi } from './validation'
|
||||
import {
|
||||
configApi,
|
||||
materialTypeApi,
|
||||
materialsApi,
|
||||
reportApi,
|
||||
settingsApi,
|
||||
updateApi,
|
||||
userErpConfigApi
|
||||
} from './materials'
|
||||
import { loggerApi } from './logger'
|
||||
import { playwrightBrowserApi } from './browser-download'
|
||||
import { operationHistoryApi } from './operation-history'
|
||||
|
||||
export const api = {
|
||||
process: processApi,
|
||||
file: fileApi,
|
||||
extractor: extractorApi,
|
||||
cleaner: cleanerApi,
|
||||
resolver: resolverApi,
|
||||
auth: authApi,
|
||||
database: databaseApi,
|
||||
validation: validationApi,
|
||||
materials: materialsApi,
|
||||
settings: settingsApi,
|
||||
materialType: materialTypeApi,
|
||||
userErpConfig: userErpConfigApi,
|
||||
config: configApi,
|
||||
logger: loggerApi,
|
||||
report: reportApi,
|
||||
update: updateApi,
|
||||
playwrightBrowser: playwrightBrowserApi,
|
||||
operationHistory: operationHistoryApi
|
||||
} as const
|
||||
|
||||
export type ElectronApi = typeof api
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user