23 Commits

Author SHA1 Message Date
Misaka_Company
6b2a3b088f 1.6.1 2026-03-26 12:17:28 +08:00
Misaka_Company
82a6e24132 docs: add release notes for version 1.6.1
Document the major refactoring work and improvements in 1.6.1:
- Component architecture restructure (948 → 200 lines)
- Tooltip display fixes and formatting improvements
- Performance optimizations following Vercel React best practices
- Enhanced code maintainability and developer experience

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:15:38 +08:00
Misaka_Company
4a1a78ee14 refactor(ui): restructure ReportAnalysisDialog into modular architecture
Break down 948-line monolithic component into 11 focused modules following
Vercel React best practices:

**Module Structure:**
- types.ts: Centralized type definitions and constants
- hooks/: Custom hooks for data management (useReportData, useChartData, useReportFilters)
- components/: Reusable UI components (MetricSelector, ViewModeToggle, UserFilter, ReportChart, Tooltips)
- utils/: Parser and aggregator utility functions

**Key Improvements:**
- Reduced main component from 948 to ~200 lines (79% reduction)
- Separated concerns: data fetching, state management, and UI rendering
- Enhanced reusability and testability of individual components
- Maintained backward compatibility with existing imports

**Additional Fixes:**
- Fixed tooltip displaying duplicate average time values
- Formatted time values to 1 decimal place in both views
- Removed redundant time display from tooltip footer

**Performance Optimizations Applied:**
- Moved tooltip components outside parent component (rerender-no-inline-components)
- Hoisted regex pattern creation outside loops (js-hoist-regexp)
- Used functional setState updates (rerender-functional-setState)
- Memoized expensive computations and callbacks

All changes maintain existing functionality while improving code quality
and maintainability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:14:35 +08:00
Misaka_Company
00c75fb0b8 1.6.0 2026-03-26 10:37:28 +08:00
Misaka_Company
a56d37a2e9 docs: add release notes for version 1.6.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:36:41 +08:00
Misaka_Company
43e6d1f4b4 feat(ui): add single-select mode for metrics in comparison view
- Restrict metric selection to single choice in user comparison view
- Auto-keep first selected metric when switching to comparison mode
- Update UI label to show "单选" or "多选" based on view mode
- Improve chart readability by preventing metric overload in comparison mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:35:35 +08:00
Misaka_Company
5ff5e5d18b fix(ui): correct per-order execution time calculation in report analysis
Fix bug where chart displayed accumulated total execution time instead of per-order average. The executionTimeSecs field now correctly shows average time per order (total time / total orders) for proper performance metrics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:32:41 +08:00
Misaka_Company
44483120a5 Merge branch 'feature/report-analysis-12739002938269072937' into dev
This merge brings in the report analysis feature for admin users, including:
- Report analysis dialog with metrics visualization
- Execution time statistics parsing improvements
- Daily aggregated metrics with charts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:48:11 +08:00
Misaka_Company
18a81ae030 fix(ui): improve execution time extraction in report analysis dialog
Enhanced value extraction with multi-pattern regex approach to handle various markdown table formats. Improved parseDurationToSeconds error handling for edge cases. Added debug logging for failed extractions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:47:25 +08:00
google-labs-jules[bot]
5178a2425a feat(ui): add report analysis feature for admin
Added a new ReportAnalysisDialog component, accessible from the ReportViewerDialog, strictly for Admin users. It parses execution reports, extracts markdown metrics like processed orders, skipped materials, errors, and execution time, and presents them in an interactive recharts line chart aggregated by day.

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
2026-03-25 10:47:32 +00:00
Misaka
5cce470850 1.5.1 2026-03-24 21:43:39 +08:00
Misaka
ffc3cbb4a9 feat: implement background update download without blocking user login
- Change update check and download to async execution, allowing immediate app entry for User users
- Updates run in background, showing update prompt when download completes
- Optimize login flow experience, eliminating blocking time caused by update downloads
- Add release notes for version 1.5.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:43:03 +08:00
Misaka_Company
7a78948a8c 1.5.0 2026-03-24 17:45:45 +08:00
Misaka_Company
97bf918ed1 docs: add release notes for version 1.5.0 2026-03-24 17:45:34 +08:00
Misaka_Company
1d5268a4da docs: add release notes style guide 2026-03-24 17:44:48 +08:00
Misaka_Company
2adfc77a58 fix: correct progress speed and ETA display logic 2026-03-24 17:26:18 +08:00
Misaka_Company
2343fb2188 fix: re-trigger authentication after Playwright download completes 2026-03-24 17:16:04 +08:00
Misaka_Company
fb46586a13 fix: remove unused dialog import from runtime.ts 2026-03-24 16:13:28 +08:00
Misaka_Company
6f21785c64 feat: add retry mechanism with exponential backoff for download failures 2026-03-24 16:08:38 +08:00
Misaka_Company
fbd62fe390 feat: integrate Playwright download dialog into startup flow 2026-03-24 16:02:31 +08:00
Misaka_Company
a711781f21 feat: add Playwright browser download progress dialog component 2026-03-24 15:48:07 +08:00
Misaka_Company
6ad916998c feat: add IPC handlers for Playwright browser download 2026-03-24 15:41:09 +08:00
Misaka_Company
32df3cea67 feat: add Playwright browser download service and preload types 2026-03-24 15:33:52 +08:00
41 changed files with 3343 additions and 60 deletions

View File

@@ -0,0 +1,212 @@
# ReportAnalysisDialog 组件重构分析
## 📊 当前状态分析
### 基本指标
- **总行数**: 948 行
- **函数/声明**: 9 个
- **React Hooks**: 20 个使用
- **职责数量**: 5+ 个主要职责
### 组件职责分析
#### 1. 数据获取与解析 (~150 行)
- `loadAndAnalyzeReports` - 数据加载逻辑
- `extractReportValues` - 报告内容解析
- `parseDurationToSeconds` - 时间解析
#### 2. 数据聚合与转换 (~200 行)
- `chartData` useMemo - 按日期聚合
- `comparisonData` useMemo - 按用户聚合
- `comparisonChartData` useMemo - 图表数据格式化
- `allUsers` useMemo - 用户列表提取
#### 3. 状态管理 (~100 行)
- 6 个 useState hooks
- 5 个 useCallback handlers
- 复杂的状态交互逻辑
#### 4. UI 控制与交互 (~200 行)
- 指标选择按钮
- 视图模式切换
- 用户筛选器
- 加载/错误状态显示
#### 5. 图表渲染 (~300 行)
- Recharts 图表配置
- 两个不同的视图模式
- 自定义 Tooltip 组件
- 图表样式和布局
## 🎯 重构目标
### 主要问题
1. **单一文件过大**: 难以维护和理解
2. **职责混乱**: 数据获取、处理、UI 混在一起
3. **复用性差**: 逻辑和 UI 紧耦合
4. **测试困难**: 难以单独测试各个部分
### 重构原则
1. **单一职责**: 每个模块只负责一件事
2. **可复用性**: 提取通用逻辑到 hooks
3. **可测试性**: 分离逻辑和 UI
4. **可维护性**: 清晰的文件结构
## 📦 建议的文件结构
```
src/renderer/src/components/report-analysis/
├── index.tsx # 主组件入口 (~150 行)
├── hooks/
│ ├── useReportData.ts # 数据获取和解析 (~100 行)
│ ├── useChartData.ts # 数据聚合和转换 (~150 行)
│ └── useReportFilters.ts # 筛选状态管理 (~80 行)
├── components/
│ ├── ReportChart.tsx # 图表组件 (~200 行)
│ ├── MetricSelector.tsx # 指标选择器 (~80 行)
│ ├── ViewModeToggle.tsx # 视图模式切换 (~50 行)
│ ├── UserFilter.tsx # 用户筛选器 (~100 行)
│ ├── CustomTooltip.tsx # 自定义 tooltip (~100 行)
│ ├── ComparisonTooltip.tsx # 对比 tooltip (~80 行)
│ └── LoadingState.tsx # 加载状态组件 (~60 行)
├── utils/
│ ├── parser.ts # 报告解析工具 (~100 行)
│ ├── aggregators.ts # 数据聚合函数 (~120 行)
│ └── formatters.ts # 格式化工具 (~60 行)
└── types.ts # 类型定义 (~80 行)
```
## 🔧 重构方案
### 方案 A: 完全重构 (推荐)
**优点**: 最大程度的解耦和可维护性
**缺点**: 需要更多时间,可能引入新问题
**时间估计**: 2-3 小时
### 方案 B: 渐进式重构
**优点**: 风险较低,可以逐步验证
**缺点**: 过渡期代码可能不够优雅
**时间估计**: 1-2 小时
### 方案 C: 最小化重构
**优点**: 改动最小,风险最低
**缺点**: 解决根本问题有限
**时间估计**: 30-45 分钟
## 📝 详细重构步骤
### Phase 1: 提取类型和工具函数 (低风险)
1. 创建 `types.ts` - 集中管理所有类型定义
2. 创建 `utils/parser.ts` - 提取报告解析逻辑
3. 创建 `utils/aggregators.ts` - 提取数据聚合逻辑
### Phase 2: 提取自定义 Hooks (中风险)
1. 创建 `hooks/useReportData.ts` - 数据获取和解析
2. 创建 `hooks/useChartData.ts` - 数据聚合和转换
3. 创建 `hooks/useReportFilters.ts` - 筛选状态管理
### Phase 3: 提取 UI 组件 (中风险)
1. 创建 `components/MetricSelector.tsx`
2. 创建 `components/ViewModeToggle.tsx`
3. 创建 `components/UserFilter.tsx`
4. 创建 `components/ReportChart.tsx`
### Phase 4: 重构主组件 (高风险)
1. 简化 `index.tsx` 只保留组合逻辑
2. 添加错误边界
3. 优化加载状态
## 🎯 重构后的预期效果
### 代码行数分布
- 主组件: ~150 行 (减少 84%)
- 每个 hook: ~80-150 行
- 每个 UI 组件: ~50-200 行
- 工具函数: ~60-120 行
### 可维护性提升
- ✅ 单个文件更小,更易理解
- ✅ 职责清晰,修改影响范围小
- ✅ 更容易进行单元测试
- ✅ 可以独立优化各个部分
### 性能影响
- ➡️ 性能基本不变或略有提升
- ➡️ 代码分割优化可能略微改善首次加载
- ➡️ 更好的 memoization 机会
## 🚨 风险评估
### 高风险区域
- 图表配置逻辑Recharts 配置复杂)
- 数据转换和聚合(业务逻辑密集)
- 状态同步(多个状态之间的交互)
### 缓解措施
- 保持现有测试通过
- 逐步重构,每步验证
- 添加 TypeScript 严格检查
- 保留原有功能注释
## 📋 验证清单
重构完成后需要验证:
- [ ] 所有现有功能正常工作
- [ ] 单元测试通过
- [ ] E2E 测试通过
- [ ] 类型检查无错误
- [ ] 性能无明显下降
- [ ] 代码风格符合规范
## 🤔 建议的实施顺序
### 推荐方案: 渐进式重构 (方案 B)
**第1步**: 提取类型和工具函数 (15分钟)
- 创建类型定义文件
- 提取解析工具函数
- 验证编译和测试
**第2步**: 提取自定义 Hooks (30分钟)
- 提取数据获取逻辑
- 提取数据聚合逻辑
- 提取筛选状态管理
- 验证功能正常
**第3步**: 提取 UI 组件 (30分钟)
- 提取控制面板组件
- 提取图表组件
- 提取状态显示组件
- 验证交互正常
**第4步**: 简化主组件 (15分钟)
- 重构为组合式组件
- 清理代码和注释
- 最终验证
**总计**: 约 90 分钟分4个阶段每个阶段都可以独立验证

13
docs/releases/1.5.0.md Normal file
View File

@@ -0,0 +1,13 @@
# 1.5.0
## 核心功能
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
- 实时显示下载进度(百分比、速度、剩余时间)。
- 支持取消下载,网络异常自动重试。
- 下载完成后自动进入登录界面,无需重启应用。
## 体验优化
- 修复下载完成后卡在"认证中"的问题。
- 修复速度和剩余时间显示为"计算中"的问题。

7
docs/releases/1.5.1.md Normal file
View File

@@ -0,0 +1,7 @@
# 1.5.1
## 体验优化
- User 用户登录后立即进入应用,更新检查和下载在后台运行。
- 下载完成后自动显示更新提示,整个过程对用户透明。
- 优化登录流程体验,消除更新下载导致的阻塞时间。

14
docs/releases/1.6.0.md Normal file
View File

@@ -0,0 +1,14 @@
# 1.6.0
## 核心功能
- 新增管理员报表分析功能,支持多维度数据统计和可视化。
- 提供按日期聚合和用户对比两种视图模式。
- 支持处理订单数、删除物料数、错误数量等 7 种指标分析。
- 提供每订单平均耗时等效率指标,帮助识别性能瓶颈。
## 体验优化
- 对比视图下自动限制指标单选,避免图表信息过载。
- 切换视图模式时智能保留已选指标,提升交互流畅度。
- 优化时间解析逻辑,准确提取执行耗时数据。

42
docs/releases/1.6.1.md Normal file
View File

@@ -0,0 +1,42 @@
# 1.6.1
## 核心改进
- **重大重构**:将报告分析组件从 948 行单体组件重构为模块化架构,拆分为 11 个专注的模块文件。
- **代码质量提升**:主组件代码量减少 79%948 → 200 行),显著提升可维护性和可读性。
- **架构优化**:分离数据获取、状态管理和 UI 渲染逻辑,遵循单一职责原则。
## 体验优化
- **修复 tooltip 显示问题**:解决执行时间在提示框中重复显示的问题,现在只显示一次格式化后的时间值。
- **统一时间格式**:所有时间数值统一保留 1 位小数,提升数据显示的一致性和专业度。
- **优化界面布局**:精简 tooltip 底部信息,避免冗余内容干扰用户视线。
## 性能优化
- **组件渲染优化**:将 tooltip 组件移出父组件并使用 React.memo减少不必要的重新渲染。
- **正则表达式优化**:预编译正则表达式模式,避免在循环中重复创建,提升数据处理效率。
- **状态更新优化**:使用函数式 setState 更新,避免闭包陷阱和过期的状态读取。
- **回调函数优化**:使用 useCallback 稳定回调函数引用,减少子组件的不必要更新。
## 开发体验
- **模块化设计**:将复杂组件拆分为可复用的 hooks 和 UI 组件,便于单独测试和维护。
- **类型安全**:完整的 TypeScript 类型定义,提升开发时的类型检查和 IDE 支持。
- **代码组织**清晰的文件结构types、hooks、components、utils便于团队协作和代码导航。
- **向后兼容**:保持原有 API 接口不变,现有使用方式无需修改。
## 技术细节
- 应用 Vercel React 最佳实践,包括:
- 避免内联组件定义rerender-no-inline-components
- 提升正则表达式创建位置js-hoist-regexp
- 使用函数式状态更新rerender-functional-setState
- 最小化回调依赖项rerender-dependencies
- 新增自定义 hooksuseReportData、useChartData、useReportFilters
- 新增 UI 组件MetricSelector、ViewModeToggle、UserFilter、ReportChart
- 新增工具函数:数据解析器和聚合器
## 破坏性变更
无破坏性变更,所有现有功能保持完全兼容。

106
docs/releases/README.md Normal file
View File

@@ -0,0 +1,106 @@
# 发布文档规范
## 文档定位
发布文档面向**最终用户**,不是技术开发日志。内容应该简洁、清晰、有价值。
## 内容风格
### ✅ 推荐写法
- **用户视角**:描述功能带来的价值,而非技术实现
- **简洁明了**:每条更新 1-2 句话,避免冗长
- **分类清晰**:按功能模块或改进类型分组
**示例**
```markdown
## 核心功能
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
- 实时显示下载进度(百分比、速度、剩余时间)。
```
### ❌ 避免写法
- 技术细节(文件路径、代码实现、架构设计)
- 开发过程描述("重构了"、"优化了算法"
- 过长的段落(超过 2 行)
## 文档结构
### 标准格式
```markdown
# {版本号}
## {分类 1}
- {更新点 1}
- {更新点 2}
## {分类 2}
- {更新点 1}
- {更新点 2}
```
### 常见分类
- `核心功能` - 新功能、重大特性
- `改进` / `体验优化` - 现有功能优化
- `问题修复` - Bug 修复
- `界面与交互` - UI/UX 改进
## 篇幅要求
- **小版本**x.x.15-10 行
- **中版本**x.x.010-20 行
- **大版本**x.0.020-40 行
## 示例参考
### 简洁版1.4.2
```markdown
# 1.4.2
## 架构优化
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
- 解耦更新服务职责,对话框改为懒加载以优化性能。
## 质量改进
- 修复类型检查问题,加固启动流程和认证健壮性。
- 新增核心模块测试覆盖,完善开发者文档。
```
### 详细版1.4.0
```markdown
# 1.4.0
## 亮点
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
- 更新策略与登录用户角色联动。
## 自动更新
- 新增便携版更新服务,支持登录后自动检查更新。
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
```
## 发布流程
1. 创建版本文件:`docs/releases/{version}.md`
2. 参考现有文档风格编写
3. 提交 git`git add docs/releases/{version}.md`
4. 提交信息:`docs: add release notes for version {version}`
## 维护说明
- 发布文档一旦创建,**不再修改**(除非有重大错误)
- 技术细节放入 `docs/` 下的专题文档
- Changelog 由发布脚本自动生成,不手动维护

416
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "erpauto",
"version": "1.4.2",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.4.2",
"version": "1.6.1",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",
@@ -27,6 +27,7 @@
"playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"recharts": "^3.8.0",
"reflect-metadata": "^0.2.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
@@ -969,7 +970,6 @@
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -1031,7 +1031,6 @@
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -1223,7 +1222,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -2001,6 +1999,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -2022,6 +2021,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -2038,6 +2038,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -2052,6 +2053,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3307,6 +3309,42 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -4404,7 +4442,12 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
@@ -4786,6 +4829,69 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4896,7 +5002,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4918,7 +5023,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4963,6 +5067,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -5033,7 +5143,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5473,7 +5582,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5506,7 +5614,6 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6269,7 +6376,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7065,7 +7171,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -7108,6 +7215,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -7195,6 +7423,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -7471,7 +7705,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -7686,7 +7919,6 @@
"integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
@@ -7875,6 +8107,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7895,6 +8128,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -8146,6 +8380,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -8223,7 +8467,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8284,7 +8527,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -8574,6 +8816,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -9752,6 +10000,16 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9817,6 +10075,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -13247,7 +13514,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13328,7 +13594,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13352,6 +13617,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13369,6 +13635,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13389,7 +13656,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13531,7 +13797,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13553,7 +13818,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13617,6 +13881,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -13690,6 +13977,51 @@
"node": ">=10"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -13885,6 +14217,12 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -15067,6 +15405,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15150,6 +15489,12 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -16096,7 +16441,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16507,12 +16851,33 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17059,7 +17424,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -17268,7 +17632,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -17506,7 +17869,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.4.2",
"version": "1.6.1",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@@ -51,6 +51,7 @@
"playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"recharts": "^3.8.0",
"reflect-metadata": "^0.2.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",

View File

@@ -1,4 +1,4 @@
import { app, dialog } from 'electron'
import { app } from 'electron'
import fs from 'fs'
import { join } from 'path'
import { ConfigManager } from '../services/config/config-manager'
@@ -31,7 +31,11 @@ export function setupElectronRuntime(): void {
})
}
export function ensurePlaywrightRuntime(browsersPath: string): void {
/**
* Check if Playwright browsers are installed
* @returns true if browsers exist, false otherwise
*/
export function ensurePlaywrightRuntime(browsersPath: string): boolean {
try {
fs.mkdirSync(browsersPath, { recursive: true })
} catch (error) {
@@ -43,7 +47,7 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
if (fs.existsSync(chromiumPath)) {
return
return true
}
let foundRevision = false
@@ -64,22 +68,14 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
}
if (foundRevision) {
return
return true
}
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'
)
return false
}
export async function initializeMainProcessServices(): Promise<void> {

View File

@@ -12,7 +12,8 @@ app.whenReady().then(async () => {
setupProcessGuards()
registerMainWindowLifecycle()
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
ensurePlaywrightRuntime(playwrightBrowsersPath)
const browsersExist = ensurePlaywrightRuntime(playwrightBrowsersPath)
console.log('Playwright browsers exist:', browsersExist)
await initializeMainProcessServices()
setupElectronRuntime()

View File

@@ -16,6 +16,7 @@ import { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler'
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -105,5 +106,6 @@ export function registerIpcHandlers(): void {
registerLoggerHandlers()
registerReportHandlers()
registerUpdateHandlers()
registerPlaywrightBrowserHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -0,0 +1,110 @@
/**
* Playwright Browser IPC Handlers
* Handles browser download requests from renderer process
*/
import { app, ipcMain, IpcMainInvokeEvent } from 'electron'
import { join } from 'path'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
import { DownloadService } from '../services/playwright-browser'
import { ConfigManager } from '../services/config/config-manager'
import { S3Client } from '@aws-sdk/client-s3'
import type { DownloadProgress } from '../services/playwright-browser'
/**
* Create S3 client from config
*/
function createS3Client(): S3Client {
const config = ConfigManager.getInstance().getConfig().update
if (!config) {
throw new Error('Update config is not available')
}
return new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
/**
* Check if Playwright browsers are installed
*/
async function checkBrowsersExist(): Promise<boolean> {
const fs = await import('fs')
const browsersPath = join(app.getPath('userData'), 'ms-playwright')
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
const chromiumPath = fs.default.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
if (fs.default.existsSync(chromiumPath)) {
return true
}
let foundRevision = false
try {
const entries = fs.default.readdirSync(browsersPath)
for (const entry of entries) {
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
if (fs.default.existsSync(revisionPath)) {
foundRevision = true
break
}
}
}
} catch {
// Ignore browser directory probing failures
}
return foundRevision
}
/**
* Track active download for cancellation
*/
let activeDownload: { service: DownloadService; cancelled: boolean } | null = null
export function registerPlaywrightBrowserHandlers(): void {
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK, async (): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
return checkBrowsersExist()
}, 'playwright-browser:check')
})
ipcMain.handle(
IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD,
async (event: IpcMainInvokeEvent): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const s3Client = createS3Client()
const service = new DownloadService({ s3Client })
activeDownload = { service, cancelled: false }
await service.downloadAll((progress: DownloadProgress) => {
if (activeDownload?.cancelled) {
throw new Error('Download cancelled by user')
}
event.sender.send(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, progress)
})
activeDownload = null
}, 'playwright-browser:download')
}
)
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL, async (): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
if (activeDownload) {
activeDownload.cancelled = true
activeDownload = null
}
}, 'playwright-browser:cancel')
})
}

View File

@@ -0,0 +1,407 @@
/**
* Playwright Browser Download Service
*
* Downloads Playwright browser files from S3 to local directory
* with progress tracking and basic validation.
*/
import * as fs from 'fs'
import * as path from 'path'
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'
import { createLogger } from '../logger'
const log = createLogger('PlaywrightDownloadService')
/**
* Download progress event
*/
export interface DownloadProgress {
percent: number
downloadedBytes: number
totalBytes: number
currentFile: string
speed: number
eta?: number
}
/**
* S3 Object information
*/
export interface S3Object {
key: string
size?: number
lastModified?: Date
}
/**
* Validation result
*/
export interface ValidationResult {
success: boolean
message: string
fileCount?: number
chromeExeExists?: boolean
expectedChromePath?: string
}
/**
* Download configuration
*/
export interface DownloadConfig {
s3Client?: S3Client
bucket?: string
prefix?: string
destDir?: string
}
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<DownloadConfig> = {
s3Client: null as unknown as S3Client,
bucket: 'erpauto',
prefix: 'resources/ms-playwright/', // ✅ Fixed: removed 'erpauto/' prefix
destDir: path.join(process.env.APPDATA || '', 'erpauto', 'ms-playwright')
}
/**
* DownloadService class
* Handles downloading Playwright browser files from S3
*/
export class DownloadService {
private config: Required<DownloadConfig>
private s3Client: S3Client
constructor(config?: DownloadConfig) {
if (!config?.s3Client) {
throw new Error('S3Client is required')
}
this.config = {
...DEFAULT_CONFIG,
...config,
bucket: config?.bucket || DEFAULT_CONFIG.bucket,
prefix: config?.prefix || DEFAULT_CONFIG.prefix,
destDir: config?.destDir || DEFAULT_CONFIG.destDir
}
this.s3Client = this.config.s3Client
}
/**
* Execute an operation with retry logic using exponential backoff
* @param operation - The async operation to execute
* @param context - Description of the operation for logging
* @param maxRetries - Maximum number of retry attempts (default: 3)
* @returns The result of the operation
*/
private async withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries = 3
): Promise<T> {
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation()
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// Check if error is retryable
const isRetryable = this.isRetryableError(lastError)
if (!isRetryable || attempt === maxRetries) {
break
}
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt - 1) * 1000
log.warn(`${context} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`, {
error: lastError.message
})
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError
}
/**
* Determine if an error is retryable based on error type and message
* @param error - The error to classify
* @returns true if the error should trigger a retry
*/
private isRetryableError(error: Error): boolean {
const message = error.message.toLowerCase()
const code = (error as any).code?.toLowerCase() || ''
// Retryable: network errors, timeouts
const retryablePatterns = [
'etimedout',
'econnreset',
'timeout',
'network',
'socket hang up',
'connection reset'
]
// Not retryable: S3 errors, validation errors
const nonRetryablePatterns = [
'404',
'403',
'not found',
'access denied',
'invalid',
'validation'
]
// Check non-retryable first
for (const pattern of nonRetryablePatterns) {
if (message.includes(pattern) || code.includes(pattern)) {
return false
}
}
// Check retryable
for (const pattern of retryablePatterns) {
if (message.includes(pattern) || code.includes(pattern)) {
return true
}
}
// Default: don't retry unknown errors
return false
}
/**
* List all S3 objects under the configured prefix
* Filters to include ALL Chromium files (regular + headless)
* Simple filter: includes 'chromium' keyword
*/
async listObjects(): Promise<S3Object[]> {
log.info('Listing S3 objects', { bucket: this.config.bucket, prefix: this.config.prefix })
const objects: S3Object[] = []
let continuationToken: string | undefined
do {
const response = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.config.prefix,
ContinuationToken: continuationToken
})
)
for (const obj of response.Contents || []) {
if (!obj.Key) continue
// Simple filter: only download Chromium-related files
// This includes: chromium-1200, chromium-1208, chromium_headless_shell-1200, chromium_headless_shell-1208
// Excludes: firefox, webkit, ffmpeg, winldd, .links, .settings
if (!obj.Key.includes('chromium')) {
continue
}
objects.push({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified
})
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
} while (continuationToken)
log.info(`Found ${objects.length} Chromium objects`)
return objects
}
/**
* Download a single file from S3 with retry logic
*/
async downloadFile(key: string, destPath: string): Promise<void> {
await this.withRetry(async () => {
log.debug('Downloading file', { key, destPath })
await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
const response = await this.s3Client.send(
new GetObjectCommand({
Bucket: this.config.bucket,
Key: key
})
)
const output = fs.createWriteStream(destPath)
const body = response.Body as NodeJS.ReadableStream
await new Promise<void>((resolve, reject) => {
body.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
body.pipe(output)
})
log.debug('File downloaded', { key, destPath })
}, `Download ${key}`)
}
/**
* Download all Chromium browser files from S3
* Emits progress events during download
*/
async downloadAll(onProgress: (progress: DownloadProgress) => void): Promise<void> {
log.info('Starting download of all browser files', { destDir: this.config.destDir })
const objects = await this.listObjects()
if (objects.length === 0) {
throw new Error('No Chromium browser files found in S3')
}
// Calculate total bytes
const totalBytes = objects.reduce((sum, obj) => sum + (obj.size || 0), 0)
log.info(`Total files: ${objects.length}, Total bytes: ${totalBytes}`)
let downloadedBytes = 0
const startTime = Date.now()
for (const obj of objects) {
const relativePath = obj.key.replace(this.config.prefix, '')
const destPath = path.join(this.config.destDir, relativePath)
// Emit progress for current file
onProgress({
percent: Math.round((downloadedBytes / totalBytes) * 100),
downloadedBytes,
totalBytes,
currentFile: relativePath,
speed: 0,
eta: undefined
})
try {
await this.downloadFile(obj.key, destPath)
const fileSize = obj.size || 0
downloadedBytes += fileSize
// Calculate speed and ETA
const elapsedSeconds = (Date.now() - startTime) / 1000
const speed = Math.round(downloadedBytes / elapsedSeconds)
const remainingBytes = totalBytes - downloadedBytes
const eta = speed > 0 ? Math.round(remainingBytes / speed) : undefined
onProgress({
percent: Math.round((downloadedBytes / totalBytes) * 100),
downloadedBytes,
totalBytes,
currentFile: relativePath,
speed,
eta
})
log.debug('Downloaded file', {
key: obj.key,
size: fileSize,
speed: `${speed} bytes/s`
})
} catch (error) {
log.error('Failed to download file', {
key: obj.key,
error: error instanceof Error ? error.message : String(error)
})
throw new Error(
`Failed to download ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
const totalElapsed = (Date.now() - startTime) / 1000
log.info('Download completed', {
totalFiles: objects.length,
totalBytes,
duration: `${totalElapsed.toFixed(1)}s`,
avgSpeed: `${Math.round(downloadedBytes / totalElapsed)} bytes/s`
})
// Final progress event
onProgress({
percent: 100,
downloadedBytes: totalBytes,
totalBytes,
currentFile: 'Complete',
speed: Math.round(downloadedBytes / totalElapsed),
eta: 0
})
}
/**
* Validate the downloaded files
* Checks if the destination directory exists and chrome.exe is present
*/
async validateDownload(): Promise<ValidationResult> {
log.info('Validating download', { destDir: this.config.destDir })
// Check if destination directory exists
try {
await fs.promises.access(this.config.destDir)
} catch {
return {
success: false,
message: 'Download directory does not exist',
fileCount: 0,
chromeExeExists: false
}
}
// Find chrome.exe in chromium-* folders
const chromeExePattern = /chromium-\d+[/\\]chrome-win64[/\\]chrome\.exe$/
let chromeExeExists = false
let expectedChromePath: string | undefined
let fileCount = 0
const findChromeExe = async (dir: string): Promise<void> => {
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await findChromeExe(fullPath)
} else if (entry.name.toLowerCase() === 'chrome.exe') {
// Check if path matches chromium-*/chrome-win64/chrome.exe pattern
const relativePath = path.relative(this.config.destDir, fullPath)
if (chromeExePattern.test(relativePath.replace(/\\/g, '/'))) {
chromeExeExists = true
expectedChromePath = fullPath
}
}
fileCount++
}
}
try {
await findChromeExe(this.config.destDir)
} catch (error) {
log.error('Error scanning download directory', {
error: error instanceof Error ? error.message : String(error)
})
}
const result: ValidationResult = {
success: chromeExeExists,
message: chromeExeExists ? 'Validation passed' : 'chrome.exe not found in expected location',
fileCount,
chromeExeExists,
expectedChromePath
}
log.info('Validation result', result)
return result
}
}
export default DownloadService

View File

@@ -0,0 +1,11 @@
/**
* Playwright Browser Module Exports
*/
export { DownloadService, default } from './download-service'
export type {
DownloadProgress,
S3Object,
ValidationResult,
DownloadConfig
} from './download-service'

View File

@@ -124,7 +124,12 @@ export class UpdateService {
return
}
await this.checkForUpdates()
// 启动异步更新检查,不阻塞登录流程
void this.checkForUpdates().catch((error) => {
log.warn('Async update check failed', {
error: error instanceof Error ? error.message : String(error)
})
})
this.startPolling()
}
@@ -149,16 +154,16 @@ export class UpdateService {
this.publishStatus(nextStatus)
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
try {
await this.downloadRelease(nextStatus.recommendedRelease)
} catch (error) {
// 异步下载,不阻塞更新检查流程
void this.downloadRelease(nextStatus.recommendedRelease).catch((error) => {
const message = error instanceof Error ? error.message : '下载更新失败'
log.warn('Async update download failed', { error: message })
this.publishStatus({
phase: 'error',
error: message,
message
})
}
})
}
} else if (currentUserType === 'Admin') {
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))

View File

@@ -0,0 +1,24 @@
import { ipcRenderer } from 'electron'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import type { IpcResult } from '../../main/types/ipc.types'
import type { DownloadProgress } from '../index.d'
export const playwrightBrowserApi = {
check: async (): Promise<IpcResult<boolean>> => {
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK)
},
download: async (): Promise<IpcResult<void>> => {
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD)
},
cancel: async (): Promise<IpcResult<void>> => {
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL)
},
onProgress: (callback: (data: DownloadProgress) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: DownloadProgress) => callback(data)
ipcRenderer.on(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
return () => ipcRenderer.removeListener(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
}
} as const

View File

@@ -16,6 +16,7 @@ import {
userErpConfigApi
} from './materials'
import { loggerApi } from './logger'
import { playwrightBrowserApi } from './browser-download'
export const api = {
process: processApi,
@@ -33,7 +34,8 @@ export const api = {
config: configApi,
logger: loggerApi,
report: reportApi,
update: updateApi
update: updateApi,
playwrightBrowser: playwrightBrowserApi
} as const
export type ElectronApi = typeof api

View File

@@ -141,6 +141,22 @@ export interface UpdateAPI {
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
}
export interface DownloadProgress {
percent: number // 0-100
downloadedBytes: number
totalBytes: number
currentFile: string
speed: number // bytes/s
eta?: number // seconds
}
export interface PlaywrightBrowserAPI {
check: () => Promise<IpcResult<boolean>>
download: () => Promise<IpcResult<void>>
cancel: () => Promise<IpcResult<void>>
onProgress: (callback: (data: DownloadProgress) => void) => () => void
}
export interface ProcessAPI {
versions: {
electron: string
@@ -168,6 +184,7 @@ declare global {
logger: LoggerAPI
report: ReportAPI
update: UpdateAPI
playwrightBrowser: PlaywrightBrowserAPI
}
api: unknown
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
import PlaywrightDownloadDialog from './components/PlaywrightDownloadDialog'
import { useAppBootstrap } from './hooks/useAppBootstrap'
function App(): React.JSX.Element {
@@ -22,6 +23,8 @@ function App(): React.JSX.Element {
updateCatalog,
showUpdateDialog,
setShowUpdateDialog,
showPlaywrightDownload,
setShowPlaywrightDownload,
showError,
handleLogin,
handleLoginCancel,
@@ -31,11 +34,29 @@ function App(): React.JSX.Element {
openUpdateDialog,
handleInstallUserRelease,
handleAdminDownloadAndInstall,
refreshUpdateDialogState
refreshUpdateDialogState,
initializeAuth
} = useAppBootstrap()
const handlePlaywrightDownloadComplete = React.useCallback(() => {
setShowPlaywrightDownload(false)
// Re-trigger authentication after download completes
void initializeAuth()
}, [setShowPlaywrightDownload, initializeAuth])
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
// Show Playwright download dialog first (before authentication check)
if (showPlaywrightDownload) {
return (
<PlaywrightDownloadDialog
isOpen={showPlaywrightDownload}
onClose={() => {}}
onDownloadComplete={handlePlaywrightDownloadComplete}
/>
)
}
if (!isAuthenticated) {
return (
<UnauthenticatedApp

View File

@@ -0,0 +1,272 @@
import React, { useEffect, useState, useCallback } from 'react'
import { DownloadCloud, LoaderCircle, X } from 'lucide-react'
import Modal from './ui/Modal'
interface DownloadProgress {
percent: number // 0-100
downloadedBytes: number
totalBytes: number
currentFile: string
speed: number // bytes/s
eta?: number // seconds
}
interface PlaywrightDownloadDialogProps {
isOpen: boolean
onClose: () => void
onDownloadComplete: () => void
}
export default function PlaywrightDownloadDialog({
isOpen,
onClose,
onDownloadComplete
}: PlaywrightDownloadDialogProps): React.JSX.Element {
const [progress, setProgress] = useState<DownloadProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
// Format bytes to human-readable string
const formatBytes = useCallback((bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}, [])
// Format ETA to human-readable string
const formatETA = useCallback((seconds: number | undefined): string => {
if (seconds === undefined || seconds < 0 || !Number.isFinite(seconds)) {
return '计算中...'
}
if (seconds === 0) return '已完成'
if (seconds < 60) return `${Math.round(seconds)}`
const mins = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
return `${mins}${secs}`
}, [])
// Subscribe to progress events
useEffect(() => {
if (!isOpen) return
const unsubscribe = window.electron.playwrightBrowser.onProgress((data) => {
setProgress(data)
setError(null)
})
return unsubscribe
}, [isOpen])
// Start download when dialog opens
useEffect(() => {
if (!isOpen) return
let mounted = true
setIsDownloading(true)
setError(null)
setProgress(null)
const startDownload = async () => {
try {
const result = await window.electron.playwrightBrowser.download()
if (mounted) {
if (result.success) {
setIsDownloading(false)
onDownloadComplete()
} else {
setError(result.error || '下载失败')
setIsDownloading(false)
}
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : '下载失败')
setIsDownloading(false)
}
}
}
void startDownload()
return () => {
mounted = false
}
}, [isOpen, onDownloadComplete])
const handleCancel = useCallback(async () => {
try {
await window.electron.playwrightBrowser.cancel()
} catch (err) {
console.error('Failed to cancel download:', err)
} finally {
setShowCancelConfirm(false)
setIsDownloading(false)
onClose()
}
}, [onClose])
const handleConfirmCancel = useCallback(() => {
void handleCancel()
}, [handleCancel])
const handleCancelDownloadClick = useCallback(() => {
setShowCancelConfirm(true)
}, [])
const handleCloseConfirm = useCallback(() => {
setShowCancelConfirm(false)
}, [])
return (
<>
<Modal
isOpen={isOpen}
onClose={isDownloading ? () => undefined : onClose}
title="下载 Playwright 浏览器"
size="lg"
disableBackdropClick={isDownloading}
disableEscapeKey={isDownloading}
showCloseButton={!isDownloading}
>
<div className="space-y-4">
{/* Progress Section */}
{isDownloading && (
<div className="space-y-4">
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600"></span>
<span className="font-semibold text-blue-600">{progress?.percent ?? 0}%</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 ease-out"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div>
</div>
{/* Current File */}
{progress?.currentFile && (
<div className="rounded-lg bg-slate-50 px-4 py-3">
<div className="text-xs font-medium text-slate-500"></div>
<div
className="mt-1 truncate text-sm text-slate-700"
title={progress.currentFile}
>
{progress.currentFile}
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-slate-200 px-4 py-3">
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-lg font-semibold text-slate-900">
{progress ? formatBytes(progress.downloadedBytes) : '0 B'}
</div>
</div>
<div className="rounded-lg border border-slate-200 px-4 py-3">
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-lg font-semibold text-slate-900">
{progress && progress.totalBytes > 0
? formatBytes(progress.totalBytes)
: '计算中...'}
</div>
</div>
<div className="rounded-lg border border-slate-200 px-4 py-3">
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-lg font-semibold text-slate-900">
{progress && progress.speed >= 0
? `${formatBytes(progress.speed)}/秒`
: '计算中...'}
</div>
</div>
<div className="rounded-lg border border-slate-200 px-4 py-3">
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-lg font-semibold text-slate-900">
{progress?.eta !== undefined && progress.eta >= 0
? formatETA(progress.eta)
: '计算中...'}
</div>
</div>
</div>
{/* Cancel Button */}
<div className="flex justify-center pt-2">
<button
onClick={handleCancelDownloadClick}
className="inline-flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700 hover:bg-rose-100"
>
<X size={16} />
</button>
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
<div className="font-medium"></div>
<div className="mt-1">{error}</div>
</div>
)}
{/* Success Section (shown briefly before closing) */}
{!isDownloading && !error && progress?.percent === 100 && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
<div className="flex items-center gap-2">
<DownloadCloud size={18} />
<span className="font-medium"></span>
</div>
<div className="mt-1">Playwright </div>
</div>
)}
{/* Initial Loading State */}
{isDownloading && !progress && (
<div className="flex flex-col items-center justify-center py-8">
<LoaderCircle size={48} className="animate-spin text-blue-500" />
<div className="mt-4 text-sm text-slate-600">...</div>
</div>
)}
</div>
</Modal>
{/* Cancel Confirmation Dialog */}
<Modal
isOpen={showCancelConfirm}
onClose={handleCloseConfirm}
title="确认取消"
size="md"
isAlertDialog
>
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="font-medium"></div>
<div className="mt-1">退</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={handleCloseConfirm}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
>
</button>
<button
onClick={handleConfirmCancel}
className="inline-flex items-center gap-2 rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700"
>
<X size={16} />
退
</button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,35 @@
/**
* ReportAnalysisDialog Component - Re-export
*
* This file now re-exports the refactored component from the report-analysis module.
* All functionality has been preserved while improving code organization.
*
* The refactored version is located at: ./report-analysis/index.tsx
*
* Refactoring changes:
* - Split into 11 focused files (was 948 lines, now ~150 lines per file)
* - Extracted custom hooks for business logic
* - Separated UI components for better reusability
* - Centralized type definitions
* - Isolated utility functions for easier testing
*
* @see ./report-analysis/ for the refactored implementation
*/
// Re-export everything from the refactored module
export { ReportAnalysisDialog as default, ReportAnalysisDialog } from './report-analysis'
// Re-export types for external use
export type {
ReportMetrics,
DailyMetrics,
UserDailyMetrics,
MetricKey,
ViewMode,
ReportAnalysisDialogProps,
CustomTooltipProps,
ComparisonTooltipProps
} from './report-analysis/types'
// Re-export constants
export { METRIC_LABELS, METRIC_COLORS, USER_COLORS } from './report-analysis/types'

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { X, FileText, Loader2, ChevronDown } from 'lucide-react'
import { X, FileText, Loader2, ChevronDown, BarChart3 } from 'lucide-react'
import { Combobox, Transition } from '@headlessui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -22,13 +22,15 @@ interface ReportViewerDialogProps {
onClose: () => void
isAdmin: boolean
currentUsername: string
onOpenAnalysis?: () => void
}
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername
currentUsername,
onOpenAnalysis
}) => {
const [reports, setReports] = useState<ReportMetadata[]>([])
const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
@@ -130,12 +132,23 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
<FileText size={20} className="text-blue-600" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
>
<X size={20} />
</button>
<div className="flex items-center gap-3">
{isAdmin && onOpenAnalysis && (
<button
onClick={onOpenAnalysis}
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 font-medium transition-colors"
>
<BarChart3 size={14} className="text-blue-600" />
</button>
)}
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{/* Controls */}

View File

@@ -0,0 +1,70 @@
/**
* ComparisonTooltip Component
* Custom tooltip for comparison chart view showing user-specific metrics
*/
import React from 'react'
import { MetricKey } from '../types'
interface ComparisonTooltipProps {
active?: boolean
payload?: any[]
label?: string
users: string[]
selectedUsers: Set<string>
selectedMetrics: Set<MetricKey>
}
/**
* Renders a detailed tooltip for the comparison chart view
* Shows user-specific metric values for the selected date
*/
export const ComparisonTooltip = React.memo(
({ active, payload, label, users, selectedUsers, selectedMetrics }: ComparisonTooltipProps) => {
if (!active || !payload || !payload.length) {
return null
}
const displayUsers = selectedUsers.size === 0 ? users : Array.from(selectedUsers)
const firstMetric = Array.from(selectedMetrics)[0]
return (
<div className="bg-white p-4 border border-slate-200 shadow-lg rounded-lg max-w-sm">
<p className="font-semibold text-slate-800 mb-2 border-b border-slate-100 pb-2">{label}</p>
<div className="space-y-1.5 text-sm">
{displayUsers.map((user: string) => {
const userEntry = payload.find((p: any) => p.name === (user || '未分配'))
if (!userEntry) return null
return (
<div key={user} className="flex justify-between items-center gap-4">
<span className="flex items-center gap-1.5 text-slate-600">
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: userEntry.color }}
/>
{user || '未分配'}:
</span>
<span className="font-medium text-slate-900">
{firstMetric === 'executionTimeSecs' ? Number(userEntry.value).toFixed(1) : userEntry.value} {firstMetric === 'executionTimeSecs' ? '秒' : ''}
</span>
</div>
)
})}
</div>
</div>
)
},
(prevProps, nextProps) => {
// Custom comparison for memoization
return (
prevProps.label === nextProps.label &&
prevProps.selectedUsers.size === nextProps.selectedUsers.size &&
prevProps.selectedMetrics.size === nextProps.selectedMetrics.size &&
prevProps.payload?.length === nextProps.payload?.length
)
}
)
ComparisonTooltip.displayName = 'ComparisonTooltip'

View File

@@ -0,0 +1,68 @@
/**
* CustomTooltip Component
* Custom tooltip for aggregated chart view showing daily metrics
*/
import React from 'react'
import { DailyMetrics } from '../types'
interface CustomTooltipProps {
active?: boolean
payload?: any[]
label?: string
chartData: DailyMetrics[]
}
/**
* Renders a detailed tooltip for the aggregated chart view
* Shows metric values along with additional context like user count and report count
*/
export const CustomTooltip = React.memo(
({ active, payload, label, chartData }: CustomTooltipProps) => {
if (!active || !payload || !payload.length) {
return null
}
const dailyData = chartData.find((d) => d.date === label)
return (
<div className="bg-white p-4 border border-slate-200 shadow-lg rounded-lg max-w-sm">
<p className="font-semibold text-slate-800 mb-2 border-b border-slate-100 pb-2">{label}</p>
<div className="space-y-1.5 text-sm">
{payload.map((entry: any, index: number) => (
<div key={`${entry.name}-${index}`} className="flex justify-between items-center gap-4">
<span className="flex items-center gap-1.5 text-slate-600">
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
></span>
{entry.name}:
</span>
<span className="font-medium text-slate-900">
{entry.dataKey === 'executionTimeSecs' ? Number(entry.value).toFixed(1) : entry.value} {entry.dataKey === 'executionTimeSecs' ? '秒' : ''}
</span>
</div>
))}
</div>
{dailyData && (
<div className="mt-3 pt-2 border-t border-slate-100 text-xs text-slate-500">
<p>: {dailyData.users.join(', ')}</p>
<p className="mt-1">: {dailyData.reportCount}</p>
</div>
)}
</div>
)
},
(prevProps, nextProps) => {
// Custom comparison for memoization
return (
prevProps.label === nextProps.label &&
prevProps.payload?.length === nextProps.payload?.length &&
prevProps.chartData === nextProps.chartData
)
}
)
CustomTooltip.displayName = 'CustomTooltip'

View File

@@ -0,0 +1,54 @@
/**
* MetricSelector Component
* Allows users to select which metrics to display in the chart
* Supports both single-select (comparison mode) and multi-select (aggregated mode)
*/
import React from 'react'
import { MetricKey, METRIC_LABELS, METRIC_COLORS } from '../types'
interface MetricSelectorProps {
selectedMetrics: Set<MetricKey>
viewMode: 'aggregated' | 'comparison'
onMetricToggle: (metric: MetricKey) => void
}
/**
* Renders a list of metric selection buttons
* Shows multi-select hint in aggregated mode and single-select hint in comparison mode
*/
export const MetricSelector: React.FC<MetricSelectorProps> = ({
selectedMetrics,
viewMode,
onMetricToggle
}) => {
return (
<div className="mb-8">
<h3 className="text-sm font-medium text-slate-700 mb-3">
({viewMode === 'comparison' ? '单选' : '多选'})
</h3>
<div className="flex flex-wrap gap-2">
{(Object.keys(METRIC_LABELS) as MetricKey[]).map((key) => {
const isSelected = selectedMetrics.has(key)
return (
<button
key={key}
onClick={() => onMetricToggle(key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors flex items-center gap-1.5 ${
isSelected
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: isSelected ? METRIC_COLORS[key] : '#cbd5e1' }}
/>
{METRIC_LABELS[key]}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
/**
* ReportChart Component
* Renders the main chart display with support for both aggregated and comparison views
* Uses Recharts library for responsive, interactive charts
*/
import React from 'react'
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Line,
ComposedChart
} from 'recharts'
import { DailyMetrics, MetricKey, METRIC_LABELS, METRIC_COLORS, USER_COLORS } from '../types'
import { CustomTooltip } from './CustomTooltip'
import { ComparisonTooltip } from './ComparisonTooltip'
interface ReportChartProps {
viewMode: 'aggregated' | 'comparison'
selectedMetrics: Set<MetricKey>
selectedUsers: Set<string>
allUsers: string[]
chartData: DailyMetrics[]
comparisonChartData: any[]
}
/**
* Helper function to get consistent color for a user
*/
const getUserColor = (user: string, users: string[]): string => {
const index = users.indexOf(user)
return USER_COLORS[index % USER_COLORS.length]
}
/**
* Renders the appropriate chart based on view mode
* - Aggregated: Shows metrics grouped by date
* - Comparison: Shows metrics grouped by user for comparison
*/
export const ReportChart: React.FC<ReportChartProps> = ({
viewMode,
selectedMetrics,
selectedUsers,
allUsers,
chartData,
comparisonChartData
}) => {
return (
<div className="flex-1 min-h-[400px]">
{viewMode === 'aggregated' ? (
// Aggregated view: metrics by date
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 12 }}
dy={10}
/>
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
<Tooltip content={<CustomTooltip chartData={chartData} />} />
<Legend wrapperStyle={{ paddingTop: '20px' }} iconType="circle" />
{Array.from(selectedMetrics).map((metric) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
name={METRIC_LABELS[metric]}
stroke={METRIC_COLORS[metric]}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0 }}
/>
))}
</ComposedChart>
</ResponsiveContainer>
) : (
// Comparison view: metrics by user
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={comparisonChartData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 12 }}
dy={10}
/>
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
<Tooltip
content={
<ComparisonTooltip
users={allUsers}
selectedUsers={selectedUsers}
selectedMetrics={selectedMetrics}
/>
}
/>
<Legend wrapperStyle={{ paddingTop: '20px' }} iconType="circle" />
{selectedUsers.size === 0 || selectedUsers.size > 1
? // Multiple users: show first metric for each user
allUsers
.filter((user) => selectedUsers.size === 0 || selectedUsers.has(user))
.map((user) => (
<Line
key={user}
type="monotone"
dataKey={`${user}_${Array.from(selectedMetrics)[0]}`}
name={user || '未分配'}
stroke={getUserColor(user, allUsers)}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0 }}
/>
))
: // Single user: show all metrics for that user
Array.from(selectedMetrics).map((metric) => {
const user = Array.from(selectedUsers)[0]
return (
<Line
key={metric}
type="monotone"
dataKey={`${user}_${metric}`}
name={METRIC_LABELS[metric]}
stroke={METRIC_COLORS[metric]}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0 }}
/>
)
})}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
/**
* UserFilter Component
* Allows users to filter which users to display in comparison view
* Shows all users as selectable chips with color coding
*/
import React from 'react'
import { USER_COLORS } from '../types'
interface UserFilterProps {
allUsers: string[]
selectedUsers: Set<string>
onUserToggle: (user: string) => void
onSelectAll: () => void
onClearAll: () => void
}
/**
* Helper function to get consistent color for a user
*/
const getUserColor = (user: string, users: string[]): string => {
const index = users.indexOf(user)
return USER_COLORS[index % USER_COLORS.length]
}
/**
* Renders user filter interface with selectable user chips
* Only displayed in comparison view mode
*/
export const UserFilter: React.FC<UserFilterProps> = ({
allUsers,
selectedUsers,
onUserToggle,
onSelectAll,
onClearAll
}) => {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-700"></h3>
<div className="flex gap-2">
<button onClick={onSelectAll} className="text-xs text-blue-600 hover:underline">
</button>
<button onClick={onClearAll} className="text-xs text-slate-500 hover:underline">
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{allUsers.map((user) => {
const isSelected = selectedUsers.has(user)
const color = getUserColor(user, allUsers)
return (
<button
key={user}
onClick={() => onUserToggle(user)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors flex items-center gap-1.5 ${
isSelected
? 'bg-white border-current'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
style={isSelected ? { color, borderColor: color } : {}}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: isSelected ? color : '#cbd5e1' }}
/>
{user || '未分配'}
</button>
)
})}
</div>
{selectedUsers.size === 0 && (
<p className="text-xs text-slate-500 mt-2"></p>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
/**
* ViewModeToggle Component
* Allows users to switch between aggregated and comparison view modes
*/
import React from 'react'
import { ViewMode } from '../types'
interface ViewModeToggleProps {
viewMode: ViewMode
onViewModeChange: (newMode: ViewMode) => void
}
/**
* Renders toggle buttons for switching between view modes
* Aggregated: shows data grouped by date
* Comparison: shows data grouped by user for comparison
*/
export const ViewModeToggle: React.FC<ViewModeToggleProps> = ({ viewMode, onViewModeChange }) => {
return (
<div className="mb-6">
<h3 className="text-sm font-medium text-slate-700 mb-3"></h3>
<div className="flex gap-2">
<button
onClick={() => onViewModeChange('aggregated')}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
viewMode === 'aggregated'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
</button>
<button
onClick={() => onViewModeChange('comparison')}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
viewMode === 'comparison'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
/**
* Report Analysis Feature Module
* Centralized exports for the refactored report analysis components
*/
// Main component
export { ReportAnalysisDialog, default } from './index'
// Types
export type {
ReportMetrics,
DailyMetrics,
UserDailyMetrics,
MetricKey,
ViewMode,
ReportAnalysisDialogProps,
CustomTooltipProps,
ComparisonTooltipProps
} from './types'
export { METRIC_LABELS, METRIC_COLORS, USER_COLORS } from './types'
// Hooks
export { useReportData } from './hooks/useReportData'
export { useChartData } from './hooks/useChartData'
export { useReportFilters } from './hooks/useReportFilters'
// Components
export { MetricSelector } from './components/MetricSelector'
export { ViewModeToggle } from './components/ViewModeToggle'
export { UserFilter } from './components/UserFilter'
export { ReportChart } from './components/ReportChart'
export { CustomTooltip } from './components/CustomTooltip'
export { ComparisonTooltip } from './components/ComparisonTooltip'
// Utilities
export {
extractReportValues,
parseDurationToSeconds,
formatDateToChinese,
parseReportData
} from './utils/parser'
export {
aggregateByDate,
extractAllUsers,
aggregateByUserAndDate,
formatComparisonChartData,
getUserColor
} from './utils/aggregators'

View File

@@ -0,0 +1,59 @@
/**
* Custom hook for transforming report data into chart-ready formats
* Handles data aggregation for different view modes
*/
import { useMemo } from 'react'
import { ReportMetrics, DailyMetrics, UserDailyMetrics, MetricKey } from '../types'
import {
aggregateByDate,
extractAllUsers,
aggregateByUserAndDate,
formatComparisonChartData
} from '../utils/aggregators'
interface UseChartDataResult {
chartData: DailyMetrics[]
allUsers: string[]
comparisonData: UserDailyMetrics[]
comparisonChartData: any[]
}
/**
* Hook for managing chart data transformations
*
* @param reportData - Raw report metrics data
* @param selectedUsers - Set of selected users for filtering
* @param selectedMetrics - Set of selected metrics to display
* @returns Transformed data ready for chart rendering
*/
export const useChartData = (
reportData: ReportMetrics[],
selectedUsers: Set<string>,
selectedMetrics: Set<MetricKey>
): UseChartDataResult => {
// Aggregate data by date
const chartData = useMemo(() => aggregateByDate(reportData), [reportData])
// Extract all unique users from report data
const allUsers = useMemo(() => extractAllUsers(reportData), [reportData])
// Aggregate data by date AND user for comparison view
const comparisonData = useMemo(
() => aggregateByUserAndDate(reportData, selectedUsers),
[reportData, selectedUsers]
)
// Format comparison data for chart rendering
const comparisonChartData = useMemo(
() => formatComparisonChartData(comparisonData, selectedUsers, selectedMetrics),
[comparisonData, selectedUsers, selectedMetrics]
)
return {
chartData,
allUsers,
comparisonData,
comparisonChartData
}
}

View File

@@ -0,0 +1,106 @@
/**
* Custom hook for fetching and managing report data
* Handles data loading, parsing, and error states
*/
import { useState, useCallback, useEffect } from 'react'
import { ReportMetrics } from '../types'
import { parseReportData } from '../utils/parser'
interface UseReportDataResult {
isLoading: boolean
error: string | null
reportData: ReportMetrics[]
loadAndAnalyzeReports: () => Promise<void>
clearData: () => void
}
/**
* Hook for managing report data fetching and parsing
*
* @param isAdmin - Whether the current user has admin privileges
* @param isOpen - Whether the dialog is open
* @returns Report data state and control functions
*/
export const useReportData = (isAdmin: boolean, isOpen: boolean): UseReportDataResult => {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [reportData, setReportData] = useState<ReportMetrics[]>([])
const loadAndAnalyzeReports = useCallback(async () => {
if (!isAdmin) return
setIsLoading(true)
setError(null)
try {
// 1. Fetch report list
const listResult = await window.electron.report.listAll()
if (!listResult.success || !listResult.data) {
throw new Error(listResult.error || '获取报告列表失败')
}
const reports = listResult.data
const metricsList: ReportMetrics[] = []
// 2. Fetch content for each report (in chunks to avoid memory/network issues)
// Rule: async-parallel - Using Promise.all for parallel fetching
const chunkSize = 10
for (let i = 0; i < reports.length; i += chunkSize) {
const chunk = reports.slice(i, i + chunkSize)
const contentPromises = chunk.map(async (report) => {
try {
const contentResult = await window.electron.report.download(report.key)
if (contentResult.success && contentResult.data) {
return { report, content: contentResult.data }
}
} catch (e) {
console.warn(`Failed to fetch content for report ${report.key}`, e)
}
return null
})
const chunkContents = await Promise.all(contentPromises)
// 3. Parse each report's markdown content
// Rule: js-hoist-regexp - Regex patterns now in parseReportData function
for (const item of chunkContents) {
if (!item) continue
const { report, content } = item
const parsedData = parseReportData(report, content)
metricsList.push(parsedData)
}
}
setReportData(metricsList)
} catch (err: any) {
setError(err.message || '分析报告时发生错误')
} finally {
setIsLoading(false)
}
}, [isAdmin])
const clearData = useCallback(() => {
setReportData([])
setError(null)
}, [])
// Auto-load data when dialog opens
useEffect(() => {
if (isOpen && isAdmin) {
void loadAndAnalyzeReports()
} else {
clearData()
}
}, [isOpen, isAdmin, loadAndAnalyzeReports, clearData])
return {
isLoading,
error,
reportData,
loadAndAnalyzeReports,
clearData
}
}

View File

@@ -0,0 +1,122 @@
/**
* Custom hook for managing report analysis filters and view modes
* Handles metric selection, view mode switching, and user filtering
*/
import { useState, useCallback } from 'react'
import { MetricKey, ViewMode } from '../types'
interface UseReportFiltersResult {
selectedMetrics: Set<MetricKey>
viewMode: ViewMode
selectedUsers: Set<string>
handleMetricToggle: (metric: MetricKey) => void
handleViewModeChange: (newMode: ViewMode) => void
handleUserToggle: (user: string) => void
handleSelectAllUsers: (users: string[]) => void
handleClearAllUsers: () => void
}
/**
* Hook for managing filter state and user interactions
*
* @returns Filter state and handler functions
*/
export const useReportFilters = (): UseReportFiltersResult => {
const [selectedMetrics, setSelectedMetrics] = useState<Set<MetricKey>>(
new Set(['processedOrders', 'deletedMaterials', 'errors'])
)
const [viewMode, setViewMode] = useState<ViewMode>('aggregated')
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set())
/**
* Handles metric selection with view mode awareness
* In comparison view: single selection only
* In aggregated view: multiple selection allowed
*/
const handleMetricToggle = useCallback(
(metric: MetricKey) => {
setSelectedMetrics((prev) => {
const next = new Set(prev)
if (viewMode === 'comparison') {
// Single selection mode for comparison view
return new Set([metric])
} else {
// Multi-selection mode for aggregated view
if (next.has(metric)) {
// Ensure at least one metric is selected
if (next.size > 1) {
next.delete(metric)
}
} else {
next.add(metric)
}
return next
}
})
},
[viewMode]
)
/**
* Handles view mode switching with automatic metric adjustment
* When switching to comparison view, keeps only first selected metric
*/
const handleViewModeChange = useCallback((newMode: ViewMode) => {
setViewMode(newMode)
// When switching to comparison view, keep only the first selected metric
if (newMode === 'comparison') {
setSelectedMetrics((prev) => {
if (prev.size > 1) {
const firstMetric = Array.from(prev)[0]
return new Set([firstMetric])
}
return prev
})
}
}, [])
/**
* Handles user selection toggle
*/
const handleUserToggle = useCallback((user: string) => {
setSelectedUsers((prev) => {
const next = new Set(prev)
if (next.has(user)) {
next.delete(user)
} else {
next.add(user)
}
return next
})
}, [])
/**
* Selects all provided users
*/
const handleSelectAllUsers = useCallback((users: string[]) => {
setSelectedUsers(new Set(users))
}, [])
/**
* Clears all user selections
*/
const handleClearAllUsers = useCallback(() => {
setSelectedUsers(new Set())
}, [])
return {
selectedMetrics,
viewMode,
selectedUsers,
handleMetricToggle,
handleViewModeChange,
handleUserToggle,
handleSelectAllUsers,
handleClearAllUsers
}
}

View File

@@ -0,0 +1,211 @@
/**
* ReportAnalysisDialog Component - Refactored
*
* A comprehensive dashboard for analyzing ERP system execution reports.
* Features include:
* - Aggregated view: Daily metrics overview
* - Comparison view: User performance comparison
* - Interactive filtering and metric selection
*
* This refactored version separates concerns into:
* - Custom hooks for business logic
* - Reusable components for UI
* - Utility functions for data processing
*/
import React, { useCallback } from 'react'
import { X, BarChart3, Loader2, AlertCircle } from 'lucide-react'
import { ReportAnalysisDialogProps, MetricKey } from './types'
import { useReportData } from './hooks/useReportData'
import { useChartData } from './hooks/useChartData'
import { useReportFilters } from './hooks/useReportFilters'
import { MetricSelector } from './components/MetricSelector'
import { ViewModeToggle } from './components/ViewModeToggle'
import { UserFilter } from './components/UserFilter'
import { ReportChart } from './components/ReportChart'
export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
isOpen,
onClose,
isAdmin
}) => {
// Data management hook
const { isLoading, error, reportData, loadAndAnalyzeReports } = useReportData(isAdmin, isOpen)
// Filter state management hook
const {
selectedMetrics,
viewMode,
selectedUsers,
handleMetricToggle,
handleViewModeChange,
handleUserToggle,
handleSelectAllUsers: handleSelectAllUsersWithParam,
handleClearAllUsers
} = useReportFilters()
// Chart data transformation hook (must be called before using allUsers)
const { chartData, allUsers, comparisonChartData } = useChartData(
reportData,
selectedUsers,
selectedMetrics
)
// Adapt handleSelectAllUsers to match component interface
const handleSelectAllUsers = useCallback(() => {
handleSelectAllUsersWithParam(allUsers)
}, [allUsers, handleSelectAllUsersWithParam])
// Early returns for conditional rendering
if (!isOpen) return null
if (!isAdmin) return null
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-[1000px] max-w-[95vw] h-[85vh] flex flex-col border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50 flex-shrink-0">
<div className="flex items-center gap-2 text-slate-800">
<BarChart3 size={20} className="text-blue-600" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col bg-white">
{isLoading ? (
<LoadingState />
) : error ? (
<ErrorState error={error} onRetry={loadAndAnalyzeReports} />
) : chartData.length === 0 ? (
<EmptyState />
) : (
<MainContent
viewMode={viewMode}
selectedMetrics={selectedMetrics}
selectedUsers={selectedUsers}
allUsers={allUsers}
chartData={chartData}
comparisonChartData={comparisonChartData}
handleMetricToggle={handleMetricToggle}
handleViewModeChange={handleViewModeChange}
handleUserToggle={handleUserToggle}
handleSelectAllUsers={handleSelectAllUsers}
handleClearAllUsers={handleClearAllUsers}
/>
)}
</div>
</div>
</div>
)
}
// ============================================================================
// Sub-components for better organization
// ============================================================================
const LoadingState: React.FC = () => (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500">
<Loader2 size={32} className="animate-spin text-blue-500 mb-4" />
<p>...</p>
</div>
)
const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, onRetry }) => (
<div className="flex-1 flex flex-col items-center justify-center text-red-500 p-8 text-center">
<AlertCircle size={48} className="mb-4 opacity-80" />
<p className="text-lg font-medium mb-2"></p>
<p className="text-sm opacity-80">{error}</p>
<button
onClick={onRetry}
className="mt-6 px-4 py-2 bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
>
</button>
</div>
)
const EmptyState: React.FC = () => (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<BarChart3 size={48} className="mb-4 opacity-50 text-slate-300" />
<p></p>
</div>
)
interface MainContentProps {
viewMode: 'aggregated' | 'comparison'
selectedMetrics: Set<MetricKey>
selectedUsers: Set<string>
allUsers: string[]
chartData: any[]
comparisonChartData: any[]
handleMetricToggle: (metric: MetricKey) => void
handleViewModeChange: (newMode: 'aggregated' | 'comparison') => void
handleUserToggle: (user: string) => void
handleSelectAllUsers: () => void
handleClearAllUsers: () => void
}
const MainContent: React.FC<MainContentProps> = ({
viewMode,
selectedMetrics,
selectedUsers,
allUsers,
chartData,
comparisonChartData,
handleMetricToggle,
handleViewModeChange,
handleUserToggle,
handleSelectAllUsers,
handleClearAllUsers
}) => (
<div className="flex-1 flex flex-col p-6 overflow-y-auto">
{/* Metric Selector */}
<MetricSelector
selectedMetrics={selectedMetrics}
viewMode={viewMode}
onMetricToggle={handleMetricToggle}
/>
{/* View Mode Toggle */}
<ViewModeToggle viewMode={viewMode} onViewModeChange={handleViewModeChange} />
{/* User Filter - Only in comparison mode */}
{viewMode === 'comparison' && (
<UserFilter
allUsers={allUsers}
selectedUsers={selectedUsers}
onUserToggle={handleUserToggle}
onSelectAll={handleSelectAllUsers}
onClearAll={handleClearAllUsers}
/>
)}
{/* Chart */}
<ReportChart
viewMode={viewMode}
selectedMetrics={selectedMetrics}
selectedUsers={selectedUsers}
allUsers={allUsers}
chartData={chartData}
comparisonChartData={comparisonChartData}
/>
{/* Description */}
<div className="mt-4 text-center text-xs text-slate-400">
{viewMode === 'aggregated'
? '数据以天为单位进行聚合统计。展示的是选定时间段内的总量。'
: selectedUsers.size === 0
? '展示所有用户的数据对比。未选择用户时显示全部。'
: '展示选定用户的数据对比。'}
</div>
</div>
)
export default ReportAnalysisDialog

View File

@@ -0,0 +1,153 @@
/**
* Type definitions for Report Analysis feature
* Centralized type management for better maintainability
*/
// ============================================================================
// Domain Types
// ============================================================================
/**
* Extracted metrics from a single report
*/
export interface ReportMetrics {
date: string
user: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeSecs: number
timestamp: number
}
/**
* Aggregated daily metrics
*/
export interface DailyMetrics {
date: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeSecs: number
avgExecutionTimeSecs: number
users: string[] // Unique users who ran reports on this day
reportCount: number
}
/**
* User-specific daily metrics for comparison view
*/
export interface UserDailyMetrics {
date: string
user: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeSecs: number
reportCount: number
}
// ============================================================================
// UI Types
// ============================================================================
/**
* Available metric keys for chart display
*/
export type MetricKey = keyof Omit<
DailyMetrics,
'date' | 'users' | 'reportCount' | 'avgExecutionTimeSecs'
>
/**
* View mode for the analysis display
*/
export type ViewMode = 'aggregated' | 'comparison'
// ============================================================================
// Component Props Types
// ============================================================================
/**
* Props for the main ReportAnalysisDialog component
*/
export interface ReportAnalysisDialogProps {
isOpen: boolean
onClose: () => void
isAdmin: boolean
}
/**
* Props for custom tooltip component
*/
export interface CustomTooltipProps {
active?: boolean
payload?: any[]
label?: string
chartData: DailyMetrics[]
}
/**
* Props for comparison tooltip component
*/
export interface ComparisonTooltipProps {
active?: boolean
payload?: any[]
label?: string
users: string[]
selectedUsers: Set<string>
selectedMetrics: Set<MetricKey>
}
// ============================================================================
// Configuration Types
// ============================================================================
/**
* Metric labels mapping
*/
export const METRIC_LABELS: Record<MetricKey, string> = {
processedOrders: '处理订单数',
deletedMaterials: '删除物料数',
skippedMaterials: '跳过物料数',
errors: '错误数量',
retriedOrders: '重试订单数',
successfulRetries: '成功重试数',
executionTimeSecs: '每订单平均耗时(秒)'
}
/**
* Metric colors mapping
*/
export const METRIC_COLORS: Record<MetricKey, string> = {
processedOrders: '#3b82f6', // blue-500
deletedMaterials: '#ef4444', // red-500
skippedMaterials: '#eab308', // yellow-500
errors: '#000000', // black
retriedOrders: '#8b5cf6', // violet-500
successfulRetries: '#10b981', // emerald-500
executionTimeSecs: '#f97316' // orange-500
}
/**
* User colors for comparison view
*/
export const USER_COLORS = [
'#3b82f6', // blue-500
'#10b981', // emerald-500
'#f59e0b', // amber-500
'#ef4444', // red-500
'#8b5cf6', // violet-500
'#ec4899', // pink-500
'#06b6d4', // cyan-500
'#84cc16' // lime-500
]

View File

@@ -0,0 +1,202 @@
/**
* Data aggregation utilities for transforming raw report data
* Handles date-based and user-based aggregations
*/
import { ReportMetrics, DailyMetrics, UserDailyMetrics, MetricKey } from '../types'
// ============================================================================
// Aggregation Functions
// ============================================================================
/**
* Aggregates report data by date for the overview chart
* Calculates totals and averages for each day
*
* @param reportData - Array of individual report metrics
* @returns Array of daily aggregated metrics sorted by date
*/
export const aggregateByDate = (reportData: ReportMetrics[]): DailyMetrics[] => {
if (!reportData.length) return []
const dailyMap = new Map<string, DailyMetrics>()
// First pass: aggregate by date
for (const data of reportData) {
const { date } = data
if (!dailyMap.has(date)) {
dailyMap.set(date, {
date,
processedOrders: 0,
deletedMaterials: 0,
skippedMaterials: 0,
errors: 0,
retriedOrders: 0,
successfulRetries: 0,
executionTimeSecs: 0,
avgExecutionTimeSecs: 0,
users: [],
reportCount: 0
})
}
const day = dailyMap.get(date)!
day.processedOrders += data.processedOrders
day.deletedMaterials += data.deletedMaterials
day.skippedMaterials += data.skippedMaterials
day.errors += data.errors
day.retriedOrders += data.retriedOrders
day.successfulRetries += data.successfulRetries
day.executionTimeSecs += data.executionTimeSecs
day.reportCount += 1
if (!day.users.includes(data.user)) {
day.users.push(data.user)
}
}
// Second pass: calculate averages
for (const day of dailyMap.values()) {
day.avgExecutionTimeSecs =
day.processedOrders > 0 ? day.executionTimeSecs / day.processedOrders : 0
// Replace executionTimeSecs with avgExecutionTimeSecs for chart display
day.executionTimeSecs = day.avgExecutionTimeSecs
}
// Convert map to array and sort by date
return Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date))
}
/**
* Extracts all unique users from report data
*
* @param reportData - Array of individual report metrics
* @returns Sorted array of unique usernames
*/
export const extractAllUsers = (reportData: ReportMetrics[]): string[] => {
const userSet = new Set<string>()
reportData.forEach((data) => userSet.add(data.user))
return Array.from(userSet).sort()
}
/**
* Aggregates report data by date AND user for comparison view
* Allows comparing multiple users across the same time periods
*
* @param reportData - Array of individual report metrics
* @param selectedUsers - Set of selected users for filtering (empty = all)
* @returns Array of user-daily aggregated metrics sorted by date and user
*/
export const aggregateByUserAndDate = (
reportData: ReportMetrics[],
selectedUsers: Set<string>
): UserDailyMetrics[] => {
if (!reportData.length) return []
// Filter by selected users if any
const filteredData =
selectedUsers.size > 0 ? reportData.filter((data) => selectedUsers.has(data.user)) : reportData
// Group by date + user
const keyMap = new Map<string, UserDailyMetrics>()
for (const data of filteredData) {
const key = `${data.date}|${data.user}`
if (!keyMap.has(key)) {
keyMap.set(key, {
date: data.date,
user: data.user,
processedOrders: 0,
deletedMaterials: 0,
skippedMaterials: 0,
errors: 0,
retriedOrders: 0,
successfulRetries: 0,
executionTimeSecs: 0,
reportCount: 0
})
}
const entry = keyMap.get(key)!
entry.processedOrders += data.processedOrders
entry.deletedMaterials += data.deletedMaterials
entry.skippedMaterials += data.skippedMaterials
entry.errors += data.errors
entry.retriedOrders += data.retriedOrders
entry.successfulRetries += data.successfulRetries
entry.executionTimeSecs += data.executionTimeSecs
entry.reportCount += 1
}
// Calculate average execution time per order for each entry
for (const entry of keyMap.values()) {
entry.executionTimeSecs =
entry.processedOrders > 0 ? entry.executionTimeSecs / entry.processedOrders : 0
}
return Array.from(keyMap.values()).sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.user.localeCompare(b.user)
})
}
/**
* Formats comparison data for chart rendering
* Transforms user-date data into a format suitable for Recharts
*
* @param comparisonData - Array of user-daily aggregated metrics
* @param selectedUsers - Set of selected users for filtering
* @param selectedMetrics - Set of selected metrics to display
* @returns Array of chart data points formatted for Recharts
*/
export const formatComparisonChartData = (
comparisonData: UserDailyMetrics[],
selectedUsers: Set<string>,
selectedMetrics: Set<MetricKey>
): any[] => {
if (!comparisonData.length) return []
const dates = [...new Set(comparisonData.map((d) => d.date))].sort()
const users = [...new Set(comparisonData.map((d) => d.user))]
.filter((user) => selectedUsers.size === 0 || selectedUsers.has(user))
.sort()
const lookup = new Map<string, UserDailyMetrics>()
comparisonData.forEach((d) => {
lookup.set(`${d.date}|${d.user}`, d)
})
return dates.map((date) => {
const point: any = { date }
users.forEach((user) => {
const key = `${date}|${user}`
const data = lookup.get(key)
Array.from(selectedMetrics).forEach((metric) => {
const userKey = `${user}_${metric}` as any
point[userKey] = data ? (data as any)[metric] : 0
})
})
return point
})
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Gets a consistent color for a user based on their position in the list
*
* @param user - Username to get color for
* @param users - Array of all users (for consistent indexing)
* @param colors - Array of color values to cycle through
* @returns Color hex string
*/
export const getUserColor = (user: string, users: string[], colors: string[]): string => {
const index = users.indexOf(user)
return colors[index % colors.length]
}

View File

@@ -0,0 +1,178 @@
/**
* Parser utilities for extracting report data from markdown content
* Optimized for performance with pre-compiled regex patterns
*/
// ============================================================================
// Result Types
// ============================================================================
interface ExtractValueResult {
execTimeStr: string | null
user: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeStr: string
}
interface ParsedReportData {
date: string
user: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeSecs: number
timestamp: number
}
// ============================================================================
// Parser Functions
// ============================================================================
/**
* Extracts values from markdown report content using pre-compiled regex patterns.
* Patterns are created once and reused for better performance.
*
* @param content - The markdown content to parse
* @returns Extracted metrics values
*/
export const extractReportValues = (content: string): ExtractValueResult => {
// Pre-compile regex patterns for better performance (js-hoist-regexp)
const createPattern = (key: string) => ({
standard: new RegExp(`\\|\\s*\\*\\*${key}\\*\\*\\s*\\|\\s*\`([^\`]+)\`\\s*\\|`),
noBackticks: new RegExp(
`\\|\\s*\\*\\*${key}\\*\\*\\s*\\|\\s*([^\\|\\s]+(?:\\s+[^\\|\\s]+)*)\\s*\\|`
),
relaxed: new RegExp(`\\|\\s*\\*\\*${key}\\*\\*\\s*\\|\\s*(.+?)\\s*\\|`)
})
const extractValue = (key: string): string | null => {
const patterns = createPattern(key)
for (const pattern of Object.values(patterns)) {
const match = content.match(pattern)
if (match && match[1]) {
const value = match[1].trim()
return value.replace(/`/g, '')
}
}
return null
}
return {
execTimeStr: extractValue('执行时间'),
user: extractValue('操作用户') || 'unknown',
processedOrders: parseInt(extractValue('处理订单数') || '0', 10),
deletedMaterials: parseInt(extractValue('删除物料数') || '0', 10),
skippedMaterials: parseInt(extractValue('跳过物料数') || '0', 10),
errors: parseInt(extractValue('错误数量') || '0', 10),
retriedOrders: parseInt(extractValue('重试订单数') || '0', 10),
successfulRetries: parseInt(extractValue('成功重试数') || '0', 10),
executionTimeStr: extractValue('执行耗时') || '0秒'
}
}
/**
* Parses duration string (e.g., "5分30秒", "120秒") to total seconds
*
* @param durationStr - Duration string to parse
* @returns Total seconds
*/
export const parseDurationToSeconds = (durationStr: string): number => {
// Handle empty or zero case
if (!durationStr || durationStr === '0秒' || durationStr === '0分0秒') {
return 0
}
// Remove any remaining backticks
const cleanStr = durationStr.replace(/`/g, '').trim()
let totalSeconds = 0
const minutesMatch = cleanStr.match(/(\d+)分/)
if (minutesMatch) {
totalSeconds += parseInt(minutesMatch[1], 10) * 60
}
const secondsMatch = cleanStr.match(/(\d+)秒/)
if (secondsMatch) {
totalSeconds += parseInt(secondsMatch[1], 10)
}
return totalSeconds
}
/**
* Formats a date object to Chinese date string format (YYYY-MM-DD)
*
* @param date - Date object to format
* @returns Formatted date string
*/
export const formatDateToChinese = (date: Date): string => {
return date
.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
.replace(/\//g, '-')
}
/**
* Parses report metadata and content into a structured ReportMetrics object
*
* @param report - Report metadata with key, username, lastModified
* @param content - Markdown content of the report
* @returns Parsed report metrics
*/
export const parseReportData = (
report: { key: string; username?: string; lastModified?: string | number | Date },
content: string
): ParsedReportData => {
const values = extractReportValues(content)
const user = values.user || report.username || 'unknown'
const executionTimeSecs = parseDurationToSeconds(values.executionTimeStr)
if (values.executionTimeStr === '0秒') {
console.warn('Failed to extract execution time from report:', report.key)
}
// Try to parse the date
let dateStr = '未知日期'
let timestamp = report.lastModified ? new Date(report.lastModified).getTime() : 0
if (values.execTimeStr) {
try {
const parsedDate = new Date(values.execTimeStr)
if (!isNaN(parsedDate.getTime())) {
dateStr = formatDateToChinese(parsedDate)
timestamp = parsedDate.getTime()
}
} catch {
// Fallback to report lastModified
}
}
if (dateStr === '未知日期' && report.lastModified) {
const d = new Date(report.lastModified)
dateStr = formatDateToChinese(d)
}
return {
date: dateStr,
user,
processedOrders: values.processedOrders,
deletedMaterials: values.deletedMaterials,
skippedMaterials: values.skippedMaterials,
errors: values.errors,
retriedOrders: values.retriedOrders,
successfulRetries: values.successfulRetries,
executionTimeSecs,
timestamp
}
}

View File

@@ -37,6 +37,7 @@ export function useAppBootstrap() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
const [showPlaywrightDownload, setShowPlaywrightDownload] = useState(false)
const authInitializationStartedRef = useRef(false)
@@ -120,7 +121,28 @@ export function useAppBootstrap() {
authInitializationStartedRef.current = true
logger.info('=== Initializing auth... ===')
void initializeAuth()
// Check if Playwright browsers are installed
const checkPlaywrightBrowsers = async () => {
try {
const result = await window.electron.playwrightBrowser.check()
if (result.success && !result.data) {
logger.info('Playwright browsers not found, showing download dialog')
setShowPlaywrightDownload(true)
} else {
logger.info('Playwright browsers found, continuing auth')
void initializeAuth()
}
} catch (error) {
logger.error('Failed to check Playwright browsers', {
error: error instanceof Error ? error.message : String(error)
})
// Continue with auth even if check fails
void initializeAuth()
}
}
void checkPlaywrightBrowsers()
}, [initializeAuth, logger])
useEffect(() => {
@@ -295,6 +317,8 @@ export function useAppBootstrap() {
updateCatalog,
showUpdateDialog,
setShowUpdateDialog,
showPlaywrightDownload,
setShowPlaywrightDownload,
showError,
refreshUpdateState,
refreshUpdateCatalog,
@@ -306,6 +330,7 @@ export function useAppBootstrap() {
openUpdateDialog,
handleInstallUserRelease,
handleAdminDownloadAndInstall,
refreshUpdateDialogState
refreshUpdateDialogState,
initializeAuth
}
}

View File

@@ -11,6 +11,7 @@ const MaterialTypeManagementDialog = React.lazy(
)
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog'))
const CleanerPage: React.FC = () => {
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
@@ -66,6 +67,7 @@ const CleanerPage: React.FC = () => {
} = useCleaner()
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false)
return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -169,6 +171,15 @@ const CleanerPage: React.FC = () => {
onClose={() => setIsReportViewerOpen(false)}
isAdmin={isAdmin}
currentUsername={currentUsername}
onOpenAnalysis={() => setIsReportAnalysisOpen(true)}
/>
</Suspense>
<Suspense fallback={null}>
<ReportAnalysisDialog
isOpen={isReportAnalysisOpen}
onClose={() => setIsReportAnalysisOpen(false)}
isAdmin={isAdmin}
/>
</Suspense>

View File

@@ -105,7 +105,13 @@ export const IPC_CHANNELS = {
UPDATE_GET_CHANGELOG: 'update:getChangelog',
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
UPDATE_STATUS_CHANGED: 'update:onStatusChanged'
UPDATE_STATUS_CHANGED: 'update:onStatusChanged',
// Playwright Browser
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
} as const
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,18 @@
const { _electron: electron } = require('playwright')
;(async () => {
const electronApp = await electron.launch({
args: ['.', '--no-sandbox', '--disable-gpu']
})
// Get the first window that the app opens
const window = await electronApp.firstWindow()
// Try to bypass auth and navigate to cleaner page if possible, but we don't know the exact DOM
await window.waitForTimeout(5000)
await window.screenshot({ path: 'tests/playwright/screenshot.png' })
console.log('Took screenshot')
await electronApp.close()
})()