Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811361a1a3 | ||
|
|
ffbda4c618 | ||
|
|
348b02600d | ||
|
|
d004f8e9f8 | ||
|
|
3cbe9eef12 | ||
|
|
5b310d944b | ||
|
|
6e04f21b10 | ||
|
|
17fbd7d251 | ||
|
|
c6eb60ada7 | ||
|
|
571ec2325f | ||
|
|
557ed174c3 | ||
|
|
dd2cf1c576 | ||
|
|
b7e9e5e472 | ||
|
|
491f2afe3f | ||
|
|
6b2a3b088f | ||
|
|
82a6e24132 | ||
|
|
4a1a78ee14 | ||
|
|
00c75fb0b8 | ||
|
|
a56d37a2e9 | ||
|
|
43e6d1f4b4 | ||
|
|
5ff5e5d18b | ||
|
|
44483120a5 | ||
|
|
18a81ae030 | ||
|
|
5178a2425a | ||
|
|
5cce470850 | ||
|
|
ffc3cbb4a9 | ||
|
|
7a78948a8c | ||
|
|
97bf918ed1 | ||
|
|
1d5268a4da | ||
|
|
2adfc77a58 | ||
|
|
2343fb2188 | ||
|
|
fb46586a13 | ||
|
|
6f21785c64 | ||
|
|
fbd62fe390 | ||
|
|
a711781f21 | ||
|
|
6ad916998c | ||
|
|
32df3cea67 |
@@ -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
13
docs/releases/1.5.0.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 1.5.0
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||||
|
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||||
|
- 支持取消下载,网络异常自动重试。
|
||||||
|
- 下载完成后自动进入登录界面,无需重启应用。
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 修复下载完成后卡在"认证中"的问题。
|
||||||
|
- 修复速度和剩余时间显示为"计算中"的问题。
|
||||||
7
docs/releases/1.5.1.md
Normal file
7
docs/releases/1.5.1.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 1.5.1
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- User 用户登录后立即进入应用,更新检查和下载在后台运行。
|
||||||
|
- 下载完成后自动显示更新提示,整个过程对用户透明。
|
||||||
|
- 优化登录流程体验,消除更新下载导致的阻塞时间。
|
||||||
14
docs/releases/1.6.0.md
Normal file
14
docs/releases/1.6.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 1.6.0
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增管理员报表分析功能,支持多维度数据统计和可视化。
|
||||||
|
- 提供按日期聚合和用户对比两种视图模式。
|
||||||
|
- 支持处理订单数、删除物料数、错误数量等 7 种指标分析。
|
||||||
|
- 提供每订单平均耗时等效率指标,帮助识别性能瓶颈。
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 对比视图下自动限制指标单选,避免图表信息过载。
|
||||||
|
- 切换视图模式时智能保留已选指标,提升交互流畅度。
|
||||||
|
- 优化时间解析逻辑,准确提取执行耗时数据。
|
||||||
42
docs/releases/1.6.1.md
Normal file
42
docs/releases/1.6.1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 1.6.1
|
||||||
|
|
||||||
|
## 核心改进
|
||||||
|
|
||||||
|
- **重大重构**:将报告分析组件从 948 行单体组件重构为模块化架构,拆分为 11 个专注的模块文件。
|
||||||
|
- **代码质量提升**:主组件代码量减少 79%(948 → 200 行),显著提升可维护性和可读性。
|
||||||
|
- **架构优化**:分离数据获取、状态管理和 UI 渲染逻辑,遵循单一职责原则。
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- **修复 tooltip 显示问题**:解决执行时间在提示框中重复显示的问题,现在只显示一次格式化后的时间值。
|
||||||
|
- **统一时间格式**:所有时间数值统一保留 1 位小数,提升数据显示的一致性和专业度。
|
||||||
|
- **优化界面布局**:精简 tooltip 底部信息,避免冗余内容干扰用户视线。
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- **组件渲染优化**:将 tooltip 组件移出父组件并使用 React.memo,减少不必要的重新渲染。
|
||||||
|
- **正则表达式优化**:预编译正则表达式模式,避免在循环中重复创建,提升数据处理效率。
|
||||||
|
- **状态更新优化**:使用函数式 setState 更新,避免闭包陷阱和过期的状态读取。
|
||||||
|
- **回调函数优化**:使用 useCallback 稳定回调函数引用,减少子组件的不必要更新。
|
||||||
|
|
||||||
|
## 开发体验
|
||||||
|
|
||||||
|
- **模块化设计**:将复杂组件拆分为可复用的 hooks 和 UI 组件,便于单独测试和维护。
|
||||||
|
- **类型安全**:完整的 TypeScript 类型定义,提升开发时的类型检查和 IDE 支持。
|
||||||
|
- **代码组织**:清晰的文件结构(types、hooks、components、utils),便于团队协作和代码导航。
|
||||||
|
- **向后兼容**:保持原有 API 接口不变,现有使用方式无需修改。
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
- 应用 Vercel React 最佳实践,包括:
|
||||||
|
- 避免内联组件定义(rerender-no-inline-components)
|
||||||
|
- 提升正则表达式创建位置(js-hoist-regexp)
|
||||||
|
- 使用函数式状态更新(rerender-functional-setState)
|
||||||
|
- 最小化回调依赖项(rerender-dependencies)
|
||||||
|
- 新增自定义 hooks:useReportData、useChartData、useReportFilters
|
||||||
|
- 新增 UI 组件:MetricSelector、ViewModeToggle、UserFilter、ReportChart
|
||||||
|
- 新增工具函数:数据解析器和聚合器
|
||||||
|
|
||||||
|
## 破坏性变更
|
||||||
|
|
||||||
|
无破坏性变更,所有现有功能保持完全兼容。
|
||||||
6
docs/releases/1.6.2.md
Normal file
6
docs/releases/1.6.2.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.6.2
|
||||||
|
|
||||||
|
## 系统优化
|
||||||
|
|
||||||
|
- 简化用户角色体系,移除未使用的 Guest 角色。
|
||||||
|
- 优化类型安全性,加强用户认证流程健壮性。
|
||||||
18
docs/releases/1.7.0.md
Normal file
18
docs/releases/1.7.0.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 1.7.0
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
|
||||||
|
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
|
||||||
|
- 批次记录可展开查看,显示总排号与订单号的对应关系。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
|
||||||
|
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
|
||||||
|
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
|
||||||
|
- 需执行数据库脚本创建表结构(详见项目文档)。
|
||||||
6
docs/releases/1.7.1.md
Normal file
6
docs/releases/1.7.1.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.7.1
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复 MySQL 数据库下操作历史查询报错问题。
|
||||||
|
- 优化历史记录数据结构,支持按订单统计记录数量。
|
||||||
6
docs/releases/1.7.2.md
Normal file
6
docs/releases/1.7.2.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.7.2
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复操作历史时间显示错误(时区转换导致时间快8小时)。
|
||||||
|
- 操作历史支持一键复制总排号和订单号。
|
||||||
106
docs/releases/README.md
Normal file
106
docs/releases/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 发布文档规范
|
||||||
|
|
||||||
|
## 文档定位
|
||||||
|
|
||||||
|
发布文档面向**最终用户**,不是技术开发日志。内容应该简洁、清晰、有价值。
|
||||||
|
|
||||||
|
## 内容风格
|
||||||
|
|
||||||
|
### ✅ 推荐写法
|
||||||
|
|
||||||
|
- **用户视角**:描述功能带来的价值,而非技术实现
|
||||||
|
- **简洁明了**:每条更新 1-2 句话,避免冗长
|
||||||
|
- **分类清晰**:按功能模块或改进类型分组
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||||
|
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 避免写法
|
||||||
|
|
||||||
|
- 技术细节(文件路径、代码实现、架构设计)
|
||||||
|
- 开发过程描述("重构了"、"优化了算法")
|
||||||
|
- 过长的段落(超过 2 行)
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
### 标准格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# {版本号}
|
||||||
|
|
||||||
|
## {分类 1}
|
||||||
|
|
||||||
|
- {更新点 1}
|
||||||
|
- {更新点 2}
|
||||||
|
|
||||||
|
## {分类 2}
|
||||||
|
|
||||||
|
- {更新点 1}
|
||||||
|
- {更新点 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见分类
|
||||||
|
|
||||||
|
- `核心功能` - 新功能、重大特性
|
||||||
|
- `改进` / `体验优化` - 现有功能优化
|
||||||
|
- `问题修复` - Bug 修复
|
||||||
|
- `界面与交互` - UI/UX 改进
|
||||||
|
|
||||||
|
## 篇幅要求
|
||||||
|
|
||||||
|
- **小版本**(x.x.1):5-10 行
|
||||||
|
- **中版本**(x.x.0):10-20 行
|
||||||
|
- **大版本**(x.0.0):20-40 行
|
||||||
|
|
||||||
|
## 示例参考
|
||||||
|
|
||||||
|
### 简洁版(1.4.2)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 1.4.2
|
||||||
|
|
||||||
|
## 架构优化
|
||||||
|
|
||||||
|
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
|
||||||
|
- 解耦更新服务职责,对话框改为懒加载以优化性能。
|
||||||
|
|
||||||
|
## 质量改进
|
||||||
|
|
||||||
|
- 修复类型检查问题,加固启动流程和认证健壮性。
|
||||||
|
- 新增核心模块测试覆盖,完善开发者文档。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 详细版(1.4.0)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 1.4.0
|
||||||
|
|
||||||
|
## 亮点
|
||||||
|
|
||||||
|
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||||
|
- 更新策略与登录用户角色联动。
|
||||||
|
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
- 新增便携版更新服务,支持登录后自动检查更新。
|
||||||
|
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
1. 创建版本文件:`docs/releases/{version}.md`
|
||||||
|
2. 参考现有文档风格编写
|
||||||
|
3. 提交 git:`git add docs/releases/{version}.md`
|
||||||
|
4. 提交信息:`docs: add release notes for version {version}`
|
||||||
|
|
||||||
|
## 维护说明
|
||||||
|
|
||||||
|
- 发布文档一旦创建,**不再修改**(除非有重大错误)
|
||||||
|
- 技术细节放入 `docs/` 下的专题文档
|
||||||
|
- Changelog 由发布脚本自动生成,不手动维护
|
||||||
416
package-lock.json
generated
416
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.7.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
"react-focus-lock": "^2.13.7",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
@@ -969,7 +970,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
|
||||||
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
|
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/abort-controller": "^2.1.2",
|
"@azure/abort-controller": "^2.1.2",
|
||||||
"@azure/core-auth": "^1.10.0",
|
"@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",
|
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
|
||||||
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
|
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/abort-controller": "^2.1.2",
|
"@azure/abort-controller": "^2.1.2",
|
||||||
"@azure/core-auth": "^1.10.0",
|
"@azure/core-auth": "^1.10.0",
|
||||||
@@ -1223,7 +1222,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -2001,6 +1999,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-dirname": "^0.1.0",
|
"cross-dirname": "^0.1.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@@ -2022,6 +2021,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -2038,6 +2038,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -2052,6 +2053,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"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"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -4404,7 +4442,12 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
@@ -4786,6 +4829,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||||
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
|
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -4918,7 +5023,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -4963,6 +5067,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -5033,7 +5143,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -5473,7 +5582,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5506,7 +5614,6 @@
|
|||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -6269,7 +6376,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -7065,7 +7171,8 @@
|
|||||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
@@ -7108,6 +7215,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
@@ -7471,7 +7705,6 @@
|
|||||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "26.8.1",
|
"app-builder-lib": "26.8.1",
|
||||||
"builder-util": "26.8.1",
|
"builder-util": "26.8.1",
|
||||||
@@ -7686,7 +7919,6 @@
|
|||||||
"integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==",
|
"integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^22.7.7",
|
"@types/node": "^22.7.7",
|
||||||
@@ -7875,6 +8107,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/asar": "^3.2.1",
|
"@electron/asar": "^3.2.1",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
@@ -7895,6 +8128,7 @@
|
|||||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.1.2",
|
"graceful-fs": "^4.1.2",
|
||||||
"jsonfile": "^4.0.0",
|
"jsonfile": "^4.0.0",
|
||||||
@@ -8146,6 +8380,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/es6-error": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
"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==",
|
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -8284,7 +8527,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -8574,6 +8816,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -9752,6 +10000,16 @@
|
|||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -9817,6 +10075,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -13328,7 +13594,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -13352,6 +13617,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^9.4.0"
|
"commander": "^9.4.0"
|
||||||
},
|
},
|
||||||
@@ -13369,6 +13635,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
@@ -13389,7 +13656,6 @@
|
|||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -13531,7 +13797,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -13553,7 +13818,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -13617,6 +13881,29 @@
|
|||||||
"react": ">=18"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -13690,6 +13977,51 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
@@ -13885,6 +14217,12 @@
|
|||||||
"url": "https://github.com/sponsors/jet2jet"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
@@ -15067,6 +15405,7 @@
|
|||||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"rimraf": "~2.6.2"
|
"rimraf": "~2.6.2"
|
||||||
@@ -15150,6 +15489,12 @@
|
|||||||
"semver": "bin/semver"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -16096,7 +16441,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -16507,12 +16851,33 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -17059,7 +17424,6 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -17268,7 +17632,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
@@ -17506,7 +17869,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.7.2",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
"react-focus-lock": "^2.13.7",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, dialog } from 'electron'
|
import { app } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ConfigManager } from '../services/config/config-manager'
|
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 {
|
try {
|
||||||
fs.mkdirSync(browsersPath, { recursive: true })
|
fs.mkdirSync(browsersPath, { recursive: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,7 +47,7 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
|
|||||||
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||||
|
|
||||||
if (fs.existsSync(chromiumPath)) {
|
if (fs.existsSync(chromiumPath)) {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let foundRevision = false
|
let foundRevision = false
|
||||||
@@ -64,22 +68,14 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundRevision) {
|
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(
|
console.warn(
|
||||||
'Playwright browser not found. Available:',
|
'Playwright browser not found. Available:',
|
||||||
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeMainProcessServices(): Promise<void> {
|
export async function initializeMainProcessServices(): Promise<void> {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ app.whenReady().then(async () => {
|
|||||||
setupProcessGuards()
|
setupProcessGuards()
|
||||||
registerMainWindowLifecycle()
|
registerMainWindowLifecycle()
|
||||||
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
||||||
ensurePlaywrightRuntime(playwrightBrowsersPath)
|
const browsersExist = ensurePlaywrightRuntime(playwrightBrowsersPath)
|
||||||
|
console.log('Playwright browsers exist:', browsersExist)
|
||||||
await initializeMainProcessServices()
|
await initializeMainProcessServices()
|
||||||
setupElectronRuntime()
|
setupElectronRuntime()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
|
|||||||
import { ExtractorService } from '../services/erp/extractor'
|
import { ExtractorService } from '../services/erp/extractor'
|
||||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||||
import { create, type IDatabaseService } from '../services/database'
|
import { create, type IDatabaseService } from '../services/database'
|
||||||
|
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { logAudit } from '../services/logger/audit-logger'
|
import { logAudit } from '../services/logger/audit-logger'
|
||||||
import { SessionManager } from '../services/user/session-manager'
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
|
|||||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||||
import { ConfigManager } from '../services/config/config-manager'
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
const log = createLogger('ExtractorHandler')
|
const log = createLogger('ExtractorHandler')
|
||||||
|
|
||||||
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
|
|||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||||
|
|
||||||
|
// Initialize operation history recording
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
const historyDao = new ExtractorOperationHistoryDAO()
|
||||||
|
const batchId = randomUUID()
|
||||||
|
|
||||||
|
// Save order records to history (preserve productionId -> orderNumber mapping)
|
||||||
|
if (currentUser) {
|
||||||
|
const orderRecords = mappings.map((m) => ({
|
||||||
|
productionId: m.productionId || null,
|
||||||
|
orderNumber: m.orderNumber || m.input
|
||||||
|
}))
|
||||||
|
await historyDao.insertBatchRecords(
|
||||||
|
batchId,
|
||||||
|
currentUser.id,
|
||||||
|
currentUser.username,
|
||||||
|
orderRecords
|
||||||
|
)
|
||||||
|
log.info('Operation history batch created', {
|
||||||
|
batchId,
|
||||||
|
recordCount: orderRecords.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Log deduplication summary
|
// Log deduplication summary
|
||||||
sendLog(sender, 'info', dedupReport.summary)
|
sendLog(sender, 'info', dedupReport.summary)
|
||||||
|
|
||||||
@@ -223,11 +248,29 @@ export function registerExtractorHandlers(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update operation history batch status
|
||||||
|
if (currentUser) {
|
||||||
|
const status: 'success' | 'failed' | 'partial' =
|
||||||
|
result.errors.length > 0 && result.recordCount > 0
|
||||||
|
? 'partial'
|
||||||
|
: result.errors.length > 0
|
||||||
|
? 'failed'
|
||||||
|
: 'success'
|
||||||
|
|
||||||
|
// Write per-order record counts
|
||||||
|
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
|
||||||
|
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update batch status without recordCount (per-order counts are set individually)
|
||||||
|
await historyDao.updateBatchStatus(batchId, status)
|
||||||
|
log.info('Operation history batch status updated', { batchId, status })
|
||||||
|
}
|
||||||
|
|
||||||
// Audit log: EXTRACT (non-blocking)
|
// Audit log: EXTRACT (non-blocking)
|
||||||
const os = await import('os')
|
const os = await import('os')
|
||||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const status: 'success' | 'failure' | 'partial' =
|
const auditStatus: 'success' | 'failure' | 'partial' =
|
||||||
result.errors.length > 0 && result.recordCount > 0
|
result.errors.length > 0 && result.recordCount > 0
|
||||||
? 'partial'
|
? 'partial'
|
||||||
: result.errors.length > 0
|
: result.errors.length > 0
|
||||||
@@ -237,7 +280,7 @@ export function registerExtractorHandlers(): void {
|
|||||||
username: currentUser.username,
|
username: currentUser.username,
|
||||||
computerName: os.hostname(),
|
computerName: os.hostname(),
|
||||||
resource: 'MATERIAL_PLAN',
|
resource: 'MATERIAL_PLAN',
|
||||||
status,
|
status: auditStatus,
|
||||||
metadata: {
|
metadata: {
|
||||||
orderCount: validOrderNumbers.length,
|
orderCount: validOrderNumbers.length,
|
||||||
recordCount: result.recordCount,
|
recordCount: result.recordCount,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
|||||||
import { registerLoggerHandlers } from './logger-handler'
|
import { registerLoggerHandlers } from './logger-handler'
|
||||||
import { registerReportHandlers } from './report-handler'
|
import { registerReportHandlers } from './report-handler'
|
||||||
import { registerUpdateHandlers } from './update-handler'
|
import { registerUpdateHandlers } from './update-handler'
|
||||||
|
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||||
|
import { registerOperationHistoryHandlers } from './operation-history-handler'
|
||||||
import { createLogger, logError } from '../services/logger'
|
import { createLogger, logError } from '../services/logger'
|
||||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
@@ -105,5 +107,7 @@ export function registerIpcHandlers(): void {
|
|||||||
registerLoggerHandlers()
|
registerLoggerHandlers()
|
||||||
registerReportHandlers()
|
registerReportHandlers()
|
||||||
registerUpdateHandlers()
|
registerUpdateHandlers()
|
||||||
|
registerPlaywrightBrowserHandlers()
|
||||||
|
registerOperationHistoryHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
124
src/main/ipc/operation-history-handler.ts
Normal file
124
src/main/ipc/operation-history-handler.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* IPC Handler for Extractor Operation History
|
||||||
|
*
|
||||||
|
* Handles IPC requests for operation history management:
|
||||||
|
* - Get batch list (filtered by user for non-admin users)
|
||||||
|
* - Get batch details
|
||||||
|
* - Delete batches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||||
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { createLogger } from '../services/logger'
|
||||||
|
import type {
|
||||||
|
BatchStats,
|
||||||
|
OperationHistoryRecord,
|
||||||
|
GetBatchesOptions
|
||||||
|
} from '../types/operation-history.types'
|
||||||
|
|
||||||
|
const log = createLogger('OperationHistoryHandler')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register IPC handlers for operation history
|
||||||
|
*/
|
||||||
|
export function registerOperationHistoryHandlers(): void {
|
||||||
|
const dao = new ExtractorOperationHistoryDAO()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batches list
|
||||||
|
* Admin users get all batches, regular users get only their own
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
|
||||||
|
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin gets all batches, User gets only their own
|
||||||
|
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||||
|
|
||||||
|
log.info('Getting operation history batches', {
|
||||||
|
userId: currentUser.id,
|
||||||
|
userType: currentUser.userType,
|
||||||
|
filtered: userId !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const batches = await dao.getBatches(userId, options)
|
||||||
|
return batches
|
||||||
|
}, 'operationHistory:getBatches')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch details
|
||||||
|
* Users can only view their own batch details, admins can view all
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
|
||||||
|
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Getting batch details', { batchId, userId: currentUser.id })
|
||||||
|
|
||||||
|
const details = await dao.getBatchDetails(batchId)
|
||||||
|
|
||||||
|
// For non-admin users, verify they own this batch
|
||||||
|
if (currentUser.userType !== 'Admin' && details.length > 0) {
|
||||||
|
const batchOwnerId = details[0].userId
|
||||||
|
if (batchOwnerId !== currentUser.id) {
|
||||||
|
throw new Error('没有权限查看此批次详情')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}, 'operationHistory:getBatchDetails')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a batch
|
||||||
|
* Users can only delete their own batches, admins can delete any
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
|
||||||
|
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = currentUser.userType === 'Admin'
|
||||||
|
|
||||||
|
log.info('Deleting batch', {
|
||||||
|
batchId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
isAdmin
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '删除批次失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true }
|
||||||
|
}, 'operationHistory:deleteBatch')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Operation history IPC handlers registered')
|
||||||
|
}
|
||||||
110
src/main/ipc/playwright-browser.ts
Normal file
110
src/main/ipc/playwright-browser.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser IPC Handlers
|
||||||
|
* Handles browser download requests from renderer process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
import { DownloadService } from '../services/playwright-browser'
|
||||||
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
|
import { S3Client } from '@aws-sdk/client-s3'
|
||||||
|
import type { DownloadProgress } from '../services/playwright-browser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create S3 client from config
|
||||||
|
*/
|
||||||
|
function createS3Client(): S3Client {
|
||||||
|
const config = ConfigManager.getInstance().getConfig().update
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Update config is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Playwright browsers are installed
|
||||||
|
*/
|
||||||
|
async function checkBrowsersExist(): Promise<boolean> {
|
||||||
|
const fs = await import('fs')
|
||||||
|
const browsersPath = join(app.getPath('userData'), 'ms-playwright')
|
||||||
|
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
|
||||||
|
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
|
||||||
|
const chromiumPath = fs.default.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||||
|
|
||||||
|
if (fs.default.existsSync(chromiumPath)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundRevision = false
|
||||||
|
try {
|
||||||
|
const entries = fs.default.readdirSync(browsersPath)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
|
||||||
|
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
|
||||||
|
if (fs.default.existsSync(revisionPath)) {
|
||||||
|
foundRevision = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore browser directory probing failures
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track active download for cancellation
|
||||||
|
*/
|
||||||
|
let activeDownload: { service: DownloadService; cancelled: boolean } | null = null
|
||||||
|
|
||||||
|
export function registerPlaywrightBrowserHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK, async (): Promise<IpcResult<boolean>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
return checkBrowsersExist()
|
||||||
|
}, 'playwright-browser:check')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD,
|
||||||
|
async (event: IpcMainInvokeEvent): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const s3Client = createS3Client()
|
||||||
|
const service = new DownloadService({ s3Client })
|
||||||
|
|
||||||
|
activeDownload = { service, cancelled: false }
|
||||||
|
|
||||||
|
await service.downloadAll((progress: DownloadProgress) => {
|
||||||
|
if (activeDownload?.cancelled) {
|
||||||
|
throw new Error('Download cancelled by user')
|
||||||
|
}
|
||||||
|
|
||||||
|
event.sender.send(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, progress)
|
||||||
|
})
|
||||||
|
|
||||||
|
activeDownload = null
|
||||||
|
}, 'playwright-browser:download')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL, async (): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
if (activeDownload) {
|
||||||
|
activeDownload.cancelled = true
|
||||||
|
activeDownload = null
|
||||||
|
}
|
||||||
|
}, 'playwright-browser:cancel')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,7 +28,13 @@ export function registerSettingsHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
||||||
return withErrorHandling(
|
return withErrorHandling(
|
||||||
async () => (sessionManager.getUserType() as UserType) || 'Guest',
|
async () => {
|
||||||
|
const userType = sessionManager.getUserType()
|
||||||
|
if (!userType) {
|
||||||
|
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
|
||||||
|
}
|
||||||
|
return userType as UserType
|
||||||
|
},
|
||||||
'settings:getUserType'
|
'settings:getUserType'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
|
|||||||
export const UserInfoSchema = z.object({
|
export const UserInfoSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
userType: z.enum(['Admin', 'User', 'Guest']),
|
userType: z.enum(['Admin', 'User']),
|
||||||
computerName: z.string().optional()
|
computerName: z.string().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
615
src/main/services/database/extractor-operation-history-dao.ts
Normal file
615
src/main/services/database/extractor-operation-history-dao.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
/**
|
||||||
|
* Data Access Object for ExtractorOperationHistory table
|
||||||
|
*
|
||||||
|
* Handles database operations for tracking extraction operation history:
|
||||||
|
* - Batch record insertion
|
||||||
|
* - Batch status updates
|
||||||
|
* - Querying batches (with user filtering for non-admin users)
|
||||||
|
* - Getting batch details
|
||||||
|
* - Deleting batches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create, type IDatabaseService } from './index'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
import type {
|
||||||
|
OperationHistoryRecord,
|
||||||
|
BatchStats,
|
||||||
|
InsertBatchRecordInput,
|
||||||
|
UpdateBatchStatusResult,
|
||||||
|
GetBatchesOptions
|
||||||
|
} from '../../types/operation-history.types'
|
||||||
|
|
||||||
|
const log = createLogger('ExtractorOperationHistoryDAO')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime value from database to ISO string
|
||||||
|
* mssql driver returns Date objects in UTC format
|
||||||
|
*/
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString()
|
||||||
|
}
|
||||||
|
return value ? String(value) : new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for ExtractorOperationHistory table
|
||||||
|
*/
|
||||||
|
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
|
||||||
|
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
|
||||||
|
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
BATCH_ID: 'BatchId',
|
||||||
|
USER_ID: 'UserId',
|
||||||
|
USERNAME: 'Username',
|
||||||
|
PRODUCTION_ID: 'ProductionId',
|
||||||
|
ORDER_NUMBER: 'OrderNumber',
|
||||||
|
OPERATION_TIME: 'OperationTime',
|
||||||
|
STATUS: 'Status',
|
||||||
|
RECORD_COUNT: 'RecordCount',
|
||||||
|
ERROR_MESSAGE: 'ErrorMessage'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExtractorOperationHistory DAO Class
|
||||||
|
*/
|
||||||
|
export class ExtractorOperationHistoryDAO {
|
||||||
|
private dbService: IDatabaseService | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate table name based on database type
|
||||||
|
*/
|
||||||
|
private getTableName(): string {
|
||||||
|
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||||
|
return isSqlServer
|
||||||
|
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
|
||||||
|
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database service instance using DatabaseFactory
|
||||||
|
*/
|
||||||
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||||
|
if (this.dbService && this.dbService.isConnected()) {
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dbService = await create()
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build placeholders for IN clause based on database type
|
||||||
|
*/
|
||||||
|
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||||
|
return isSqlServer
|
||||||
|
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||||
|
: Array.from({ length: count }, () => '?').join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INSERT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert batch records for a single extraction operation
|
||||||
|
* @param batchId - Unique batch identifier
|
||||||
|
* @param userId - User ID performing the operation
|
||||||
|
* @param username - Username performing the operation
|
||||||
|
* @param records - Array of order records to insert
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async insertBatchRecords(
|
||||||
|
batchId: string,
|
||||||
|
userId: number,
|
||||||
|
username: string,
|
||||||
|
records: InsertBatchRecordInput[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!records || records.length === 0) {
|
||||||
|
log.warn('No records to insert')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
if (isSqlServer) {
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName}
|
||||||
|
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||||
|
VALUES
|
||||||
|
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
|
||||||
|
`
|
||||||
|
await dbService.query(sqlString, [
|
||||||
|
batchId,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
record.productionId || null,
|
||||||
|
record.orderNumber
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName}
|
||||||
|
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, NOW(), 'pending')
|
||||||
|
`
|
||||||
|
await dbService.query(sqlString, [
|
||||||
|
batchId,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
record.productionId || null,
|
||||||
|
record.orderNumber
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error inserting individual record', {
|
||||||
|
batchId,
|
||||||
|
orderNumber: record.orderNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Batch records inserted', { batchId, count: records.length })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Insert batch records error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UPDATE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the status of all records in a batch
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param status - New status (success, failed, partial)
|
||||||
|
* @returns Update result
|
||||||
|
*/
|
||||||
|
async updateBatchStatus(
|
||||||
|
batchId: string,
|
||||||
|
status: string
|
||||||
|
): Promise<UpdateBatchStatusResult> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${isSqlServer ? '@p0' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
||||||
|
`
|
||||||
|
const params = [status, batchId]
|
||||||
|
|
||||||
|
await dbService.query(sqlString, params)
|
||||||
|
|
||||||
|
log.info('Batch status updated', { batchId, status })
|
||||||
|
return { success: true, updatedCount: 1 }
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Update batch status error', {
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { success: false, updatedCount: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single record's status, error message, and optional record count
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param orderNumber - Order number
|
||||||
|
* @param status - New status
|
||||||
|
* @param errorMessage - Optional error message
|
||||||
|
* @param recordCount - Optional per-order record count
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async updateRecordStatus(
|
||||||
|
batchId: string,
|
||||||
|
orderNumber: string,
|
||||||
|
status: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
recordCount?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
let sqlString: string
|
||||||
|
let params: (string | number | null)[]
|
||||||
|
|
||||||
|
if (recordCount !== undefined) {
|
||||||
|
sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||||
|
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
|
||||||
|
RecordCount = ${isSqlServer ? '@p2' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
|
||||||
|
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
|
||||||
|
`
|
||||||
|
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
|
||||||
|
} else {
|
||||||
|
sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||||
|
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||||
|
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
||||||
|
`
|
||||||
|
params = [status, errorMessage || null, batchId, orderNumber]
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbService.query(sqlString, params)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Update record status error', {
|
||||||
|
batchId,
|
||||||
|
orderNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== READ ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch statistics with optional user filtering
|
||||||
|
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
|
||||||
|
* @param options - Query options (limit, offset)
|
||||||
|
* @returns Array of batch statistics
|
||||||
|
*/
|
||||||
|
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
let sqlString = `
|
||||||
|
SELECT
|
||||||
|
BatchId,
|
||||||
|
UserId,
|
||||||
|
Username,
|
||||||
|
MIN(OperationTime) as OperationTime,
|
||||||
|
MAX(Status) as Status,
|
||||||
|
COUNT(*) as TotalOrders,
|
||||||
|
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||||
|
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||||
|
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||||
|
FROM ${tableName}
|
||||||
|
`
|
||||||
|
|
||||||
|
const params: (number | string)[] = []
|
||||||
|
|
||||||
|
if (userId !== undefined) {
|
||||||
|
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||||
|
params.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlString += `
|
||||||
|
GROUP BY BatchId, UserId, Username
|
||||||
|
ORDER BY OperationTime DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
const safeLimit = Math.floor(options.limit)
|
||||||
|
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
|
||||||
|
|
||||||
|
if (isSqlServer) {
|
||||||
|
// SQL Server: use parameterized OFFSET/FETCH
|
||||||
|
const offsetIndex = params.length
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
params.push(safeOffset)
|
||||||
|
}
|
||||||
|
params.push(safeLimit)
|
||||||
|
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
|
||||||
|
} else {
|
||||||
|
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MySQL: embed validated integer values directly.
|
||||||
|
// connection.execute() uses binary protocol prepared statements,
|
||||||
|
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
|
||||||
|
} else {
|
||||||
|
sqlString += ` LIMIT ${safeLimit}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, params)
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
userId: row.UserId as number,
|
||||||
|
username: row.Username as string,
|
||||||
|
operationTime: formatDateTime(row.OperationTime),
|
||||||
|
status: row.Status as string,
|
||||||
|
totalOrders: row.TotalOrders as number,
|
||||||
|
totalRecords: (row.TotalRecords as number) || 0,
|
||||||
|
successCount: (row.SuccessCount as number) || 0,
|
||||||
|
failedCount: (row.FailedCount as number) || 0
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get batches error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed records for a specific batch
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @returns Array of operation records
|
||||||
|
*/
|
||||||
|
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
SELECT
|
||||||
|
ID,
|
||||||
|
BatchId,
|
||||||
|
UserId,
|
||||||
|
Username,
|
||||||
|
ProductionId,
|
||||||
|
OrderNumber,
|
||||||
|
OperationTime,
|
||||||
|
Status,
|
||||||
|
RecordCount,
|
||||||
|
ErrorMessage
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE BatchId = ${placeholder}
|
||||||
|
ORDER BY ID
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [batchId])
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
userId: row.UserId as number,
|
||||||
|
username: row.Username as string,
|
||||||
|
productionId: row.ProductionId as string | null,
|
||||||
|
orderNumber: row.OrderNumber as string,
|
||||||
|
operationTime: new Date(row.OperationTime as string),
|
||||||
|
status: row.Status as string,
|
||||||
|
recordCount: row.RecordCount as number | null,
|
||||||
|
errorMessage: row.ErrorMessage as string | null
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get batch details error', {
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single batch's statistics
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @returns Batch statistics or null
|
||||||
|
*/
|
||||||
|
async getBatchStats(batchId: string): Promise<BatchStats | null> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
SELECT
|
||||||
|
BatchId,
|
||||||
|
UserId,
|
||||||
|
Username,
|
||||||
|
MIN(OperationTime) as OperationTime,
|
||||||
|
MAX(Status) as Status,
|
||||||
|
COUNT(*) as TotalOrders,
|
||||||
|
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||||
|
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||||
|
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE BatchId = ${placeholder}
|
||||||
|
GROUP BY BatchId, UserId, Username
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [batchId])
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
userId: row.UserId as number,
|
||||||
|
username: row.Username as string,
|
||||||
|
operationTime: formatDateTime(row.OperationTime),
|
||||||
|
status: row.Status as string,
|
||||||
|
totalOrders: row.TotalOrders as number,
|
||||||
|
totalRecords: (row.TotalRecords as number) || 0,
|
||||||
|
successCount: (row.SuccessCount as number) || 0,
|
||||||
|
failedCount: (row.FailedCount as number) || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get batch stats error', {
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a batch with permission checking
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param requestingUserId - User ID requesting the deletion
|
||||||
|
* @param isAdmin - Whether the requesting user is an admin
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async deleteBatch(
|
||||||
|
batchId: string,
|
||||||
|
requestingUserId: number,
|
||||||
|
isAdmin: boolean
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
// First check if the batch exists and if the user has permission
|
||||||
|
const batchStats = await this.getBatchStats(batchId)
|
||||||
|
|
||||||
|
if (!batchStats) {
|
||||||
|
return { success: false, error: '批次不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admin users can only delete their own batches
|
||||||
|
if (!isAdmin && batchStats.userId !== requestingUserId) {
|
||||||
|
return { success: false, error: '没有权限删除此批次' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the batch
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
DELETE FROM ${tableName}
|
||||||
|
WHERE BatchId = ${placeholder}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [batchId])
|
||||||
|
|
||||||
|
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Delete batch error', {
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all batches for a specific user
|
||||||
|
* @param userId - User ID
|
||||||
|
* @returns Number of batches deleted
|
||||||
|
*/
|
||||||
|
async deleteByUser(userId: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
DELETE FROM ${tableName}
|
||||||
|
WHERE UserId = ${placeholder}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [userId])
|
||||||
|
return result.rowCount
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Delete by user error', {
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UTILITIES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a batch exists
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @returns True if batch exists
|
||||||
|
*/
|
||||||
|
async batchExists(batchId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
|
const sqlString = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE BatchId = ${placeholder}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, [batchId])
|
||||||
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Batch exists error', {
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total batches with optional user filtering
|
||||||
|
* @param userId - Optional user ID for filtering
|
||||||
|
* @returns Total number of batches
|
||||||
|
*/
|
||||||
|
async countBatches(userId?: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
|
let sqlString = `
|
||||||
|
SELECT COUNT(DISTINCT BatchId) as count
|
||||||
|
FROM ${tableName}
|
||||||
|
`
|
||||||
|
|
||||||
|
const params: number[] = []
|
||||||
|
|
||||||
|
if (userId !== undefined) {
|
||||||
|
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||||
|
params.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, params)
|
||||||
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Count batches error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.dbService) {
|
||||||
|
await this.dbService.disconnect()
|
||||||
|
this.dbService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,8 @@ export class ExtractorService {
|
|||||||
downloadedFiles: [],
|
downloadedFiles: [],
|
||||||
mergedFile: null,
|
mergedFile: null,
|
||||||
recordCount: 0,
|
recordCount: 0,
|
||||||
errors: []
|
errors: [],
|
||||||
|
orderRecordCounts: []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +80,7 @@ export class ExtractorService {
|
|||||||
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
||||||
result.mergedFile = mergeResult.mergedFile
|
result.mergedFile = mergeResult.mergedFile
|
||||||
result.recordCount = mergeResult.recordCount
|
result.recordCount = mergeResult.recordCount
|
||||||
|
result.orderRecordCounts = mergeResult.orderRecordCounts
|
||||||
|
|
||||||
// Add merge error to result if any
|
// Add merge error to result if any
|
||||||
if (mergeResult.error) {
|
if (mergeResult.error) {
|
||||||
@@ -123,9 +125,14 @@ export class ExtractorService {
|
|||||||
*/
|
*/
|
||||||
private async mergeFiles(
|
private async mergeFiles(
|
||||||
filePaths: string[]
|
filePaths: string[]
|
||||||
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
|
): Promise<{
|
||||||
|
mergedFile: string | null
|
||||||
|
recordCount: number
|
||||||
|
error?: string
|
||||||
|
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
|
||||||
|
}> {
|
||||||
if (filePaths.length === 0) {
|
if (filePaths.length === 0) {
|
||||||
return { mergedFile: null, recordCount: 0 }
|
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting merge', { fileCount: filePaths.length })
|
log.info('Starting merge', { fileCount: filePaths.length })
|
||||||
@@ -154,15 +161,21 @@ export class ExtractorService {
|
|||||||
|
|
||||||
// Calculate total record count (total material rows)
|
// Calculate total record count (total material rows)
|
||||||
let recordCount = 0
|
let recordCount = 0
|
||||||
|
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
|
||||||
for (const order of allOrders) {
|
for (const order of allOrders) {
|
||||||
recordCount += order.materials.length
|
const count = order.materials.length
|
||||||
|
recordCount += count
|
||||||
|
orderRecordCounts.push({
|
||||||
|
orderNumber: order.orderInfo.productionOrder || '',
|
||||||
|
recordCount: count
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
||||||
|
|
||||||
if (recordCount === 0) {
|
if (recordCount === 0) {
|
||||||
log.warn('No records found in any downloaded files')
|
log.warn('No records found in any downloaded files')
|
||||||
return { mergedFile: null, recordCount: 0 }
|
return { mergedFile: null, recordCount: 0, orderRecordCounts }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate output filename with timestamp
|
// Generate output filename with timestamp
|
||||||
@@ -178,13 +191,13 @@ export class ExtractorService {
|
|||||||
log.info('Saving merged file', { outputPath })
|
log.info('Saving merged file', { outputPath })
|
||||||
await this.saveMergedOrders(allOrders, outputPath)
|
await this.saveMergedOrders(allOrders, outputPath)
|
||||||
log.info('Merged file saved successfully', { recordCount })
|
log.info('Merged file saved successfully', { recordCount })
|
||||||
return { mergedFile: outputPath, recordCount }
|
return { mergedFile: outputPath, recordCount, orderRecordCounts }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
const errorStack = error instanceof Error ? error.stack : ''
|
const errorStack = error instanceof Error ? error.stack : ''
|
||||||
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
|
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
|
||||||
// Return parsed record count and error info even if save fails
|
// Return parsed record count and error info even if save fails
|
||||||
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
|
return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
407
src/main/services/playwright-browser/download-service.ts
Normal file
407
src/main/services/playwright-browser/download-service.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser Download Service
|
||||||
|
*
|
||||||
|
* Downloads Playwright browser files from S3 to local directory
|
||||||
|
* with progress tracking and basic validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('PlaywrightDownloadService')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download progress event
|
||||||
|
*/
|
||||||
|
export interface DownloadProgress {
|
||||||
|
percent: number
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
currentFile: string
|
||||||
|
speed: number
|
||||||
|
eta?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 Object information
|
||||||
|
*/
|
||||||
|
export interface S3Object {
|
||||||
|
key: string
|
||||||
|
size?: number
|
||||||
|
lastModified?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
fileCount?: number
|
||||||
|
chromeExeExists?: boolean
|
||||||
|
expectedChromePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download configuration
|
||||||
|
*/
|
||||||
|
export interface DownloadConfig {
|
||||||
|
s3Client?: S3Client
|
||||||
|
bucket?: string
|
||||||
|
prefix?: string
|
||||||
|
destDir?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: Required<DownloadConfig> = {
|
||||||
|
s3Client: null as unknown as S3Client,
|
||||||
|
bucket: 'erpauto',
|
||||||
|
prefix: 'resources/ms-playwright/', // ✅ Fixed: removed 'erpauto/' prefix
|
||||||
|
destDir: path.join(process.env.APPDATA || '', 'erpauto', 'ms-playwright')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadService class
|
||||||
|
* Handles downloading Playwright browser files from S3
|
||||||
|
*/
|
||||||
|
export class DownloadService {
|
||||||
|
private config: Required<DownloadConfig>
|
||||||
|
private s3Client: S3Client
|
||||||
|
|
||||||
|
constructor(config?: DownloadConfig) {
|
||||||
|
if (!config?.s3Client) {
|
||||||
|
throw new Error('S3Client is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
bucket: config?.bucket || DEFAULT_CONFIG.bucket,
|
||||||
|
prefix: config?.prefix || DEFAULT_CONFIG.prefix,
|
||||||
|
destDir: config?.destDir || DEFAULT_CONFIG.destDir
|
||||||
|
}
|
||||||
|
|
||||||
|
this.s3Client = this.config.s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an operation with retry logic using exponential backoff
|
||||||
|
* @param operation - The async operation to execute
|
||||||
|
* @param context - Description of the operation for logging
|
||||||
|
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
||||||
|
* @returns The result of the operation
|
||||||
|
*/
|
||||||
|
private async withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error | undefined
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation()
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
const isRetryable = this.isRetryableError(lastError)
|
||||||
|
if (!isRetryable || attempt === maxRetries) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
const delay = Math.pow(2, attempt - 1) * 1000
|
||||||
|
log.warn(`${context} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`, {
|
||||||
|
error: lastError.message
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error is retryable based on error type and message
|
||||||
|
* @param error - The error to classify
|
||||||
|
* @returns true if the error should trigger a retry
|
||||||
|
*/
|
||||||
|
private isRetryableError(error: Error): boolean {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
const code = (error as any).code?.toLowerCase() || ''
|
||||||
|
|
||||||
|
// Retryable: network errors, timeouts
|
||||||
|
const retryablePatterns = [
|
||||||
|
'etimedout',
|
||||||
|
'econnreset',
|
||||||
|
'timeout',
|
||||||
|
'network',
|
||||||
|
'socket hang up',
|
||||||
|
'connection reset'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Not retryable: S3 errors, validation errors
|
||||||
|
const nonRetryablePatterns = [
|
||||||
|
'404',
|
||||||
|
'403',
|
||||||
|
'not found',
|
||||||
|
'access denied',
|
||||||
|
'invalid',
|
||||||
|
'validation'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check non-retryable first
|
||||||
|
for (const pattern of nonRetryablePatterns) {
|
||||||
|
if (message.includes(pattern) || code.includes(pattern)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check retryable
|
||||||
|
for (const pattern of retryablePatterns) {
|
||||||
|
if (message.includes(pattern) || code.includes(pattern)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: don't retry unknown errors
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all S3 objects under the configured prefix
|
||||||
|
* Filters to include ALL Chromium files (regular + headless)
|
||||||
|
* Simple filter: includes 'chromium' keyword
|
||||||
|
*/
|
||||||
|
async listObjects(): Promise<S3Object[]> {
|
||||||
|
log.info('Listing S3 objects', { bucket: this.config.bucket, prefix: this.config.prefix })
|
||||||
|
|
||||||
|
const objects: S3Object[] = []
|
||||||
|
let continuationToken: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Prefix: this.config.prefix,
|
||||||
|
ContinuationToken: continuationToken
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const obj of response.Contents || []) {
|
||||||
|
if (!obj.Key) continue
|
||||||
|
|
||||||
|
// Simple filter: only download Chromium-related files
|
||||||
|
// This includes: chromium-1200, chromium-1208, chromium_headless_shell-1200, chromium_headless_shell-1208
|
||||||
|
// Excludes: firefox, webkit, ffmpeg, winldd, .links, .settings
|
||||||
|
if (!obj.Key.includes('chromium')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.push({
|
||||||
|
key: obj.Key,
|
||||||
|
size: obj.Size,
|
||||||
|
lastModified: obj.LastModified
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
|
||||||
|
} while (continuationToken)
|
||||||
|
|
||||||
|
log.info(`Found ${objects.length} Chromium objects`)
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single file from S3 with retry logic
|
||||||
|
*/
|
||||||
|
async downloadFile(key: string, destPath: string): Promise<void> {
|
||||||
|
await this.withRetry(async () => {
|
||||||
|
log.debug('Downloading file', { key, destPath })
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
|
||||||
|
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const output = fs.createWriteStream(destPath)
|
||||||
|
const body = response.Body as NodeJS.ReadableStream
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
body.on('error', reject)
|
||||||
|
output.on('error', reject)
|
||||||
|
output.on('finish', resolve)
|
||||||
|
body.pipe(output)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.debug('File downloaded', { key, destPath })
|
||||||
|
}, `Download ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download all Chromium browser files from S3
|
||||||
|
* Emits progress events during download
|
||||||
|
*/
|
||||||
|
async downloadAll(onProgress: (progress: DownloadProgress) => void): Promise<void> {
|
||||||
|
log.info('Starting download of all browser files', { destDir: this.config.destDir })
|
||||||
|
|
||||||
|
const objects = await this.listObjects()
|
||||||
|
if (objects.length === 0) {
|
||||||
|
throw new Error('No Chromium browser files found in S3')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total bytes
|
||||||
|
const totalBytes = objects.reduce((sum, obj) => sum + (obj.size || 0), 0)
|
||||||
|
log.info(`Total files: ${objects.length}, Total bytes: ${totalBytes}`)
|
||||||
|
|
||||||
|
let downloadedBytes = 0
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
const relativePath = obj.key.replace(this.config.prefix, '')
|
||||||
|
const destPath = path.join(this.config.destDir, relativePath)
|
||||||
|
|
||||||
|
// Emit progress for current file
|
||||||
|
onProgress({
|
||||||
|
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: relativePath,
|
||||||
|
speed: 0,
|
||||||
|
eta: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.downloadFile(obj.key, destPath)
|
||||||
|
|
||||||
|
const fileSize = obj.size || 0
|
||||||
|
downloadedBytes += fileSize
|
||||||
|
|
||||||
|
// Calculate speed and ETA
|
||||||
|
const elapsedSeconds = (Date.now() - startTime) / 1000
|
||||||
|
const speed = Math.round(downloadedBytes / elapsedSeconds)
|
||||||
|
const remainingBytes = totalBytes - downloadedBytes
|
||||||
|
const eta = speed > 0 ? Math.round(remainingBytes / speed) : undefined
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: relativePath,
|
||||||
|
speed,
|
||||||
|
eta
|
||||||
|
})
|
||||||
|
|
||||||
|
log.debug('Downloaded file', {
|
||||||
|
key: obj.key,
|
||||||
|
size: fileSize,
|
||||||
|
speed: `${speed} bytes/s`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Failed to download file', {
|
||||||
|
key: obj.key,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw new Error(
|
||||||
|
`Failed to download ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalElapsed = (Date.now() - startTime) / 1000
|
||||||
|
log.info('Download completed', {
|
||||||
|
totalFiles: objects.length,
|
||||||
|
totalBytes,
|
||||||
|
duration: `${totalElapsed.toFixed(1)}s`,
|
||||||
|
avgSpeed: `${Math.round(downloadedBytes / totalElapsed)} bytes/s`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Final progress event
|
||||||
|
onProgress({
|
||||||
|
percent: 100,
|
||||||
|
downloadedBytes: totalBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: 'Complete',
|
||||||
|
speed: Math.round(downloadedBytes / totalElapsed),
|
||||||
|
eta: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the downloaded files
|
||||||
|
* Checks if the destination directory exists and chrome.exe is present
|
||||||
|
*/
|
||||||
|
async validateDownload(): Promise<ValidationResult> {
|
||||||
|
log.info('Validating download', { destDir: this.config.destDir })
|
||||||
|
|
||||||
|
// Check if destination directory exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(this.config.destDir)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Download directory does not exist',
|
||||||
|
fileCount: 0,
|
||||||
|
chromeExeExists: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find chrome.exe in chromium-* folders
|
||||||
|
const chromeExePattern = /chromium-\d+[/\\]chrome-win64[/\\]chrome\.exe$/
|
||||||
|
let chromeExeExists = false
|
||||||
|
let expectedChromePath: string | undefined
|
||||||
|
let fileCount = 0
|
||||||
|
|
||||||
|
const findChromeExe = async (dir: string): Promise<void> => {
|
||||||
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await findChromeExe(fullPath)
|
||||||
|
} else if (entry.name.toLowerCase() === 'chrome.exe') {
|
||||||
|
// Check if path matches chromium-*/chrome-win64/chrome.exe pattern
|
||||||
|
const relativePath = path.relative(this.config.destDir, fullPath)
|
||||||
|
if (chromeExePattern.test(relativePath.replace(/\\/g, '/'))) {
|
||||||
|
chromeExeExists = true
|
||||||
|
expectedChromePath = fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await findChromeExe(this.config.destDir)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error scanning download directory', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ValidationResult = {
|
||||||
|
success: chromeExeExists,
|
||||||
|
message: chromeExeExists ? 'Validation passed' : 'chrome.exe not found in expected location',
|
||||||
|
fileCount,
|
||||||
|
chromeExeExists,
|
||||||
|
expectedChromePath
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Validation result', result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadService
|
||||||
11
src/main/services/playwright-browser/index.ts
Normal file
11
src/main/services/playwright-browser/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser Module Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DownloadService, default } from './download-service'
|
||||||
|
export type {
|
||||||
|
DownloadProgress,
|
||||||
|
S3Object,
|
||||||
|
ValidationResult,
|
||||||
|
DownloadConfig
|
||||||
|
} from './download-service'
|
||||||
@@ -29,7 +29,7 @@ export class UpdateCatalogService {
|
|||||||
|
|
||||||
public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog {
|
public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog {
|
||||||
const currentUserType = status.currentUserType
|
const currentUserType = status.currentUserType
|
||||||
if (!status.enabled || !currentUserType || currentUserType === 'Guest') {
|
if (!status.enabled || !currentUserType) {
|
||||||
return { mode: 'disabled' }
|
return { mode: 'disabled' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class UpdateService {
|
|||||||
this.ensureInitialized()
|
this.ensureInitialized()
|
||||||
this.status.currentUserType = userType
|
this.status.currentUserType = userType
|
||||||
|
|
||||||
if (!this.status.enabled || !userType || userType === 'Guest') {
|
if (!this.status.enabled || !userType) {
|
||||||
this.clearPolling()
|
this.clearPolling()
|
||||||
this.catalog = { stable: [], preview: [] }
|
this.catalog = { stable: [], preview: [] }
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
@@ -124,7 +124,12 @@ export class UpdateService {
|
|||||||
return
|
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()
|
this.startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,16 +154,16 @@ export class UpdateService {
|
|||||||
this.publishStatus(nextStatus)
|
this.publishStatus(nextStatus)
|
||||||
|
|
||||||
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
|
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
|
||||||
try {
|
// 异步下载,不阻塞更新检查流程
|
||||||
await this.downloadRelease(nextStatus.recommendedRelease)
|
void this.downloadRelease(nextStatus.recommendedRelease).catch((error) => {
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : '下载更新失败'
|
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||||
|
log.warn('Async update download failed', { error: message })
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
error: message,
|
error: message,
|
||||||
message
|
message
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
} else if (currentUserType === 'Admin') {
|
} else if (currentUserType === 'Admin') {
|
||||||
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))
|
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class BIPUsersDAO {
|
|||||||
return {
|
return {
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
username: row.UserName as string,
|
username: row.UserName as string,
|
||||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
userType: row.UserType as 'Admin' | 'User'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -157,7 +157,7 @@ export class BIPUsersDAO {
|
|||||||
return {
|
return {
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
username: row.UserName as string,
|
username: row.UserName as string,
|
||||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
userType: row.UserType as 'Admin' | 'User'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -198,7 +198,7 @@ export class BIPUsersDAO {
|
|||||||
return {
|
return {
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
username: row.UserName as string,
|
username: row.UserName as string,
|
||||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
userType: row.UserType as 'Admin' | 'User'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -216,7 +216,7 @@ export class BIPUsersDAO {
|
|||||||
return {
|
return {
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
username: row.UserName as string,
|
username: row.UserName as string,
|
||||||
userType: row.UserType as 'Admin' | 'User' | 'Guest'
|
userType: row.UserType as 'Admin' | 'User'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -254,7 +254,7 @@ export class BIPUsersDAO {
|
|||||||
return result.rows.map((row) => ({
|
return result.rows.map((row) => ({
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
username: row.UserName as string,
|
username: row.UserName as string,
|
||||||
userType: row.UserType as 'Admin' | 'User' | 'Guest',
|
userType: row.UserType as 'Admin' | 'User',
|
||||||
createTime: row.CreateTime as Date | undefined
|
createTime: row.CreateTime as Date | undefined
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -270,7 +270,7 @@ export class BIPUsersDAO {
|
|||||||
* Create a new user
|
* Create a new user
|
||||||
* @param username - The username (must be unique)
|
* @param username - The username (must be unique)
|
||||||
* @param password - The password
|
* @param password - The password
|
||||||
* @param userType - User type ('Admin', 'User', or 'Guest')
|
* @param userType - User type ('Admin' or 'User')
|
||||||
* @param computerName - Optional computer name for silent login
|
* @param computerName - Optional computer name for silent login
|
||||||
* @returns True if successful
|
* @returns True if successful
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -126,13 +126,6 @@ export class SessionManager {
|
|||||||
return this.currentUser?.userType === 'Admin'
|
return this.currentUser?.userType === 'Admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a guest
|
|
||||||
*/
|
|
||||||
public isGuest(): boolean {
|
|
||||||
return this.currentUser?.userType === 'Guest'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current username
|
* Get the current username
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface ExtractorResult {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
/** Database import result (only populated if mergedFile was created) */
|
/** Database import result (only populated if mergedFile was created) */
|
||||||
importResult?: ImportResult
|
importResult?: ImportResult
|
||||||
|
/** Per-order material row counts */
|
||||||
|
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderInfo {
|
export interface OrderInfo {
|
||||||
|
|||||||
86
src/main/types/operation-history.types.ts
Normal file
86
src/main/types/operation-history.types.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Operation History Type Definitions
|
||||||
|
*
|
||||||
|
* Type definitions for the Extractor Operation History feature.
|
||||||
|
* Tracks extraction operations with batch and individual order record details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual operation history record
|
||||||
|
*/
|
||||||
|
export interface OperationHistoryRecord {
|
||||||
|
/** Auto-increment ID */
|
||||||
|
id?: number
|
||||||
|
/** Batch ID - shared among all orders in a single extraction operation */
|
||||||
|
batchId: string
|
||||||
|
/** User ID who performed the operation */
|
||||||
|
userId: number
|
||||||
|
/** Username who performed the operation */
|
||||||
|
username: string
|
||||||
|
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
|
||||||
|
productionId: string | null
|
||||||
|
/** Resolved order number (e.g., "SC70202602120085") */
|
||||||
|
orderNumber: string
|
||||||
|
/** When the operation was performed */
|
||||||
|
operationTime: Date
|
||||||
|
/** Operation status: pending, success, failed, partial */
|
||||||
|
status: string
|
||||||
|
/** Number of records extracted for this order */
|
||||||
|
recordCount: number | null
|
||||||
|
/** Error message if operation failed */
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch statistics - aggregated view of a batch operation
|
||||||
|
*/
|
||||||
|
export interface BatchStats {
|
||||||
|
/** Unique batch identifier */
|
||||||
|
batchId: string
|
||||||
|
/** User ID who performed the operation */
|
||||||
|
userId: number
|
||||||
|
/** Username who performed the operation */
|
||||||
|
username: string
|
||||||
|
/** When the operation started */
|
||||||
|
operationTime: string
|
||||||
|
/** Overall batch status: pending, success, failed, partial */
|
||||||
|
status: string
|
||||||
|
/** Total number of orders in the batch */
|
||||||
|
totalOrders: number
|
||||||
|
/** Total records extracted across all orders */
|
||||||
|
totalRecords: number
|
||||||
|
/** Number of orders that succeeded */
|
||||||
|
successCount: number
|
||||||
|
/** Number of orders that failed */
|
||||||
|
failedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for inserting batch records
|
||||||
|
*/
|
||||||
|
export interface InsertBatchRecordInput {
|
||||||
|
/** Original input production ID (e.g., "22A1") */
|
||||||
|
productionId: string | null
|
||||||
|
/** Resolved order number (e.g., "SC70202602120085") */
|
||||||
|
orderNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result for batch status update
|
||||||
|
*/
|
||||||
|
export interface UpdateBatchStatusResult {
|
||||||
|
/** Whether the update was successful */
|
||||||
|
success: boolean
|
||||||
|
/** Number of records updated */
|
||||||
|
updatedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for querying batches
|
||||||
|
*/
|
||||||
|
export interface GetBatchesOptions {
|
||||||
|
/** Maximum number of batches to return */
|
||||||
|
limit?: number
|
||||||
|
/** Number of batches to skip (for pagination) */
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
/**
|
/**
|
||||||
* User type for settings permission control
|
* User type for settings permission control
|
||||||
*/
|
*/
|
||||||
export type UserType = 'Admin' | 'User' | 'Guest'
|
export type UserType = 'Admin' | 'User'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database type selection
|
* Database type selection
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
/**
|
/**
|
||||||
* User type enumeration
|
* User type enumeration
|
||||||
*/
|
*/
|
||||||
export type UserType = 'Admin' | 'User' | 'Guest'
|
export type UserType = 'Admin' | 'User'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User information interface
|
* User information interface
|
||||||
|
|||||||
24
src/preload/api/browser-download.ts
Normal file
24
src/preload/api/browser-download.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ipcRenderer } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import type { IpcResult } from '../../main/types/ipc.types'
|
||||||
|
import type { DownloadProgress } from '../index.d'
|
||||||
|
|
||||||
|
export const playwrightBrowserApi = {
|
||||||
|
check: async (): Promise<IpcResult<boolean>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK)
|
||||||
|
},
|
||||||
|
|
||||||
|
download: async (): Promise<IpcResult<void>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (): Promise<IpcResult<void>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL)
|
||||||
|
},
|
||||||
|
|
||||||
|
onProgress: (callback: (data: DownloadProgress) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, data: DownloadProgress) => callback(data)
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||||
|
}
|
||||||
|
} as const
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
userErpConfigApi
|
userErpConfigApi
|
||||||
} from './materials'
|
} from './materials'
|
||||||
import { loggerApi } from './logger'
|
import { loggerApi } from './logger'
|
||||||
|
import { playwrightBrowserApi } from './browser-download'
|
||||||
|
import { operationHistoryApi } from './operation-history'
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
process: processApi,
|
process: processApi,
|
||||||
@@ -33,7 +35,9 @@ export const api = {
|
|||||||
config: configApi,
|
config: configApi,
|
||||||
logger: loggerApi,
|
logger: loggerApi,
|
||||||
report: reportApi,
|
report: reportApi,
|
||||||
update: updateApi
|
update: updateApi,
|
||||||
|
playwrightBrowser: playwrightBrowserApi,
|
||||||
|
operationHistory: operationHistoryApi
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ElectronApi = typeof api
|
export type ElectronApi = typeof api
|
||||||
|
|||||||
30
src/preload/api/operation-history.ts
Normal file
30
src/preload/api/operation-history.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { invokeIpc } from '../lib/ipc'
|
||||||
|
import type {
|
||||||
|
BatchStats,
|
||||||
|
OperationHistoryRecord,
|
||||||
|
GetBatchesOptions
|
||||||
|
} from '../../main/types/operation-history.types'
|
||||||
|
import type { IpcResult } from '../../main/types/ipc.types'
|
||||||
|
|
||||||
|
export const operationHistoryApi = {
|
||||||
|
/**
|
||||||
|
* Get list of operation batches
|
||||||
|
* Admin users receive all batches, regular users only their own
|
||||||
|
*/
|
||||||
|
getBatches: (options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed records for a specific batch
|
||||||
|
*/
|
||||||
|
getBatchDetails: (batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a batch
|
||||||
|
* Admin users can delete any batch, regular users only their own
|
||||||
|
*/
|
||||||
|
deleteBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId)
|
||||||
|
} as const
|
||||||
49
src/preload/index.d.ts
vendored
49
src/preload/index.d.ts
vendored
@@ -141,6 +141,53 @@ export interface UpdateAPI {
|
|||||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
|
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 OperationHistoryAPI {
|
||||||
|
getBatches: (options?: { limit?: number; offset?: number }) => Promise<IpcResult<BatchStats[]>>
|
||||||
|
getBatchDetails: (batchId: string) => Promise<IpcResult<OperationHistoryRecord[]>>
|
||||||
|
deleteBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchStats {
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
status: string
|
||||||
|
totalOrders: number
|
||||||
|
totalRecords: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationHistoryRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
productionId: string | null
|
||||||
|
orderNumber: string
|
||||||
|
operationTime: Date
|
||||||
|
status: string
|
||||||
|
recordCount: number | null
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProcessAPI {
|
export interface ProcessAPI {
|
||||||
versions: {
|
versions: {
|
||||||
electron: string
|
electron: string
|
||||||
@@ -168,6 +215,8 @@ declare global {
|
|||||||
logger: LoggerAPI
|
logger: LoggerAPI
|
||||||
report: ReportAPI
|
report: ReportAPI
|
||||||
update: UpdateAPI
|
update: UpdateAPI
|
||||||
|
playwrightBrowser: PlaywrightBrowserAPI
|
||||||
|
operationHistory: OperationHistoryAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
|
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
|
||||||
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
|
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
|
||||||
|
import PlaywrightDownloadDialog from './components/PlaywrightDownloadDialog'
|
||||||
import { useAppBootstrap } from './hooks/useAppBootstrap'
|
import { useAppBootstrap } from './hooks/useAppBootstrap'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
@@ -22,6 +23,8 @@ function App(): React.JSX.Element {
|
|||||||
updateCatalog,
|
updateCatalog,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
|
showPlaywrightDownload,
|
||||||
|
setShowPlaywrightDownload,
|
||||||
showError,
|
showError,
|
||||||
handleLogin,
|
handleLogin,
|
||||||
handleLoginCancel,
|
handleLoginCancel,
|
||||||
@@ -31,11 +34,29 @@ function App(): React.JSX.Element {
|
|||||||
openUpdateDialog,
|
openUpdateDialog,
|
||||||
handleInstallUserRelease,
|
handleInstallUserRelease,
|
||||||
handleAdminDownloadAndInstall,
|
handleAdminDownloadAndInstall,
|
||||||
refreshUpdateDialogState
|
refreshUpdateDialogState,
|
||||||
|
initializeAuth
|
||||||
} = useAppBootstrap()
|
} = useAppBootstrap()
|
||||||
|
|
||||||
|
const handlePlaywrightDownloadComplete = React.useCallback(() => {
|
||||||
|
setShowPlaywrightDownload(false)
|
||||||
|
// Re-trigger authentication after download completes
|
||||||
|
void initializeAuth()
|
||||||
|
}, [setShowPlaywrightDownload, initializeAuth])
|
||||||
|
|
||||||
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
|
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) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<UnauthenticatedApp
|
<UnauthenticatedApp
|
||||||
|
|||||||
430
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
430
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Extractor Operation History Modal
|
||||||
|
*
|
||||||
|
* Displays extraction operation history with batch statistics and details.
|
||||||
|
* Admin users see all users' records, regular users see only their own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Copy
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { UserInfo } from './UserSelectionDialog'
|
||||||
|
import type {
|
||||||
|
BatchStats,
|
||||||
|
OperationHistoryRecord
|
||||||
|
} from '../../../main/types/operation-history.types'
|
||||||
|
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
|
||||||
|
|
||||||
|
interface ExtractorOperationHistoryModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
user?: UserInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
success: 'bg-green-100 text-green-700',
|
||||||
|
partial: 'bg-amber-100 text-amber-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
pending: 'bg-gray-100 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
success: '成功',
|
||||||
|
partial: '部分成功',
|
||||||
|
failed: '失败',
|
||||||
|
pending: '进行中'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIcons: Record<string, React.ReactNode> = {
|
||||||
|
success: <CheckCircle size={16} className="text-green-600" />,
|
||||||
|
partial: <Clock size={16} className="text-amber-600" />,
|
||||||
|
failed: <XCircle size={16} className="text-red-600" />,
|
||||||
|
pending: <Clock size={16} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
|
||||||
|
// Check if the date is valid
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return dateStr // Return original if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use UTC methods to display the time as stored in database (without timezone conversion)
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getUTCHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [batches, setBatches] = useState<BatchStats[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
|
||||||
|
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
|
||||||
|
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const isAdmin = user?.userType === 'Admin'
|
||||||
|
|
||||||
|
const fetchBatches = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.electron.operationHistory.getBatches({ limit: 100 })
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBatches(result.data)
|
||||||
|
} else {
|
||||||
|
setError(result.error || '获取历史记录失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取历史记录失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchBatchDetails = useCallback(
|
||||||
|
async (batchId: string) => {
|
||||||
|
// If already loaded, don't fetch again
|
||||||
|
if (batchDetails.has(batchId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.operationHistory.getBatchDetails(batchId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch batch details:', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchDetails]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch batches when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void fetchBatches()
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchBatches])
|
||||||
|
|
||||||
|
const toggleBatchExpansion = (batchId: string) => {
|
||||||
|
setExpandedBatches((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(batchId)) {
|
||||||
|
newSet.delete(batchId)
|
||||||
|
} else {
|
||||||
|
newSet.add(batchId)
|
||||||
|
void fetchBatchDetails(batchId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteBatch = async (batchId: string) => {
|
||||||
|
if (deleting.has(batchId)) return
|
||||||
|
|
||||||
|
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setDeleting((prev) => new Set(prev).add(batchId))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.operationHistory.deleteBatch(batchId)
|
||||||
|
if (result.success) {
|
||||||
|
// Remove from local state
|
||||||
|
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
||||||
|
setBatchDetails((prev) => {
|
||||||
|
const newMap = new Map(prev)
|
||||||
|
newMap.delete(batchId)
|
||||||
|
return newMap
|
||||||
|
})
|
||||||
|
setExpandedBatches((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(batchId)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert(result.error || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : '删除失败')
|
||||||
|
} finally {
|
||||||
|
setDeleting((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(batchId)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
|
||||||
|
const details = batchDetails.get(batchId) || []
|
||||||
|
const values = details
|
||||||
|
.map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
|
||||||
|
.filter(Boolean) // 移除空值
|
||||||
|
.join('\n') // 使用换行符分隔
|
||||||
|
|
||||||
|
if (!values) {
|
||||||
|
showWarning('没有可复制的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(values)
|
||||||
|
showSuccess(`已复制 ${values.split('\n').length} 条数据`)
|
||||||
|
} catch {
|
||||||
|
showError('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
|
||||||
|
<div className="flex flex-col h-[70vh]">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-amber-600 font-medium">管理员模式:显示所有用户记录</span>
|
||||||
|
) : (
|
||||||
|
<span>仅显示您的操作记录</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{batches.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
onClick={() => void fetchBatches()}
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||||
|
) : batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{batches.map((batch) => {
|
||||||
|
const isExpanded = expandedBatches.has(batch.batchId)
|
||||||
|
const details = batchDetails.get(batch.batchId) || []
|
||||||
|
const isDeleting = deleting.has(batch.batchId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={batch.batchId}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Batch summary */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
||||||
|
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleBatchExpansion(batch.batchId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<button className="p-1 hover:bg-gray-200 rounded">
|
||||||
|
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-6 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">操作时间</div>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{formatDateTime(batch.operationTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">操作用户</div>
|
||||||
|
<div className="font-medium text-gray-900">{batch.username}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">状态</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{statusIcons[batch.status] || statusIcons.pending}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
statusStyles[batch.status] || statusStyles.pending
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabels[batch.status] || batch.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">订单数</div>
|
||||||
|
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">记录数</div>
|
||||||
|
<div className="font-medium text-gray-900">{batch.totalRecords}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">成功/失败</div>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
<span className="text-green-600">{batch.successCount}</span>
|
||||||
|
{batch.failedCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' / '}
|
||||||
|
<span className="text-red-600">{batch.failedCount}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void handleDeleteBatch(batch.batchId)
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
title="删除批次"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch details */}
|
||||||
|
{isExpanded && details.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 bg-white">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
总排号
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopyColumn('productionId', batch.batchId)
|
||||||
|
}
|
||||||
|
title="复制所有总排号"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={14}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
订单号
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopyColumn('orderNumber', batch.batchId)
|
||||||
|
}
|
||||||
|
title="复制所有订单号"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={14}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
记录数
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
错误信息
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{details.map((detail) => (
|
||||||
|
<tr key={detail.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-900">
|
||||||
|
{detail.productionId || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||||
|
{detail.orderNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
statusStyles[detail.status] || statusStyles.pending
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusIcons[detail.status]}
|
||||||
|
{statusLabels[detail.status] || detail.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-900">
|
||||||
|
{detail.recordCount ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
|
||||||
|
{detail.errorMessage || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-4 border-t border-gray-200 flex justify-end">
|
||||||
|
<button
|
||||||
|
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExtractorOperationHistoryModal
|
||||||
272
src/renderer/src/components/PlaywrightDownloadDialog.tsx
Normal file
272
src/renderer/src/components/PlaywrightDownloadDialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/renderer/src/components/ReportAnalysisDialog.tsx
Normal file
35
src/renderer/src/components/ReportAnalysisDialog.tsx
Normal 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'
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
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 { Combobox, Transition } from '@headlessui/react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -22,13 +22,15 @@ interface ReportViewerDialogProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
currentUsername: string
|
currentUsername: string
|
||||||
|
onOpenAnalysis?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername
|
currentUsername,
|
||||||
|
onOpenAnalysis
|
||||||
}) => {
|
}) => {
|
||||||
const [reports, setReports] = useState<ReportMetadata[]>([])
|
const [reports, setReports] = useState<ReportMetadata[]>([])
|
||||||
const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
|
const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
|
||||||
@@ -130,6 +132,16 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
|||||||
<FileText size={20} className="text-blue-600" />
|
<FileText size={20} className="text-blue-600" />
|
||||||
<h2 className="text-lg font-semibold">执行报告浏览器</h2>
|
<h2 className="text-lg font-semibold">执行报告浏览器</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
|
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200/50 p-1.5 rounded-lg transition-colors"
|
||||||
@@ -137,6 +149,7 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
|||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
|
<div className="px-6 py-4 border-b border-slate-200 bg-white flex-shrink-0">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Modal } from './ui/Modal'
|
|||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
userType: 'Admin' | 'User' | 'Guest'
|
userType: 'Admin' | 'User'
|
||||||
createTime?: Date
|
createTime?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +61,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
|||||||
|
|
||||||
const userTypeStyles: Record<string, string> = {
|
const userTypeStyles: Record<string, string> = {
|
||||||
Admin: 'bg-amber-50 text-amber-600',
|
Admin: 'bg-amber-50 text-amber-600',
|
||||||
User: 'bg-blue-50 text-blue-600',
|
User: 'bg-blue-50 text-blue-600'
|
||||||
Guest: 'bg-gray-100 text-gray-600'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from '../UserS
|
|||||||
|
|
||||||
interface CurrentUser {
|
interface CurrentUser {
|
||||||
username: string
|
username: string
|
||||||
userType: 'Admin' | 'User' | 'Guest'
|
userType: 'Admin' | 'User'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnauthenticatedAppProps {
|
interface UnauthenticatedAppProps {
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/renderer/src/components/report-analysis/export.ts
Normal file
50
src/renderer/src/components/report-analysis/export.ts
Normal 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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/renderer/src/components/report-analysis/index.tsx
Normal file
211
src/renderer/src/components/report-analysis/index.tsx
Normal 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
|
||||||
153
src/renderer/src/components/report-analysis/types.ts
Normal file
153
src/renderer/src/components/report-analysis/types.ts
Normal 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
|
||||||
|
]
|
||||||
202
src/renderer/src/components/report-analysis/utils/aggregators.ts
Normal file
202
src/renderer/src/components/report-analysis/utils/aggregators.ts
Normal 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]
|
||||||
|
}
|
||||||
178
src/renderer/src/components/report-analysis/utils/parser.ts
Normal file
178
src/renderer/src/components/report-analysis/utils/parser.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export interface CurrentUser {
|
|||||||
export interface SelectedUserInfo {
|
export interface SelectedUserInfo {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
userType: 'Admin' | 'User' | 'Guest'
|
userType: 'Admin' | 'User'
|
||||||
computerName?: string
|
computerName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export function useAppBootstrap() {
|
|||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||||
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
||||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||||
|
const [showPlaywrightDownload, setShowPlaywrightDownload] = useState(false)
|
||||||
|
|
||||||
const authInitializationStartedRef = useRef(false)
|
const authInitializationStartedRef = useRef(false)
|
||||||
|
|
||||||
@@ -120,7 +121,28 @@ export function useAppBootstrap() {
|
|||||||
|
|
||||||
authInitializationStartedRef.current = true
|
authInitializationStartedRef.current = true
|
||||||
logger.info('=== Initializing auth... ===')
|
logger.info('=== Initializing auth... ===')
|
||||||
|
|
||||||
|
// 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()
|
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])
|
}, [initializeAuth, logger])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -295,6 +317,8 @@ export function useAppBootstrap() {
|
|||||||
updateCatalog,
|
updateCatalog,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
|
showPlaywrightDownload,
|
||||||
|
setShowPlaywrightDownload,
|
||||||
showError,
|
showError,
|
||||||
refreshUpdateState,
|
refreshUpdateState,
|
||||||
refreshUpdateCatalog,
|
refreshUpdateCatalog,
|
||||||
@@ -306,6 +330,7 @@ export function useAppBootstrap() {
|
|||||||
openUpdateDialog,
|
openUpdateDialog,
|
||||||
handleInstallUserRelease,
|
handleInstallUserRelease,
|
||||||
handleAdminDownloadAndInstall,
|
handleAdminDownloadAndInstall,
|
||||||
refreshUpdateDialogState
|
refreshUpdateDialogState,
|
||||||
|
initializeAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useCallback } from 'react'
|
|||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
userType: 'Admin' | 'User' | 'Guest'
|
userType: 'Admin' | 'User'
|
||||||
computerName?: string
|
computerName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const MaterialTypeManagementDialog = React.lazy(
|
|||||||
)
|
)
|
||||||
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
|
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
|
||||||
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
|
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
|
||||||
|
const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog'))
|
||||||
|
|
||||||
const CleanerPage: React.FC = () => {
|
const CleanerPage: React.FC = () => {
|
||||||
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
|
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
@@ -66,6 +67,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
} = useCleaner()
|
} = useCleaner()
|
||||||
|
|
||||||
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
|
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
|
||||||
|
const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<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)}
|
onClose={() => setIsReportViewerOpen(false)}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
currentUsername={currentUsername}
|
currentUsername={currentUsername}
|
||||||
|
onOpenAnalysis={() => setIsReportAnalysisOpen(true)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReportAnalysisDialog
|
||||||
|
isOpen={isReportAnalysisOpen}
|
||||||
|
onClose={() => setIsReportAnalysisOpen(false)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Download, Play, CheckCircle } from 'lucide-react'
|
import { Download, Play, CheckCircle, History } from 'lucide-react'
|
||||||
import OrderNumberInput from '../components/OrderNumberInput'
|
import OrderNumberInput from '../components/OrderNumberInput'
|
||||||
import { useExtractor } from '../hooks/useExtractor'
|
import { useExtractor } from '../hooks/useExtractor'
|
||||||
import { usePersistentTextState } from '../hooks/usePersistentTextState'
|
import { usePersistentTextState } from '../hooks/usePersistentTextState'
|
||||||
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
|
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
|
||||||
import LogPanel from '../components/ui/LogPanel'
|
import LogPanel from '../components/ui/LogPanel'
|
||||||
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
|
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
|
||||||
|
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
|
||||||
|
import { useUserStore } from '../stores/useUserStore'
|
||||||
|
|
||||||
const ExtractorPage: React.FC = () => {
|
const ExtractorPage: React.FC = () => {
|
||||||
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
|
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
|
||||||
|
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
|
||||||
|
const user = useUserStore((state) => state.user)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isRunning,
|
isRunning,
|
||||||
@@ -67,6 +71,14 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2.5 rounded-lg flex items-center gap-2 font-medium transition-colors"
|
||||||
|
onClick={() => setShowHistoryModal(true)}
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
<History size={18} />
|
||||||
|
操作历史
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
|
||||||
onClick={handleExtract}
|
onClick={handleExtract}
|
||||||
@@ -78,6 +90,14 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showHistoryModal ? (
|
||||||
|
<ExtractorOperationHistoryModal
|
||||||
|
isOpen={showHistoryModal}
|
||||||
|
onClose={() => setShowHistoryModal(false)}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!isRunning && isComplete && (
|
{!isRunning && isComplete && (
|
||||||
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
|
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
|
||||||
<CheckCircle className="text-green-600" size={35} />
|
<CheckCircle className="text-green-600" size={35} />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { create } from 'zustand'
|
|||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
userType: 'Admin' | 'User' | 'Guest'
|
userType: 'Admin' | 'User'
|
||||||
computerName?: string
|
computerName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,18 @@ export const IPC_CHANNELS = {
|
|||||||
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
||||||
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
||||||
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
|
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',
|
||||||
|
|
||||||
|
// Operation History
|
||||||
|
OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches',
|
||||||
|
OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails',
|
||||||
|
OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
BIN
tests/playwright/screenshot.png
Normal file
BIN
tests/playwright/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
18
tests/playwright/verify_report_analysis.js
Normal file
18
tests/playwright/verify_report_analysis.js
Normal 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()
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user