feat: overhaul application to APM 应用商店 with enhanced download management

- Removed CHANGELOG.md and electron-vite-vue.gif files.
- Updated LICENSE to reflect new copyright holder.
- Transformed README.md to reflect new project identity and features.
- Introduced DownloadQueue and DownloadDetail components for managing downloads.
- Implemented download simulation and management logic in App.vue.
- Added URL scheme handling in Electron main process.
- Integrated electron-app-universal-protocol-client for protocol handling.
- Updated package.json to include new dependencies.
This commit is contained in:
Elysia
2026-01-17 23:17:14 +08:00
parent 4dd3bd321c
commit 9b17c57c5c
10 changed files with 1296 additions and 97 deletions

View File

@@ -1,33 +0,0 @@
## 2022-10-03
[v2.1.0](https://github.com/electron-vite/electron-vite-vue/pull/267)
- `vite-electron-plugin` is Fast, and WYSIWYG. 🌱
- last-commit: db2e830 v2.1.0: use `vite-electron-plugin` instead `vite-plugin-electron`
## 2022-06-04
[v2.0.0](https://github.com/electron-vite/electron-vite-vue/pull/156)
- 🖖 Based on the `vue-ts` template created by `npm create vite`, integrate `vite-plugin-electron`
- ⚡️ More simplify, is in line with Vite project structure
- last-commit: a15028a (HEAD -> main) feat: hoist `process.env`
## 2022-01-30
[v1.0.0](https://github.com/electron-vite/electron-vite-vue/releases/tag/v1.0.0)
- ⚡️ Main、Renderer、preload, all built with vite
## 2022-01-27
- Refactor the scripts part.
- Remove `configs` directory.
## 2021-11-11
- Refactor the project. Use vite.config.ts build `Main-process`, `Preload-script` and `Renderer-process` alternative rollup.
- Scenic `Vue>=3.2.13`, `@vue/compiler-sfc` is no longer necessary.
- If you prefer Rollup, Use rollup branch.
```bash
Error: @vitejs/plugin-vue requires vue (>=3.2.13) or @vue/compiler-sfc to be present in the dependency tree.
```

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 草鞋没号 Copyright (c) 2026 elysia-best
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
Original work Copyright (c) 2023 草鞋没号

214
README.md
View File

@@ -1,79 +1,177 @@
# electron-vite-vue # APM 应用商店
🥳 Really simple `Electron` + `Vue` + `Vite` boilerplate. <div align="center">
<!-- [![awesome-vite](https://awesome.re/mentioned-badge.svg)](https://github.com/vitejs/awesome-vite) --> ![APM Logo](public/icon.png)
<!-- [![Netlify Status](https://api.netlify.com/api/v1/badges/ae3863e3-1aec-4eb1-8f9f-1890af56929d/deploy-status)](https://app.netlify.com/sites/electron-vite/deploys) -->
<!-- [![GitHub license](https://img.shields.io/github/license/caoxiemeihao/electron-vite-vue)](https://github.com/electron-vite/electron-vite-vue/blob/main/LICENSE) -->
<!-- [![GitHub stars](https://img.shields.io/github/stars/caoxiemeihao/electron-vite-vue?color=fa6470)](https://github.com/electron-vite/electron-vite-vue) -->
<!-- [![GitHub forks](https://img.shields.io/github/forks/caoxiemeihao/electron-vite-vue)](https://github.com/electron-vite/electron-vite-vue) -->
[![GitHub Build](https://github.com/electron-vite/electron-vite-vue/actions/workflows/build.yml/badge.svg)](https://github.com/electron-vite/electron-vite-vue/actions/workflows/build.yml)
[![GitHub Discord](https://img.shields.io/badge/chat-discord-blue?logo=discord)](https://discord.gg/sRqjYpEAUK)
## Features **星火 APM 琥珀软件包管理器 - 桌面应用商店**
📦 Out of the box 基于 Electron + Vue 3 + Vite 构建的现代化应用商店客户端
🎯 Based on the official [template-vue-ts](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-vue-ts), less invasive
🌱 Extensible, really simple directory structure
💪 Support using Node.js API in Electron-Renderer
🔩 Support C/C++ native addons
🖥 It's easy to implement multiple windows
## Quick Setup [![GitHub Build](https://img.shields.io/badge/build-passing-brightgreen)](https://github.com/elysia-best/apm-app-store)
[![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Linux-orange)](https://github.com/elysia-best/apm-app-store)
```sh </div>
# clone the project
git clone https://github.com/electron-vite/electron-vite-vue.git
# enter the project directory ---
cd electron-vite-vue
# install dependency ## 📦 关于 APM
**APM (AmberPM)** 是基于 `fuse-overlayfs` + `dpkg` + `AmberCE` 的容器化兼容层,为多发行版提供轻量级的应用运行方案。
### 核心特性
**多发行版支持** - 在 Arch Linux、Fedora、银河麒麟、统信 UOS 等主流发行版上运行星火商店应用
**轻量兼容层** - 利用 overlayfs 技术实现极速启动,无需完整容器
🎮 **NVIDIA 加速** - 自动获取主机 GPU 驱动,支持硬件加速
🔧 **开发者友好** - 兼容 dpkg提供完整的打包工具链
🌐 **现代化界面** - 基于 Electron + Vue 3 的流畅用户体验
---
## 🚀 快速开始
### 安装应用商店
**现在不要安装,没开发完**
TODO
### 使用命令行工具
TODO
---
## 💻 开发指南
### 环境要求
- Node.js >= 20
### 本地开发
```bash
# 克隆项目
git clone https://github.com/elysia-best/apm-app-store.git
# 进入项目目录
cd apm-app-store
# 安装依赖
npm install npm install
# develop # 启动开发服务器
npm run dev npm run dev
``` ```
## Debug ### 构建打包
![electron-vite-react-debug.gif](https://github.com/electron-vite/electron-vite-react/blob/main/electron-vite-react-debug.gif?raw=true) ```bash
# 构建生产版本
## Directory npm run build
```diff
+ ├─┬ electron
+ │ ├─┬ main
+ │ │ └── index.ts entry of Electron-Main
+ │ └─┬ preload
+ │ └── index.ts entry of Preload-Scripts
├─┬ src
│ └── main.ts entry of Electron-Renderer
├── index.html
├── package.json
└── vite.config.ts
``` ```
<!-- ---
## Be aware
🚨 By default, this template integrates Node.js in the Renderer process. If you don't need it, you just remove the option below. [Because it will modify the default config of Vite](https://github.com/electron-vite/vite-plugin-electron-renderer#config-presets-opinionated). ## 📂 项目结构
```diff
# vite.config.ts
export default {
plugins: [
- // Use Node.js API in the Renderer-process
- renderer({
- nodeIntegration: true,
- }),
],
}
``` ```
--> apm-app-store/
├── electron/ # Electron 主进程
│ ├── main/
│ │ ├── index.ts # 主进程入口
│ │ └── handle-url-scheme.ts # URL 协议处理
│ └── preload/
│ └── index.ts # 预加载脚本
├── src/ # Vue 渲染进程
│ ├── components/ # Vue 组件
│ │ ├── AppCard.vue # 应用卡片
│ │ ├── AppGrid.vue # 应用网格
│ │ ├── AppHeader.vue # 应用头部
│ │ ├── AppSidebar.vue # 侧边栏
│ │ ├── AppDetailModal.vue # 详情弹窗
│ │ ├── DownloadQueue.vue # 下载队列
│ │ ├── DownloadDetail.vue # 下载详情
│ │ └── ScreenPreview.vue # 截图预览
│ ├── global/ # 全局配置
│ │ └── StoreConfig.ts # 商店配置
│ ├── assets/ # 静态资源
│ ├── App.vue # 根组件
│ └── main.ts # 渲染进程入口
├── public/ # 公共资源
├── dist-electron/ # Electron 构建输出
├── release/ # 打包发布文件
└── package.json
```
## FAQ ---
- [C/C++ addons, Node.js modules - Pre-Bundling](https://github.com/electron-vite/vite-plugin-electron-renderer#dependency-pre-bundling) ## 🎨 主要功能
- [dependencies vs devDependencies](https://github.com/electron-vite/vite-plugin-electron-renderer#dependencies-vs-devdependencies)
### 应用浏览与搜索
- 分类浏览应用
- 实时搜索过滤
- 应用详情查看
- 截图预览
### 下载管理
- 下载队列管理
- 实时进度显示
- 暂停/继续/取消
- 下载日志查看
### 主题切换
- 明暗主题自动切换
- 本地偏好保存
### 协议支持
- `apmstore://` 自定义协议
- 一键安装/启动应用
---
## 🔗 相关链接
- 📖 [APM 项目文档](https://gitee.com/spark-store-project/AmberPM)
- 💾 [Gitee 仓库](https://gitee.com/spark-store-project/apm-app-store)
- 🐛 [问题反馈](https://gitee.com/spark-store-project/apm-app-store/issues)
- 📦 [打包示例](https://gitee.com/spark-store-project/AmberPM/tree/main/Packaging-demo)
---
## 🛠️ 技术栈
- **Electron** - 跨平台桌面应用框架
- **Vue 3** - 渐进式 JavaScript 框架
- **Vite** - 下一代前端构建工具
- **TypeScript** - JavaScript 的超集
- **Axios** - HTTP 客户端
---
## 📄 开源协议
本项目采用 [MIT](LICENSE) 协议开源。
---
## 🙏 致谢
- [Electron](https://www.electronjs.org/)
- [Vue.js](https://vuejs.org/)
- [Vite](https://vitejs.dev/)
- [星火应用商店](https://www.spark-app.store/)
---
<div align="center">
**© 2026 APM / AmberPM | The Spark Project**
Made with ❤️ by the Spark Store Team
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

View File

@@ -0,0 +1,5 @@
import { dialog } from 'electron'
export async function handleUrlScheme(requestUrl: string) {
console.log('Handling URL scheme request:', requestUrl);
}

View File

@@ -1,8 +1,15 @@
import { app, BrowserWindow, shell, ipcMain } from 'electron' import { app, BrowserWindow, shell, ipcMain, dialog } from 'electron'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import os from 'node:os' import os from 'node:os'
import { electronAppUniversalProtocolClient } from 'electron-app-universal-protocol-client'
import { handleUrlScheme } from './handle-url-scheme.js'
if (!app.requestSingleInstanceLock()) {
app.exit(0);
}
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -76,6 +83,17 @@ async function createWindow() {
return { action: 'deny' } return { action: 'deny' }
}) })
// win.webContents.on('will-navigate', (event, url) => { }) #344 // win.webContents.on('will-navigate', (event, url) => { }) #344
// Initialize universal protocol client
electronAppUniversalProtocolClient.on(
'request',
handleUrlScheme
);
await electronAppUniversalProtocolClient.initialize({
protocol: 'apmstore',
mode: 'development', // Make sure to use 'production' when script is executed in bundled app
});
} }
app.whenReady().then(createWindow) app.whenReady().then(createWindow)

View File

@@ -38,6 +38,7 @@
"vue-tsc": "^2.0.6" "vue-tsc": "^2.0.6"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2" "axios": "^1.13.2",
"electron-app-universal-protocol-client": "^2.1.1"
} }
} }

View File

@@ -16,6 +16,14 @@
<ScreenPreview :show="showPreview" :screenshots="screenshots" :current-screen-index="currentScreenIndex" <ScreenPreview :show="showPreview" :screenshots="screenshots" :current-screen-index="currentScreenIndex"
@close="closeScreenPreview" @prev="prevScreen" @next="nextScreen" /> @close="closeScreenPreview" @prev="prevScreen" @next="nextScreen" />
<DownloadQueue :downloads="downloads" @pause="pauseDownload" @resume="resumeDownload"
@cancel="cancelDownload" @retry="retryDownload" @clear-completed="clearCompletedDownloads"
@show-detail="showDownloadDetailModalFunc" />
<DownloadDetail :show="showDownloadDetailModal" :download="currentDownload" @close="closeDownloadDetail"
@pause="pauseDownload" @resume="resumeDownload" @cancel="cancelDownload" @retry="retryDownload"
@open-app="openDownloadedApp" />
</div> </div>
</template> </template>
@@ -26,6 +34,8 @@ import AppHeader from './components/AppHeader.vue';
import AppGrid from './components/AppGrid.vue'; import AppGrid from './components/AppGrid.vue';
import AppDetailModal from './components/AppDetailModal.vue'; import AppDetailModal from './components/AppDetailModal.vue';
import ScreenPreview from './components/ScreenPreview.vue'; import ScreenPreview from './components/ScreenPreview.vue';
import DownloadQueue from './components/DownloadQueue.vue';
import DownloadDetail from './components/DownloadDetail.vue';
import { APM_STORE_ARCHITECTURE, APM_STORE_BASE_URL } from './global/StoreConfig'; import { APM_STORE_ARCHITECTURE, APM_STORE_BASE_URL } from './global/StoreConfig';
import axios from 'axios'; import axios from 'axios';
@@ -47,7 +57,10 @@ const currentApp = ref(null);
const currentScreenIndex = ref(0); const currentScreenIndex = ref(0);
const screenshots = ref([]); const screenshots = ref([]);
const loading = ref(true); const loading = ref(true);
const observer = ref(null); const downloads = ref([]);
const showDownloadDetailModal = ref(false);
const currentDownload = ref(null);
let downloadIdCounter = 0;
// 计算属性 // 计算属性
const filteredApps = computed(() => { const filteredApps = computed(() => {
@@ -160,6 +173,31 @@ const nextScreen = () => {
const handleInstall = () => { const handleInstall = () => {
if (!currentApp.value?.Pkgname) return; if (!currentApp.value?.Pkgname) return;
// 创建下载任务
const download = {
id: ++downloadIdCounter,
name: currentApp.value.Name,
pkgname: currentApp.value.Pkgname,
version: currentApp.value.Version,
icon: `${APM_STORE_BASE_URL}/${APM_STORE_ARCHITECTURE}/${currentApp.value._category}/${currentApp.value.Pkgname}/icon.png`,
status: 'downloading',
progress: 0,
downloadedSize: 0,
totalSize: 0,
speed: 0,
timeRemaining: 0,
startTime: Date.now(),
logs: [
{ time: Date.now(), message: '开始下载...' }
],
source: 'APM Store'
};
downloads.value.push(download);
// 模拟下载进度(实际应该调用真实的下载 API
simulateDownload(download);
const encodedPkg = encodeURIComponent(currentApp.value.Pkgname); const encodedPkg = encodeURIComponent(currentApp.value.Pkgname);
openApmStoreUrl(`apmstore://install?pkg=${encodedPkg}`, { openApmStoreUrl(`apmstore://install?pkg=${encodedPkg}`, {
fallbackText: `/usr/bin/apm-installer --install ${currentApp.value.Pkgname}` fallbackText: `/usr/bin/apm-installer --install ${currentApp.value.Pkgname}`
@@ -242,6 +280,131 @@ const escapeHtml = (s) => {
})[c]); })[c]);
}; };
// 下载管理方法
const simulateDownload = (download) => {
// 模拟下载进度(实际应该调用真实的下载 API
const totalSize = Math.random() * 100 + 50; // MB
download.totalSize = totalSize * 1024 * 1024;
const interval = setInterval(() => {
const downloadObj = downloads.value.find(d => d.id === download.id);
if (!downloadObj || downloadObj.status !== 'downloading') {
clearInterval(interval);
return;
}
// 更新进度
downloadObj.progress = Math.min(downloadObj.progress + Math.random() * 10, 100);
downloadObj.downloadedSize = (downloadObj.progress / 100) * downloadObj.totalSize;
downloadObj.speed = (Math.random() * 5 + 1) * 1024 * 1024; // 1-6 MB/s
const remainingBytes = downloadObj.totalSize - downloadObj.downloadedSize;
downloadObj.timeRemaining = Math.ceil(remainingBytes / downloadObj.speed);
// 添加日志
if (downloadObj.progress % 20 === 0 && downloadObj.progress > 0 && downloadObj.progress < 100) {
downloadObj.logs.push({
time: Date.now(),
message: `下载进度: ${downloadObj.progress.toFixed(0)}%`
});
}
// 下载完成
if (downloadObj.progress >= 100) {
clearInterval(interval);
downloadObj.status = 'installing';
downloadObj.logs.push({
time: Date.now(),
message: '下载完成,开始安装...'
});
// 模拟安装
setTimeout(() => {
downloadObj.status = 'completed';
downloadObj.endTime = Date.now();
downloadObj.logs.push({
time: Date.now(),
message: '安装完成!'
});
}, 2000);
}
}, 500);
};
const pauseDownload = (id) => {
const download = downloads.value.find(d => d.id === id);
if (download && download.status === 'downloading') {
download.status = 'paused';
download.logs.push({
time: Date.now(),
message: '下载已暂停'
});
}
};
const resumeDownload = (id) => {
const download = downloads.value.find(d => d.id === id);
if (download && download.status === 'paused') {
download.status = 'downloading';
download.logs.push({
time: Date.now(),
message: '继续下载...'
});
simulateDownload(download);
}
};
const cancelDownload = (id) => {
const index = downloads.value.findIndex(d => d.id === id);
if (index !== -1) {
const download = downloads.value[index];
download.status = 'cancelled';
download.logs.push({
time: Date.now(),
message: '下载已取消'
});
// 延迟删除,让用户看到取消状态
setTimeout(() => {
downloads.value.splice(index, 1);
}, 1000);
}
};
const retryDownload = (id) => {
const download = downloads.value.find(d => d.id === id);
if (download && download.status === 'failed') {
download.status = 'downloading';
download.progress = 0;
download.downloadedSize = 0;
download.logs.push({
time: Date.now(),
message: '重新开始下载...'
});
simulateDownload(download);
}
};
const clearCompletedDownloads = () => {
downloads.value = downloads.value.filter(d => d.status !== 'completed');
};
const showDownloadDetailModalFunc = (download) => {
currentDownload.value = download;
showDownloadDetailModal.value = true;
};
const closeDownloadDetail = () => {
showDownloadDetailModal.value = false;
currentDownload.value = null;
};
const openDownloadedApp = (download) => {
const encodedPkg = encodeURIComponent(download.pkgname);
openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
fallbackText: `打开应用: ${download.pkgname}`
});
};
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const response = await axiosInstance.get(`/${APM_STORE_ARCHITECTURE}/categories.json`); const response = await axiosInstance.get(`/${APM_STORE_ARCHITECTURE}/categories.json`);
@@ -314,8 +477,6 @@ watch(isDarkTheme, (newVal) => {
} }
}); });
// 暴露给模板
const appsList = computed(() => filteredApps.value);
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,591 @@
<template>
<transition name="modal-fade">
<div v-if="show" class="modal-overlay" @click="handleOverlayClick">
<div class="modal-content download-detail" @click.stop>
<!-- 头部 -->
<div class="detail-header">
<button class="close-btn" @click="close">
<i class="fas fa-times"></i>
</button>
<h2>下载详情</h2>
</div>
<!-- 内容 -->
<div class="detail-body" v-if="download">
<!-- 应用信息 -->
<div class="app-section">
<div class="app-icon-large">
<img :src="download.icon" :alt="download.name" />
</div>
<div class="app-info-detail">
<h3 class="app-name">{{ download.name }}</h3>
<div class="app-meta">
<span>{{ download.pkgname }}</span>
<span class="separator">·</span>
<span>{{ download.version }}</span>
</div>
</div>
</div>
<!-- 下载状态 -->
<div class="status-section">
<div class="status-header">
<span class="status-label">状态</span>
<span class="status-value" :class="download.status">
{{ getStatusText(download.status) }}
</span>
</div>
<!-- 进度条 -->
<div v-if="download.status === 'downloading'" class="progress-section">
<div class="progress-bar-large">
<div class="progress-fill" :style="{ width: download.progress + '%' }"></div>
</div>
<div class="progress-info">
<span>{{ download.progress }}%</span>
<span v-if="download.downloadedSize && download.totalSize">
{{ formatSize(download.downloadedSize) }} / {{ formatSize(download.totalSize) }}
</span>
</div>
<div v-if="download.speed" class="download-speed">
<i class="fas fa-tachometer-alt"></i>
<span>{{ formatSpeed(download.speed) }}</span>
<span v-if="download.timeRemaining" class="time-remaining">
剩余 {{ formatTime(download.timeRemaining) }}
</span>
</div>
</div>
</div>
<!-- 下载信息 -->
<div class="info-section">
<div class="info-item">
<span class="info-label">下载源</span>
<span class="info-value">{{ download.source || 'APM Store' }}</span>
</div>
<div class="info-item" v-if="download.startTime">
<span class="info-label">开始时间</span>
<span class="info-value">{{ formatDate(download.startTime) }}</span>
</div>
<div class="info-item" v-if="download.endTime">
<span class="info-label">完成时间</span>
<span class="info-value">{{ formatDate(download.endTime) }}</span>
</div>
<div class="info-item" v-if="download.error">
<span class="info-label">错误信息</span>
<span class="info-value error">{{ download.error }}</span>
</div>
</div>
<!-- 日志 -->
<div v-if="download.logs && download.logs.length > 0" class="logs-section">
<div class="logs-header">
<span>下载日志</span>
<button @click="copyLogs" class="copy-logs-btn">
<i class="fas fa-copy"></i>
复制日志
</button>
</div>
<div class="logs-content">
<div v-for="(log, index) in download.logs" :key="index" class="log-entry">
<span class="log-time">{{ formatLogTime(log.time) }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="actions-section">
<button
v-if="download.status === 'downloading'"
@click="pause"
class="action-btn secondary"
>
<i class="fas fa-pause"></i>
暂停下载
</button>
<button
v-else-if="download.status === 'paused'"
@click="resume"
class="action-btn primary"
>
<i class="fas fa-play"></i>
继续下载
</button>
<button
v-if="download.status === 'failed'"
@click="retry"
class="action-btn primary"
>
<i class="fas fa-redo"></i>
重试下载
</button>
<button
v-if="download.status !== 'completed'"
@click="cancel"
class="action-btn danger"
>
<i class="fas fa-times"></i>
取消下载
</button>
<button
v-if="download.status === 'completed'"
@click="openApp"
class="action-btn primary"
>
<i class="fas fa-external-link-alt"></i>
打开应用
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false
},
download: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'pause', 'resume', 'cancel', 'retry', 'open-app']);
const close = () => {
emit('close');
};
const handleOverlayClick = () => {
close();
};
const pause = () => {
emit('pause', props.download.id);
};
const resume = () => {
emit('resume', props.download.id);
};
const cancel = () => {
emit('cancel', props.download.id);
};
const retry = () => {
emit('retry', props.download.id);
};
const openApp = () => {
emit('open-app', props.download);
};
const getStatusText = (status) => {
const statusMap = {
'pending': '等待中',
'downloading': '下载中',
'installing': '安装中',
'completed': '已完成',
'failed': '失败',
'paused': '已暂停',
'cancelled': '已取消'
};
return statusMap[status] || status;
};
const formatSize = (bytes) => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
};
const formatSpeed = (bytesPerSecond) => {
return formatSize(bytesPerSecond) + '/s';
};
const formatTime = (seconds) => {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟`;
return `${Math.floor(seconds / 3600)}小时${Math.floor((seconds % 3600) / 60)}分钟`;
};
const formatDate = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
};
const formatLogTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('zh-CN');
};
const copyLogs = () => {
if (!props.download?.logs) return;
const logsText = props.download.logs
.map(log => `[${formatLogTime(log.time)}] ${log.message}`)
.join('\n');
navigator.clipboard?.writeText(logsText).then(() => {
alert('日志已复制到剪贴板');
}).catch(() => {
prompt('请手动复制日志:', logsText);
});
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
.modal-content {
background: var(--card);
border-radius: 16px;
max-width: 600px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.detail-header {
padding: 24px;
border-bottom: 1px solid var(--border);
position: relative;
}
.detail-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.close-btn {
position: absolute;
top: 24px;
right: 24px;
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--muted);
padding: 4px;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--text);
}
.detail-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.app-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.app-icon-large {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.app-icon-large img {
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.app-info-detail {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.app-name {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.app-meta {
color: var(--muted);
font-size: 14px;
}
.separator {
margin: 0 8px;
}
.status-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-label {
font-weight: 600;
color: var(--muted);
}
.status-value {
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
}
.status-value.downloading {
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
}
.status-value.completed {
background: rgba(52, 199, 89, 0.1);
color: #34C759;
}
.status-value.failed {
background: rgba(255, 59, 48, 0.1);
color: #FF3B30;
}
.status-value.paused {
background: rgba(255, 149, 0, 0.1);
color: #FF9500;
}
.progress-section {
margin-top: 16px;
}
.progress-bar-large {
width: 100%;
height: 8px;
background: var(--glass);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 14px;
color: var(--muted);
margin-bottom: 8px;
}
.download-speed {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text);
}
.download-speed i {
color: var(--primary);
}
.time-remaining {
margin-left: auto;
color: var(--muted);
}
.info-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
}
.info-label {
color: var(--muted);
font-weight: 500;
}
.info-value {
text-align: right;
max-width: 60%;
word-break: break-word;
}
.info-value.error {
color: #FF3B30;
}
.logs-section {
margin-bottom: 24px;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
}
.copy-logs-btn {
background: transparent;
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.copy-logs-btn:hover {
background: var(--glass);
border-color: var(--primary);
color: var(--primary);
}
.logs-content {
background: var(--glass);
border-radius: 8px;
padding: 12px;
max-height: 200px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.log-entry {
display: flex;
gap: 12px;
padding: 4px 0;
color: var(--text);
}
.log-time {
color: var(--muted);
flex-shrink: 0;
}
.log-message {
flex: 1;
}
.actions-section {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 140px;
padding: 12px 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.action-btn.primary {
background: var(--primary);
color: white;
}
.action-btn.primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.action-btn.secondary {
background: var(--glass);
color: var(--text);
}
.action-btn.secondary:hover {
background: var(--border);
}
.action-btn.danger {
background: rgba(255, 59, 48, 0.1);
color: #FF3B30;
}
.action-btn.danger:hover {
background: rgba(255, 59, 48, 0.2);
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-active .modal-content,
.modal-fade-leave-active .modal-content {
transition: transform 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-content,
.modal-fade-leave-to .modal-content {
transform: scale(0.9);
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<div class="download-queue" :class="{ 'expanded': isExpanded }">
<!-- 队列头部 -->
<div class="queue-header" @click="toggleExpand">
<div class="queue-info">
<i class="fas fa-download"></i>
<span class="queue-title">下载队列</span>
<span class="queue-count" v-if="downloads.length > 0">({{ activeDownloads }}/{{ downloads.length }})</span>
</div>
<div class="queue-actions">
<button v-if="downloads.length > 0" @click.stop="clearCompleted" class="clear-btn" title="清除已完成">
<i class="fas fa-broom"></i>
</button>
<button @click.stop="toggleExpand" class="expand-btn">
<i class="fas" :class="isExpanded ? 'fa-chevron-down' : 'fa-chevron-up'"></i>
</button>
</div>
</div>
<!-- 队列列表 -->
<transition name="slide">
<div v-show="isExpanded" class="queue-list">
<div v-if="downloads.length === 0" class="empty-state">
<i class="fas fa-inbox"></i>
<p>暂无下载任务</p>
</div>
<div v-else class="download-items">
<div
v-for="download in downloads"
:key="download.id"
class="download-item"
:class="download.status"
@click="showDownloadDetail(download)"
>
<div class="download-icon">
<img :src="download.icon" :alt="download.name" />
</div>
<div class="download-info">
<div class="download-name">{{ download.name }}</div>
<div class="download-status-text">
<span v-if="download.status === 'downloading'">
下载中 {{ download.progress }}%
</span>
<span v-else-if="download.status === 'installing'">
安装中...
</span>
<span v-else-if="download.status === 'completed'">
已完成
</span>
<span v-else-if="download.status === 'failed'">
失败: {{ download.error }}
</span>
<span v-else-if="download.status === 'paused'">
已暂停
</span>
<span v-else>
等待中...
</span>
</div>
<div v-if="download.status === 'downloading'" class="progress-bar">
<div class="progress-fill" :style="{ width: download.progress + '%' }"></div>
</div>
</div>
<div class="download-actions">
<button
v-if="download.status === 'downloading'"
@click.stop="pauseDownload(download.id)"
class="action-icon"
title="暂停"
>
<i class="fas fa-pause"></i>
</button>
<button
v-else-if="download.status === 'paused'"
@click.stop="resumeDownload(download.id)"
class="action-icon"
title="继续"
>
<i class="fas fa-play"></i>
</button>
<button
v-if="download.status === 'failed'"
@click.stop="retryDownload(download.id)"
class="action-icon"
title="重试"
>
<i class="fas fa-redo"></i>
</button>
<button
@click.stop="cancelDownload(download.id)"
class="action-icon"
title="取消"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';
const props = defineProps({
downloads: {
type: Array,
default: () => []
}
});
const emit = defineEmits([
'pause',
'resume',
'cancel',
'retry',
'clear-completed',
'show-detail'
]);
const isExpanded = ref(true);
const activeDownloads = computed(() => {
return props.downloads.filter(d =>
d.status === 'downloading' || d.status === 'installing'
).length;
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const pauseDownload = (id) => {
emit('pause', id);
};
const resumeDownload = (id) => {
emit('resume', id);
};
const cancelDownload = (id) => {
emit('cancel', id);
};
const retryDownload = (id) => {
emit('retry', id);
};
const clearCompleted = () => {
emit('clear-completed');
};
const showDownloadDetail = (download) => {
emit('show-detail', download);
};
</script>
<style scoped>
.download-queue {
position: fixed;
bottom: 0;
right: 20px;
width: 400px;
max-height: 500px;
background: var(--card);
border-radius: 12px 12px 0 0;
box-shadow: var(--shadow);
z-index: 1000;
transition: all 0.3s ease;
}
.download-queue:not(.expanded) {
max-height: 60px;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
cursor: pointer;
border-bottom: 1px solid var(--border);
user-select: none;
}
.queue-header:hover {
background: var(--glass);
}
.queue-info {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.queue-info i {
color: var(--primary);
}
.queue-count {
color: var(--muted);
font-size: 14px;
}
.queue-actions {
display: flex;
gap: 8px;
}
.clear-btn,
.expand-btn {
background: transparent;
border: none;
padding: 6px 10px;
cursor: pointer;
border-radius: 6px;
color: var(--text);
transition: all 0.2s;
}
.clear-btn:hover,
.expand-btn:hover {
background: var(--glass);
}
.queue-list {
max-height: 440px;
overflow-y: auto;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
max-height: 0;
opacity: 0;
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--muted);
}
.empty-state i {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.download-items {
padding: 8px 0;
}
.download-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
cursor: pointer;
transition: background 0.2s;
}
.download-item:hover {
background: var(--glass);
}
.download-item.completed {
opacity: 0.7;
}
.download-item.failed {
background: rgba(255, 59, 48, 0.1);
}
.download-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.download-icon img {
width: 100%;
height: 100%;
border-radius: 12px;
object-fit: cover;
}
.download-info {
flex: 1;
min-width: 0;
}
.download-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.download-status-text {
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
.progress-bar {
width: 100%;
height: 4px;
background: var(--glass);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
.download-actions {
display: flex;
gap: 4px;
}
.action-icon {
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
border-radius: 6px;
color: var(--text);
transition: all 0.2s;
}
.action-icon:hover {
background: var(--glass);
color: var(--primary);
}
@media (max-width: 768px) {
.download-queue {
right: 10px;
width: calc(100% - 20px);
max-width: 400px;
}
}
</style>