Commit 787c89f3 by 王路

初始化

parents
# Bug修复说明
## 问题描述
**Bug**: 点击单个文件"开始"下载时,会下载所有文件
## 问题原因
`startDownload` 方法的 `finally` 块中,当单个文件下载完成时,会调用 `startNextPendingDownload()` 方法,该方法会自动启动其他等待中的下载任务。
```javascript
// 问题代码
finally {
activeDownloadCount.value--
// 这里会启动其他等待中的任务
if (!isBatchDownloading.value) {
startNextPendingDownload()
}
}
```
## 修复方案
### 1. 添加单个下载标志
在下载store中添加 `isSingleDownloading` 标志来区分单个下载和批量下载:
```javascript
const isSingleDownloading = ref(false) // 是否正在单个下载
```
### 2. 修改自动启动逻辑
修改 `startNextPendingDownload` 方法,在单个下载时也不自动启动其他任务:
```javascript
const startNextPendingDownload = () => {
// 如果正在批量下载或单个下载,不自动启动其他任务
if (isBatchDownloading.value || isSingleDownloading.value) return
const pendingDownload = downloads.value.find(d => d.status === 'pending')
if (pendingDownload && activeDownloadCount.value < maxConcurrentDownloads.value) {
startDownload(pendingDownload.id)
}
}
```
### 3. 修改单个下载调用
创建专门的单个下载方法,设置单个下载标志:
```javascript
// 单个文件下载
const startSingleDownload = async (downloadId) => {
downloadStore.isSingleDownloading = true
try {
await downloadStore.startDownload(downloadId)
} finally {
downloadStore.isSingleDownloading = false
}
}
// 单个文件恢复下载
const resumeSingleDownload = async (downloadId) => {
downloadStore.isSingleDownloading = true
try {
await downloadStore.resumeDownload(downloadId)
} finally {
downloadStore.isSingleDownloading = false
}
}
```
### 4. 更新按钮事件
将表格中的单个下载按钮事件改为调用新的方法:
```javascript
// 开始按钮
@click="startSingleDownload(row.id)"
// 继续按钮
@click="resumeSingleDownload(row.id)"
// 重试按钮
@click="startSingleDownload(row.id)"
```
## 修复后的行为
### ✅ 正确的行为
- **单个下载**:点击单个文件的"开始"按钮,只下载该文件
- **批量下载**:点击"批量开始选中"或"下载当前页",只下载选中的文件
- **自动启动**:只有在非批量下载且非单个下载时,才会自动启动其他等待中的任务
### ❌ 错误的行为(已修复)
- 点击单个文件"开始"按钮,所有等待中的文件都开始下载
## 测试方法
### 测试单个下载
1. 上传Excel文件,解析添加多个下载任务
2. 只点击其中一个任务的"开始"按钮
3. **验证结果**:只有该任务开始下载,其他任务保持"等待"状态
### 测试批量下载
1. 选择多个任务
2. 点击"批量开始选中"按钮
3. **验证结果**:只有选中的任务开始下载
### 测试自动启动
1. 确保没有进行批量或单个下载操作
2. 手动开始一个下载任务
3. **验证结果**:其他等待中的任务可能会自动开始(这是正常的多线程下载行为)
## 技术细节
### 下载模式区分
- **单个下载模式**`isSingleDownloading = true`
- **批量下载模式**`isBatchDownloading = true`
- **自动启动模式**:两个标志都为 `false`
### 标志管理
- 单个下载:在下载开始时设置 `isSingleDownloading = true`,下载完成后重置
- 批量下载:在批量操作开始时设置 `isBatchDownloading = true`,批量操作完成后重置
- 自动启动:只有在两个标志都为 `false` 时才允许自动启动其他任务
## 影响范围
### 修复的功能
- ✅ 单个文件下载
- ✅ 单个文件恢复下载
- ✅ 单个文件重试下载
- ✅ 批量下载(不受影响)
- ✅ 自动启动(在适当时候仍然工作)
### 不受影响的功能
- 暂停下载
- 删除下载
- 状态筛选
- 分页显示
- 断点续传
## 验证清单
- [ ] 单个文件"开始"按钮只下载该文件
- [ ] 单个文件"继续"按钮只恢复该文件
- [ ] 单个文件"重试"按钮只重试该文件
- [ ] 批量下载功能正常工作
- [ ] 自动启动功能在适当时候仍然工作
- [ ] 其他功能不受影响
# 删除Bug修复说明
## 问题描述
**Bug**: 点击删除单个文件时触发"批量下载"
## 问题原因
`cancelDownload` 方法中,当删除一个正在下载的文件时:
1. 会减少 `activeDownloadCount`
2. 然后在 `finally` 块中调用 `startNextPendingDownload()`
3. 该方法会自动启动其他等待中的下载任务
4. 这导致删除操作意外触发了"批量下载"行为
```javascript
// 问题代码
const cancelDownload = (downloadId) => {
// ... 删除逻辑
if (download.status === 'downloading') {
activeDownloadCount.value-- // 减少活跃下载数
}
// ... 删除任务
// 这里会启动其他等待中的任务
if (!isBatchDownloading.value && !isSingleDownloading.value) {
startNextPendingDownload()
}
}
```
## 修复方案
### 1. 修改单个删除逻辑
删除操作不应该触发自动启动其他下载任务:
```javascript
const cancelDownload = (downloadId) => {
const download = downloads.value.find(d => d.id === downloadId)
if (download && download.controller) {
download.controller.abort()
if (download.status === 'downloading') {
activeDownloadCount.value--
}
}
const index = downloads.value.findIndex(d => d.id === downloadId)
if (index > -1) {
downloads.value.splice(index, 1)
}
// 清除本地存储
localStorage.removeItem(`download_${downloadId}`)
// 删除操作不触发自动启动其他下载任务
// 删除操作应该保持当前状态,不自动启动新任务
}
```
### 2. 修改批量删除逻辑
批量删除时设置标志,防止自动启动:
```javascript
const batchDeleteDownloads = (downloadIds) => {
// 设置批量操作标志,防止自动启动
isBatchDownloading.value = true
try {
downloadIds.forEach(id => cancelDownload(id))
} finally {
isBatchDownloading.value = false
}
}
```
## 修复后的行为
### ✅ 正确的行为
- **单个删除**:点击单个文件的"删除"按钮,只删除该文件,不启动其他下载
- **批量删除**:点击"批量删除选中"按钮,只删除选中的文件,不启动其他下载
- **清除已完成**:只清除已完成的任务,不影响其他任务
- **清除所有**:清除所有任务,重置状态
### ❌ 错误的行为(已修复)
- 点击删除单个文件时,其他等待中的文件开始下载
- 删除操作意外触发"批量下载"行为
## 测试方法
### 测试单个删除
1. 上传Excel文件,解析添加多个下载任务
2. 开始下载几个任务
3. 点击其中一个任务的"删除"按钮
4. **验证结果**:只删除该任务,其他任务保持当前状态
### 测试批量删除
1. 选择多个任务
2. 点击"批量删除选中"按钮
3. **验证结果**:只删除选中的任务,其他任务保持当前状态
### 测试删除正在下载的任务
1. 开始下载一个任务
2. 在下载过程中点击"删除"按钮
3. **验证结果**:任务被删除,不会启动其他等待中的任务
## 技术细节
### 删除操作的特点
- **不触发自动启动**:删除操作应该保持当前状态
- **清理资源**:正确清理下载控制器和本地存储
- **更新计数**:正确更新活跃下载计数
### 标志管理
- **单个删除**:不设置任何标志,直接删除
- **批量删除**:设置 `isBatchDownloading = true`,防止自动启动
- **清除操作**:直接操作数组,不涉及自动启动逻辑
## 影响范围
### 修复的功能
- ✅ 单个文件删除
- ✅ 批量文件删除
- ✅ 删除正在下载的文件
- ✅ 清除已完成任务
- ✅ 清除所有任务
### 不受影响的功能
- 单个文件下载
- 批量文件下载
- 暂停和恢复
- 状态筛选
- 分页显示
## 验证清单
- [ ] 单个文件删除不触发其他下载
- [ ] 批量删除不触发其他下载
- [ ] 删除正在下载的文件正常工作
- [ ] 清除已完成任务正常工作
- [ ] 清除所有任务正常工作
- [ ] 其他功能不受影响
## 设计原则
### 删除操作的设计原则
1. **静默操作**:删除操作不应该影响其他任务的状态
2. **资源清理**:确保正确清理所有相关资源
3. **状态一致性**:保持下载列表状态的一致性
4. **用户预期**:删除操作应该符合用户的预期行为
### 自动启动的设计原则
1. **明确触发**:只有在明确需要时才自动启动
2. **状态检查**:在自动启动前检查当前状态
3. **标志控制**:使用标志控制自动启动行为
4. **用户控制**:用户的操作应该优先于自动行为
# 功能更新说明
## 新增功能
### 1. 状态筛选功能
- **位置**:下载任务管理页面顶部
- **功能**:可以按下载状态筛选显示的任务
- **筛选选项**
- 全部
- 等待中
- 下载中
- 已暂停
- 已完成
- 失败
### 2. 智能操作控制
根据任务状态,只有特定状态的任务可以执行相应操作:
#### 开始下载
- **可操作状态**:等待中、已暂停、失败
- **按钮显示**:状态为"等待中"时显示"开始"按钮
- **批量操作**:只对可开始下载的任务生效
#### 暂停下载
- **可操作状态**:仅下载中
- **按钮显示**:状态为"下载中"时显示"暂停"按钮
- **批量操作**:只对正在下载的任务生效
#### 继续下载
- **可操作状态**:仅已暂停
- **按钮显示**:状态为"已暂停"时显示"继续"按钮
- **功能**:支持断点续传
#### 重试下载
- **可操作状态**:仅失败
- **按钮显示**:状态为"失败"时显示"重试"按钮
#### 删除任务
- **可操作状态**:所有状态
- **按钮显示**:始终显示"删除"按钮
## 操作逻辑
### 批量开始选中
1. 检查选中的任务
2. 过滤出可以开始下载的任务(等待中、已暂停、失败)
3. 如果过滤后没有可操作任务,显示警告
4. 只对可操作的任务执行下载
### 批量暂停选中
1. 检查选中的任务
2. 过滤出正在下载的任务
3. 如果过滤后没有可操作任务,显示警告
4. 只对可操作的任务执行暂停
### 下载当前页
1. 获取当前页所有任务
2. 过滤出可以开始下载的任务
3. 如果过滤后没有可操作任务,显示警告
4. 只对可操作的任务执行下载
### 暂停当前页
1. 获取当前页所有任务
2. 过滤出正在下载的任务
3. 如果过滤后没有可操作任务,显示警告
4. 只对可操作的任务执行暂停
## 状态说明
### 等待中 (pending)
- 任务已添加但未开始下载
- 可以执行:开始下载、删除
### 下载中 (downloading)
- 任务正在下载中
- 可以执行:暂停、删除
- 显示下载进度和速度
### 已暂停 (paused)
- 任务已暂停,支持断点续传
- 可以执行:继续下载、删除
- 保留已下载的进度
### 已完成 (completed)
- 任务下载完成
- 可以执行:删除
- 显示100%进度
### 失败 (error)
- 任务下载失败
- 可以执行:重试、删除
- 显示错误信息
## 使用示例
### 场景1:筛选查看失败的任务
1. 在状态筛选下拉框中选择"失败"
2. 页面只显示失败的任务
3. 可以批量选择失败任务进行重试
### 场景2:暂停所有正在下载的任务
1. 在状态筛选下拉框中选择"下载中"
2. 页面只显示正在下载的任务
3. 点击"下载当前页"按钮暂停所有任务
### 场景3:批量重试失败的任务
1. 在状态筛选下拉框中选择"失败"
2. 选择需要重试的任务
3. 点击"批量开始选中"按钮
## 技术实现
### 状态筛选
```javascript
const filteredDownloads = computed(() => {
return downloadStore.downloads.filter(item => {
if (statusFilter.value) {
return item.status === statusFilter.value
}
return true
})
})
```
### 智能操作过滤
```javascript
// 过滤可开始下载的任务
const startableItems = selectedItems.value.filter(item =>
['pending', 'paused', 'error'].includes(item.status)
)
// 过滤可暂停的任务
const pausableItems = selectedItems.value.filter(item =>
item.status === 'downloading'
)
```
### 分页适配
- 分页基于筛选后的数据
- 筛选后自动回到第一页
- 总数显示筛选后的数量
## 用户体验改进
1. **智能提示**:当没有可操作任务时,显示具体的警告信息
2. **状态一致性**:确保操作按钮与任务状态匹配
3. **批量操作优化**:只对符合条件的任务执行操作
4. **筛选功能**:快速定位特定状态的任务
5. **断点续传**:暂停的任务可以从中断点继续下载
## 浏览器兼容性
- 支持所有现代浏览器
- 状态筛选功能基于前端计算,无需后端支持
- 断点续传功能使用HTTP Range头部,兼容性好
# 文件下载器
一个基于Vue 3的现代化下载器应用,支持从Excel文件批量下载文件,具有类似迅雷的功能。
## 主要功能
- 🔐 **用户认证**: 登录系统,保护下载功能
- 📊 **Excel解析**: 支持灵活的Excel格式,可自定义文件名和URL列
- 📁 **文件名前缀**: 为所有下载文件添加自定义前缀
- 📄 **多列组合**: 支持多列组合生成文件名,用"-"分隔
- 📋 **分页显示**: 下载列表分页显示,默认每页10条
-**批量操作**: 支持批量开始、暂停、删除下载
- 🔄 **断点续传**: 支持暂停和恢复下载
- 🚀 **多线程下载**: 防止界面卡顿
- 📱 **响应式设计**: 适配不同屏幕尺寸
- 🔔 **下载通知**: 下载完成后通知用户
- 🎯 **按页下载**: 支持只下载当前页面的文件
- 🎛️ **状态筛选**: 按下载状态筛选文件
- 🛠️ **智能操作**: 根据文件状态智能显示操作按钮
## 技术栈
- **前端框架**: Vue 3 (Composition API)
- **状态管理**: Pinia
- **UI组件库**: Element Plus
- **路由管理**: Vue Router
- **Excel解析**: SheetJS (xlsx)
- **HTTP客户端**: XMLHttpRequest (支持断点续传)
- **样式**: CSS3 + Element Plus主题
## 安装和运行
### 环境要求
- Node.js 16+
- npm 或 yarn
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
### 构建生产版本
```bash
npm run build
```
## 使用说明
### 1. 用户登录
- 默认用户名: `admin`
- 默认密码: `123456`
### 2. Excel文件格式
支持任意格式的Excel文件,用户可自定义:
- **文件名列**: 可选择多列,用"-"连接
- **URL列**: 选择包含下载链接的列
### 3. 下载设置
- **文件名前缀**: 为所有下载文件添加前缀(如:`vue-downloader_文件名.pdf`
- **并发数**: 控制同时下载的文件数量
- **通知设置**: 下载完成后显示通知
### 4. 下载管理
- **单个操作**: 开始、暂停、恢复、删除单个文件
- **批量操作**: 批量开始、暂停、删除选中的文件
- **按页操作**: 下载或暂停当前页面的所有文件
- **状态筛选**: 按状态筛选文件(等待中、下载中、已暂停、已完成、错误)
### 5. 高级功能
- **断点续传**: 支持暂停后继续下载
- **自动重试**: 下载失败时自动重试
- **进度显示**: 实时显示下载进度和速度
- **文件扩展名**: 自动从URL提取文件扩展名
## 功能特性
### 文件名前缀
- 支持为所有下载文件添加自定义前缀
- 格式:`前缀_原文件名.扩展名`
- 示例:设置前缀为"vue-downloader",文件"document.pdf"将保存为"vue-downloader_document.pdf"
### 多线程下载
- 控制并发下载数量,防止界面卡顿
- 默认最大并发数:3个
- 可自定义调整并发数
### 断点续传
- 支持暂停下载后继续
- 使用HTTP Range头实现
- 自动保存下载进度
### 智能操作
- 只有"下载中"状态的文件可以暂停
- 只有"已暂停"状态的文件可以恢复
- "已完成"和"等待中"状态的文件不受影响
### 状态筛选
- 支持按状态筛选:等待中、下载中、已暂停、已完成、错误
- 筛选后分页显示
- 支持按页操作
## 浏览器兼容性
### 现代浏览器(Chrome 86+, Edge 86+)
- ✅ 支持File System Access API
- ✅ 自动文件名前缀
- ✅ 完整的下载功能
### 传统浏览器
- ✅ 降级到传统下载方法
- ✅ 文件名前缀功能
- ✅ 基本下载功能
## 项目结构
```
src/
├── components/ # 组件
├── views/ # 页面
│ ├── Login.vue # 登录页面
│ └── Downloader.vue # 下载器主页面
├── stores/ # 状态管理
│ ├── auth.js # 认证状态
│ └── download.js # 下载状态
├── router/ # 路由配置
│ └── index.js
├── style.css # 全局样式
└── main.js # 应用入口
```
## 开发说明
### 状态管理
使用Pinia进行状态管理:
- `auth` store: 管理用户认证状态
- `download` store: 管理下载任务和设置
### 下载逻辑
- 使用XMLHttpRequest实现断点续传
- 支持暂停、恢复、取消操作
- 自动重试机制
- 进度和速度计算
### 文件处理
- 使用SheetJS解析Excel文件
- 支持多种Excel格式
- 自动提取文件扩展名
## 许可证
MIT License
# 批量下载修复测试说明
## 问题描述
之前的版本在批量下载时会下载所有文件,而不是只下载选中的文件。
## 修复内容
1. 添加了 `isBatchDownloading` 标志来控制批量下载模式
2. 在批量下载期间,禁用自动启动其他等待中的下载任务
3. 确保只有选中的文件会被下载
## 测试步骤
### 1. 准备测试数据
1. 创建一个Excel文件,包含多行数据(建议10-20行)
2. 确保有文件名列和URL列
3. 上传Excel文件并解析
### 2. 测试选中下载功能
1. 在下载列表中,只选择2-3个任务(不要全选)
2. 点击"批量开始选中"按钮
3. **验证结果**:只有选中的任务开始下载,其他任务保持"等待"状态
### 3. 测试当前页下载功能
1. 确保当前页有多个任务
2. 点击"下载当前页"按钮
3. **验证结果**:只有当前页的任务开始下载,其他页面的任务保持"等待"状态
### 4. 测试分页功能
1. 切换到不同页面
2. 在不同页面选择不同的任务
3. 验证每个页面的批量操作只影响当前页
### 5. 测试文件路径
1. 设置自定义子文件夹为 "vue-downloader-files"
2. 开始下载
3. **验证结果**
- 现代浏览器:会提示保存到指定文件夹
- 传统浏览器:会显示提示信息要求手动保存到指定文件夹
## 预期行为
### ✅ 正确的行为
- 批量开始选中:只下载选中的任务
- 下载当前页:只下载当前页的任务
- 其他任务保持"等待"状态
- 文件保存到指定文件夹
### ❌ 错误的行为(已修复)
- 所有任务都开始下载
- 文件保存到默认位置
- 无法控制下载范围
## 技术实现
### 关键代码修改
```javascript
// 添加批量下载标志
const isBatchDownloading = ref(false)
// 在批量下载期间禁用自动启动
const startNextPendingDownload = () => {
if (isBatchDownloading.value) return
// ... 自动启动逻辑
}
// 批量下载方法
const batchStartDownloads = async (downloadIds) => {
isBatchDownloading.value = true
try {
// 只下载指定的任务
await Promise.all(downloadIds.map(id => startDownload(id)))
} finally {
isBatchDownloading.value = false
}
}
```
## 浏览器兼容性
### 现代浏览器(Chrome 86+, Edge 86+)
- 支持 File System Access API
- 可以直接选择保存位置
- 支持文件夹路径
### 传统浏览器
- 使用传统下载方法
- 显示提示信息
- 需要手动保存到指定文件夹
## 故障排除
### 如果仍然下载全部文件
1. 检查浏览器控制台是否有错误
2. 确认 `isBatchDownloading` 标志正常工作
3. 验证选中状态是否正确
### 如果文件路径不正确
1. 检查自定义子文件夹设置
2. 确认浏览器是否支持 File System Access API
3. 查看控制台提示信息
## 联系支持
如果测试中发现任何问题,请提供:
1. 浏览器版本
2. 控制台错误信息
3. 具体操作步骤
4. 预期结果和实际结果
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文件下载器 - 类似迅雷的下载工具</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "vue-downloader",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vue-downloader",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"axios": "^1.5.0",
"element-plus": "^2.3.9",
"file-saver": "^2.0.5",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.4",
"sass": "^1.66.1",
"vite": "^4.4.9"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
"integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.0.0 || ^5.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.20.tgz",
"integrity": "sha512-8TWXUyiqFd3GmP4JTX9hbiTFRwYHgVL/vr3cqhr4YQ258+9FADwvj7golk2sWNGHR67QgmCZ8gz80nQcMokhwg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/shared": "3.5.20",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.20.tgz",
"integrity": "sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.20",
"@vue/shared": "3.5.20"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.20.tgz",
"integrity": "sha512-SFcxapQc0/feWiSBfkGsa1v4DOrnMAQSYuvDMpEaxbpH5dKbnEM5KobSNSgU+1MbHCl+9ftm7oQWxvwDB6iBfw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/compiler-core": "3.5.20",
"@vue/compiler-dom": "3.5.20",
"@vue/compiler-ssr": "3.5.20",
"@vue/shared": "3.5.20",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.20.tgz",
"integrity": "sha512-RSl5XAMc5YFUXpDQi+UQDdVjH9FnEpLDHIALg5J0ITHxkEzJ8uQLlo7CIbjPYqmZtt6w0TsIPbo1izYXwDG7JA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.20",
"@vue/shared": "3.5.20"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.20.tgz",
"integrity": "sha512-hS8l8x4cl1fmZpSQX/NXlqWKARqEsNmfkwOIYqtR2F616NGfsLUm0G6FQBK6uDKUCVyi1YOL8Xmt/RkZcd/jYQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.20"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.20.tgz",
"integrity": "sha512-vyQRiH5uSZlOa+4I/t4Qw/SsD/gbth0SW2J7oMeVlMFMAmsG1rwDD6ok0VMmjXY3eI0iHNSSOBilEDW98PLRKw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.20",
"@vue/shared": "3.5.20"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.20.tgz",
"integrity": "sha512-KBHzPld/Djw3im0CQ7tGCpgRedryIn4CcAl047EhFTCCPT2xFf4e8j6WeKLgEEoqPSl9TYqShc3Q6tpWpz/Xgw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.20",
"@vue/runtime-core": "3.5.20",
"@vue/shared": "3.5.20",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.20.tgz",
"integrity": "sha512-HthAS0lZJDH21HFJBVNTtx+ULcIbJQRpjSVomVjfyPkFSpCwvsPTA+jIzOaUm3Hrqx36ozBHePztQFg6pj5aKg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.20",
"@vue/shared": "3.5.20"
},
"peerDependencies": {
"vue": "3.5.20"
}
},
"node_modules/@vue/shared": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.20.tgz",
"integrity": "sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz",
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.13.0",
"@vueuse/shared": "9.13.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.15",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz",
"integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/element-plus": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.1.tgz",
"integrity": "sha512-weYFIniyNXTAe9vJZnmZpYzurh4TDbdKhBsJwhbzuo0SDZ8PLwHVll0qycJUxc6SLtH+7A9F7dvdDh5CnqeIVA==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.1",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.13",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.18.20",
"@esbuild/android-arm64": "0.18.20",
"@esbuild/android-x64": "0.18.20",
"@esbuild/darwin-arm64": "0.18.20",
"@esbuild/darwin-x64": "0.18.20",
"@esbuild/freebsd-arm64": "0.18.20",
"@esbuild/freebsd-x64": "0.18.20",
"@esbuild/linux-arm": "0.18.20",
"@esbuild/linux-arm64": "0.18.20",
"@esbuild/linux-ia32": "0.18.20",
"@esbuild/linux-loong64": "0.18.20",
"@esbuild/linux-mips64el": "0.18.20",
"@esbuild/linux-ppc64": "0.18.20",
"@esbuild/linux-riscv64": "0.18.20",
"@esbuild/linux-s390x": "0.18.20",
"@esbuild/linux-x64": "0.18.20",
"@esbuild/netbsd-x64": "0.18.20",
"@esbuild/openbsd-x64": "0.18.20",
"@esbuild/sunos-x64": "0.18.20",
"@esbuild/win32-arm64": "0.18.20",
"@esbuild/win32-ia32": "0.18.20",
"@esbuild/win32-x64": "0.18.20"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.18",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rollup": {
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/sass": {
"version": "1.91.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/vite": {
"version": "4.5.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
"rollup": "^3.27.1"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vue": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.20.tgz",
"integrity": "sha512-2sBz0x/wis5TkF1XZ2vH25zWq3G1bFEPOfkBcx2ikowmphoQsPH6X0V3mmPCXA2K1N/XGTnifVyDQP4GfDDeQw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.20",
"@vue/compiler-sfc": "3.5.20",
"@vue/runtime-dom": "3.5.20",
"@vue/server-renderer": "3.5.20",
"@vue/shared": "3.5.20"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
}
}
}
{
"name": "vue-downloader",
"version": "1.0.0",
"description": "A Vue-based downloader application similar to Thunder",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"serve": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"pinia": "^2.1.6",
"axios": "^1.5.0",
"element-plus": "^2.3.9",
"xlsx": "^0.18.5",
"file-saver": "^2.0.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.4",
"vite": "^4.4.9",
"sass": "^1.66.1"
},
"keywords": [
"vue",
"downloader",
"thunder",
"file-download"
],
"author": "Your Name",
"license": "MIT",
"volta": {
"node": "22.18.0",
"npm": "10.9.3"
}
}
// Excel处理Worker
// 使用本地XLSX库
importScripts('./xlsx.full.min.js');
self.onmessage = function(e) {
try {
const { data, type, neededColumns } = e.data;
if (type === 'parseExcel') {
const result = parseExcelData(data, neededColumns);
self.postMessage({
type: 'success',
data: result
});
}
} catch (error) {
console.error('Worker error:', error);
self.postMessage({
type: 'error',
error: error.message || '未知错误'
});
}
};
function parseExcelData(arrayBuffer, neededColumns = null) {
try {
if (!arrayBuffer) {
throw new Error('没有接收到数据');
}
const data = new Uint8Array(arrayBuffer);
const workbook = XLSX.read(data, {
type: 'array',
cellDates: true,
cellNF: false,
cellStyles: false,
cellHTML: false,
cellFormula: false,
cellText: false
});
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('Excel文件中没有找到工作表');
}
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
throw new Error('无法读取工作表数据');
}
// 限制最大行数,防止内存溢出
const maxRows = 100000; // 增加到10万行
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
if (range.e.r > maxRows) {
range.e.r = maxRows - 1;
worksheet['!ref'] = XLSX.utils.encode_range(range);
}
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
blankrows: false
});
if (!jsonData || jsonData.length === 0) {
throw new Error('Excel文件中没有数据');
}
// 转换为对象数组
const headers = jsonData[0];
if (!headers || headers.length === 0) {
throw new Error('Excel文件中没有列标题');
}
const rows = jsonData.slice(1).map(row => {
const obj = {};
headers.forEach((header, index) => {
obj[header] = row[index] || '';
});
return obj;
});
// 如果指定了需要的列,则只保留这些列
if (neededColumns && neededColumns.length > 0) {
return rows.map(row => {
const optimizedRow = {};
neededColumns.forEach(col => {
optimizedRow[col] = row[col] || '';
});
return optimizedRow;
});
}
return rows;
} catch (error) {
console.error('Excel解析错误:', error);
throw new Error('Excel解析失败: ' + error.message);
}
}
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
onMounted(() => {
// 初始化认证状态
authStore.initialize()
})
</script>
<style>
#app {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Login from '../views/Login.vue'
import Downloader from '../views/Downloader.vue'
import Settings from '../views/Settings.vue'
import History from '../views/History.vue'
const routes = [
{
path: '/',
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false }
},
{
path: '/downloader',
name: 'Downloader',
component: Downloader,
meta: { requiresAuth: true }
},
{
path: '/settings',
name: 'Settings',
component: Settings,
meta: { requiresAuth: true }
},
{
path: '/history',
name: 'History',
component: History,
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
// 初始化认证状态
if (!authStore.isAuthenticated) {
authStore.initialize()
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.path === '/login' && authStore.isAuthenticated) {
next('/downloader')
} else {
next()
}
})
export default router
import {defineStore} from 'pinia'
import {computed, ref} from 'vue'
export const useAuthStore = defineStore('auth', () => {
// 用户信息
const user = ref(null)
const isAuthenticated = ref(false)
// 用户列表(存储所有注册用户)
const users = ref([])
// 从本地存储加载用户数据
const loadUsersFromStorage = () => {
try {
const storedUsers = localStorage.getItem('vue_downloader_users')
if (storedUsers) {
users.value = JSON.parse(storedUsers)
}
} catch (error) {
console.error('加载用户数据失败:', error)
users.value = []
}
}
// 保存用户数据到本地存储
const saveUsersToStorage = () => {
try {
localStorage.setItem('vue_downloader_users', JSON.stringify(users.value))
} catch (error) {
console.error('保存用户数据失败:', error)
}
}
// 密码验证规则
const validatePassword = (password) => {
const errors = []
if (password.length < 6) {
errors.push('密码至少需要6位')
}
if (!/\d/.test(password)) {
errors.push('密码必须包含数字')
}
if (!/[a-zA-Z]/.test(password)) {
errors.push('密码必须包含字母')
}
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
errors.push('密码必须包含特殊字符')
}
return {
isValid: errors.length === 0,
errors
}
}
// 检查用户名是否已存在
const isUsernameExists = (username) => {
return users.value.some(user => user.username === username)
}
// 用户注册
const register = async (username, password, email = '') => {
// 验证用户名
if (!username || username.trim().length < 3) {
throw new Error('用户名至少需要3个字符')
}
if (isUsernameExists(username)) {
throw new Error('用户名已存在')
}
// 验证密码
const passwordValidation = validatePassword(password)
if (!passwordValidation.isValid) {
throw new Error(passwordValidation.errors.join(','))
}
// 创建新用户
const newUser = {
id: Date.now().toString(),
username: username.trim(),
password: btoa(password), // 简单的Base64编码(实际项目中应使用更安全的加密)
email: email.trim(),
createdAt: new Date().toISOString(),
lastLoginAt: null,
settings: {
maxConcurrentDownloads: 3,
enableNotifications: true,
fileNamePrefix: ''
}
}
// 添加到用户列表
users.value.push(newUser)
saveUsersToStorage()
return newUser
}
// 用户登录
const login = async (username, password) => {
try {
console.log('Auth store login 被调用,用户名:', username)
// 确保用户数据已加载
if (users.value.length === 0) {
console.log('用户列表为空,加载用户数据...')
loadUsersFromStorage()
}
console.log('当前用户列表:', users.value.map(u => u.username))
// 查找用户
const user = users.value.find(u => u.username === username)
if (!user) {
console.log('未找到用户:', username)
throw new Error('用户名或密码错误')
}
console.log('找到用户:', user.username)
console.log('输入的密码:', password)
console.log('输入的密码编码后:', btoa(password))
console.log('存储的密码:', user.password)
// 验证密码
if (user.password !== btoa(password)) {
console.log('密码不匹配')
throw new Error('用户名或密码错误')
}
console.log('密码验证成功')
// 更新最后登录时间
user.lastLoginAt = new Date().toISOString()
saveUsersToStorage()
// 设置当前用户
setCurrentUser(user)
console.log('登录成功,当前用户已设置')
return user
} catch (error) {
console.error('Auth store login 发生错误:', error)
throw error
}
}
// 设置当前用户
const setCurrentUser = (userData) => {
user.value = userData
isAuthenticated.value = true
// 保存到本地存储
localStorage.setItem('vue_downloader_current_user', JSON.stringify(userData))
}
// 用户登出
const logout = () => {
user.value = null
isAuthenticated.value = false
localStorage.removeItem('vue_downloader_current_user')
}
// 更新用户设置
const updateUserSettings = (settings) => {
if (!user.value) return
user.value.settings = {...user.value.settings, ...settings}
// 更新用户列表中的用户数据
const userIndex = users.value.findIndex(u => u.id === user.value.id)
if (userIndex !== -1) {
users.value[userIndex] = {...user.value}
saveUsersToStorage()
}
// 更新本地存储
localStorage.setItem('vue_downloader_current_user', JSON.stringify(user.value))
}
// 获取用户设置
const getUserSettings = () => {
return user.value?.settings || {
maxConcurrentDownloads: 3,
enableNotifications: true,
fileNamePrefix: ''
}
}
// 保存下载历史
const saveDownloadHistory = (downloads) => {
if (!user.value) return
const history = {
userId: user.value.id,
timestamp: new Date().toISOString(),
downloads: downloads.map(download => ({
id: download.id,
fileName: download.fileName,
fileNamePrefix: user.value.settings?.fileNamePrefix || '',
excelFileName: download.excelFileName || '',
url: download.url,
status: download.status,
progress: download.progress,
fileSize: download.fileSize,
downloadedBytes: download.downloadedBytes,
startTime: download.startTime,
endTime: download.endTime,
error: download.error
}))
}
// 获取现有历史记录
const existingHistory = JSON.parse(localStorage.getItem('vue_downloader_history') || '[]')
// 添加新记录
existingHistory.push(history)
// 只保留最近100条记录
if (existingHistory.length > 100) {
existingHistory.splice(0, existingHistory.length - 100)
}
localStorage.setItem('vue_downloader_history', JSON.stringify(existingHistory))
}
// 获取用户下载历史
const getDownloadHistory = () => {
if (!user.value) return []
try {
const allHistory = JSON.parse(localStorage.getItem('vue_downloader_history') || '[]')
return allHistory.filter(history => history.userId === user.value.id)
} catch (error) {
console.error('获取下载历史失败:', error)
return []
}
}
// 清除用户下载历史
const clearDownloadHistory = () => {
if (!user.value) return
try {
const allHistory = JSON.parse(localStorage.getItem('vue_downloader_history') || '[]')
const filteredHistory = allHistory.filter(history => history.userId !== user.value.id)
localStorage.setItem('vue_downloader_history', JSON.stringify(filteredHistory))
} catch (error) {
console.error('清除下载历史失败:', error)
}
}
// 初始化:从本地存储加载数据
const initialize = () => {
console.log('Auth store 初始化开始...')
// 清除旧的用户数据,重新创建默认管理员账号
console.log('清除旧的用户数据...')
localStorage.removeItem('vue_downloader_users')
localStorage.removeItem('vue_downloader_current_user')
loadUsersFromStorage()
// 如果没有用户,创建默认管理员账号
if (users.value.length === 0) {
console.log('创建默认管理员账号...')
const adminPassword = '123456'
const adminUser = {
id: 'admin',
username: 'admin',
password: btoa(adminPassword),
email: '',
createdAt: new Date().toISOString(),
lastLoginAt: null,
settings: {
maxConcurrentDownloads: 3,
enableNotifications: true,
fileNamePrefix: ''
}
}
users.value.push(adminUser)
saveUsersToStorage()
console.log('默认管理员账号创建完成')
console.log('管理员密码:', adminPassword)
console.log('管理员密码编码后:', btoa(adminPassword))
}
console.log('当前用户列表:', users.value.map(u => u.username))
// 尝试恢复当前用户会话
try {
const storedUser = localStorage.getItem('vue_downloader_current_user')
if (storedUser) {
const userData = JSON.parse(storedUser)
// 验证用户是否仍然存在
const existingUser = users.value.find(u => u.id === userData.id)
if (existingUser) {
setCurrentUser(existingUser)
console.log('用户会话已恢复:', existingUser.username)
}
}
} catch (error) {
console.error('恢复用户会话失败:', error)
}
console.log('Auth store 初始化完成')
}
// 计算属性
const currentUser = computed(() => user.value)
const isLoggedIn = computed(() => isAuthenticated.value)
const userCount = computed(() => users.value.length)
return {
// 状态
user: currentUser,
isAuthenticated: isLoggedIn,
users,
// 方法
register,
login,
logout,
setCurrentUser,
updateUserSettings,
getUserSettings,
saveDownloadHistory,
getDownloadHistory,
clearDownloadHistory,
validatePassword,
isUsernameExists,
initialize,
// 计算属性
userCount
}
})
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from './auth'
export const useDownloadStore = defineStore('download', () => {
const authStore = useAuthStore()
// 下载列表
const downloads = ref([])
// 下载设置
const downloadPath = ref('')
const customSubFolder = ref('')
const maxConcurrentDownloads = ref(3)
const activeDownloadCount = ref(0)
const enableNotifications = ref(true)
// 下载控制标志
const isBatchDownloading = ref(false)
const isSingleDownloading = ref(false)
// Excel文件存储相关
const excelFiles = ref([]) // 存储Excel文件信息
const currentExcelFile = ref(null) // 当前处理的Excel文件
// 从本地存储加载数据
const loadFromStorage = () => {
try {
// 加载用户设置
const userSettings = authStore.getUserSettings()
maxConcurrentDownloads.value = userSettings.maxConcurrentDownloads || 3
customSubFolder.value = userSettings.fileNamePrefix || ''
enableNotifications.value = userSettings.enableNotifications !== false
// 加载下载列表
const storedDownloads = localStorage.getItem('vue_downloader_downloads')
if (storedDownloads) {
downloads.value = JSON.parse(storedDownloads)
}
// 加载Excel文件列表
const storedExcelFiles = localStorage.getItem('vue-downloader-excel-files')
if (storedExcelFiles) {
excelFiles.value = JSON.parse(storedExcelFiles)
}
} catch (error) {
console.error('加载下载数据失败:', error)
}
}
// 保存到本地存储
const saveToStorage = () => {
try {
localStorage.setItem('vue_downloader_downloads', JSON.stringify(downloads.value))
localStorage.setItem('vue-downloader-settings', JSON.stringify({
customSubFolder: customSubFolder.value,
enableNotifications: enableNotifications.value,
maxConcurrentDownloads: maxConcurrentDownloads.value
}))
localStorage.setItem('vue-downloader-excel-files', JSON.stringify(excelFiles.value))
} catch (error) {
console.error('保存下载数据失败:', error)
}
}
// 保存下载历史
const saveDownloadHistory = () => {
if (downloads.value.length > 0) {
authStore.saveDownloadHistory(downloads.value)
}
}
// 初始化
const initialize = () => {
loadFromStorage()
}
// 计算属性
const pendingDownloads = computed(() =>
downloads.value.filter(d => d.status === 'pending')
)
const activeDownloads = computed(() =>
downloads.value.filter(d => d.status === 'downloading')
)
const completedDownloads = computed(() =>
downloads.value.filter(d => d.status === 'completed')
)
const failedDownloads = computed(() =>
downloads.value.filter(d => d.status === 'error')
)
// 检查是否所有下载都完成
const allDownloadsCompleted = computed(() => {
return downloads.value.length > 0 &&
downloads.value.every(d => d.status === 'completed' || d.status === 'error')
})
// 添加下载任务(修改为支持Excel文件ID)
const addDownload = (fileName, url, excelFileName = '', excelFileId = null) => {
const download = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
fileName,
url,
excelFileName,
excelFileId, // 添加Excel文件ID
status: 'pending',
progress: 0,
downloadedBytes: 0,
fileSize: 0,
speed: 0,
startTime: null,
endTime: null,
error: null,
retryCount: 0,
maxRetries: 3,
controller: null
}
downloads.value.push(download)
saveToStorage()
}
// 开始下载(支持多线程)
const startDownload = async (downloadId) => {
const download = downloads.value.find(d => d.id === downloadId)
if (!download || download.status === 'downloading') return
// 检查并发数限制
if (activeDownloadCount.value >= maxConcurrentDownloads.value) {
// 如果超过并发限制,将任务重新设为等待状态
download.status = 'pending'
saveToStorage()
return
}
download.status = 'downloading'
download.startTime = new Date()
download.progress = 0
download.downloadedBytes = 0
download.speed = 0
download.error = null
activeDownloadCount.value++
saveToStorage()
// 创建 AbortController 用于取消下载
download.controller = new AbortController()
try {
await performDownload(download)
} catch (error) {
if (error.name === 'AbortError') {
download.status = 'paused'
saveToStorage()
} else {
// 处理重试逻辑
if (download.retryCount < download.maxRetries) {
download.retryCount++
download.status = 'pending'
saveToStorage()
// 延迟重试
setTimeout(() => {
startDownload(downloadId)
}, 2000 * download.retryCount) // 递增延迟
} else {
download.status = 'error'
download.error = error.message
saveToStorage()
}
}
} finally {
activeDownloadCount.value--
// 只有在非批量下载且非单个下载模式下才自动启动下一个等待中的下载
if (!isBatchDownloading.value && !isSingleDownloading.value) {
startNextPendingDownload()
}
// 检查是否所有下载都完成
checkAllDownloadsCompleted()
}
}
// 启动下一个等待中的下载
const startNextPendingDownload = () => {
// 如果正在批量下载或单个下载,不自动启动其他任务
if (isBatchDownloading.value || isSingleDownloading.value) return
const pendingDownload = downloads.value.find(d => d.status === 'pending')
if (pendingDownload && activeDownloadCount.value < maxConcurrentDownloads.value) {
startDownload(pendingDownload.id)
}
}
// 检查所有下载是否完成
const checkAllDownloadsCompleted = () => {
const allCompleted = downloads.value.every(download =>
download.status === 'completed' || download.status === 'error'
)
if (allCompleted && enableNotifications.value) {
const completedCount = downloads.value.filter(d => d.status === 'completed').length
const failedCount = downloads.value.filter(d => d.status === 'error').length
ElMessage({
message: `下载完成!成功: ${completedCount} 个,失败: ${failedCount} 个`,
type: 'success',
duration: 5000
})
// 保存下载历史
saveDownloadHistory()
}
}
// 显示下载完成通知
const showDownloadCompletionNotification = () => {
const completedCount = completedDownloads.value.length
const failedCount = failedDownloads.value.length
let message = ''
if (failedCount === 0) {
message = `所有 ${completedCount} 个文件下载完成!`
} else {
message = `${completedCount} 个文件下载完成,${failedCount} 个文件下载失败`
}
// 创建通知元素
const notification = document.createElement('div')
notification.className = 'download-notification'
notification.innerHTML = `
<div class="notification-content">
<div class="notification-icon">✅</div>
<div class="notification-message">${message}</div>
<div class="notification-close" onclick="this.parentElement.parentElement.remove()">×</div>
</div>
`
// 添加样式
notification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #67c23a;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 9999;
animation: slideIn 0.3s ease-out;
max-width: 300px;
`
// 添加动画样式
const style = document.createElement('style')
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-content {
display: flex;
align-items: center;
gap: 10px;
}
.notification-icon {
font-size: 20px;
}
.notification-message {
flex: 1;
font-size: 14px;
}
.notification-close {
cursor: pointer;
font-size: 18px;
font-weight: bold;
opacity: 0.8;
}
.notification-close:hover {
opacity: 1;
}
`
document.head.appendChild(style)
document.body.appendChild(notification)
// 5秒后自动移除
setTimeout(() => {
if (notification.parentElement) {
notification.remove()
}
}, 5000)
}
// 执行下载
const performDownload = async (download) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', download.url, true)
xhr.responseType = 'blob'
// 设置断点续传
const existingFile = localStorage.getItem(`download_${download.id}`)
if (existingFile) {
const parsed = JSON.parse(existingFile)
download.downloadedBytes = parsed.downloadedBytes || 0
xhr.setRequestHeader('Range', `bytes=${download.downloadedBytes}-`)
}
// 设置超时
xhr.timeout = 30000 // 30秒超时
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const total = event.total + download.downloadedBytes
const loaded = event.loaded + download.downloadedBytes
download.totalBytes = total
download.downloadedBytes = loaded
download.progress = Math.round((loaded / total) * 100)
// 计算下载速度
const now = Date.now()
const timeDiff = (now - download.startTime) / 1000
download.speed = loaded / timeDiff
// 保存进度到本地存储
localStorage.setItem(`download_${download.id}`, JSON.stringify({
downloadedBytes: loaded,
progress: download.progress
}))
// 定期保存到主存储(每5%进度保存一次)
if (download.progress % 5 === 0) {
saveToStorage()
}
}
}
xhr.onload = async () => {
if (xhr.status === 200 || xhr.status === 206) {
download.status = 'completed'
download.progress = 100
download.endTime = new Date()
saveToStorage()
// 保存文件到自定义文件夹
const blob = xhr.response
try {
// 尝试使用现代浏览器的 File System Access API
if ('showSaveFilePicker' in window) {
await saveFileWithPicker(blob, download.fileName)
} else {
// 降级到传统方法
saveFileWithLegacyMethod(blob, download.fileName)
}
} catch (error) {
console.warn('文件保存失败,使用传统方法:', error)
saveFileWithLegacyMethod(blob, download.fileName)
}
// 清除本地存储的进度
localStorage.removeItem(`download_${download.id}`)
resolve()
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`))
}
}
xhr.onerror = () => {
reject(new Error('网络错误'))
}
xhr.onabort = () => {
reject(new Error('下载已取消'))
}
xhr.ontimeout = () => {
reject(new Error('下载超时'))
}
// 监听取消信号
if (download.controller) {
download.controller.signal.addEventListener('abort', () => {
xhr.abort()
})
}
xhr.send()
})
}
// 使用现代浏览器的 File System Access API 保存文件
const saveFileWithPicker = async (blob, fileName) => {
try {
// 构建带前缀的文件名
let finalFileName = fileName
if (customSubFolder.value && customSubFolder.value.trim()) {
finalFileName = `${customSubFolder.value.trim()}_${fileName}`
}
const options = {
suggestedName: finalFileName,
types: [{
description: '下载文件',
accept: {
'*/*': ['.*']
}
}]
}
const fileHandle = await window.showSaveFilePicker(options)
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
console.log(`文件已保存: ${finalFileName}`)
} catch (error) {
console.error('File System Access API 保存失败:', error)
throw error
}
}
// 使用传统方法保存文件
const saveFileWithLegacyMethod = (blob, fileName) => {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
// 构建带前缀的文件名
let finalFileName = fileName
if (customSubFolder.value && customSubFolder.value.trim()) {
finalFileName = `${customSubFolder.value.trim()}_${fileName}`
}
a.download = finalFileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
// 获取操作系统信息
const getOperatingSystem = () => {
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('windows')) {
return 'windows'
} else if (userAgent.includes('mac')) {
return 'mac'
} else if (userAgent.includes('linux')) {
return 'linux'
} else {
return 'unknown'
}
}
// 获取默认下载路径
const getDefaultDownloadPath = () => {
const os = getOperatingSystem()
switch (os) {
case 'windows':
return 'C:\\Users\\用户名\\Downloads'
case 'mac':
return '/Users/用户名/Downloads'
case 'linux':
return '/home/用户名/Downloads'
default:
return '浏览器默认下载目录'
}
}
// 构建完整下载路径
const getFullDownloadPath = () => {
const basePath = getDefaultDownloadPath()
const os = getOperatingSystem()
if (!customSubFolder.value || !customSubFolder.value.trim()) {
return basePath
}
const separator = os === 'windows' ? '\\' : '/'
return `${basePath}${separator}${customSubFolder.value.trim()}`
}
// 暂停下载
const pauseDownload = (downloadId) => {
const download = downloads.value.find(d => d.id === downloadId)
if (download && download.controller) {
download.controller.abort()
download.status = 'paused'
activeDownloadCount.value--
saveToStorage()
// 只有在非批量下载且非单个下载模式下才自动启动下一个等待中的下载
if (!isBatchDownloading.value && !isSingleDownloading.value) {
startNextPendingDownload()
}
}
}
// 恢复下载
const resumeDownload = (downloadId) => {
startDownload(downloadId)
}
// 取消下载
const cancelDownload = (downloadId) => {
const download = downloads.value.find(d => d.id === downloadId)
if (download && download.controller) {
download.controller.abort()
if (download.status === 'downloading') {
activeDownloadCount.value--
}
}
const index = downloads.value.findIndex(d => d.id === downloadId)
if (index > -1) {
downloads.value.splice(index, 1)
saveToStorage()
}
// 清除本地存储
localStorage.removeItem(`download_${downloadId}`)
// 删除操作不触发自动启动其他下载任务
// 只有在非批量下载且非单个下载模式下才自动启动下一个等待中的下载
// 删除操作应该保持当前状态,不自动启动新任务
}
// 批量开始下载
const batchStartDownloads = async (downloadIds) => {
isBatchDownloading.value = true
try {
for (const id of downloadIds) {
await startDownload(id)
}
} finally {
isBatchDownloading.value = false
}
}
// 批量暂停下载
const batchPauseDownloads = (downloadIds) => {
downloadIds.forEach(id => pauseDownload(id))
}
// 批量删除下载
const batchDeleteDownloads = (downloadIds) => {
isBatchDownloading.value = true
try {
downloadIds.forEach(id => cancelDownload(id))
} finally {
isBatchDownloading.value = false
}
}
// 设置下载路径
const setDownloadPath = (path) => {
downloadPath.value = path
}
// 设置自定义子文件夹
const setCustomSubFolder = (folder) => {
customSubFolder.value = folder
// 同步到用户设置
authStore.updateUserSettings({ fileNamePrefix: folder })
}
// 设置最大并发下载数
const setMaxConcurrentDownloads = (count) => {
maxConcurrentDownloads.value = count
// 同步到用户设置
authStore.updateUserSettings({ maxConcurrentDownloads: count })
}
// 设置启用通知
const setEnableNotifications = (enabled) => {
enableNotifications.value = enabled
// 同步到用户设置
authStore.updateUserSettings({ enableNotifications: enabled })
}
// 清除已完成的任务
const clearCompleted = () => {
downloads.value = downloads.value.filter(download => download.status !== 'completed')
saveToStorage()
}
// 清除所有任务
const clearAll = () => {
downloads.value = []
saveToStorage()
}
// 重试失败的下载
const retryFailedDownloads = () => {
downloads.value.forEach(download => {
if (download.status === 'error') {
download.status = 'pending'
download.progress = 0
download.downloadedBytes = 0
download.speed = 0
download.error = null
download.retryCount = 0
download.startTime = null
download.endTime = null
}
})
saveToStorage()
}
// 保存Excel文件到本地
const saveExcelFileToLocal = async (file, data, columnMapping) => {
try {
const excelFileInfo = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
fileName: file.name,
fileSize: file.size,
uploadTime: new Date().toISOString(),
totalRows: data.length,
columnMapping: columnMapping,
dataChunks: [], // 分块存储数据
chunkSize: 1000 // 每块1000行
}
// 将数据分块保存到IndexedDB
await saveDataToIndexedDB(excelFileInfo.id, data, excelFileInfo.chunkSize)
// 保存文件信息
excelFiles.value.push(excelFileInfo)
saveToStorage()
return excelFileInfo
} catch (error) {
console.error('保存Excel文件失败:', error)
throw error
}
}
// 保存数据到IndexedDB
const saveDataToIndexedDB = async (fileId, data, chunkSize) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('VueDownloaderDB', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const db = request.result
const transaction = db.transaction(['excelData'], 'readwrite')
const store = transaction.objectStore('excelData')
// 分块保存数据
const chunks = []
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize)
chunks.push({
fileId: fileId,
chunkIndex: Math.floor(i / chunkSize),
data: chunk
})
}
let completed = 0
chunks.forEach(chunk => {
const addRequest = store.add(chunk)
addRequest.onsuccess = () => {
completed++
if (completed === chunks.length) {
resolve()
}
}
addRequest.onerror = () => reject(addRequest.error)
})
}
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains('excelData')) {
const store = db.createObjectStore('excelData', { keyPath: ['fileId', 'chunkIndex'] })
store.createIndex('fileId', 'fileId', { unique: false })
}
}
})
}
// 从IndexedDB加载数据分页
const loadExcelDataPage = async (fileId, page = 1, pageSize = 10) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('VueDownloaderDB', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const db = request.result
const transaction = db.transaction(['excelData'], 'readonly')
const store = transaction.objectStore('excelData')
const index = store.index('fileId')
const getAllRequest = index.getAll(fileId)
getAllRequest.onsuccess = () => {
const chunks = getAllRequest.result
// 按chunkIndex排序
chunks.sort((a, b) => a.chunkIndex - b.chunkIndex)
// 合并所有数据
const allData = chunks.reduce((acc, chunk) => acc.concat(chunk.data), [])
// 计算分页
const start = (page - 1) * pageSize
const end = start + pageSize
const pageData = allData.slice(start, end)
resolve({
data: pageData,
total: allData.length,
page: page,
pageSize: pageSize,
totalPages: Math.ceil(allData.length / pageSize)
})
}
getAllRequest.onerror = () => reject(getAllRequest.error)
}
})
}
// 删除Excel文件
const deleteExcelFile = async (fileId) => {
try {
// 从IndexedDB删除数据
await deleteDataFromIndexedDB(fileId)
// 从文件列表中删除
excelFiles.value = excelFiles.value.filter(file => file.id !== fileId)
saveToStorage()
// 删除相关的下载任务
downloads.value = downloads.value.filter(download => download.excelFileId !== fileId)
saveToStorage()
} catch (error) {
console.error('删除Excel文件失败:', error)
throw error
}
}
// 从IndexedDB删除数据
const deleteDataFromIndexedDB = async (fileId) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('VueDownloaderDB', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const db = request.result
const transaction = db.transaction(['excelData'], 'readwrite')
const store = transaction.objectStore('excelData')
const index = store.index('fileId')
// 先获取所有匹配的键
const getAllKeysRequest = index.getAllKeys(fileId)
getAllKeysRequest.onsuccess = () => {
const keys = getAllKeysRequest.result
if (keys.length === 0) {
resolve() // 没有数据需要删除
return
}
// 删除所有匹配的记录
let completed = 0
keys.forEach(key => {
const deleteRequest = store.delete(key)
deleteRequest.onsuccess = () => {
completed++
if (completed === keys.length) {
resolve()
}
}
deleteRequest.onerror = () => reject(deleteRequest.error)
})
}
getAllKeysRequest.onerror = () => reject(getAllKeysRequest.error)
}
})
}
// 获取Excel文件信息
const getExcelFileInfo = (fileId) => {
return excelFiles.value.find(file => file.id === fileId)
}
// 获取所有Excel文件
const getAllExcelFiles = () => {
return excelFiles.value
}
// 设置当前Excel文件
const setCurrentExcelFile = (fileId) => {
currentExcelFile.value = fileId
}
// 获取当前Excel文件
const getCurrentExcelFile = () => {
return currentExcelFile.value
}
return {
downloads,
downloadPath,
customSubFolder,
maxConcurrentDownloads,
activeDownloadCount,
enableNotifications,
isBatchDownloading,
isSingleDownloading,
pendingDownloads,
activeDownloads,
completedDownloads,
failedDownloads,
allDownloadsCompleted,
addDownload,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
batchStartDownloads,
batchPauseDownloads,
batchDeleteDownloads,
setDownloadPath,
setCustomSubFolder,
setMaxConcurrentDownloads,
setEnableNotifications,
clearCompleted,
clearAll,
retryFailedDownloads,
getOperatingSystem,
getDefaultDownloadPath,
initialize,
excelFiles,
currentExcelFile,
saveExcelFileToLocal,
loadExcelDataPage,
deleteExcelFile,
getExcelFileInfo,
getAllExcelFiles,
setCurrentExcelFile,
getCurrentExcelFile
}
})
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 400px;
text-align: center;
}
.login-title {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 30px;
}
.downloader-container {
background: #f5f7fa;
min-height: 100vh;
}
.header {
background: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 0 20px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #409eff;
}
.main-content {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
/* 配置区域样式 */
.config-collapse {
margin-bottom: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.config-collapse .el-collapse-item__header {
background: #f8f9fa;
font-weight: 600;
font-size: 16px;
padding: 15px 20px;
}
.config-collapse .el-collapse-item__content {
padding: 20px;
}
.upload-section {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 下载列表主区域样式 */
.download-list-main {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.download-header {
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.download-title {
display: flex;
flex-direction: column;
gap: 10px;
}
.download-title h2 {
display: flex;
align-items: center;
gap: 10px;
color: #333;
margin: 0;
}
.download-stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.download-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #909399;
}
.empty-state h3 {
margin: 20px 0 10px 0;
color: #606266;
}
.empty-state p {
color: #909399;
font-size: 14px;
}
/* 下载表格容器 */
.download-table-container {
padding: 0;
}
/* 文件信息样式 */
.file-info {
display: flex;
align-items: center;
gap: 10px;
}
.file-icon {
font-size: 20px;
color: #409eff;
flex-shrink: 0;
}
.file-details {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
color: #333;
margin-bottom: 5px;
word-break: break-all;
}
.file-prefix {
color: #409eff;
font-weight: 600;
background: #ecf5ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
margin-right: 4px;
}
.excel-file-info {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
}
.excel-file-name {
font-size: 13px;
font-weight: 500;
color: #409eff;
background: #f0f9ff;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #e1f3ff;
}
.file-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
/* 进度容器样式 */
.progress-container {
display: flex;
flex-direction: column;
gap: 5px;
}
.speed-info {
font-size: 12px;
color: #666;
text-align: center;
}
.completed-text {
color: #67c23a;
font-weight: 500;
}
.pending-text {
color: #909399;
}
.size-info {
font-size: 12px;
color: #666;
}
/* 状态标签样式 */
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-pending {
background: #f0f9ff;
color: #0369a1;
}
.status-downloading {
background: #f0fdf4;
color: #16a34a;
}
.status-completed {
background: #f0fdf4;
color: #16a34a;
}
.status-error {
background: #fef2f2;
color: #dc2626;
}
.status-paused {
background: #fef3c7;
color: #d97706;
}
/* 分页容器样式 */
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
padding: 20px;
background: #f8f9fa;
border-top: 1px solid #e4e7ed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.main-content {
padding: 10px;
}
.download-header {
flex-direction: column;
align-items: flex-start;
}
.download-actions {
width: 100%;
justify-content: flex-start;
}
.download-stats {
width: 100%;
justify-content: flex-start;
}
}
/* 表格响应式 */
@media (max-width: 1200px) {
.el-table {
font-size: 12px;
}
.file-name {
font-size: 13px;
}
.file-url {
font-size: 11px;
}
}
/* 动画效果 */
.el-table__row {
transition: all 0.3s ease;
}
.el-table__row:hover {
background-color: #f5f7fa !important;
}
/* 下载通知样式 */
.download-notification {
position: fixed;
bottom: 20px;
right: 20px;
background: #67c23a;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 9999;
animation: slideIn 0.3s ease-out;
max-width: 300px;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-content {
display: flex;
align-items: center;
gap: 10px;
}
.notification-icon {
font-size: 20px;
}
.notification-message {
flex: 1;
font-size: 14px;
}
.notification-close {
cursor: pointer;
font-size: 18px;
font-weight: bold;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.notification-close:hover {
opacity: 1;
}
<template>
<div class="downloader-container">
<!-- 头部 -->
<header class="header">
<div class="logo">
<el-icon><Download /></el-icon>
文件下载器
</div>
<div style="display: flex; align-items: center; gap: 20px;">
<el-dropdown @command="handleUserCommand">
<span class="user-info">
<el-icon><User /></el-icon>
{{ authStore.user?.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="settings">个人设置</el-dropdown-item>
<el-dropdown-item command="history">下载历史</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 主要内容 -->
<div class="main-content">
<!-- 配置区域(可折叠) -->
<el-collapse v-model="activeCollapse" class="config-collapse">
<el-collapse-item title="📁 Excel文件管理" name="upload">
<!-- Excel文件列表 -->
<div v-if="downloadStore.excelFiles.length > 0" style="margin-bottom: 20px;">
<h4 style="margin-bottom: 15px; color: #333;">已上传的Excel文件 ({{ downloadStore.excelFiles.length }}个)</h4>
<el-table :data="downloadStore.excelFiles" size="small" border style="width: 100%">
<el-table-column prop="fileName" label="文件名" />
<el-table-column prop="totalRows" label="总行数" width="100" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="180">
<template #default="{ row }">
{{ new Date(row.uploadTime).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button @click="selectExcelFile(row.id)" type="primary" size="small">
选择
</el-button>
<el-button @click="deleteExcelFile(row.id)" type="danger" size="small">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-divider />
<!-- Excel文件上传配置 -->
<div class="upload-section">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.xls"
:on-change="handleFileChange"
>
<el-button type="primary" size="large">
<el-icon><Upload /></el-icon>
选择Excel文件
</el-button>
</el-upload>
<div v-if="selectedFile" style="margin-top: 15px;">
<el-alert
:title="`已选择文件: ${selectedFile.name}`"
type="success"
:closable="false"
/>
</div>
<!-- Excel列选择 -->
<div v-if="excelColumns.length > 0" style="margin-top: 20px;">
<h4 style="margin-bottom: 15px; color: #333;">配置Excel列映射</h4>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文件名列(可多选,用'-'连接)">
<el-select
v-model="columnMapping.fileNameColumns"
multiple
placeholder="选择文件名列"
style="width: 100%"
@change="handleColumnMappingChange"
>
<el-option
v-for="column in excelColumns"
:key="column"
:label="column"
:value="column"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="URL列">
<el-select
v-model="columnMapping.url"
placeholder="选择URL列"
style="width: 100%"
@change="handleColumnMappingChange"
>
<el-option
v-for="column in excelColumns"
:key="column"
:label="column"
:value="column"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 预览数据 -->
<div v-if="previewData.length > 0" style="margin-top: 15px;">
<h5 style="margin-bottom: 10px; color: #666;">
数据预览 (前5行) - 总计: {{ rawExcelData.length }} 行
</h5>
<el-table :data="previewData" size="small" border style="width: 100%">
<el-table-column prop="fileName" label="文件名" />
<el-table-column prop="url" label="URL" show-overflow-tooltip />
</el-table>
<div v-if="rawExcelData.length > 1000" style="margin-top: 10px;">
<el-alert
title="文件较大提示"
type="warning"
:closable="false"
show-icon
>
<template #default>
<div style="font-size: 12px; line-height: 1.5;">
<p>• 当前文件包含 {{ rawExcelData.length }} 行数据</p>
<p>• 已优化:只保留 {{ Object.keys(rawExcelData[0] || {}).length }} 个必要字段</p>
<p> 大文件处理可能需要较长时间,请耐心等待</p>
<p> 建议分批处理或使用较小的Excel文件</p>
</div>
</template>
</el-alert>
</div>
</div>
</div>
<!-- 下载路径设置 -->
<div style="margin-top: 20px;">
<h4 style="margin-bottom: 15px; color: #333;">下载设置</h4>
<!-- 操作系统信息 -->
<div style="margin-bottom: 15px;">
<el-alert
:title="`当前操作系统: ${downloadStore.getOperatingSystem() === 'windows' ? 'Windows' : downloadStore.getOperatingSystem() === 'mac' ? 'macOS' : downloadStore.getOperatingSystem() === 'linux' ? 'Linux' : '未知'}`"
type="info"
:closable="false"
show-icon
/>
</div>
<!-- 浏览器默认下载路径显示 -->
<div style="margin-bottom: 15px;">
<el-alert
:title="`默认下载路径: ${downloadStore.getDefaultDownloadPath()}`"
type="info"
:closable="false"
show-icon
/>
</div>
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="文件名前缀">
<el-input
v-model="customSubFolder"
placeholder="例如: vue-downloader (留空则不添加前缀)"
@input="updateDownloadPath"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-button @click="clearFileNamePrefix" type="info">
清空前缀
</el-button>
</el-col>
</el-row>
<!-- 文件名前缀说明 -->
<div v-if="customSubFolder && customSubFolder.trim()" style="margin-top: 10px;">
<el-alert
title="文件名前缀说明"
type="success"
:closable="false"
show-icon
>
<template #default>
<div style="font-size: 12px; line-height: 1.5;">
<p><strong>示例:</strong></p>
<p> 原文件名: <code>document.pdf</code></p>
<p> 添加前缀后: <code>vue-downloader_document.pdf</code></p>
<p> 所有下载的文件都会自动添加此前缀</p>
</div>
</template>
</el-alert>
</div>
<!-- 通知设置 -->
<div style="margin-top: 15px;">
<el-form-item label="下载完成通知">
<el-switch
v-model="downloadStore.enableNotifications"
@change="downloadStore.setEnableNotifications"
active-text="启用"
inactive-text="禁用"
/>
<span style="margin-left: 10px; color: #666; font-size: 12px;">
下载全部完成后在右下角显示通知
</span>
</el-form-item>
</div>
</div>
<div style="margin-top: 20px;">
<el-button
type="success"
size="large"
:disabled="!canParse"
@click="parseExcelFile"
:loading="parsing"
>
<el-icon><Document /></el-icon>
{{ parsing ? '解析中...' : '解析并添加下载任务' }}
</el-button>
<!-- 处理进度显示 -->
<div v-if="parsing && processingMessage" style="margin-top: 15px;">
<el-progress
:percentage="processingProgress"
:format="(percentage) => `${percentage}%`"
:stroke-width="8"
status="success"
/>
<div style="margin-top: 8px; color: #666; font-size: 14px;">
{{ processingMessage }}
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
<!-- 下载列表(主要区域) -->
<div class="download-list-main">
<div class="download-header">
<div class="download-title">
<h2 style="color: #333; margin: 0;">
<el-icon><List /></el-icon>
下载任务管理
</h2>
<div class="download-stats">
<el-tag type="info">总计: {{ downloadStore.downloads.length }}</el-tag>
<el-tag type="primary">等待: {{ downloadStore.pendingDownloads.length }}</el-tag>
<el-tag type="success">下载中: {{ downloadStore.activeDownloads.length }}</el-tag>
<el-tag type="warning">已完成: {{ downloadStore.completedDownloads.length }}</el-tag>
<el-tag type="danger">失败: {{ downloadStore.failedDownloads.length }}</el-tag>
</div>
</div>
<div class="download-actions">
<!-- 状态筛选 -->
<el-select
v-model="statusFilter"
placeholder="按状态筛选"
clearable
style="width: 150px; margin-right: 10px;"
@change="handleStatusFilterChange"
>
<el-option label="全部" value="" />
<el-option label="等待中" value="pending" />
<el-option label="下载中" value="downloading" />
<el-option label="已暂停" value="paused" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="error" />
</el-select>
<!-- 页面级别操作 -->
<el-button @click="downloadCurrentPage" type="info" size="small">
下载当前页
</el-button>
<el-button @click="pauseCurrentPage" type="warning" size="small">
暂停当前页
</el-button>
<el-divider direction="vertical" />
<el-button @click="downloadStore.clearCompleted" size="small">
清除已完成
</el-button>
<el-button @click="downloadStore.clearAll" type="danger" size="small">
清除所有
</el-button>
</div>
</div>
<!-- 下载任务列表 -->
<div v-if="downloadStore.downloads.length === 0" class="empty-state">
<el-icon size="64"><Document /></el-icon>
<h3>暂无下载任务</h3>
<p>请在上方配置区域上传Excel文件并解析添加下载任务</p>
</div>
<div v-else class="download-table-container">
<!-- 分页表格 -->
<el-table
:data="paginatedDownloads"
style="width: 100%"
border
stripe
highlight-current-row
>
<el-table-column label="Excel文件" width="180">
<template #default="{ row }">
<div class="excel-file-info">
<el-icon><Document /></el-icon>
<span class="excel-file-name">{{ row.excelFileName || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="250">
<template #default="{ row }">
<div class="file-info">
<div class="file-icon">
<el-icon><Document /></el-icon>
</div>
<div class="file-details">
<div class="file-name">
<span v-if="downloadStore.customSubFolder" class="file-prefix">
{{ downloadStore.customSubFolder }}_
</span>
{{ row.fileName }}
</div>
<div class="file-url">{{ row.url }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag
:class="`status-badge status-${row.status}`"
size="small"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="200" align="center">
<template #default="{ row }">
<div v-if="row.status === 'downloading'" class="progress-container">
<el-progress
:percentage="row.progress"
:format="(percentage) => `${percentage}%`"
size="small"
:stroke-width="8"
/>
<div class="speed-info">
{{ formatSpeed(row.speed) }}
</div>
</div>
<span v-else-if="row.status === 'completed'" class="completed-text">
100%
</span>
<span v-else class="pending-text">
{{ row.progress }}%
</span>
</template>
</el-table-column>
<el-table-column label="大小" width="120" align="center">
<template #default="{ row }">
<span v-if="row.totalBytes > 0" class="size-info">
{{ formatBytes(row.downloadedBytes) }} / {{ formatBytes(row.totalBytes) }}
</span>
<span v-else class="size-info">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<div class="download-actions">
<el-button
v-if="row.status === 'pending'"
@click="startSingleDownload(row.id)"
type="primary"
size="small"
>
开始
</el-button>
<el-button
v-if="row.status === 'downloading'"
@click="downloadStore.pauseDownload(row.id)"
type="warning"
size="small"
>
暂停
</el-button>
<el-button
v-if="row.status === 'paused'"
@click="resumeSingleDownload(row.id)"
type="success"
size="small"
>
继续
</el-button>
<el-button
v-if="row.status === 'error'"
@click="startSingleDownload(row.id)"
type="warning"
size="small"
>
重试
</el-button>
<el-button
@click="downloadStore.cancelDownload(row.id)"
type="danger"
size="small"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredDownloads.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Upload, Document, List, Warning, User, ArrowDown } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
import { useDownloadStore } from '../stores/download'
import * as XLSX from 'xlsx'
const router = useRouter()
const authStore = useAuthStore()
const downloadStore = useDownloadStore()
const uploadRef = ref()
const selectedFile = ref(null)
const parsing = ref(false)
const processingProgress = ref(0)
const processingMessage = ref('')
const customSubFolder = ref('')
const excelColumns = ref([])
const previewData = ref([])
const rawExcelData = ref([])
const activeCollapse = ref(['upload']) // 默认展开配置区域
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const columnMapping = ref({
fileNameColumns: [],
url: ''
})
// 状态筛选
const statusFilter = ref('')
// 计算是否可以解析
const canParse = computed(() => {
return selectedFile.value &&
columnMapping.value.fileNameColumns.length > 0 &&
columnMapping.value.url &&
previewData.value.length > 0
})
// 计算分页后的下载列表
const paginatedDownloads = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredDownloads.value.slice(start, end)
})
// 计算筛选后的下载列表
const filteredDownloads = computed(() => {
return downloadStore.downloads.filter(item => {
if (statusFilter.value) {
return item.status === statusFilter.value
}
return true
})
})
// 选择Excel文件
const selectExcelFile = async (fileId) => {
try {
const fileInfo = downloadStore.getExcelFileInfo(fileId)
if (!fileInfo) {
ElMessage.error('文件信息不存在')
return
}
downloadStore.setCurrentExcelFile(fileId)
// 加载第一页数据
const pageData = await downloadStore.loadExcelDataPage(fileId, 1, 10)
rawExcelData.value = pageData.data
// 设置列映射
columnMapping.value = fileInfo.columnMapping || { fileNameColumns: [], url: '' }
// 设置列名
if (pageData.data.length > 0) {
excelColumns.value = Object.keys(pageData.data[0])
}
// 生成预览数据
generatePreviewData()
ElMessage.success(`已选择文件: ${fileInfo.fileName}`)
} catch (error) {
console.error('选择Excel文件失败:', error)
ElMessage.error('选择Excel文件失败: ' + error.message)
}
}
// 删除Excel文件
const deleteExcelFile = async (fileId) => {
// 先尝试简单的确认
const confirmed = confirm('确定要删除这个Excel文件吗?删除后将无法恢复。')
if (!confirmed) {
return
}
try {
await downloadStore.deleteExcelFile(fileId)
ElMessage.success('Excel文件已删除')
} catch (error) {
console.error('删除Excel文件失败:', error)
ElMessage.error('删除Excel文件失败: ' + (error.message || '未知错误'))
}
}
// 处理文件选择
const handleFileChange = async (file) => {
selectedFile.value = file.raw
try {
// 检查文件大小,超过5MB时显示警告
if (file.raw.size > 5 * 1024 * 1024) {
await ElMessageBox.confirm(
`文件大小: ${(file.raw.size / 1024 / 1024).toFixed(1)}MB,较大文件可能需要较长处理时间。是否继续?`,
'文件较大',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning'
}
)
}
// 显示加载提示
ElMessage.info('正在读取Excel文件,请稍候...')
// 先读取完整数据以获取列名
const fullData = await readExcelFileOptimized(file.raw)
if (fullData.length > 0) {
// 保存原始完整数据用于后续优化
window.originalExcelData = fullData
// 获取所有列名并保存原始列名
const allColumns = Object.keys(fullData[0])
excelColumns.value = allColumns
window.originalColumns = allColumns
// 尝试自动匹配列名
autoMatchColumns()
// 生成预览数据
generatePreviewData()
// 如果自动匹配成功,则优化数据
if (columnMapping.value.fileNameColumns.length > 0 && columnMapping.value.url) {
const optimizedData = optimizeExcelData(fullData)
rawExcelData.value = optimizedData
ElMessage.success(`成功读取 ${fullData.length} 行数据,优化后保留 ${Object.keys(optimizedData[0] || {}).length} 个字段`)
} else {
rawExcelData.value = fullData
ElMessage.success(`成功读取 ${fullData.length} 行数据,请手动配置列映射`)
}
}
} catch (error) {
console.error('Excel读取错误:', error)
ElMessage.error('读取Excel文件失败: ' + error.message)
}
}
// 自动匹配列名
const autoMatchColumns = () => {
const columns = excelColumns.value
// 尝试匹配文件名列
const fileNamePatterns = ['fileName', 'filename', 'name', 'file', '文件名', '文件名称']
for (const pattern of fileNamePatterns) {
const match = columns.find(col =>
col.toLowerCase().includes(pattern.toLowerCase())
)
if (match) {
columnMapping.value.fileNameColumns = [match]
break
}
}
// 尝试匹配URL列
const urlPatterns = ['url', 'link', '地址', '链接', '下载地址']
for (const pattern of urlPatterns) {
const match = columns.find(col =>
col.toLowerCase().includes(pattern.toLowerCase())
)
if (match) {
columnMapping.value.url = match
break
}
}
// 自动优化数据
if (columnMapping.value.fileNameColumns.length > 0 && columnMapping.value.url) {
optimizeDataAfterColumnMapping()
}
}
// 列映射配置后优化数据
const optimizeDataAfterColumnMapping = () => {
if (!rawExcelData.value.length) return
// 获取原始完整数据(如果存在)
const originalData = window.originalExcelData || rawExcelData.value
// 优化数据:只保留需要的字段
const optimizedData = optimizeExcelData(originalData)
rawExcelData.value = optimizedData
// 确保excelColumns保持原始的所有列名,用于列选择
if (window.originalColumns) {
excelColumns.value = window.originalColumns
}
// 重新生成预览数据
generatePreviewData()
console.log(`数据优化完成:从 ${Object.keys(originalData[0] || {}).length} 个字段优化到 ${Object.keys(optimizedData[0] || {}).length} 个字段`)
}
// 生成预览数据
const generatePreviewData = () => {
if (!columnMapping.value.fileNameColumns.length || !columnMapping.value.url) {
previewData.value = []
return
}
const preview = rawExcelData.value.slice(0, 5).map(row => {
// 组合多个文件名列
const fileNameParts = columnMapping.value.fileNameColumns.map(col => row[col] || '').filter(part => part)
let fileName = fileNameParts.join('-')
// 从URL中提取扩展名
const url = row[columnMapping.value.url] || ''
const extension = getFileExtensionFromUrl(url)
if (extension) {
fileName += extension
}
return {
fileName: fileName,
url: url
}
}).filter(item => item.fileName && item.url)
previewData.value = preview
}
// 处理列映射变化
const handleColumnMappingChange = () => {
// 生成预览数据
generatePreviewData()
// 如果列映射配置完整,则优化数据
if (columnMapping.value.fileNameColumns.length > 0 && columnMapping.value.url) {
optimizeDataAfterColumnMapping()
}
}
// 优化数据:只保留需要的字段
const optimizeExcelData = (data) => {
if (!columnMapping.value.fileNameColumns.length || !columnMapping.value.url) {
return []
}
const neededColumns = [...columnMapping.value.fileNameColumns, columnMapping.value.url]
return data.map(row => {
const optimizedRow = {}
neededColumns.forEach(col => {
optimizedRow[col] = row[col] || ''
})
return optimizedRow
})
}
// 从URL中提取文件扩展名
const getFileExtensionFromUrl = (url) => {
try {
// 解析URL
const urlObj = new URL(url)
const pathname = urlObj.pathname
// 从路径中提取文件名
const fileName = pathname.split('/').pop()
// 检查文件名是否包含扩展名
if (fileName && fileName.includes('.')) {
const extension = fileName.substring(fileName.lastIndexOf('.'))
// 验证扩展名格式(至少2个字符,只包含字母和数字)
if (extension.length >= 2 && /^\.\w+$/.test(extension)) {
return extension
}
}
// 如果没有找到有效的扩展名,返回.tmp
return '.tmp'
} catch (error) {
// URL解析失败,返回.tmp
return '.tmp'
}
}
// 选择下载路径
const selectDownloadPath = () => {
ElMessageBox.prompt('请输入自定义子文件夹名称', '设置下载子文件夹', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: customSubFolder.value,
inputPlaceholder: '例如: vue-downloader-files (留空则直接下载到默认路径)'
}).then(({ value }) => {
customSubFolder.value = value
updateDownloadPath()
ElMessage.success('下载路径已设置')
}).catch(() => {
// 用户取消
})
}
// 清空文件名前缀
const clearFileNamePrefix = () => {
customSubFolder.value = ''
updateDownloadPath()
ElMessage.success('文件名前缀已清空')
}
// 检测浏览器默认下载路径
const detectBrowserDownloadPath = () => {
// 初始化时更新文件名前缀
updateDownloadPath()
}
// 更新文件名前缀
const updateDownloadPath = () => {
// 更新store中的文件名前缀
downloadStore.setCustomSubFolder(customSubFolder.value)
}
// 组件挂载时初始化
onMounted(() => {
// 先初始化下载store
downloadStore.initialize()
// 从下载store中同步文件名前缀
customSubFolder.value = downloadStore.customSubFolder || ''
detectBrowserDownloadPath()
})
// 解析Excel文件
const parseExcelFile = async () => {
if (!canParse.value) {
ElMessage.warning('请先选择Excel文件并配置列映射')
return
}
parsing.value = true
processingProgress.value = 0
processingMessage.value = '正在处理Excel数据...'
try {
// 显示处理进度
ElMessage.info('正在处理Excel数据,请稍候...')
// 使用setTimeout来避免阻塞UI
await new Promise(resolve => setTimeout(resolve, 100))
// 获取完整数据(从原始数据或当前数据)
const allData = window.originalExcelData || rawExcelData.value
// 过滤有效数据(分批处理)
const batchSize = 1000 // 每批处理1000行
const validData = []
const totalBatches = Math.ceil(allData.length / batchSize)
for (let i = 0; i < allData.length; i += batchSize) {
const batchIndex = Math.floor(i / batchSize) + 1
const batch = allData.slice(i, i + batchSize)
// 更新进度
processingProgress.value = Math.round((batchIndex / totalBatches) * 30) // 前30%用于数据过滤
processingMessage.value = `正在过滤数据... (${batchIndex}/${totalBatches})`
const batchValidData = batch.filter(row => {
const fileNameParts = columnMapping.value.fileNameColumns.map(col => row[col] || '').filter(part => part)
let fileName = fileNameParts.join('-')
const url = row[columnMapping.value.url]
// 从URL中提取扩展名
const extension = getFileExtensionFromUrl(url)
if (extension) {
fileName += extension
}
return fileName && url
})
validData.push(...batchValidData)
// 每处理一批数据后让出控制权,避免阻塞UI
if (i + batchSize < allData.length) {
await new Promise(resolve => setTimeout(resolve, 10))
}
}
if (validData.length === 0) {
ElMessage.warning('Excel文件中没有找到有效数据')
return
}
// 保存Excel文件到本地存储
processingProgress.value = 30
processingMessage.value = '正在保存Excel文件到本地...'
const excelFileInfo = await downloadStore.saveExcelFileToLocal(
selectedFile.value,
allData,
columnMapping.value
)
// 显示确认对话框
await ElMessageBox.confirm(
`找到 ${validData.length} 个有效下载任务,是否开始添加?`,
'确认添加下载任务',
{
confirmButtonText: '确定添加',
cancelButtonText: '取消',
type: 'info'
}
)
// 临时禁用自动下载
const originalBatchDownloading = downloadStore.isBatchDownloading
downloadStore.isBatchDownloading = true
// 分批添加下载任务
const addBatchSize = 500 // 每批添加500个任务
const totalAddBatches = Math.ceil(validData.length / addBatchSize)
for (let i = 0; i < validData.length; i += addBatchSize) {
const batchIndex = Math.floor(i / addBatchSize) + 1
const batch = validData.slice(i, i + addBatchSize)
// 更新进度
processingProgress.value = 30 + Math.round((batchIndex / totalAddBatches) * 70) // 后70%用于添加任务
processingMessage.value = `正在添加下载任务... (${batchIndex}/${totalAddBatches})`
batch.forEach(row => {
const fileNameParts = columnMapping.value.fileNameColumns.map(col => row[col] || '').filter(part => part)
let fileName = fileNameParts.join('-')
const url = row[columnMapping.value.url]
// 从URL中提取扩展名
const extension = getFileExtensionFromUrl(url)
if (extension) {
fileName += extension
}
downloadStore.addDownload(fileName, url, selectedFile.value.name, excelFileInfo.id)
})
// 每批添加后让出控制权
if (i + addBatchSize < validData.length) {
await new Promise(resolve => setTimeout(resolve, 50))
}
}
// 恢复自动下载状态
downloadStore.isBatchDownloading = originalBatchDownloading
ElMessage.success(`成功添加 ${validData.length} 个下载任务`)
// 重置状态
selectedFile.value = null
excelColumns.value = []
previewData.value = []
rawExcelData.value = []
columnMapping.value = { fileNameColumns: [], url: '' }
// 清空上传组件
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
} catch (error) {
if (error.message.includes('cancel')) {
ElMessage.info('已取消添加下载任务')
} else {
console.error('解析Excel文件错误:', error)
ElMessage.error('解析Excel文件失败: ' + error.message)
}
} finally {
parsing.value = false
processingProgress.value = 0
processingMessage.value = ''
}
}
// 下载当前页所有任务
const downloadCurrentPage = async () => {
const currentPageItems = paginatedDownloads.value
if (currentPageItems.length === 0) {
ElMessage.warning('当前页没有下载任务')
return
}
// 过滤出可以开始下载的任务
const startableItems = currentPageItems.filter(item =>
['pending', 'paused', 'error'].includes(item.status)
)
if (startableItems.length === 0) {
ElMessage.warning('当前页没有可以开始下载的任务')
return
}
try {
await ElMessageBox.confirm(`确定要下载当前页的 ${startableItems.length} 个任务吗?`, '下载当前页', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
// 获取当前页所有任务的ID
const currentPageIds = startableItems.map(item => item.id)
// 使用store的批量下载方法
await downloadStore.batchStartDownloads(currentPageIds)
ElMessage.success(`已开始下载当前页的 ${startableItems.length} 个任务`)
} catch {
// 用户取消
}
}
// 暂停当前页所有任务
const pauseCurrentPage = async () => {
const currentPageItems = paginatedDownloads.value
if (currentPageItems.length === 0) {
ElMessage.warning('当前页没有下载任务')
return
}
// 过滤出可以暂停的任务(只有下载中的任务可以暂停)
const pausableItems = currentPageItems.filter(item =>
item.status === 'downloading'
)
if (pausableItems.length === 0) {
ElMessage.warning('当前页没有正在下载的任务')
return
}
try {
await ElMessageBox.confirm(`确定要暂停当前页的 ${pausableItems.length} 个任务吗?`, '暂停当前页', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 获取当前页所有任务的ID
const currentPageIds = pausableItems.map(item => item.id)
// 使用store的批量暂停方法
downloadStore.batchPauseDownloads(currentPageIds)
ElMessage.success(`已暂停当前页的 ${pausableItems.length} 个任务`)
} catch {
// 用户取消
}
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
}
// 处理状态筛选变化
const handleStatusFilterChange = () => {
currentPage.value = 1 // 重置当前页为1,以便重新加载数据
}
// 单个文件下载
const startSingleDownload = async (downloadId) => {
downloadStore.isSingleDownloading = true
try {
await downloadStore.startDownload(downloadId)
} finally {
downloadStore.isSingleDownloading = false
}
}
// 单个文件恢复下载
const resumeSingleDownload = async (downloadId) => {
downloadStore.isSingleDownloading = true
try {
await downloadStore.resumeDownload(downloadId)
} finally {
downloadStore.isSingleDownloading = false
}
}
// 优化的Excel文件读取方法
const readExcelFileOptimized = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
// 优先使用Worker处理,如果失败则使用主线程
processLargeExcelFileWithWorker(e.target.result, resolve, (workerError) => {
console.warn('Worker处理失败,使用主线程处理:', workerError)
// 备用方案:使用主线程处理
try {
const result = processExcelInMainThread(e.target.result)
resolve(result)
} catch (mainThreadError) {
reject(new Error(`主线程处理也失败: ${mainThreadError.message}`))
}
})
} catch (error) {
reject(error)
}
}
reader.onerror = () => {
reject(new Error('读取文件失败'))
}
reader.readAsArrayBuffer(file)
})
}
// 主线程处理Excel文件(备用方案)
const processExcelInMainThread = (arrayBuffer) => {
try {
const data = new Uint8Array(arrayBuffer)
const workbook = XLSX.read(data, {
type: 'array',
cellDates: true,
cellNF: false,
cellStyles: false,
cellHTML: false,
cellFormula: false,
cellText: false
})
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('Excel文件中没有找到工作表')
}
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
if (!worksheet) {
throw new Error('无法读取工作表数据')
}
// 限制最大行数,防止内存溢出
const maxRows = 50000 // 主线程限制更严格
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1')
if (range.e.r > maxRows) {
range.e.r = maxRows - 1
worksheet['!ref'] = XLSX.utils.encode_range(range)
}
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
blankrows: false
})
if (!jsonData || jsonData.length === 0) {
throw new Error('Excel文件中没有数据')
}
// 转换为对象数组
const headers = jsonData[0]
if (!headers || headers.length === 0) {
throw new Error('Excel文件中没有列标题')
}
const rows = jsonData.slice(1).map(row => {
const obj = {}
headers.forEach((header, index) => {
obj[header] = row[index] || ''
})
return obj
})
return rows
} catch (error) {
throw new Error('主线程Excel解析失败: ' + error.message)
}
}
// 使用Web Worker处理Excel文件
const processLargeExcelFileWithWorker = (arrayBuffer, resolve, reject) => {
try {
// 创建Web Worker
const worker = new Worker('/excel-worker.js')
// 设置超时时间
const timeout = setTimeout(() => {
worker.terminate()
reject(new Error('Excel解析超时,请尝试使用较小的文件'))
}, 120000) // 增加到120秒超时
worker.onmessage = (e) => {
clearTimeout(timeout)
worker.terminate()
if (e.data.type === 'success') {
resolve(e.data.data)
} else {
reject(new Error(e.data.error || 'Worker处理失败'))
}
}
worker.onerror = (error) => {
clearTimeout(timeout)
worker.terminate()
console.error('Worker error:', error)
reject(new Error('Worker处理失败: ' + (error.message || '未知错误')))
}
// 发送数据给Worker
worker.postMessage({
type: 'parseExcel',
data: arrayBuffer
})
} catch (error) {
console.error('Worker创建失败:', error)
reject(new Error('Worker创建失败: ' + error.message))
}
}
// 保留原方法作为备用
const readExcelFile = (file) => {
return readExcelFileOptimized(file)
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
pending: '等待中',
downloading: '下载中',
completed: '已完成',
error: '失败',
paused: '已暂停'
}
return statusMap[status] || status
}
// 格式化速度
const formatSpeed = (bytesPerSecond) => {
if (bytesPerSecond === 0) return '0 B/s'
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let size = bytesPerSecond
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// 格式化字节数
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// 退出登录
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
authStore.logout()
ElMessage.success('已退出登录')
router.push('/login')
} catch {
// 用户取消
}
}
// 处理用户下拉菜单命令
const handleUserCommand = (command) => {
if (command === 'settings') {
ElMessage.info('跳转到个人设置页面')
// 实际跳转逻辑
router.push('/settings')
} else if (command === 'history') {
ElMessage.info('跳转到下载历史页面')
// 实际跳转逻辑
router.push('/history')
} else if (command === 'logout') {
handleLogout()
}
}
</script>
<style scoped>
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: background-color 0.2s;
}
.user-info:hover {
background-color: #f5f5f5;
}
</style>
uo
\ No newline at end of file
<template>
<div class="history-container">
<!-- 头部 -->
<header class="header">
<div class="logo">
<el-icon><Clock /></el-icon>
下载历史
</div>
<div class="header-actions">
<el-button @click="clearAllHistory" type="danger" size="small">
清空历史
</el-button>
<el-button @click="goBack" type="primary" size="small">
返回下载器
</el-button>
</div>
</header>
<!-- 主要内容 -->
<div class="main-content">
<!-- 统计信息 -->
<el-card class="stats-card">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-number">{{ totalRecords }}</div>
<div class="stat-label">总记录数</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ completedCount }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ failedCount }}</div>
<div class="stat-label">失败</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ totalSize }}</div>
<div class="stat-label">总大小</div>
</div>
</div>
</el-card>
<!-- 历史记录列表 -->
<el-card class="history-card">
<template #header>
<div class="card-header">
<span>历史记录</span>
<div class="header-actions">
<el-select v-model="statusFilter" placeholder="状态筛选" size="small" style="width: 120px;">
<el-option label="全部" value="" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="error" />
<el-option label="暂停" value="paused" />
</el-select>
<el-select v-model="excelFileFilter" placeholder="Excel文件筛选" size="small" style="width: 150px; margin-left: 10px;">
<el-option label="全部Excel文件" value="" />
<el-option
v-for="excelFile in uniqueExcelFiles"
:key="excelFile"
:label="excelFile"
:value="excelFile"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索文件名或Excel文件"
size="small"
style="width: 200px; margin-left: 10px;"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</template>
<!-- 历史记录表格 -->
<el-table
:data="filteredHistory"
style="width: 100%"
:default-sort="{ prop: 'timestamp', order: 'descending' }"
stripe
>
<el-table-column prop="timestamp" label="时间" width="180" sortable>
<template #default="scope">
{{ formatDate(scope.row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="excelFileName" label="Excel文件" width="180">
<template #default="scope">
<div class="excel-file-info">
<el-icon><Document /></el-icon>
<span class="excel-file-name">{{ scope.row.excelFileName || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="fileName" label="文件名" min-width="200">
<template #default="scope">
<div class="file-info">
<span class="file-name">
<span v-if="scope.row.fileNamePrefix" class="file-prefix">
{{ scope.row.fileNamePrefix }}_
</span>
{{ scope.row.fileName }}
</span>
<span class="file-url">{{ scope.row.url }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="120">
<template #default="scope">
<div v-if="scope.row.status === 'completed'">
<el-progress :percentage="100" status="success" />
</div>
<div v-else-if="scope.row.status === 'error'">
<el-progress :percentage="scope.row.progress || 0" status="exception" />
</div>
<div v-else>
<el-progress :percentage="scope.row.progress || 0" />
</div>
</template>
</el-table-column>
<el-table-column prop="fileSize" label="文件大小" width="120">
<template #default="scope">
{{ formatFileSize(scope.row.fileSize) }}
</template>
</el-table-column>
<el-table-column prop="downloadedBytes" label="已下载" width="120">
<template #default="scope">
{{ formatFileSize(scope.row.downloadedBytes) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'error'"
@click="retryDownload(scope.row)"
type="primary"
size="small"
>
重试
</el-button>
<el-button
@click="viewDetails(scope.row)"
type="info"
size="small"
>
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredHistory.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailsVisible" title="下载详情" width="600px">
<div v-if="selectedRecord" class="details-content">
<el-descriptions :column="1" border>
<el-descriptions-item label="Excel文件">{{ selectedRecord.excelFileName || '-' }}</el-descriptions-item>
<el-descriptions-item label="文件名">
<span v-if="selectedRecord.fileNamePrefix" class="file-prefix">
{{ selectedRecord.fileNamePrefix }}_
</span>
{{ selectedRecord.fileName }}
</el-descriptions-item>
<el-descriptions-item label="下载地址">{{ selectedRecord.url }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(selectedRecord.status)">
{{ getStatusText(selectedRecord.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进度">{{ selectedRecord.progress || 0 }}%</el-descriptions-item>
<el-descriptions-item label="文件大小">{{ formatFileSize(selectedRecord.fileSize) }}</el-descriptions-item>
<el-descriptions-item label="已下载">{{ formatFileSize(selectedRecord.downloadedBytes) }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatDate(selectedRecord.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ formatDate(selectedRecord.endTime) }}</el-descriptions-item>
<el-descriptions-item v-if="selectedRecord.error" label="错误信息">
<span class="error-message">{{ selectedRecord.error }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, Search } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
// 状态
const historyData = ref([])
const statusFilter = ref('')
const excelFileFilter = ref('')
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const detailsVisible = ref(false)
const selectedRecord = ref(null)
// 加载历史数据
const loadHistory = () => {
historyData.value = authStore.getDownloadHistory()
}
// 获取唯一的Excel文件列表
const uniqueExcelFiles = computed(() => {
const allDownloads = historyData.value.flatMap(record =>
record.downloads.map(download => ({
...download,
timestamp: record.timestamp
}))
)
const excelFiles = allDownloads
.map(item => item.excelFileName)
.filter(file => file && file.trim())
return [...new Set(excelFiles)]
})
// 筛选历史记录
const filteredHistory = computed(() => {
let filtered = historyData.value.flatMap(record =>
record.downloads.map(download => ({
...download,
timestamp: record.timestamp
}))
)
// 状态筛选
if (statusFilter.value) {
filtered = filtered.filter(item => item.status === statusFilter.value)
}
// Excel文件筛选
if (excelFileFilter.value) {
filtered = filtered.filter(item => item.excelFileName === excelFileFilter.value)
}
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filtered = filtered.filter(item =>
item.fileName.toLowerCase().includes(keyword) ||
item.url.toLowerCase().includes(keyword) ||
(item.excelFileName && item.excelFileName.toLowerCase().includes(keyword))
)
}
return filtered
})
// 分页后的数据
const paginatedHistory = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredHistory.value.slice(start, end)
})
// 统计信息
const totalRecords = computed(() => filteredHistory.value.length)
const completedCount = computed(() => filteredHistory.value.filter(item => item.status === 'completed').length)
const failedCount = computed(() => filteredHistory.value.filter(item => item.status === 'error').length)
const totalSize = computed(() => {
const total = filteredHistory.value
.filter(item => item.status === 'completed')
.reduce((sum, item) => sum + (item.fileSize || 0), 0)
return formatFileSize(total)
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString()
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'completed': return 'success'
case 'error': return 'danger'
case 'paused': return 'warning'
default: return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'completed': return '已完成'
case 'error': return '失败'
case 'paused': return '暂停'
case 'downloading': return '下载中'
default: return '未知'
}
}
// 查看详情
const viewDetails = (record) => {
selectedRecord.value = record
detailsVisible.value = true
}
// 重试下载
const retryDownload = async (record) => {
try {
await ElMessageBox.confirm(
`确定要重新下载 "${record.fileName}" 吗?`,
'确认重试',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
// 这里可以调用下载store的方法重新下载
ElMessage.success('已添加到下载队列')
} catch {
// 用户取消
}
}
// 清空所有历史
const clearAllHistory = async () => {
try {
await ElMessageBox.confirm(
'确定要清空所有下载历史记录吗?此操作不可恢复。',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
authStore.clearDownloadHistory()
loadHistory()
ElMessage.success('历史记录已清空')
} catch {
// 用户取消
}
}
// 分页处理
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
}
const handleCurrentChange = (page) => {
currentPage.value = page
}
// 返回下载器
const goBack = () => {
router.push('/downloader')
}
// 组件挂载时初始化
onMounted(() => {
loadHistory()
})
</script>
<style scoped>
.history-container {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 10px;
}
.main-content {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.stats-card {
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.stat-number {
font-size: 32px;
font-weight: 600;
color: #409eff;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #666;
}
.history-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 600;
color: #333;
}
.file-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-weight: 500;
color: #333;
}
.file-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
.details-content {
max-height: 400px;
overflow-y: auto;
}
.error-message {
color: #f56c6c;
word-break: break-all;
}
@media (max-width: 768px) {
.main-content {
padding: 0 10px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.header-actions {
flex-direction: column;
}
}
</style>
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<el-icon class="logo-icon">
<Download/>
</el-icon>
<h1>文件下载器</h1>
<p>登录或注册以开始使用</p>
</div>
<!-- 切换按钮 -->
<div class="mode-switch">
<el-button
:type="isLoginMode ? 'primary' : 'default'"
@click="switchToLogin"
:disabled="isLoginMode"
>
登录
</el-button>
<el-button
:type="!isLoginMode ? 'primary' : 'default'"
@click="switchToRegister"
:disabled="!isLoginMode"
>
注册
</el-button>
</div>
<!-- 错误消息显示 -->
<div v-if="errorMessage" class="error-message">
<el-alert
:title="errorMessage"
type="error"
:closable="false"
show-icon
/>
</div>
<!-- 成功消息显示 -->
<div v-if="successMessage" class="success-message">
<el-alert
:title="successMessage"
type="success"
:closable="false"
show-icon
/>
</div>
<!-- 登录表单 -->
<el-form
v-if="isLoginMode"
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<!-- 注册表单 -->
<el-form
v-else
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="login-form"
@submit.prevent="handleRegister"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="用户名(至少3个字符)"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="邮箱(可选)"
prefix-icon="Message"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleRegister"
/>
</el-form-item>
<!-- 密码强度提示 -->
<div v-if="registerForm.password" class="password-strength">
<div class="strength-title">密码要求:</div>
<div class="strength-items">
<div class="strength-item" :class="{ valid: registerForm.password.length >= 6 }">
<el-icon>
<Check v-if="registerForm.password.length >= 6"/>
<Close v-else/>
</el-icon>
至少6位
</div>
<div class="strength-item" :class="{ valid: /\d/.test(registerForm.password) }">
<el-icon>
<Check v-if="/\d/.test(registerForm.password)"/>
<Close v-else/>
</el-icon>
包含数字
</div>
<div class="strength-item" :class="{ valid: /[a-zA-Z]/.test(registerForm.password) }">
<el-icon>
<Check v-if="/[a-zA-Z]/.test(registerForm.password)"/>
<Close v-else/>
</el-icon>
包含字母
</div>
<div class="strength-item" :class="{ valid: hasSpecialChar }">
<el-icon>
<Check v-if="hasSpecialChar"/>
<Close v-else/>
</el-icon>
包含特殊字符
</div>
</div>
</div>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn"
:loading="loading"
@click="handleRegister"
>
注册
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import {computed, nextTick, onMounted, reactive, ref} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {Check, Close, Download} from '@element-plus/icons-vue'
import {useAuthStore} from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
// 表单引用
const loginFormRef = ref()
const registerFormRef = ref()
// 状态
const isLoginMode = ref(true)
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
// 密码验证计算属性
const hasSpecialChar = computed(() => {
return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(registerForm.password)
})
// 登录表单
const loginForm = reactive({
username: '',
password: ''
})
// 注册表单
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
// 登录验证规则
const loginRules = {
username: [
{required: true, message: '请输入用户名', trigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', trigger: 'blur'}
]
}
// 注册验证规则
const registerRules = {
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, message: '用户名至少3个字符', trigger: 'blur'}
],
email: [
{type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', trigger: 'blur'},
{min: 6, message: '密码至少6位', trigger: 'blur'},
{
validator: (rule, value, callback) => {
if (!/\d/.test(value)) {
callback(new Error('密码必须包含数字'))
} else if (!/[a-zA-Z]/.test(value)) {
callback(new Error('密码必须包含字母'))
} else if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(value)) {
callback(new Error('密码必须包含特殊字符'))
} else {
callback()
}
},
trigger: 'blur'
}
],
confirmPassword: [
{required: true, message: '请确认密码', trigger: 'blur'},
{
validator: (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
// 切换到登录模式
const switchToLogin = () => {
isLoginMode.value = true
clearMessages()
resetForms()
}
// 切换到注册模式
const switchToRegister = () => {
isLoginMode.value = false
clearMessages()
resetForms()
}
// 清除消息
const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
}
// 重置表单
const resetForms = () => {
Object.assign(loginForm, {username: '', password: ''})
Object.assign(registerForm, {username: '', email: '', password: '', confirmPassword: ''})
if (loginFormRef.value) {
loginFormRef.value.clearValidate()
}
if (registerFormRef.value) {
registerFormRef.value.clearValidate()
}
}
// 处理登录
const handleLogin = async () => {
try {
console.log('开始登录处理...')
// 检查用户名和密码是否为空
if (!loginForm.username.trim()) {
ElMessage.warning('请输入用户名')
return
}
if (!loginForm.password.trim()) {
ElMessage.warning('请输入密码')
return
}
// 等待 DOM 更新,确保表单引用可用
await nextTick()
// 检查表单引用是否存在,如果不存在则跳过验证
if (loginFormRef.value) {
try {
await loginFormRef.value.validate()
} catch (validationError) {
console.log('表单验证失败:', validationError)
ElMessage.error('请检查输入信息是否正确')
return
}
} else {
console.warn('登录表单引用不存在,跳过表单验证')
}
loading.value = true
clearMessages()
console.log('登录表单验证通过,用户名:', loginForm.username)
await authStore.login(loginForm.username, loginForm.password)
console.log('登录成功,跳转到下载器页面')
ElMessage.success('登录成功!')
router.push('/downloader')
} catch (error) {
console.error('登录失败:', error)
const errorMsg = error.message || '登录失败,请检查用户名和密码'
console.log('显示错误消息:', errorMsg)
// 使用多种方式显示错误消息
try {
// 方式1: ElMessage
ElMessage.error(errorMsg)
console.log('ElMessage 已调用')
} catch (msgError) {
console.error('ElMessage 失败:', msgError)
}
// 方式2: 直接设置错误消息到页面
errorMessage.value = errorMsg
console.log('错误消息已设置到页面:', errorMessage.value)
// 方式3: 尝试显示对话框
setTimeout(async () => {
try {
await ElMessageBox.alert(errorMsg, '登录失败', {
confirmButtonText: '确定',
type: 'error'
})
console.log('对话框显示成功')
} catch (dialogError) {
console.error('显示对话框失败:', dialogError)
}
}, 100)
} finally {
loading.value = false
}
}
// 处理注册
const handleRegister = async () => {
try {
// 检查用户名和密码是否为空
if (!registerForm.username.trim()) {
ElMessage.warning('请输入用户名')
return
}
if (!registerForm.password.trim()) {
ElMessage.warning('请输入密码')
return
}
if (!registerForm.confirmPassword.trim()) {
ElMessage.warning('请确认密码')
return
}
// 等待 DOM 更新,确保表单引用可用
await nextTick()
// 检查表单引用是否存在,如果不存在则跳过验证
if (registerFormRef.value) {
try {
await registerFormRef.value.validate()
} catch (validationError) {
console.log('注册表单验证失败:', validationError)
ElMessage.error('请检查输入信息是否正确')
return
}
} else {
console.warn('注册表单引用不存在,跳过表单验证')
}
loading.value = true
clearMessages()
// 检查用户名是否已存在
if (authStore.isUsernameExists(registerForm.username)) {
ElMessage.error('用户名已存在')
errorMessage.value = '用户名已存在'
return
}
// 注册用户
await authStore.register(registerForm.username, registerForm.password, registerForm.email)
ElMessage.success('注册成功!请登录')
successMessage.value = '注册成功!请登录'
// 自动切换到登录模式
setTimeout(() => {
switchToLogin()
loginForm.username = registerForm.username
}, 2000)
} catch (error) {
console.error('注册失败:', error)
const errorMsg = error.message || '注册失败'
console.log('显示注册错误对话框:', errorMsg)
// 直接使用 ElMessage 显示错误,确保能显示
ElMessage.error(errorMsg)
// 尝试显示对话框,但不阻塞
setTimeout(async () => {
try {
await ElMessageBox.alert(errorMsg, '注册失败', {
confirmButtonText: '确定',
type: 'error'
})
} catch (dialogError) {
console.error('显示对话框失败:', dialogError)
}
}, 100)
errorMessage.value = errorMsg
} finally {
loading.value = false
}
}
// 组件挂载时初始化
onMounted(() => {
authStore.initialize()
})
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-box {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.logo-icon {
font-size: 48px;
color: #409eff;
margin-bottom: 16px;
}
.login-header h1 {
margin: 0 0 8px 0;
color: #333;
font-size: 28px;
font-weight: 600;
}
.login-header p {
margin: 0;
color: #666;
font-size: 14px;
}
.mode-switch {
display: flex;
gap: 10px;
margin-bottom: 30px;
}
.mode-switch .el-button {
flex: 1;
}
.login-form {
margin-bottom: 20px;
}
.submit-btn {
width: 100%;
height: 44px;
font-size: 16px;
}
.error-message {
margin-bottom: 20px;
}
.success-message {
margin-bottom: 20px;
}
.password-strength {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.strength-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 10px;
}
.strength-items {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.strength-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
.strength-item.valid {
color: #67c23a;
}
.strength-item .el-icon {
font-size: 14px;
}
.strength-item.valid .el-icon {
color: #67c23a;
}
.strength-item:not(.valid) .el-icon {
color: #f56c6c;
}
@media (max-width: 480px) {
.login-box {
padding: 30px 20px;
}
.strength-items {
grid-template-columns: 1fr;
}
}
</style>
<template>
<div class="settings-container">
<!-- 头部 -->
<header class="header">
<div class="logo">
<el-icon><Setting /></el-icon>
个人设置
</div>
<el-button @click="goBack" type="primary" size="small">
返回下载器
</el-button>
</header>
<!-- 主要内容 -->
<div class="main-content">
<el-card class="settings-card">
<template #header>
<div class="card-header">
<span>用户信息</span>
</div>
</template>
<el-form :model="userInfo" label-width="120px">
<el-form-item label="用户名">
<el-input v-model="userInfo.username" disabled />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="userInfo.email" disabled />
</el-form-item>
<el-form-item label="注册时间">
<el-input v-model="userInfo.createdAt" disabled />
</el-form-item>
<el-form-item label="最后登录">
<el-input v-model="userInfo.lastLoginAt" disabled />
</el-form-item>
</el-form>
</el-card>
<el-card class="settings-card">
<template #header>
<div class="card-header">
<span>下载设置</span>
</div>
</template>
<el-form :model="downloadSettings" label-width="120px">
<el-form-item label="最大并发数">
<el-input-number
v-model="downloadSettings.maxConcurrentDownloads"
:min="1"
:max="10"
@change="saveSettings"
/>
<span class="form-tip">同时下载的最大文件数量</span>
</el-form-item>
<el-form-item label="文件名前缀">
<el-input
v-model="downloadSettings.fileNamePrefix"
placeholder="例如: vue-downloader"
@input="saveSettings"
/>
<span class="form-tip">为所有下载文件添加前缀</span>
</el-form-item>
<el-form-item label="下载通知">
<el-switch
v-model="downloadSettings.enableNotifications"
@change="saveSettings"
/>
<span class="form-tip">下载完成后显示通知</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="settings-card">
<template #header>
<div class="card-header">
<span>数据管理</span>
</div>
</template>
<div class="data-management">
<div class="data-item">
<div class="data-info">
<h4>下载历史</h4>
<p>清除所有下载历史记录</p>
</div>
<el-button @click="clearHistory" type="danger" size="small">
清除历史
</el-button>
</div>
<div class="data-item">
<div class="data-info">
<h4>用户数据</h4>
<p>删除当前用户的所有数据</p>
</div>
<el-button @click="deleteUserData" type="danger" size="small">
删除数据
</el-button>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Setting } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
// 用户信息
const userInfo = reactive({
username: '',
email: '',
createdAt: '',
lastLoginAt: ''
})
// 下载设置
const downloadSettings = reactive({
maxConcurrentDownloads: 3,
fileNamePrefix: '',
enableNotifications: true
})
// 初始化数据
const initializeData = () => {
const user = authStore.user
if (user) {
userInfo.username = user.username
userInfo.email = user.email || '未设置'
userInfo.createdAt = new Date(user.createdAt).toLocaleString()
userInfo.lastLoginAt = user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '从未登录'
// 加载用户设置
const settings = authStore.getUserSettings()
Object.assign(downloadSettings, settings)
}
}
// 保存设置
const saveSettings = () => {
try {
authStore.updateUserSettings(downloadSettings)
ElMessage.success('设置已保存')
} catch (error) {
ElMessage.error('保存设置失败')
}
}
// 清除历史记录
const clearHistory = async () => {
try {
await ElMessageBox.confirm(
'确定要清除所有下载历史记录吗?此操作不可恢复。',
'确认清除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
authStore.clearDownloadHistory()
ElMessage.success('历史记录已清除')
} catch {
// 用户取消
}
}
// 删除用户数据
const deleteUserData = async () => {
try {
await ElMessageBox.confirm(
'确定要删除当前用户的所有数据吗?包括设置、历史记录等,此操作不可恢复。',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 清除历史记录
authStore.clearDownloadHistory()
// 重置用户设置
const defaultSettings = {
maxConcurrentDownloads: 3,
fileNamePrefix: '',
enableNotifications: true
}
authStore.updateUserSettings(defaultSettings)
Object.assign(downloadSettings, defaultSettings)
ElMessage.success('用户数据已删除')
} catch {
// 用户取消
}
}
// 返回下载器
const goBack = () => {
router.push('/downloader')
}
// 组件挂载时初始化
onMounted(() => {
initializeData()
})
</script>
<style scoped>
.settings-container {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 600;
color: #333;
}
.main-content {
max-width: 800px;
margin: 20px auto;
padding: 0 20px;
}
.settings-card {
margin-bottom: 20px;
}
.card-header {
font-size: 16px;
font-weight: 600;
color: #333;
}
.form-tip {
margin-left: 10px;
color: #666;
font-size: 12px;
}
.data-management {
display: flex;
flex-direction: column;
gap: 20px;
}
.data-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.data-info h4 {
margin: 0 0 5px 0;
color: #333;
font-size: 14px;
}
.data-info p {
margin: 0;
color: #666;
font-size: 12px;
}
@media (max-width: 768px) {
.main-content {
padding: 0 10px;
}
.data-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>
\ No newline at end of file
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment