36 Commits

Author SHA1 Message Date
Misaka_Company
37eade6360 1.4.2 2026-03-23 10:23:41 +08:00
Misaka_Company
5cd1e98bbf docs: add release notes for v1.4.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 10:20:52 +08:00
Misaka
8713f8c0e2 chore: ignore skill directories in lint 2026-03-21 21:12:42 +08:00
Misaka
bdde4fefd0 docs: add architecture handbook index 2026-03-21 20:51:16 +08:00
Misaka
180f4aab0a docs: add developer guides 2026-03-21 20:48:09 +08:00
Misaka
31a32db899 docs: add module handbook index 2026-03-21 20:39:28 +08:00
Misaka
14e7abe11e docs: add developer module guides 2026-03-21 20:37:50 +08:00
Misaka
57ca1d5650 docs: add developer architecture handbook 2026-03-21 20:25:45 +08:00
Misaka
f40512449a docs: add developer docs index 2026-03-21 20:10:50 +08:00
Misaka
c09e0eb4e7 test: cover renderer state helpers 2026-03-21 19:45:19 +08:00
Misaka
86614efa22 refactor: lazy load renderer dialogs 2026-03-21 19:32:03 +08:00
Misaka
16ac892c93 refactor: isolate extractor and update dialog state 2026-03-21 19:28:01 +08:00
Misaka
1fc2eab816 refactor: split cleaner page layout 2026-03-21 19:15:35 +08:00
Misaka
2baf55bd2e refactor: split app bootstrap and shell 2026-03-21 19:05:10 +08:00
Misaka
957cfbda46 docs: add react optimization plan 2026-03-21 18:40:55 +08:00
Misaka
bede488230 chore: align packaging with windows release flow 2026-03-21 18:30:05 +08:00
Misaka
ed2a42ad6b test: cover electron boundary modules 2026-03-21 18:26:13 +08:00
Misaka
13db99d51d refactor: extract update catalog decisions 2026-03-21 18:14:08 +08:00
Misaka
84c6b81959 refactor: split update service responsibilities 2026-03-21 18:06:10 +08:00
Misaka
9a1f5a483e fix: harden startup flow and auth re-entry 2026-03-21 11:02:17 +08:00
Misaka
ed65312fff refactor: split preload api by domain 2026-03-21 10:49:13 +08:00
Misaka
26c05f3726 refactor: move ipc orchestration into application services 2026-03-21 10:40:36 +08:00
Misaka
546d005c19 refactor: split main process bootstrap flow 2026-03-21 10:33:33 +08:00
Misaka
325e6fcc89 docs: add electron optimization plan 2026-03-21 10:28:11 +08:00
Misaka
2e01542cd8 chore: update skill lock metadata 2026-03-21 10:11:36 +08:00
Misaka
0e77479955 style: format codebase files 2026-03-21 09:34:44 +08:00
Misaka
2b4a09dabe fix: resolve lint and typecheck issues 2026-03-21 09:33:07 +08:00
Misaka
2fba07fd8f fix: restore full typecheck stability 2026-03-21 09:17:58 +08:00
Misaka
08bd2cb7d5 docs: add refactor overview documents 2026-03-21 09:07:13 +08:00
Misaka
7b57545127 refactor: extract cleaner hook helpers and api 2026-03-21 09:02:45 +08:00
Misaka
b979b73ba1 refactor: split validation handler responsibilities 2026-03-21 08:54:35 +08:00
Misaka
68e6c9483f 1.4.1 2026-03-20 23:30:05 +08:00
Misaka
63ff32817e docs: add 1.4.1 release notes 2026-03-20 23:29:55 +08:00
Misaka
78544af8de Merge branch 'dev' 2026-03-20 23:28:24 +08:00
Misaka
7d73592d41 Merge remote-tracking branch 'origin/dev' into dev 2026-03-20 23:25:32 +08:00
Misaka_Company
7644b8d4ea feat(report-viewer): add searchable combobox for report selection
Replace native select dropdown with Headless UI Combobox component to enable:
- Search/filter functionality for reports
- Better UX with keyboard navigation
- Improved visual feedback for selected items
- Empty state handling for no matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 17:33:16 +08:00
129 changed files with 10934 additions and 3886 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ out
# AI Agent
.claude
.agents
# Environment
.env

View File

@@ -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 目标。
## 使用指南
### 数据提取

View File

@@ -7,7 +7,7 @@
根据 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json),项目当前依赖为:
| 组件 | 当前版本 |
| --- | --- |
| ------------------ | --------- |
| `playwright` | `^1.58.2` |
| `playwright-core` | `^1.58.2` |
| `@playwright/test` | `^1.58.2` |

137
docs/developer/README.md Normal file
View 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/` 的总索引持续维护。

View 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`
后续如果新增新的架构层文档,也建议同步更新这份索引页。

View 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 只做转发和错误包装
- 复杂状态流尽量配套时序图或单测

View 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 servicehandler 保持为薄壳。
当前典型结构:
```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. 相关文件
建议记录的场景包括:
- 新增跨层通信机制
- 重构核心模块边界
- 修改更新、认证、校验、清理主链路
- 引入新的状态管理或测试策略

View 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]
```

View 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/` 目录中的模块文档
深入理解各业务模块。

View 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`

View 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/` 的入口文档。

View 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. 调试原则
- 先缩小层级,再深入代码
- 先看入口与边界,再看实现细节
- 能复现就尽量用最小路径复现
- 复杂主链路优先画调用链再改代码

View 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

View 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`

View 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/*`

View 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 同时管理初始化、交互、请求、持久化和对话框
- 非首屏重型组件全部静态导入

View 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` 持续保持为模块层总入口。

View 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 流程不要破坏当前的防重入保护

View 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 层

View 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而不是塞回页面

View 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 凭据保存逻辑重新分散到多个模块
- 如果后续扩展更多设置项,建议引入更清晰的分组和局部表单结构

View 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

View 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
- 跨模块共享数据要保持单向来源清晰

View File

@@ -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 工程化重构的主索引文档持续维护。

View File

@@ -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 渲染层重构的主索引文档持续维护。

11
docs/releases/1.4.1.md Normal file
View File

@@ -0,0 +1,11 @@
# 1.4.1
## 改进
- 报告查看器的报告选择器升级为可搜索下拉框。
- 报表较多时,可以通过输入关键字快速筛选目标报告,减少滚动查找成本。
## 体验优化
- 优化了报告选择交互,选择流程更适合长列表场景。
- 同步合并 `dev` 分支中已完成的报告查看器可用性改进。

11
docs/releases/1.4.2.md Normal file
View File

@@ -0,0 +1,11 @@
# 1.4.2
## 架构优化
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
- 解耦更新服务职责,对话框改为懒加载以优化性能。
## 质量改进
- 修复类型检查问题,加固启动流程和认证健壮性。
- 新增核心模块测试覆盖,完善开发者文档。

View 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 模块开始从“页面驱动逻辑”向“模块化前端能力”转变。

View 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。

View File

@@ -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

View File

@@ -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
)

238
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{
"name": "erpauto",
"version": "1.4.0",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.4.0",
"version": "1.4.2",
"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",
@@ -2757,6 +2758,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 +3210,103 @@
"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/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -4236,6 +4407,15 @@
"dev": true,
"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 +4685,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",
@@ -6657,6 +6864,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",
@@ -13336,8 +13552,8 @@
"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",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13968,7 +14184,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 +14895,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",
@@ -16207,6 +16428,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",

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.4.0",
"version": "1.4.2",
"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",

View File

@@ -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
View 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"
}
}
}

View 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()
}
})
}

View 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)
})
}

View File

@@ -0,0 +1,96 @@
import { app, dialog } 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()
}
})
})
}
export function ensurePlaywrightRuntime(browsersPath: string): void {
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
}
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
}
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'
)
}
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()
}

View File

@@ -1,191 +1,22 @@
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()
ensurePlaywrightRuntime(playwrightBrowsersPath)
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.

View File

@@ -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')
})
}

View File

@@ -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...`
return withErrorHandling(
async () => cleanerService.runCleaner(event.sender, input),
'cleaner:run'
)
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')
}
)
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'
)
}
)
}

View File

@@ -19,17 +19,15 @@ import { registerUpdateHandlers } from './update-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 +68,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 {

View File

@@ -27,9 +27,7 @@ function getRustfsService(): RustfsService | null {
}
export function registerReportHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_ALL,
async (): Promise<IpcResult<ReportMetadata[]>> => {
ipcMain.handle(IPC_CHANNELS.REPORT_LIST_ALL, async (): Promise<IpcResult<ReportMetadata[]>> => {
return withErrorHandling(async () => {
const rustfs = getRustfsService()
if (!rustfs) {
@@ -91,8 +89,7 @@ export function registerReportHandlers(): void {
return reports
}, 'report:listAll')
}
)
})
ipcMain.handle(
IPC_CHANNELS.REPORT_LIST_BY_USER,

View File

@@ -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
*/

View File

@@ -1,245 +1,29 @@
/**
* 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
try {
// Get current user info
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
@@ -257,222 +41,10 @@ export function registerValidationHandlers(): void {
}
}
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)
})
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)
})
}
}
}
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,12 +89,7 @@ export function registerValidationHandlers(): void {
}
)
/**
* Get unique manager names
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
async (_event): Promise<{ managers: string[] }> => {
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_MANAGERS, async (): Promise<{ managers: string[] }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const managers = await dao.getManagers()
@@ -538,12 +100,8 @@ export function registerValidationHandlers(): void {
})
return { managers: [] }
}
}
)
})
/**
* Update manager for a single material
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
async (
@@ -554,176 +112,49 @@ 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 }> => {
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_STATISTICS,
async (): Promise<{ stats: MaterialStats | null }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const stats = await dao.getStatistics()
@@ -734,62 +165,47 @@ export function registerValidationHandlers(): void {
})
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()
try {
// Get current user
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
@@ -797,94 +213,7 @@ export function registerValidationHandlers(): void {
}
}
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)
})
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)
})
}
}
}
return validationApplicationService.getCleanerData(userInfo, event.sender.id)
}
)
}

View 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 })
)
}
}

View 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)
})
}
}
}

View File

@@ -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>]
}
}
}

View File

@@ -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'

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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

View File

@@ -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[])
})

View File

@@ -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'),

View 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 || currentUserType === 'Guest') {
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
}
}
}

View 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
}
}

View File

@@ -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> {
@@ -269,7 +130,7 @@ export class UpdateService {
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 +142,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) {
try {
await this.downloadRelease(nextStatus.recommendedRelease)
} catch (error) {
const message = error instanceof Error ? 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 +198,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 +231,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 +239,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 +254,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.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 +280,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
}
}

View 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)
})
}

View 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)
})
}
}

View 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
}

View File

@@ -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
: []

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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)
})
}
}
}
@@ -173,13 +188,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)
})
}
}
}
}

View 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)]
}

View 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()

View 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()

View 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
}

View 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
}

View File

@@ -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

View File

@@ -0,0 +1,6 @@
export interface IpcResult<T = unknown> {
success: boolean
data?: T
error?: string
code?: string
}

View 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
}

View 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
}

15
src/preload/api/auth.ts Normal file
View 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

View 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

View 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

View 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
View 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

39
src/preload/api/index.ts Normal file
View File

@@ -0,0 +1,39 @@
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'
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
} as const
export type ElectronApi = typeof api

14
src/preload/api/logger.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { LogLevel } from '../../shared/ipc-channels'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ipcRenderer } from '../lib/ipc'
export const loggerApi = {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level,
message,
context,
timestamp: Date.now()
})
}
} as const

View File

@@ -0,0 +1,91 @@
import type {
MaterialTypeBatchRequest,
MaterialTypeRecord
} from '../../main/types/validation.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import type { CleanerConfig } from '../../main/types/config.schema'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../../main/types/update.types'
import { invokeIpc, ipcRenderer } from '../lib/ipc'
type ErpSettingsPayload = {
erp?: {
username?: string
password?: string
}
}
export const materialsApi = {
upsertBatch: (materials: { materialCode: string; managerName: string }[]) =>
invokeIpc(IPC_CHANNELS.MATERIALS_UPSERT_BATCH, { materials }),
delete: (materialCodes: string[]) => invokeIpc(IPC_CHANNELS.MATERIALS_DELETE, { materialCodes }),
getManagers: () => invokeIpc(IPC_CHANNELS.MATERIALS_GET_MANAGERS),
getByManager: (managerName: string) =>
invokeIpc(IPC_CHANNELS.MATERIALS_GET_BY_MANAGER, managerName),
getAll: () => invokeIpc(IPC_CHANNELS.MATERIALS_GET_ALL),
getStatistics: () => invokeIpc(IPC_CHANNELS.MATERIALS_GET_STATISTICS),
updateManager: (materialCode: string, managerName: string) =>
invokeIpc(IPC_CHANNELS.MATERIALS_UPDATE_MANAGER, { materialCode, managerName })
} as const
export const settingsApi = {
getUserType: () => invokeIpc(IPC_CHANNELS.SETTINGS_GET_USER_TYPE),
getSettings: () => invokeIpc(IPC_CHANNELS.SETTINGS_GET_SETTINGS),
saveSettings: (settings: ErpSettingsPayload) =>
invokeIpc(IPC_CHANNELS.SETTINGS_SAVE_SETTINGS, settings),
resetDefaults: () => invokeIpc(IPC_CHANNELS.SETTINGS_RESET_DEFAULTS),
testDbConnection: () => invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
} as const
export const materialTypeApi = {
getAll: () => invokeIpc<MaterialTypeRecord[]>(IPC_CHANNELS.MATERIAL_TYPE_GET_ALL),
getByManager: (managerName: string) =>
invokeIpc<MaterialTypeRecord[]>(IPC_CHANNELS.MATERIAL_TYPE_GET_BY_MANAGER, managerName),
getManagers: () => invokeIpc<string[]>(IPC_CHANNELS.MATERIAL_TYPE_GET_MANAGERS),
upsert: (materialName: string, managerName: string) =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_UPSERT, { materialName, managerName }),
delete: (materialName: string, managerName: string) =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_DELETE, { materialName, managerName }),
upsertBatch: (request: MaterialTypeBatchRequest) =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_UPSERT_BATCH, request)
} as const
export const userErpConfigApi = {
getCurrent: () => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT),
update: (config: { url: string; username: string; password: string }) =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_UPDATE, config),
testConnection: (config: { url: string; username: string; password: string }) =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION, config),
getAll: () => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
} as const
export const configApi = {
getCleaner: () => invokeIpc<CleanerConfig>(IPC_CHANNELS.CONFIG_GET_CLEANER),
updateCleaner: (updates: Partial<CleanerConfig>) =>
invokeIpc<CleanerConfig>(IPC_CHANNELS.CONFIG_UPDATE_CLEANER, updates)
} as const
export const reportApi = {
listAll: () => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
listByUser: (username: string) => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string) => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
} as const
export const updateApi = {
getStatus: () => invokeIpc<UpdateStatus>(IPC_CHANNELS.UPDATE_GET_STATUS),
checkNow: () => invokeIpc<UpdateStatus>(IPC_CHANNELS.UPDATE_CHECK_NOW),
getCatalog: () => invokeIpc<UpdateDialogCatalog>(IPC_CHANNELS.UPDATE_GET_CATALOG),
getChangelog: (release: DownloadReleaseRequest) =>
invokeIpc<string>(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release),
downloadRelease: (release: DownloadReleaseRequest) =>
invokeIpc<UpdateStatus>(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release),
installDownloaded: () => invokeIpc<void>(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED),
onStatusChanged: (callback: (data: UpdateStatus) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: UpdateStatus) => callback(data)
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
}
} as const

View File

@@ -0,0 +1,7 @@
export const processApi = {
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node
}
} as const

View File

@@ -0,0 +1,8 @@
import type { ResolverInput } from '../../main/types/resolver-ipc.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { invokeIpc } from '../lib/ipc'
export const resolverApi = {
resolve: (input: ResolverInput) => invokeIpc(IPC_CHANNELS.RESOLVER_RESOLVE, input),
validateFormat: (inputs: string[]) => invokeIpc(IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT, inputs)
} as const

View File

@@ -0,0 +1,12 @@
import type { ValidationRequest } from '../../main/types/validation.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { invokeIpc } from '../lib/ipc'
export const validationApi = {
validate: (request: ValidationRequest) => invokeIpc(IPC_CHANNELS.VALIDATION_VALIDATE, request),
setSharedProductionIds: (productionIds: string[]) =>
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
getSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
clearSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS),
getCleanerData: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
} as const

View File

@@ -1,13 +1,19 @@
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI, ReportAPI } from '../main/types/ipc-api.types'
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
import type {
FileAPI,
ExtractorAPI,
CleanerAPI,
DatabaseAPI,
ReportAPI
} from '../main/types/ipc-api.types'
import type { ResolverInput, ResolverResponse } from '../main/types/resolver-ipc.types'
import type { UserInfo } from '../main/types/user.types'
import type {
CurrentUserResponse,
LoginRequest,
LoginResponse,
SilentLoginResponse,
UserSelectionResponse,
CurrentUserResponse
} from '../main/ipc/auth-handler'
UserSelectionResponse
} from '../main/types/auth-ipc.types'
import type {
ValidationRequest,
ValidationResponse,
@@ -19,7 +25,7 @@ import type {
ConnectionTestResult,
SaveSettingsResult
} from '../main/types/settings.types'
import type { IpcResult } from '../main/ipc'
import type { IpcResult } from '../main/types/ipc.types'
import type { LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
import type {

View File

@@ -1,246 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { MySqlConfig, SqlServerConfig } from '../main/types/ipc-api.types'
import type { ExtractorInput, ExtractionProgress } from '../main/types/extractor.types'
import type { CleanerInput, CleanerProgress, ExportResultItem } from '../main/types/cleaner.types'
import type { ResolverInput } from '../main/ipc/resolver-handler'
import type { LoginRequest } from '../main/ipc/auth-handler'
import type { UserInfo } from '../main/types/user.types'
import type {
ValidationRequest,
MaterialTypeRecord,
MaterialTypeBatchRequest
} from '../main/types/validation.types'
import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
import type { DownloadReleaseRequest, UpdateStatus } from '../main/types/update.types'
import type { UpdateDialogCatalog } from '../main/types/update.types'
type ErpSettingsPayload = {
erp?: {
username?: string
password?: string
}
}
const processApi = {
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node
}
}
function invokeIpc<T = unknown>(channel: string, ...args: unknown[]): Promise<IpcResult<T>> {
return ipcRenderer.invoke(channel, ...args).then((result: unknown) => {
if (result && typeof result === 'object' && 'success' in (result as Record<string, unknown>)) {
const typed = result as Record<string, unknown>
const hasIpcShape = 'data' in typed || 'code' in typed || 'error' in typed
if (hasIpcShape) {
return typed as unknown as IpcResult<T>
}
if (typed.success === true) {
return { success: true, data: result as T }
}
return {
success: false,
error: typeof typed.error === 'string' ? typed.error : 'IPC operation failed'
}
}
return { success: true, data: result as T }
})
}
const api = {
process: processApi,
file: {
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)
},
extractor: {
runExtractor: (input: ExtractorInput): Promise<IpcResult> =>
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)
}
},
cleaner: {
runCleaner: (input: CleanerInput): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.CLEANER_RUN, input),
exportResults: (items: ExportResultItem[]): Promise<IpcResult> =>
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)
}
},
resolver: {
resolve: (input: ResolverInput): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.RESOLVER_RESOLVE, input),
validateFormat: (inputs: string[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT, inputs)
},
auth: {
getComputerName: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
silentLogin: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_SILENT_LOGIN),
login: (request: LoginRequest): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.AUTH_LOGIN, request),
logout: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_LOGOUT),
getCurrentUser: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_GET_CURRENT_USER),
getAllUsers: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_GET_ALL_USERS),
switchUser: (userInfo: UserInfo): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.AUTH_SWITCH_USER, userInfo),
isAdmin: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_IS_ADMIN)
},
database: {
connectMySql: (config: MySqlConfig): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_CONNECT, config),
disconnectMySql: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT),
isMySqlConnected: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
queryMySql: (sql: string, params?: any[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_QUERY, sql, params),
connectSqlServer: (config: SqlServerConfig): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT, config),
disconnectSqlServer: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT),
isSqlServerConnected: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED),
querySqlServer: (sql: string, params?: Record<string, unknown>): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_SQLSERVER_QUERY, sql, params)
},
validation: {
validate: (request: ValidationRequest): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_VALIDATE, request),
setSharedProductionIds: (productionIds: string[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
getSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
clearSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS),
getCleanerData: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
},
materials: {
upsertBatch: (materials: { materialCode: string; managerName: string }[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIALS_UPSERT_BATCH, { materials }),
delete: (materialCodes: string[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIALS_DELETE, { materialCodes }),
getManagers: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.MATERIALS_GET_MANAGERS),
getByManager: (managerName: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIALS_GET_BY_MANAGER, managerName),
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.MATERIALS_GET_ALL),
getStatistics: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.MATERIALS_GET_STATISTICS),
updateManager: (materialCode: string, managerName: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIALS_UPDATE_MANAGER, { materialCode, managerName })
},
settings: {
getUserType: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_GET_USER_TYPE),
getSettings: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_GET_SETTINGS),
saveSettings: (settings: ErpSettingsPayload): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.SETTINGS_SAVE_SETTINGS, settings),
resetDefaults: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_RESET_DEFAULTS),
testDbConnection: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
},
materialType: {
getAll: (): Promise<IpcResult<MaterialTypeRecord[]>> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_GET_ALL),
getByManager: (managerName: string): Promise<IpcResult<MaterialTypeRecord[]>> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_GET_BY_MANAGER, managerName),
getManagers: (): Promise<IpcResult<string[]>> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_GET_MANAGERS),
upsert: (materialName: string, managerName: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_UPSERT, { materialName, managerName }),
delete: (materialName: string, managerName: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_DELETE, { materialName, managerName }),
upsertBatch: (request: MaterialTypeBatchRequest): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.MATERIAL_TYPE_UPSERT_BATCH, request)
},
userErpConfig: {
getCurrent: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT),
update: (config: { url: string; username: string; password: string }): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_UPDATE, config),
testConnection: (config: {
url: string
username: string
password: string
}): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION, config),
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
},
config: {
getCleaner: (): Promise<IpcResult<CleanerConfig>> => invokeIpc(IPC_CHANNELS.CONFIG_GET_CLEANER),
updateCleaner: (updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> =>
invokeIpc(IPC_CHANNELS.CONFIG_UPDATE_CLEANER, updates)
},
logger: {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level,
message,
context,
timestamp: Date.now()
})
}
},
report: {
listAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL),
listByUser: (username: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
},
update: {
getStatus: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_GET_STATUS),
checkNow: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_CHECK_NOW),
getCatalog: (): Promise<IpcResult<UpdateDialogCatalog>> =>
invokeIpc(IPC_CHANNELS.UPDATE_GET_CATALOG),
getChangelog: (release: DownloadReleaseRequest): Promise<IpcResult<string>> =>
invokeIpc(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release),
downloadRelease: (release: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> =>
invokeIpc(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release),
installDownloaded: (): Promise<IpcResult<void>> =>
invokeIpc(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED),
onStatusChanged: (callback: (data: UpdateStatus) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: UpdateStatus) =>
callback(data)
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
}
}
} as const
import { contextBridge } from 'electron'
import { api } from './api'
if (process.contextIsolated) {
try {

28
src/preload/lib/ipc.ts Normal file
View File

@@ -0,0 +1,28 @@
import { ipcRenderer } from 'electron'
import type { IpcResult } from '../../main/types/ipc.types'
export function invokeIpc<T = unknown>(channel: string, ...args: unknown[]): Promise<IpcResult<T>> {
return ipcRenderer.invoke(channel, ...args).then((result: unknown) => {
if (result && typeof result === 'object' && 'success' in (result as Record<string, unknown>)) {
const typed = result as Record<string, unknown>
const hasIpcShape = 'data' in typed || 'code' in typed || 'error' in typed
if (hasIpcShape) {
return typed as unknown as IpcResult<T>
}
if (typed.success === true) {
return { success: true, data: result as T }
}
return {
success: false,
error: typeof typed.error === 'string' ? typed.error : 'IPC operation failed'
}
}
return { success: true, data: result as T }
})
}
export { ipcRenderer }

View File

@@ -1,569 +1,78 @@
/**
* ERP App - Main application with authentication
*
* Mimics the Python ERPApp functionality:
* - Silent login by computer name on startup
* - Show login dialog if silent login fails
* - Show user selection dialog for Admin users
* - Display main content after successful authentication
*/
import React, { useState, useEffect } from 'react'
import {
LayoutDashboard,
Download,
Trash2,
Settings,
Database,
User,
LogOut,
ArrowUpCircle,
LoaderCircle
} from 'lucide-react'
import { useLogger } from './hooks/useLogger'
import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, {
type UserInfo as SelectedUserInfo
} from './components/UserSelectionDialog'
import { Toast } from './components/ui/Toast'
import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
import UpdateDialog from './components/UpdateDialog'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../../main/types/update.types'
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
interface CurrentUser {
username: string
userType: 'Admin' | 'User' | 'Guest'
}
import React from 'react'
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
import { useAppBootstrap } from './hooks/useAppBootstrap'
function App(): React.JSX.Element {
// Create logger instance for App component
const logger = useLogger('App')
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isAuthenticating, setIsAuthenticating] = useState(true)
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [computerName, setComputerName] = useState('')
// Dialog state
const [showLoginDialog, setShowLoginDialog] = useState(false)
const [showUserSelection, setShowUserSelection] = useState(false)
const [allUsers, setAllUsers] = useState<SelectedUserInfo[]>([])
const [errorMessage, setErrorMessage] = useState('')
// Track if current session is switched by Admin
const [isSwitchedByAdmin, setIsSwitchedByAdmin] = useState(false)
// Ref for logout button (for focus restoration)
const logoutButtonRef = React.useRef<HTMLButtonElement>(null)
// Navigation state
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
const {
isAuthenticated,
isAuthenticating,
currentUser,
computerName,
showLoginDialog,
showUserSelection,
allUsers,
errorMessage,
isSwitchedByAdmin,
currentPage,
setCurrentPage,
updateStatus,
updateCatalog,
showUpdateDialog,
setShowUpdateDialog,
showError,
handleLogin,
handleLoginCancel,
handleUserSelect,
handleUserSelectionCancel,
handleLogout,
openUpdateDialog,
handleInstallUserRelease,
handleAdminDownloadAndInstall,
refreshUpdateDialogState
} = useAppBootstrap()
// Load error message from sessionStorage
const showError = (message: string) => {
setErrorMessage(message)
setTimeout(() => setErrorMessage(''), 3000)
}
// Initialize authentication on mount
useEffect(() => {
logger.info('=== Initializing auth... ===')
initializeAuth()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const unsubscribe = window.electron.update.onStatusChanged((status) => {
setUpdateStatus(status)
if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') {
void refreshUpdateCatalog()
}
})
void refreshUpdateState()
return unsubscribe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (isAuthenticated) {
void refreshUpdateState()
void refreshUpdateCatalog()
return
}
setUpdateStatus(null)
setUpdateCatalog(null)
setShowUpdateDialog(false)
}, [isAuthenticated])
const refreshUpdateState = async () => {
const result = await window.electron.update.getStatus()
if (result.success && result.data) {
setUpdateStatus(result.data)
}
}
const refreshUpdateCatalog = async () => {
const result = await window.electron.update.getCatalog()
if (result.success && result.data) {
setUpdateCatalog(result.data)
}
}
const initializeAuth = async () => {
logger.info('=== Starting initializeAuth ===')
try {
// Get computer name
logger.debug('Getting computer name...')
const computerNameResult = await window.electron.auth.getComputerName()
const name =
computerNameResult.success && computerNameResult.data ? computerNameResult.data : ''
logger.debug('Computer name obtained', { name })
setComputerName(name)
// Try silent login
logger.debug('Trying silent login...')
const silentLoginResult = await window.electron.auth.silentLogin()
const result = silentLoginResult.data
logger.debug('Silent login result', { result })
if (silentLoginResult.success && result?.success && result.userInfo) {
logger.info('Silent login success', { username: result.userInfo.username })
setCurrentUser({
username: result.userInfo.username,
userType: result.userInfo.userType
})
// Check if admin needs user selection
if (result.requiresUserSelection) {
logger.info('Admin user needs to select user')
// Load all users for selection
const usersResult = await window.electron.auth.getAllUsers()
setAllUsers(usersResult.success && usersResult.data ? usersResult.data : [])
setShowUserSelection(true)
} else {
logger.info('Setting authenticated to true')
setIsAuthenticated(true)
}
} else {
logger.info('Silent login failed, showing login dialog')
// Silent login failed, show login dialog
setShowLoginDialog(true)
}
} catch (error) {
logger.error('Auth initialization error', {
error: error instanceof Error ? error.message : String(error)
})
setShowLoginDialog(true)
} finally {
logger.debug('Setting isAuthenticating to false')
setIsAuthenticating(false)
}
logger.info('=== Auth initialization complete ===')
}
// Handle login dialog submit
const handleLogin = async (username: string, password: string): Promise<boolean> => {
try {
const result = await window.electron.auth.login({ username, password })
const loginData = result.success ? result.data : undefined
if (result.success && loginData?.userInfo) {
setCurrentUser({
username: loginData.userInfo.username,
userType: loginData.userInfo.userType
})
// Check if admin needs user selection
if (loginData.userInfo.userType === 'Admin') {
setShowLoginDialog(false)
// Load all users for selection
const usersResult = await window.electron.auth.getAllUsers()
setAllUsers(usersResult.success && usersResult.data ? usersResult.data : [])
setShowUserSelection(true)
} else {
setIsAuthenticated(true)
setShowLoginDialog(false)
}
return true
}
return false
} catch (error) {
logger.error('Login error', { error: error instanceof Error ? error.message : String(error) })
return false
}
}
// Handle login dialog cancel
const handleLoginCancel = () => {
// User cancelled, keep showing dialog or exit
setShowLoginDialog(false)
}
// Handle user selection
const handleUserSelect = async (user: SelectedUserInfo) => {
try {
const result = await window.electron.auth.switchUser(user)
const switchData = result.success ? result.data : undefined
if (result.success && switchData) {
setCurrentUser({
username: switchData.userInfo?.username || user.username,
userType: switchData.userInfo?.userType || user.userType
})
setIsAuthenticated(true)
setShowUserSelection(false)
// Mark as switched by Admin
setIsSwitchedByAdmin(true)
}
} catch (error) {
logger.error('User selection error', {
error: error instanceof Error ? error.message : String(error)
})
showError('切换用户失败')
}
}
// Handle user selection cancel
const handleUserSelectionCancel = () => {
// User cancelled selection, logout and show login dialog
window.electron.auth.logout()
setShowUserSelection(false)
setShowLoginDialog(true)
}
// Handle logout
const handleLogout = async () => {
await window.electron.auth.logout()
setIsAuthenticated(false)
setCurrentUser(null)
setIsSwitchedByAdmin(false)
setShowLoginDialog(true)
}
const openUpdateDialog = async () => {
await refreshUpdateCatalog()
setShowUpdateDialog(true)
}
const handleInstallUserRelease = async () => {
if (!updateCatalog?.recommendedRelease) {
showError('暂无可安装更新')
return
}
if (updateStatus?.phase !== 'downloaded') {
const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => {
const downloadResult = await window.electron.update.downloadRelease(release)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
// Check if should show logout button
// Show logout if: user is Admin, OR user was switched by Admin
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
// Show loading state during authentication
if (isAuthenticating) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner"></div>
<p>...</p>
</div>
<style>{`
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
// Show login dialog if not authenticated
if (!isAuthenticated) {
logger.debug('Render: not authenticated', { showLoginDialog, computerName })
return (
<>
<LoginDialog
isOpen={showLoginDialog}
<UnauthenticatedApp
isAuthenticating={isAuthenticating}
showLoginDialog={showLoginDialog}
showUserSelection={showUserSelection}
computerName={computerName}
currentUser={currentUser}
allUsers={allUsers}
errorMessage={errorMessage}
onLogin={handleLogin}
onCancel={handleLoginCancel}
onError={showError}
/>
{/* User Selection Dialog for Admin */}
<UserSelectionDialog
isOpen={showUserSelection}
users={allUsers}
currentUsername={currentUser?.username || ''}
onLoginCancel={handleLoginCancel}
onSelectUser={handleUserSelect}
onCancel={handleUserSelectionCancel}
triggerRef={logoutButtonRef}
onUserSelectionCancel={handleUserSelectionCancel}
onError={showError}
logoutButtonRef={logoutButtonRef}
/>
{errorMessage && <div className="error-toast">{errorMessage}</div>}
{/* If showLoginDialog is false but not authenticated, show a message */}
{!showLoginDialog && !showUserSelection && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}
>
<div
style={{
padding: '40px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center'
}}
>
<p style={{ color: '#52c41a', fontSize: '18px', fontWeight: 600 }}>
{currentUser?.username}
</p>
<p style={{ color: '#666', marginTop: '16px' }}>...</p>
</div>
</div>
)}
<style>{`
.error-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #fff1f0;
border: 1px solid #ffa39e;
padding: 12px 24px;
border-radius: 6px;
color: #ff4d4f;
font-size: 14px;
z-index: 10000;
animation: slideDown 0.3s ease-out;
}
`}</style>
</>
)
}
// Show main content when authenticated
logger.debug('Render: authenticated', { currentUser, currentPage })
const navItems = [
{ id: 'extractor', label: '数据提取 (Extractor)', icon: <Download size={18} /> },
{ id: 'cleaner', label: '物料验证与清理 (Cleaner)', icon: <Trash2 size={18} /> },
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
]
const showUpdateEntry =
!!updateStatus &&
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
(currentUser?.userType === 'Admin' &&
(updateStatus.adminHasAnyRelease ||
updateStatus.phase === 'downloading' ||
updateStatus.phase === 'downloaded' ||
updateStatus.phase === 'installing')))
const updateButtonLabel =
currentUser?.userType === 'Admin'
? updateStatus?.phase === 'downloading'
? '下载更新中...'
: '有可用版本'
: updateStatus?.phase === 'downloaded'
? `发现稳定版 V${updateStatus.latestVersion}`
: '发现新版本'
return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* ================= 顶部导航与标题栏 ================= */}
<header
className="h-16 bg-slate-900 text-slate-300 flex items-center justify-between px-4 shadow-md z-20 flex-shrink-0"
style={{ WebkitAppRegion: 'drag' } as any}
>
<div className="flex items-center gap-6">
<div className="flex gap-2 pl-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div
className="flex flex-col cursor-pointer"
onClick={() => setCurrentPage('home')}
style={{ WebkitAppRegion: 'no-drag' } as any}
>
<div className="flex items-center gap-2 text-white font-bold text-lg">
<LayoutDashboard size={22} className="text-blue-500" />
<span>ERP Auto</span>
</div>
<span className="text-xs text-slate-400 ml-7">
{__APP_VERSION__}({__GIT_HASH__})
</span>
</div>
</div>
<nav
className="flex items-center gap-2 bg-slate-800 p-1 rounded-lg"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
{navItems.map((item) => (
<button
key={item.id}
onClick={() => setCurrentPage(item.id as Page)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentPage === item.id
? 'bg-blue-600 text-white shadow'
: 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
{item.icon}
{item.label}
</button>
))}
</nav>
<div
className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
{showUpdateEntry && (
<button
onClick={() => void openUpdateDialog()}
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
title={updateStatus?.message || '查看更新'}
>
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
<LoaderCircle size={15} className="animate-spin" />
) : (
<ArrowUpCircle size={15} />
)}
<span>{updateButtonLabel}</span>
</button>
)}
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<Database size={14} className="text-green-500" />
<span className="text-slate-300"></span>
</div>
<div className="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded-full">
<User size={16} className="text-slate-400" />
<span
className="font-medium text-slate-200"
title={`User Type: ${currentUser?.userType}`}
>
{currentUser?.username}
</span>
{shouldShowLogout && (
<button
ref={logoutButtonRef}
onClick={handleLogout}
className="ml-2 text-slate-400 hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut size={16} />
</button>
)}
</div>
</div>
</header>
{/* ================= 主体内容区域 ================= */}
<div className="flex flex-1 overflow-hidden relative">
<main className="flex-1 overflow-hidden bg-slate-50 p-6 h-full">
{currentPage === 'home' && (
<div className="h-full overflow-auto">
<div className="max-w-4xl mx-auto mt-10 text-center animate-in fade-in slide-in-from-bottom-4 duration-500">
<LayoutDashboard size={48} className="mx-auto text-blue-500 mb-4" />
<h1 className="text-3xl font-bold text-slate-800 mb-4">使 ERP Auto</h1>
<p className="text-slate-500 text-lg max-w-2xl mx-auto">
ERP 使
</p>
</div>
</div>
)}
{currentPage === 'extractor' && <ExtractorPage />}
{currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />}
</main>
</div>
{/* Toast Notifications */}
<Toast />
<UpdateDialog
isOpen={showUpdateDialog}
userType={currentUser?.userType ?? null}
status={updateStatus}
catalog={updateCatalog}
onClose={() => setShowUpdateDialog(false)}
<AuthenticatedAppShell
currentUser={currentUser}
currentPage={currentPage}
onNavigate={setCurrentPage}
updateStatus={updateStatus}
updateCatalog={updateCatalog}
showUpdateDialog={showUpdateDialog}
onOpenUpdateDialog={openUpdateDialog}
onCloseUpdateDialog={() => setShowUpdateDialog(false)}
onInstallUserRelease={handleInstallUserRelease}
onDownloadAndInstallAdminRelease={async (release) =>
handleAdminDownloadAndInstall(release)
}
onRefreshCatalog={async () => {
await window.electron.update.checkNow()
await refreshUpdateCatalog()
await refreshUpdateState()
}}
onDownloadAndInstallAdminRelease={handleAdminDownloadAndInstall}
onRefreshCatalog={refreshUpdateDialogState}
shouldShowLogout={shouldShowLogout}
onLogout={handleLogout}
logoutButtonRef={logoutButtonRef}
/>
</div>
)
}

View File

@@ -10,17 +10,7 @@
import React from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
import { Modal } from './ui/Modal'
interface CleanerProgress {
message: string
progress: number
currentOrderIndex: number
totalOrders: number
currentMaterialIndex: number
totalMaterialsInOrder: number
currentOrderNumber?: string
phase: 'login' | 'processing' | 'complete'
}
import type { CleanerProgress } from '../hooks/cleaner/types'
interface ExecutionReportDialogProps {
isOpen: boolean

View File

@@ -10,8 +10,8 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
import { Modal } from './ui/Modal'
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
import { useConfirmDialog } from './ui/ConfirmDialog'
import { ConfirmDialog } from './ui/ConfirmDialog'
import { useConfirmDialog } from './ui/useConfirmDialog'
interface MaterialTypeRecord {
id?: number
@@ -51,6 +51,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
const tableRef = useRef<HTMLTableElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const selectRef = useRef<HTMLSelectElement>(null)
// Confirmation dialog hook
const { confirm, dialog: confirmDialog } = useConfirmDialog()
@@ -60,22 +61,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
).length
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
loadData()
}
}, [isOpen, isAdmin, currentUsername])
// Focus input when editing starts
useEffect(() => {
if (editingCell && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingCell])
const loadData = async () => {
const loadData = useCallback(async () => {
setLoading(true)
try {
// Load managers list
@@ -114,7 +100,25 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
} finally {
setLoading(false)
}
}, [currentUsername, isAdmin])
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
void loadData()
}
}, [isOpen, loadData])
// Focus input when editing starts
useEffect(() => {
const activeElement = inputRef.current ?? selectRef.current
if (editingCell && activeElement) {
activeElement.focus()
if (activeElement instanceof HTMLInputElement) {
activeElement.select()
}
}
}, [editingCell])
// Filter rows by selected managers (admin only)
const filteredRows = React.useMemo(() => {
@@ -296,7 +300,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
variant: 'warning'
})
if (confirmed) {
loadData()
void loadData()
}
}
@@ -443,7 +447,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
<tr>
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
"新增"
&quot;&quot;
</td>
</tr>
) : (
@@ -495,7 +499,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
{isEditingManager ? (
isAdmin ? (
<select
ref={inputRef as any}
ref={selectRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { X, FileText, Loader2 } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { X, FileText, Loader2, ChevronDown } from 'lucide-react'
import { Combobox, Transition } from '@headlessui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@@ -30,25 +31,14 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
currentUsername
}) => {
const [reports, setReports] = useState<ReportMetadata[]>([])
const [selectedReportKey, setSelectedReportKey] = useState<string>('')
const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
const [reportContent, setReportContent] = useState<string>('')
const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
useEffect(() => {
if (isOpen) {
loadReports()
} else {
// Reset state when closed
setReports([])
setSelectedReportKey('')
setReportContent('')
setError(null)
}
}, [isOpen, isAdmin, currentUsername])
const loadReports = async () => {
const loadReports = useCallback(async () => {
setIsLoadingList(true)
setError(null)
try {
@@ -64,18 +54,29 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
} else {
setError(result.error || '无法获取报告列表')
}
} catch (err) {
} catch {
setError('获取报告列表时发生错误')
} finally {
setIsLoadingList(false)
}
}, [currentUsername, isAdmin])
useEffect(() => {
if (isOpen) {
void loadReports()
} else {
// Reset state when closed
setReports([])
setSelectedReport(null)
setReportContent('')
setError(null)
}
}, [isOpen, loadReports])
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const key = e.target.value
setSelectedReportKey(key)
const handleReportChange = async (report: ReportMetadata | null) => {
setSelectedReport(report)
if (!key) {
if (!report) {
setReportContent('')
return
}
@@ -83,14 +84,14 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
setIsLoadingContent(true)
setError(null)
try {
const result = await window.electron.report.download(key)
const result = await window.electron.report.download(report.key)
if (result.success && result.data) {
setReportContent(result.data)
} else {
setError(result.error || '无法获取报告内容')
setReportContent('')
}
} catch (err) {
} catch {
setError('获取报告内容时发生错误')
setReportContent('')
} finally {
@@ -98,6 +99,12 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
}
}
// Filter reports based on search query
const filteredReports = useMemo(() => {
if (!query) return reports
return reports.filter((report) => report.filename.toLowerCase().includes(query.toLowerCase()))
}, [reports, query])
if (!isOpen) return null
// Format date to local string
@@ -136,29 +143,82 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-slate-700 flex-shrink-0">:</label>
<div className="relative flex-1 max-w-2xl">
<select
value={selectedReportKey}
onChange={handleReportChange}
<Combobox value={selectedReport} onChange={handleReportChange} nullable>
<div className="relative">
<Combobox.Input
className="w-full bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
displayValue={(report: ReportMetadata | null) => {
if (!report) return '请选择要查看的报告...'
return `${isAdmin ? `[${report.username}] ` : ''}${report.filename} (${formatDate(report.lastModified)})`
}}
onChange={(event) => setQuery(event.target.value)}
placeholder="请选择要查看的报告..."
disabled={isLoadingList}
className="w-full appearance-none bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
>
<option value="">...</option>
{reports.map((report) => (
<option key={report.key} value={report.key}>
{isAdmin ? `[${report.username}] ` : ''}
{report.filename} ({formatDate(report.lastModified)})
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
<svg
className="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 hover:text-slate-700 transition-colors">
<ChevronDown size={20} className="h-5 w-5" />
</Combobox.Button>
</div>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Combobox.Options className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
{filteredReports.length === 0 && query ? (
<div className="px-4 py-8 text-center text-slate-400 text-sm">
</div>
) : (
<>
{/* Empty option to allow clearing selection */}
<Combobox.Option
value={null}
className={({ active }) =>
`px-4 py-2.5 text-sm cursor-pointer ${
active ? 'bg-slate-100 text-slate-900' : 'text-slate-500 italic'
}`
}
>
...
</Combobox.Option>
{filteredReports.map((report) => (
<Combobox.Option
key={report.key}
value={report}
className={({ active, selected }) =>
`px-4 py-2.5 text-sm cursor-pointer transition-colors ${
selected
? 'bg-blue-500 text-white'
: active
? 'bg-blue-50 text-blue-700'
: 'text-slate-900'
}`
}
>
{({ selected }) => (
<div className="flex items-center justify-between">
<span className="truncate flex-1">
{isAdmin ? `[${report.username}] ` : ''}
{report.filename}
</span>
<span
className={`ml-2 text-xs ${selected ? 'text-blue-100' : 'text-slate-400'}`}
>
{formatDate(report.lastModified)}
</span>
</div>
)}
</Combobox.Option>
))}
</>
)}
</Combobox.Options>
</Transition>
</Combobox>
</div>
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
</div>

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'
import React, { startTransition } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
import Modal from './ui/Modal'
import { useUpdateDialogState } from '../hooks/useUpdateDialogState'
import type { UserType } from '../../../main/types/user.types'
import type {
DownloadReleaseRequest,
@@ -22,10 +23,6 @@ interface UpdateDialogProps {
onRefreshCatalog: () => Promise<void>
}
function releaseKey(release: UpdateRelease): string {
return `${release.channel}:${release.version}`
}
export default function UpdateDialog({
isOpen,
userType,
@@ -36,57 +33,13 @@ export default function UpdateDialog({
onDownloadAndInstallAdminRelease,
onRefreshCatalog
}: UpdateDialogProps): React.JSX.Element {
const [selectedRelease, setSelectedRelease] = useState<UpdateRelease | undefined>(undefined)
const [changelog, setChangelog] = useState<string>('')
const [isLoadingChangelog, setIsLoadingChangelog] = useState(false)
useEffect(() => {
if (!isOpen || !catalog) return
if (catalog.mode === 'user') {
setSelectedRelease(catalog.recommendedRelease)
return
}
if (catalog.mode === 'admin') {
setSelectedRelease(
catalog.recommendedRelease ?? catalog.channels?.stable[0] ?? catalog.channels?.preview[0]
)
}
}, [catalog, isOpen])
useEffect(() => {
if (!isOpen || !selectedRelease) {
setChangelog('')
return
}
let active = true
setIsLoadingChangelog(true)
window.electron.update
.getChangelog(selectedRelease)
.then((result) => {
if (!active) return
setChangelog(result.success && result.data ? result.data : '暂无更新说明。')
})
.catch(() => {
if (!active) return
setChangelog('暂无更新说明。')
})
.finally(() => {
if (active) {
setIsLoadingChangelog(false)
}
})
return () => {
active = false
}
}, [isOpen, selectedRelease])
const { selectedRelease, setSelectedRelease, changelog, isLoadingChangelog, isSelectedRelease } =
useUpdateDialogState(isOpen, catalog)
const isBusy =
status?.phase === 'downloading' || status?.phase === 'installing' || status?.phase === 'checking'
status?.phase === 'downloading' ||
status?.phase === 'installing' ||
status?.phase === 'checking'
const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
if (releases.length === 0) {
@@ -101,11 +54,15 @@ export default function UpdateDialog({
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{releases.map((release) => {
const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release)
const selected = isSelectedRelease(release)
return (
<button
key={releaseKey(release)}
onClick={() => setSelectedRelease(release)}
key={`${release.channel}:${release.version}`}
onClick={() => {
startTransition(() => {
setSelectedRelease(release)
})
}}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selected
? 'border-blue-500 bg-blue-50 text-blue-900'
@@ -233,7 +190,9 @@ export default function UpdateDialog({
{userType === 'Admin' ? (
<button
onClick={() =>
selectedRelease ? void onDownloadAndInstallAdminRelease(selectedRelease) : undefined
selectedRelease
? void onDownloadAndInstallAdminRelease(selectedRelease)
: undefined
}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy}

View File

@@ -7,7 +7,7 @@
* - Return selected user info
*/
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useRef } from 'react'
import { Modal } from './ui/Modal'
export interface UserInfo {
@@ -37,13 +37,6 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
// Reset selection when dialog opens
useEffect(() => {
if (isOpen) {
setSelectedUserId(null)
}
}, [isOpen])
const handleConfirm = () => {
if (selectedUserId === null) {
return
@@ -51,14 +44,21 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
const selectedUser = users.find((u) => u.id === selectedUserId)
if (selectedUser) {
setSelectedUserId(null)
onSelectUser(selectedUser)
}
}
const handleDoubleClick = (user: UserInfo) => {
setSelectedUserId(null)
onSelectUser(user)
}
const handleCancel = () => {
setSelectedUserId(null)
onCancel()
}
const userTypeStyles: Record<string, string> = {
Admin: 'bg-amber-50 text-amber-600',
User: 'bg-blue-50 text-blue-600',
@@ -70,7 +70,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
return (
<Modal
isOpen={isOpen}
onClose={onCancel}
onClose={handleCancel}
title="选择用户"
size="md"
triggerRef={triggerRef}
@@ -129,7 +129,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
</button>
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onCancel}
onClick={handleCancel}
>
</button>

View File

@@ -0,0 +1,212 @@
import React, { Suspense } from 'react'
import {
ArrowUpCircle,
Database,
Download,
LayoutDashboard,
LoaderCircle,
LogOut,
Settings,
Trash2,
User
} from 'lucide-react'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../../../../main/types/update.types'
import type { CurrentUser, Page } from '../../hooks/useAppBootstrap'
import { Toast } from '../ui/Toast'
import CleanerPage from '../../pages/CleanerPage'
import ExtractorPage from '../../pages/ExtractorPage'
import SettingsPage from '../../pages/SettingsPage'
const UpdateDialog = React.lazy(() => import('../UpdateDialog'))
interface AuthenticatedAppShellProps {
currentUser: CurrentUser | null
currentPage: Page
onNavigate: (page: Page) => void
updateStatus: UpdateStatus | null
updateCatalog: UpdateDialogCatalog | null
showUpdateDialog: boolean
onOpenUpdateDialog: () => Promise<void>
onCloseUpdateDialog: () => void
onInstallUserRelease: () => Promise<void>
onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise<void>
onRefreshCatalog: () => Promise<void>
shouldShowLogout: boolean
onLogout: () => Promise<void>
logoutButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function AuthenticatedAppShell({
currentUser,
currentPage,
onNavigate,
updateStatus,
updateCatalog,
showUpdateDialog,
onOpenUpdateDialog,
onCloseUpdateDialog,
onInstallUserRelease,
onDownloadAndInstallAdminRelease,
onRefreshCatalog,
shouldShowLogout,
onLogout,
logoutButtonRef
}: AuthenticatedAppShellProps): React.JSX.Element {
const navItems = [
{ id: 'extractor' as const, label: '数据提取 (Extractor)', icon: <Download size={18} /> },
{ id: 'cleaner' as const, label: '物料验证与清理 (Cleaner)', icon: <Trash2 size={18} /> },
{ id: 'settings' as const, label: '系统设置 (Settings)', icon: <Settings size={18} /> }
]
const showUpdateEntry =
!!updateStatus &&
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
(currentUser?.userType === 'Admin' &&
(updateStatus.adminHasAnyRelease ||
updateStatus.phase === 'downloading' ||
updateStatus.phase === 'downloaded' ||
updateStatus.phase === 'installing')))
const updateButtonLabel =
currentUser?.userType === 'Admin'
? updateStatus?.phase === 'downloading'
? '下载更新中...'
: '有可用版本'
: updateStatus?.phase === 'downloaded'
? `发现稳定版 V${updateStatus.latestVersion}`
: '发现新版本'
return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
<header
className="h-16 bg-slate-900 text-slate-300 flex items-center justify-between px-4 shadow-md z-20 flex-shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex items-center gap-6">
<div className="flex gap-2 pl-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div
className="flex flex-col cursor-pointer"
onClick={() => onNavigate('home')}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<div className="flex items-center gap-2 text-white font-bold text-lg">
<LayoutDashboard size={22} className="text-blue-500" />
<span>ERP Auto</span>
</div>
<span className="text-xs text-slate-400 ml-7">
{__APP_VERSION__}({__GIT_HASH__})
</span>
</div>
</div>
<nav
className="flex items-center gap-2 bg-slate-800 p-1 rounded-lg"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentPage === item.id
? 'bg-blue-600 text-white shadow'
: 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
{item.icon}
{item.label}
</button>
))}
</nav>
<div
className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{showUpdateEntry && (
<button
onClick={() => void onOpenUpdateDialog()}
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
title={updateStatus?.message || '查看更新'}
>
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
<LoaderCircle size={15} className="animate-spin" />
) : (
<ArrowUpCircle size={15} />
)}
<span>{updateButtonLabel}</span>
</button>
)}
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<Database size={14} className="text-green-500" />
<span className="text-slate-300"></span>
</div>
<div className="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded-full">
<User size={16} className="text-slate-400" />
<span
className="font-medium text-slate-200"
title={`User Type: ${currentUser?.userType}`}
>
{currentUser?.username}
</span>
{shouldShowLogout && (
<button
ref={logoutButtonRef}
onClick={() => void onLogout()}
className="ml-2 text-slate-400 hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut size={16} />
</button>
)}
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden relative">
<main className="flex-1 overflow-hidden bg-slate-50 p-6 h-full">
{currentPage === 'home' && (
<div className="h-full overflow-auto">
<div className="max-w-4xl mx-auto mt-10 text-center animate-in fade-in slide-in-from-bottom-4 duration-500">
<LayoutDashboard size={48} className="mx-auto text-blue-500 mb-4" />
<h1 className="text-3xl font-bold text-slate-800 mb-4">使 ERP Auto</h1>
<p className="text-slate-500 text-lg max-w-2xl mx-auto">
ERP 使
</p>
</div>
</div>
)}
{currentPage === 'extractor' && <ExtractorPage />}
{currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />}
</main>
</div>
<Toast />
<Suspense fallback={null}>
<UpdateDialog
isOpen={showUpdateDialog}
userType={currentUser?.userType ?? null}
status={updateStatus}
catalog={updateCatalog}
onClose={onCloseUpdateDialog}
onInstallUserRelease={onInstallUserRelease}
onDownloadAndInstallAdminRelease={async (release) =>
onDownloadAndInstallAdminRelease(release)
}
onRefreshCatalog={onRefreshCatalog}
/>
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import React from 'react'
import LoginDialog from '../LoginDialog'
import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from '../UserSelectionDialog'
interface CurrentUser {
username: string
userType: 'Admin' | 'User' | 'Guest'
}
interface UnauthenticatedAppProps {
isAuthenticating: boolean
showLoginDialog: boolean
showUserSelection: boolean
computerName: string
currentUser: CurrentUser | null
allUsers: SelectedUserInfo[]
errorMessage: string
onLogin: (username: string, password: string) => Promise<boolean>
onLoginCancel: () => void
onSelectUser: (user: SelectedUserInfo) => Promise<void>
onUserSelectionCancel: () => void
onError: (message: string) => void
logoutButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function UnauthenticatedApp({
isAuthenticating,
showLoginDialog,
showUserSelection,
computerName,
currentUser,
allUsers,
errorMessage,
onLogin,
onLoginCancel,
onSelectUser,
onUserSelectionCancel,
onError,
logoutButtonRef
}: UnauthenticatedAppProps): React.JSX.Element {
if (isAuthenticating) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner"></div>
<p>...</p>
</div>
<style>{`
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
return (
<>
<LoginDialog
isOpen={showLoginDialog}
computerName={computerName}
onLogin={onLogin}
onCancel={onLoginCancel}
onError={onError}
/>
<UserSelectionDialog
isOpen={showUserSelection}
users={allUsers}
currentUsername={currentUser?.username || ''}
onSelectUser={onSelectUser}
onCancel={onUserSelectionCancel}
triggerRef={logoutButtonRef}
/>
{errorMessage && <div className="error-toast">{errorMessage}</div>}
{!showLoginDialog && !showUserSelection && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}
>
<div
style={{
padding: '40px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center'
}}
>
<p style={{ color: '#52c41a', fontSize: '18px', fontWeight: 600 }}>
{currentUser?.username}
</p>
<p style={{ color: '#666', marginTop: '16px' }}>...</p>
</div>
</div>
)}
<style>{`
.error-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #fff1f0;
border: 1px solid #ffa39e;
padding: 12px 24px;
border-radius: 6px;
color: #ff4d4f;
font-size: 14px;
z-index: 10000;
animation: slideDown 0.3s ease-out;
}
`}</style>
</>
)
}

View File

@@ -0,0 +1,121 @@
import React from 'react'
import { Play, Settings2, ToggleLeft, ToggleRight } from 'lucide-react'
interface CleanerExecutionBarProps {
filteredCount: number
selectedCount: number
dryRun: boolean
setDryRun: (value: boolean) => void
headless: boolean
setHeadless: (value: boolean) => void
processConcurrency: number
updateProcessConcurrency: (value: number) => void
showSettingsMenu: boolean
setShowSettingsMenu: (open: boolean) => void
handleExecuteDeletion: () => Promise<void>
isRunning: boolean
executeButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function CleanerExecutionBar({
filteredCount,
selectedCount,
dryRun,
setDryRun,
headless,
setHeadless,
processConcurrency,
updateProcessConcurrency,
showSettingsMenu,
setShowSettingsMenu,
handleExecuteDeletion,
isRunning,
executeButtonRef
}: CleanerExecutionBarProps): React.JSX.Element {
return (
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="text-sm text-slate-600 font-medium">
{filteredCount} | {' '}
<span className="text-blue-600">{selectedCount}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative" data-settings-menu>
<button
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
className="text-sm text-slate-600 bg-slate-100 hover:bg-slate-200 px-5 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<Settings2 size={16} />
</button>
{showSettingsMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-white rounded-lg shadow-lg border border-slate-200 py-3 px-4 min-w-[280px] z-50">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800"> (Dry-Run)</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setDryRun(!dryRun)}
className={`transition-colors flex-shrink-0 ml-4 ${dryRun ? 'text-amber-500' : 'text-slate-300'}`}
>
{dryRun ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
<div className="border-t border-slate-100 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800"> (Headless)</div>
<div className="text-xs text-slate-500 mt-0.5">
</div>
</div>
<button
onClick={() => setHeadless(!headless)}
className={`transition-colors flex-shrink-0 ml-4 ${headless ? 'text-blue-500' : 'text-slate-300'}`}
>
{headless ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
</button>
</div>
</div>
<div className="border-t border-slate-100 pt-3 space-y-3">
<div>
<div className="text-sm font-medium text-slate-800"></div>
<div className="text-xs text-slate-500 mt-0.5">
1-20
</div>
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={1}
max={20}
value={processConcurrency}
onChange={(e) => updateProcessConcurrency(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<span className="text-sm font-medium text-slate-700 w-8 text-center">
{processConcurrency}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<button
onClick={() => void handleExecuteDeletion()}
ref={executeButtonRef}
disabled={isRunning}
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
>
<Play size={18} fill="currentColor" /> {dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
import React from 'react'
import { CheckSquare, Square } from 'lucide-react'
import type { ValidationResult } from '../../hooks/cleaner/types'
interface CleanerResultsTableProps {
validationResults: ValidationResult[]
filteredResults: ValidationResult[]
selectedItems: Set<string>
isAdmin: boolean
managers: string[]
editingCell: { rowIndex: number; field: string } | null
editValue: string
setEditValue: (value: string) => void
inputRef: React.RefObject<HTMLInputElement | HTMLSelectElement | null>
handleCheckboxToggle: (materialCode: string) => void
handleAssignManagerOnSelect: (materialCode: string) => void
startEdit: (rowIndex: number, field: string) => void
saveEdit: () => void
cancelEdit: () => void
}
export function CleanerResultsTable({
validationResults,
filteredResults,
selectedItems,
isAdmin,
managers,
editingCell,
editValue,
setEditValue,
inputRef,
handleCheckboxToggle,
handleAssignManagerOnSelect,
startEdit,
saveEdit,
cancelEdit
}: CleanerResultsTableProps): React.JSX.Element {
const handleSelectionChange = (materialCode: string) => {
handleCheckboxToggle(materialCode)
if (!isAdmin) {
handleAssignManagerOnSelect(materialCode)
}
}
return (
<div className="flex-1 overflow-y-auto">
<table className="w-full text-left text-sm whitespace-nowrap table-fixed">
<thead className="bg-slate-100 text-slate-600 sticky top-0 shadow-sm z-10 text-xs font-semibold">
<tr>
<th className="px-4 py-3 w-16 text-center"></th>
<th className="px-4 py-3 w-64"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 w-40">
<span className="text-[10px] font-normal text-slate-400">()</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredResults.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-400 text-sm">
{validationResults.length === 0
? '暂无数据,请点击左侧按钮获取并校验物料'
: '当前筛选条件下暂无数据'}
</td>
</tr>
) : (
filteredResults.map((result, rowIndex) => {
const isChecked = selectedItems.has(result.materialCode)
const noManager = !result.managerName?.trim()
const isEditingManager =
editingCell?.rowIndex === rowIndex && editingCell?.field === 'managerName'
const rowClassName = `${isChecked ? 'bg-blue-50/30' : 'hover:bg-slate-50'} transition-colors ${noManager && isChecked ? 'bg-amber-50/20' : ''}`
const managerCellClassName = noManager
? 'text-amber-500 text-xs italic'
: 'text-slate-700'
return (
<tr key={result.materialCode} className={rowClassName}>
<td className="px-4 py-3 text-center truncate">
{isChecked ? (
<CheckSquare
onClick={() => handleSelectionChange(result.materialCode)}
size={16}
className="text-blue-600 inline cursor-pointer"
/>
) : (
<Square
onClick={() => handleSelectionChange(result.materialCode)}
size={16}
className="text-slate-300 inline cursor-pointer"
/>
)}
</td>
<td
className="px-4 py-3 font-medium text-slate-800 truncate"
title={result.materialName}
>
{result.materialName}
</td>
<td
className="px-4 py-3 font-mono text-xs text-slate-600 truncate"
title={result.materialCode}
>
{result.materialCode}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.specification}
>
{result.specification || '-'}
</td>
<td className="px-4 py-3 text-slate-500 text-xs truncate" title={result.model}>
{result.model || '-'}
</td>
<td
className={`px-4 py-3 truncate ${managerCellClassName}`}
title={result.managerName || '空 (待分配)'}
>
{isAdmin && isEditingManager ? (
<select
ref={inputRef as React.Ref<HTMLSelectElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
>
<option value=""></option>
{managers.map((manager) => (
<option key={manager} value={manager}>
{manager}
</option>
))}
</select>
) : isAdmin ? (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={(e) => {
e.stopPropagation()
startEdit(rowIndex, 'managerName')
}}
>
{result.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
) : (
result.managerName || '空 (待分配)'
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import React from 'react'
import { DatabaseZap, Layers, Users } from 'lucide-react'
interface CleanerSidebarProps {
valMode: 'full' | 'filtered'
setValMode: (mode: 'full' | 'filtered') => void
managers: string[]
selectedManagers: Set<string>
setSelectedManagers: React.Dispatch<React.SetStateAction<Set<string>>>
}
export function CleanerSidebar({
valMode,
setValMode,
managers,
selectedManagers,
setSelectedManagers
}: CleanerSidebarProps): React.JSX.Element {
return (
<div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800">
<DatabaseZap size={18} className="text-blue-500" />
</h3>
<div className="space-y-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'full'}
onChange={() => setValMode('full')}
/>
<div>
<div className="text-sm font-medium text-slate-800"> - </div>
<div className="text-xs text-slate-500 mt-0.5"></div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
className="mt-1"
checked={valMode === 'filtered'}
onChange={() => setValMode('filtered')}
/>
<div className="w-full">
<div className="text-sm font-medium text-slate-800"> - ProductionID </div>
<div className="text-xs text-slate-500 mt-0.5"></div>
{valMode === 'filtered' && (
<div className="mt-3 text-xs text-blue-700 bg-blue-100/50 rounded p-2 flex items-start gap-1.5">
<Layers size={14} className="mt-0.5 flex-shrink-0" />
<span>
使<strong></strong>
</span>
</div>
)}
</div>
</label>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800">
<Users size={18} className="text-indigo-500" />
()
</h3>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-3 max-h-[120px] overflow-y-auto pr-1">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers((prev) => {
const next = new Set(prev)
if (e.target.checked) next.add(manager)
else next.delete(manager)
return next
})
}}
/>
{manager || '未分配'}
</label>
))}
{managers.length === 0 && <div className="text-sm text-slate-400"></div>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import {
CheckSquare,
Eye,
EyeOff,
FileSpreadsheet,
FileText,
HardDrive,
Search,
Settings2,
Square
} from 'lucide-react'
import type { ValidationResult } from '../../hooks/cleaner/types'
interface CleanerToolbarProps {
validationResults: ValidationResult[]
filteredResults: ValidationResult[]
selectedItems: Set<string>
setSelectedItems: React.Dispatch<React.SetStateAction<Set<string>>>
setHiddenItems: React.Dispatch<React.SetStateAction<Set<string>>>
isValidationRunning: boolean
isRunning: boolean
isExporting: boolean
isTypeDialogOpen: boolean
setIsTypeDialogOpen: (open: boolean) => void
handleValidation: () => Promise<void>
handleConfirmDeletion: () => Promise<void>
handleExportResults: () => Promise<void>
setIsReportViewerOpen: (open: boolean) => void
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
}
export function CleanerToolbar({
validationResults,
filteredResults,
selectedItems,
setSelectedItems,
setHiddenItems,
isValidationRunning,
isRunning,
isExporting,
setIsTypeDialogOpen,
handleValidation,
handleConfirmDeletion,
handleExportResults,
setIsReportViewerOpen,
typeManagementButtonRef
}: CleanerToolbarProps): React.JSX.Element {
return (
<>
<div className="p-4 border-b border-slate-200 flex-shrink-0">
<button
onClick={() => void handleValidation()}
disabled={isValidationRunning}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50"
>
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
</button>
</div>
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center flex-shrink-0">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<CheckSquare size={14} className="text-blue-600" />
</button>
<button
onClick={() => {
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => {
const next = new Set(prev)
for (const code of visibleCodes) {
next.delete(code)
}
return next
})
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400" />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => {
const checkedCodes = filteredResults
.filter((r) => selectedItems.has(r.materialCode))
.map((r) => r.materialCode)
if (checkedCodes.length) {
setHiddenItems((prev) => new Set([...prev, ...checkedCodes]))
}
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<EyeOff size={14} />
</button>
<button
onClick={() => setHiddenItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Eye size={14} />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => void handleConfirmDeletion()}
disabled={isRunning || validationResults.length === 0}
className="text-xs bg-indigo-50 border border-indigo-200 text-indigo-700 px-3 py-1.5 rounded shadow-sm hover:bg-indigo-100 flex items-center gap-1.5 font-medium transition-colors disabled:opacity-50"
>
<HardDrive size={14} /> ()
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsTypeDialogOpen(true)}
ref={typeManagementButtonRef}
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
>
<Settings2 size={14} />
</button>
<button
onClick={() => void handleExportResults()}
disabled={isExporting || filteredResults.length === 0}
className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium disabled:opacity-50"
>
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button>
<button
onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
>
<FileText size={14} />
</button>
</div>
</div>
</>
)
}

View File

@@ -5,7 +5,7 @@
* Extends the Modal component with consistent styling and behavior.
*/
import React, { useState, useCallback, useEffect } from 'react'
import React, { useCallback, useEffect } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { Modal } from './Modal'
import { Button } from './Button'
@@ -115,54 +115,4 @@ export function ConfirmDialog({
)
}
/**
* Hook for using confirmation dialogs
* Returns a confirm function that shows a dialog and resolves with user's choice
*/
export function useConfirmDialog() {
const [config, setConfig] = useState<
| (Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
resolve: (value: boolean) => void
})
| null
>(null)
const confirm = useCallback(
(options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setConfig({
...options,
resolve
})
})
},
[]
)
const handleConfirm = useCallback(() => {
if (config) {
config.resolve(true)
setConfig(null)
}
}, [config])
const handleCancel = useCallback(() => {
if (config) {
config.resolve(false)
setConfig(null)
}
}, [config])
const dialog = config
? {
...config,
isOpen: true,
onConfirm: handleConfirm,
onCancel: handleCancel
}
: null
return { confirm, dialog }
}
export default ConfirmDialog

Some files were not shown because too many files have changed in this diff Show More