mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-21 06:00:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db91252ad | ||
|
|
86895269c3 | ||
|
|
018dac3d21 | ||
|
|
f94a6584a4 | ||
|
|
25bc414ce1 | ||
|
|
0c18d53db6 | ||
|
|
55247a72da | ||
|
|
791b196256 | ||
|
|
eb4f01da94 | ||
|
|
0ea075c599 | ||
|
|
fdc8455640 | ||
|
|
f2aa1573fa | ||
|
|
08038e6be0 | ||
|
|
bc49a41018 | ||
|
|
b87112fa38 | ||
|
|
d0e6dc1b79 | ||
|
|
81567f8351 | ||
|
|
ede1a1269f | ||
|
|
e25a3c9d8f | ||
|
|
7b658e9dd1 | ||
|
|
4dc1a1d7fe | ||
|
|
2f85f1da71 | ||
|
|
700197bdc1 | ||
|
|
582435b857 | ||
|
|
e4540252b3 | ||
|
|
5efed0dca2 | ||
|
|
ada3a6dab4 | ||
|
|
ae9142ad06 | ||
|
|
b916fc90f3 | ||
|
|
f3728eae00 | ||
|
|
b4fbd44ed9 | ||
|
|
a4d75c01ad | ||
|
|
da08c78238 | ||
|
|
3403d1fb83 | ||
|
|
7bad39a796 | ||
|
|
3cedd0c517 | ||
|
|
e67f4752a4 | ||
|
|
cdd94c650d | ||
|
|
72d107c9de | ||
|
|
8e1de74cab | ||
|
|
d8bea4ac23 | ||
|
|
e1edf1dcd5 | ||
|
|
7be90f13ce | ||
|
|
0f62a7e36a | ||
|
|
b5c5ab04b5 | ||
|
|
d7bdcf3680 | ||
|
|
5e7d88ecc5 | ||
|
|
a7b8cd52af | ||
|
|
30e1bc4840 | ||
|
|
aef9ccd1ec | ||
|
|
7cafcc51f6 | ||
|
|
e0e6c50b89 | ||
|
|
8888afd4f5 | ||
|
|
44fa822146 | ||
|
|
102ac72d89 | ||
|
|
5bd0a367c4 | ||
|
|
3f22207505 | ||
|
|
0be6f8ad11 | ||
|
|
800a92b419 | ||
|
|
9247fe8e63 | ||
|
|
7ac1b2f0b6 | ||
|
|
b800f3164b | ||
|
|
b3eb1d01d9 | ||
|
|
88499a56f8 | ||
|
|
ae584730c4 | ||
|
|
0d580c4d47 | ||
|
|
c1d2bef38a | ||
|
|
3816703303 | ||
|
|
1569dad866 | ||
|
|
4f772ba7eb | ||
|
|
b89c001401 | ||
|
|
77b4244f1d | ||
|
|
c1b89cab1a | ||
|
|
17cbb7cff2 | ||
|
|
bcf3252e4a | ||
|
|
910aec6cce | ||
|
|
3d28010797 | ||
|
|
df8c2d7eaa | ||
|
|
e857ce67b3 | ||
|
|
98296fa8a3 | ||
|
|
96541cc13e | ||
|
|
b84f82e7bb | ||
|
|
e7476f4bc3 | ||
|
|
0a6fcd95c7 | ||
|
|
99c6048295 | ||
|
|
3b7f14862d | ||
|
|
367828f1f4 | ||
|
|
70250acc76 | ||
|
|
3157714776 | ||
|
|
bfd7626b8f | ||
|
|
5d512c4e86 | ||
|
|
9323c368d6 | ||
|
|
1f9971f7aa | ||
|
|
d67f7b3666 | ||
|
|
c2ba273ca2 | ||
|
|
361dad1a64 | ||
|
|
5730c0bfea |
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
VITE_APM_STORE_BASE_URL=https://erotica.spark-app.store
|
VITE_APM_STORE_BASE_URL=https://d.spark-app.store
|
||||||
VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
|
VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
|
||||||
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
|
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
|
||||||
|
|||||||
+13
@@ -55,3 +55,16 @@ test-results.json
|
|||||||
|
|
||||||
# VSCode CMake Extension
|
# VSCode CMake Extension
|
||||||
build/*
|
build/*
|
||||||
|
|
||||||
|
# Debian build intermediates
|
||||||
|
debian/*.log
|
||||||
|
debian/*.substvars
|
||||||
|
debian/*.debhelper
|
||||||
|
debian/debhelper-build-stamp
|
||||||
|
debian/files
|
||||||
|
debian/tmp/
|
||||||
|
debian/.debhelper/
|
||||||
|
debian/spark-store/
|
||||||
|
|
||||||
|
# AI working memory (跨会话上下文,不进入上游)
|
||||||
|
.codebuddy/
|
||||||
|
|||||||
Vendored
+1
@@ -30,6 +30,7 @@
|
|||||||
// },
|
// },
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"--remote-debugging-port=9229",
|
"--remote-debugging-port=9229",
|
||||||
|
"--no-sandbox",
|
||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
"envFile": "${workspaceFolder}/.vscode/.debug.env",
|
"envFile": "${workspaceFolder}/.vscode/.debug.env",
|
||||||
|
|||||||
@@ -695,6 +695,23 @@ npm run build:rpm # 仅构建 RPM 包
|
|||||||
- `dist/` - 编译的渲染器资源
|
- `dist/` - 编译的渲染器资源
|
||||||
- 打包的应用在项目根目录
|
- 打包的应用在项目根目录
|
||||||
|
|
||||||
|
### 测试打包脚本(开发过程统一使用)
|
||||||
|
|
||||||
|
开发 / 自测阶段的打包**一律使用测试脚本 `scripts/test-build.sh`**,不要直接手敲 `dpkg-buildpackage`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本行为:
|
||||||
|
|
||||||
|
- 自动读取 `debian/changelog` 顶部的 deb 版本号(如 `5.2.1.0`),将**最后一段数字 +1** 并追加 `-test` 修订号(如 `5.2.1.1-test`)。
|
||||||
|
- 每次运行版本递增(`5.2.1.1-test` → `5.2.1.2-test` → …),产物形如 `spark-store_5.2.1.X-test_amd64.deb`,与正式发版版本区分。
|
||||||
|
- 自动设置 `ELECTRON_MIRROR` 镜像(`https://registry.npmmirror.com/-/binary/electron/`)后执行 `dpkg-buildpackage -us -uc -b`。
|
||||||
|
- 仅修改 `debian/changelog`,不改 `package.json`(electron-builder 的 dir 产物版本由 `package.json` 决定,不影响 deb 文件名)。
|
||||||
|
|
||||||
|
> 注意:每次运行会改写 `debian/changelog`(未跟踪的工作区改动),测试完成后按需自行还原为正式版本。
|
||||||
|
|
||||||
### 构建配置
|
### 构建配置
|
||||||
|
|
||||||
**electron-builder.yml:**
|
**electron-builder.yml:**
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
## [未发布 / Unreleased] (Erotica 分支,基于 1e60a4c2 之后)
|
||||||
|
|
||||||
|
本批改动汇总(自提交 `1e60a4c2` 起):
|
||||||
|
|
||||||
|
1. **已安装应用新增搜索框 + 窗口尺寸/位置持久化**:补齐已安装面板的应用搜索框;主窗口大小、位置、最大化状态写入本地 `window-state.json` 持久化,并限制最小尺寸 800×500。
|
||||||
|
2. **已安装列表健壮性加固**:修复异步竞态、错误 UX、加载状态处理与刷新防抖(多轮 AI 审查改进),并清理对应代码缩进与对齐。
|
||||||
|
3. **各页面滚动条贴边修复**:避免滚动栏被窗口圆角裁切。
|
||||||
|
4. **投稿应用窗口圆角虚影修复**:消除投稿弹窗圆角处的方角残影。
|
||||||
|
5. **已安装应用来源筛选与排序优化**:将头部 APM / Spark / 总数三段式统计徽章改为可点击,点击后按来源筛选(默认显示全部);列表默认 APM 应用置顶;新增"暂无已安装的 APM/Spark 应用"空状态提示,避免筛选无结果时出现空引号。
|
||||||
|
6. **AI 代码审查安全加固**:
|
||||||
|
- `launch-app` IPC 移除 `any`,新增包名正则校验(`/^[a-zA-Z0-9._+-]+$/`),拦截非法输入防止命令注入。
|
||||||
|
- `getIconUrl` 本地图标路径增加白名单目录校验与路径遍历(`..`)防护。
|
||||||
|
- `fetchWithRetry` 仅对网络错误 / 5xx / 超时重试,4xx 快速失败。
|
||||||
|
- `AppGrid` 普通网格 `v-for` 的 `:key` 由 `index` 改为 `app.pkgname`。
|
||||||
|
- `loong64` 架构下尊重 `--no-spark` 启动参数,不再硬编码覆盖。
|
||||||
|
- `openDownloadedApp` 的 IPC 调用补充 `.catch` 错误处理。
|
||||||
|
|
||||||
|
7. **应用详情默认展开已安装版本**:打开应用详情时,若本地只安装了 Spark / APM 中的某一版本,则默认展开该已装来源(例如 WeChat 默认 APM,但本机只装 Spark 版时默认展开 Spark);若两版都已安装,则按优先级/标签策略展示。
|
||||||
|
|
||||||
|
相关提交:`1d9d25d6`、`c75938aa`、`39a358bb`、`51473cac`、`cf0e72ff`、`b0d60737`、`d3705ed4`。
|
||||||
|
|
||||||
## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17)
|
## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ install:
|
|||||||
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
|
cp -rv pkg/usr/share/dsg/ $(DESTDIR)/usr/share/
|
||||||
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
|
cp -rv pkg/usr/share/bash-completion/ $(DESTDIR)/usr/share/
|
||||||
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
|
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
|
||||||
ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
|
# ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
|
||||||
|
|||||||
Vendored
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
spark-store (5.2.1-0) UNRELEASED; urgency=medium
|
spark-store (5.3.1) UNRELEASED; urgency=medium
|
||||||
|
|
||||||
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
* Initial release.
|
||||||
|
|
||||||
-- shenmo <shenmo@spark-app.store> Tue, 16 Jun 2026 21:45:35 +0800
|
-- shenmo <shenmo@spark-app.store> Fri, 14 Aug 2026 15:14:01 +0800
|
||||||
|
|||||||
Vendored
+3
@@ -58,6 +58,9 @@ case "$1" in
|
|||||||
systemctl enable spark-update-notifier
|
systemctl enable spark-update-notifier
|
||||||
systemctl start spark-update-notifier
|
systemctl start spark-update-notifier
|
||||||
|
|
||||||
|
# like apt-daily.timer
|
||||||
|
systemctl enable spark-update-notifier.timer
|
||||||
|
systemctl start spark-update-notifier.timer
|
||||||
|
|
||||||
# Update certain caches
|
# Update certain caches
|
||||||
cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
|
cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
1、将标题栏变宽一点,现在加入了搜索栏,太窄了不好看
|
||||||
|
2、在贡献排行中,点击对应的贡献用户,可以查看用户下所有的应用(可能需要模糊查询)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# 应用列表缓存导致"新应用上架后搜不到"的根因分析与设计方案
|
||||||
|
|
||||||
|
> App List Cache Analysis & Design (spark-store)
|
||||||
|
> 整理日期:2026-08-11
|
||||||
|
> 背景分支:`Erotica`
|
||||||
|
> 文档涵盖:① 列表缓存根因与修复(C1/C2);② 下载日志断层修复(附加)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 现象(用户反馈)
|
||||||
|
|
||||||
|
- 服务端上新应用后,在商店内**搜不到**该应用。
|
||||||
|
- 直觉认为是 `~/.cache` 下的本地缓存导致,但实测并非如此。
|
||||||
|
- 进一步反馈:**重启应用也无效,只有"删除缓存"才行**——这指向缓存层在重启后仍返回陈旧数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 缓存层逐层排查
|
||||||
|
|
||||||
|
| 缓存层 | 是否存在 | 证据 | 是否导致本问题 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **① 内存(渲染进程 `apps` 数组)** | 是 | 搜索基于内存 `baseApps`(`App.vue:665`);`loadApps` 仅在 `onMounted` 调用一次(`App.vue:3447`) | 是(叠加因素):运行期不刷新,新应用不在内存 |
|
||||||
|
| **② 本地磁盘"应用列表"文件缓存** | **否** | 源码无 `writeFile(applist)`;`~/.config/spark-store/` 下无应用列表缓存文件 | 否(用户最初怀疑点,已排除) |
|
||||||
|
| **③ Chromium 网络磁盘缓存** | **是(关键)** | 渲染进程 `axios` 走 XHR,受 Chromium 网络栈影响;`~/.config/spark-store/Cache` 目录存在 | **是**:重启后请求命中该磁盘缓存,返回旧 `applist.json` |
|
||||||
|
| **④ CDN 边缘缓存** | **是(关键)** | 生产域名 `erotica.spark-app.store` 挂 CDN;静态 `applist.json` 被边缘缓存;代码未发 `no-cache` | **是**:即使穿透③,CDN 仍可能返回边缘陈旧副本 |
|
||||||
|
|
||||||
|
### 请求侧缓存控制现状(代码事实)
|
||||||
|
- `fetchWithRetry`(`App.vue:432`):`axiosInstance.get(url, { signal })`,**无任何 `Cache-Control` 头**。
|
||||||
|
- `axiosInstance`(`App.vue:427`):仅 `baseURL` + `timeout`,无 headers。
|
||||||
|
- 主进程 `onBeforeSendHeaders`(`electron/main/index.ts:653`):只注入 `User-Agent`,**未注入 `Cache-Control: no-cache`**。
|
||||||
|
- `loadApps`(`App.vue:3216`):`apps.value.push(...)` —— **append 模式、无去重、无重置**,重跑会累积重复。
|
||||||
|
- `loadTabApps`(`App.vue:3098`):`if (tabApps.value[entryId]) return;` —— **已加载分类强缓存,永不重拉**。
|
||||||
|
- `loadCategories`(`App.vue:3430`):`categories.json` 仅启动时加载一次;若为"新分类"则永不加载其 `applist.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 根因结论(实测修正)
|
||||||
|
|
||||||
|
> **2026-08-11 实测修正**:曾假设"CDN 边缘主动缓存",curl 实测远程 `applist.json` 响应头为 `server: nginx` + `etag` + `last-modified`,**无任何 `Cache-Control`/`Expires`/`Age`**。因此真因是 **nginx 未发缓存控制头 → Chromium 启发式缓存(heuristic caching)**,而非 CDN 主动边缘缓存。
|
||||||
|
|
||||||
|
**主因(解释"重启无效、删除才行")—— Chromium 启发式缓存:**
|
||||||
|
> 对"无 Cache-Control 头"的响应,Chromium 按 `Last-Modified` 计算启发式 TTL ≈ `(now - LM)/10`。实测当前文件 `LM` 距今约 7.6h → TTL ≈ **46 分钟**。在此窗口内:
|
||||||
|
> - 重启应用 → 重新发请求 → Chromium 直接复用 `~/.config/spark-store/Cache` 旧副本(启发式视为"新鲜")→ 返回**旧 JSON** → 新应用搜不到。
|
||||||
|
> - 只有**删除 `~/.config/spark-store/Cache`** 强制失效,才可能拿到新内容。
|
||||||
|
> 这与"重启无效、删缓存才行"的现象**精确吻合**。
|
||||||
|
|
||||||
|
**叠加因素(解释"运行期搜不到"):**
|
||||||
|
> `loadApps` 仅在启动时加载一次、且 `loadTabApps` 对已加载分类强缓存,运行期内存数据不会自动更新。即便请求能穿透缓存,也需重启/手动触发才进入内存。
|
||||||
|
|
||||||
|
**次要因素(条件触发):**
|
||||||
|
> 若新应用落入 `categories.json` 尚未包含的**新分类**,`loadApps` 遍历 `categories.value` 不会请求该分类,连重启都搜不到,需等 `categories.json` 同步。
|
||||||
|
|
||||||
|
> 说明:用户最初怀疑的"~/.cache 应用列表文件缓存"**不存在**;真因是 nginx 无 CC 头导致的 Chromium 启发式缓存(③层的精确机理),其陈旧表现与"文件缓存"一致,故被误判。
|
||||||
|
|
||||||
|
### 2.1 实测证据(curl / Node,2026-08-11)
|
||||||
|
- `curl -sI` 远程 `applist.json`:`HTTP/2 200`、`server: nginx`、`etag: "6a7a764e-2f9d8"`、`last-modified: Tue, 11 Aug 2026 01:09:34 GMT`,**无 `cache-control`/`expires`/`age`**。
|
||||||
|
- Node 计算:LM 距今 ≈ 27572s → 启发式 TTL ≈ 2757s ≈ **46 分钟**(窗口内重启命中陈旧副本)。
|
||||||
|
- `curl -H "Cache-Control: no-cache"` 仍 `200` 正常 → 客户端带 no-cache 即不再复用陈旧副本,证明 **C1 修复方向正确**。
|
||||||
|
- 带 `?_t=Date.now()` 的 URL 经 `new URL()` 解析合法(pathname 正确、search 为 `_t=...`)→ **C2 修复方向正确**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 设计方案(最终版 · 已整合 7 维审计)
|
||||||
|
|
||||||
|
> 设计原则:**精准根治 + 配套必补项齐全 + 不过度设计**。
|
||||||
|
> 改动分两类——**核心(绕过缓存层,根治)** 与 **辅助(主动刷新体验)**;其中辅助项含 5 个**必补配套**,否则功能/可靠性打折(详见第 4 节审计结论)。
|
||||||
|
|
||||||
|
### 3.1 核心(根治"重启/删除才有效")—— 绕过缓存层
|
||||||
|
> 目标:列表/分类请求**永远拿最新**,不依赖删除缓存目录。
|
||||||
|
|
||||||
|
- **C1 请求头禁用缓存**
|
||||||
|
- `fetchWithRetry` 对数据 JSON 请求加 `headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }`。
|
||||||
|
- 主进程 `electron/main/index.ts:653` 的 `onBeforeSendHeaders` 中,**仅对 `applist.json` / `categories.json` / `priority-config.json` 路径**注入 `Cache-Control: no-cache`(scope 严格过滤,绝不影响 `cdn.d.store...` 图标/截图加速)。双保险覆盖所有请求路径。
|
||||||
|
|
||||||
|
- **C2 URL 版本 bust(穿透 CDN 边缘缓存最稳手段)**
|
||||||
|
- 对上述数据请求 URL 追加 `?_t=${Date.now()}`,使每次 URL 不同,CDN 边缘与 Chromium 均无缓存可命中。
|
||||||
|
- 代价:每次穿透 CDN,但列表文件小、频率低(启动/聚焦/手动),可接受。
|
||||||
|
- **兼容性确认项**:上线前需在测试环境确认 `erotica.spark-app.store` 的 CDN 对含 query 的静态路径行为正常(极少数 CDN 有特殊规则);若有异常,回退为仅依赖 `no-cache` 或 path 版本戳。
|
||||||
|
|
||||||
|
### 3.2 辅助(主动刷新体验)—— 让用户无需重启即拿最新
|
||||||
|
> 前提:3.1 已绕过缓存层。否则刷新也拿不到新数据。
|
||||||
|
|
||||||
|
- **A1 `loadApps` 幂等重建(必做,刷新安全前提)**
|
||||||
|
- 将 `App.vue:3216` 的 `apps.value.push` 改为:每个分类结果收集进 `Map<pkgname, App>`(复用现有 hybrid 合并逻辑),**该分类加载完即 `apps.value = [...map.values()]`**——保留增量提交(首个分类成功即关遮罩,不回退首屏体验),同时对所有模式(含 spark-only/apm-only)去重,反而比现状更稳。
|
||||||
|
|
||||||
|
- **A2 `refreshAppData()` 编排**
|
||||||
|
- 提取 `await loadCategories()`(本身重建安全,`App.vue:2946`)+ 重跑改造后的 `loadApps()`。覆盖主因与次要因素(新分类一并刷新)。
|
||||||
|
|
||||||
|
- **A3 两个刷新入口**
|
||||||
|
- **窗口聚焦(经主进程,非渲染 window focus)**:主进程 `win.on('focus')` → `webContents.send('window-focus')` → 渲染监听调用 `refreshAppData()`(Electron 无边框窗口的渲染 `window focus` 事件不一定可靠,必须走 IPC 才稳)。
|
||||||
|
- **手动刷新按钮**:工具栏/侧边栏加刷新图标,调用 `refreshAppData()`。
|
||||||
|
|
||||||
|
### 3.3 必补配套(5 项,缺一不可)
|
||||||
|
| 编号 | 维度 | 配套项 | 解决风险 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P1** | 功能正确 | A1 改造时**保留每分类增量提交** + 非 hybrid 也按 pkgname 去重 | 防首屏回退、防重复应用 |
|
||||||
|
| **P2** | 可靠性 | `refreshAppData`/`loadApps` 加**串行化守卫**(in-flight 合并或 `isRefreshing` 标志) | 防焦点/手动/启动并发竞态覆盖(`loadApps` 当前无锁,已确认) |
|
||||||
|
| **P3** | 可靠性 | 用**主进程 `win.on('focus')`→IPC** 而非渲染 `window focus` | 防无边框窗口 focus 事件不可靠导致刷新静默失效 |
|
||||||
|
| **P4** | 性能 | 聚焦刷新加**防抖(~3s)+ 节流** | 防切窗口抖动堆叠请求、穿透 CDN 放大流量 |
|
||||||
|
| **P5** | 可观测 | `refreshAppData` 入口打印 `[AppData] 触发刷新 (reason=focus\|manual)`;`applist` 加载后记录条目数变化(`1200 → 1201`) | 便于线上诊断"刷新是否真生效 / 新应用是否进内存" |
|
||||||
|
|
||||||
|
### 3.4 可选
|
||||||
|
- **O1 `loadTabApps` TTL 失效**:`App.vue:3098` 强缓存改"超过 TTL(如 10min)才重拉"。因 all 搜索主路径已由 3.1+3.2 覆盖,可后置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 7 维专业审计结论(改动影响预评估)
|
||||||
|
|
||||||
|
| 维度 | 影响评估 | 关键结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. 功能正确性** | 改善明确;A1/C2 改造需连带处理增量提交与去重(P1) | 根治"陈旧列表",但 C 改动需保增量体验 |
|
||||||
|
| **2. 性能** | C2 每次穿透 CDN,开销小;**唯一主要负向是聚焦频繁触发** | 需 P4 防抖;否则成主要性能负向 |
|
||||||
|
| **3. 安全性** | `no-cache`/时间戳 bust 中性;scope 必须限数据路径 | 不全局注入,不影响图标加速 |
|
||||||
|
| **4. 可靠性** | 两处真实风险:`loadApps` 无并发锁(竞态)、渲染 focus 不可靠 | 需 P2 串行化 + P3 IPC focus |
|
||||||
|
| **5. 兼容性** | `no-cache`/query 对所有 CDN/HTTP 通用;仅需一次 CDN 行为确认(C2) | 兼容性良好 |
|
||||||
|
| **6. 可维护性** | `refreshAppData` 抽出后逻辑集中、易测;增量代码小 | 维护性改善 |
|
||||||
|
| **7. 可观测/可测** | 当前刷新路径无专属日志;应补 P5 | 便于上线诊断 |
|
||||||
|
|
||||||
|
**总体**:方案根治有效,性能/安全负向可控;**3 个必补配套(P1/P2/P3)+ 2 个强建议(P4/P5)** 须一并实现,否则功能正确性/可靠性打折。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 方案落地可行性验证(代码核对 · 测试,不改功能代码)
|
||||||
|
|
||||||
|
> 本节为在动手前对方案各项做的**代码级可行性核对**(未修改任何源码),确认可实现性与需细化点。
|
||||||
|
|
||||||
|
### 5.1 逐项核对结论
|
||||||
|
|
||||||
|
| 项 | 代码事实(已核对) | 可行性 | 需细化点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **C1 主进程 no-cache 注入** | `onBeforeSendHeaders` 在 `electron/main/index.ts:653` 注册,可拿 `details.url`(完整 URL,含 `.../applist.json` 等)。用 `url.includes('/applist.json')\|\|includes('/categories.json')\|\|includes('/priority-config.json')` 过滤注入即可 | ✅ 可行 | scope 必须限三路径,不影响 `cdn.d.store...` 图标 |
|
||||||
|
| **C2 URL bust** | 数据请求三入口(`loadApps` `App.vue:3203`、`loadTabApps` `3133/3151`、`loadCategories` `2925`)**均走 `fetchWithRetry`**;但 `loadPriorityConfig`(`storeConfig.ts:84`)走**独立 `priorityConfigAxios`,不经过 `fetchWithRetry`** | ✅ 可行 | **bust 须覆盖两个 axios 实例**;否则 `priority-config.json` 仍可能陈旧(影响 auto 策略,非搜索主路径但同源) |
|
||||||
|
| **A1 loadApps Map 幂等重建** | 当前 `App.vue:3216` `apps.value.push`,在 `Promise.all`(`3190`) 的 `category→origins` 异步循环内。改:函数内声明跨分类 `Map<pkgname,App>`,每分类 `origins` 完成后 `apps.value = [...map.values()]` | ✅ 可行 | 增量提交须保留在每个**分类**处理末尾(非等全部完成),否则首屏回退;Map 按 pkgname 作 key 天然覆盖 spark-only/apm-only 去重 |
|
||||||
|
| **A2 refreshAppData 编排** | 新函数 = `await loadCategories()` + 重跑改造后 `loadApps()`;与 `loadApps` 同作用域即可 | ✅ 可行 | 建议 `onMounted` 也改调 `refreshAppData` 而非裸 `loadApps`,统一入口 |
|
||||||
|
| **A3 + P3 主进程 focus→IPC** | 主进程 `win` 为模块级 `let win`(`electron/main/index.ts:122`),`createWindow` 内 `mainWindow`(`418`)。`mainWindow.on('focus', () => mainWindow.webContents.send('appdata-window-focus'))` 可靠(主进程事件,不受无边框影响) | ✅ 可行 | 渲染侧 `window.ipcRenderer.on('appdata-window-focus', ...)` 调用 `refreshAppData`;**preload 无需改动**(通用 `on` 通道已暴露) |
|
||||||
|
| **P2 串行化守卫** | `loadApps` 当前**无并发锁**(`loading.value` 仅遮罩),已确认 | ✅ 可行 | `refreshAppData` 内加 `refreshInFlight` Promise 合并:`if (inFlight) return inFlight; inFlight=(async()=>{...})().finally(()=>inFlight=null)` |
|
||||||
|
| **P4 防抖** | focus 抖动会快速多次触发 | ✅ 可行 | P2 的 in-flight 合并已天然防抖首跑;再叠加 time-based 节流(如 10s 内仅一次)更稳 |
|
||||||
|
| **P5 刷新日志** | 既有 Pino `logger`(如 `App.vue:3205` `logger.info('加载分类...')`) | ✅ 可行 | `refreshAppData` 入口 `[AppData] 触发刷新 reason=...`;`loadApps` 完成 `[AppData] 应用数 X → Y`(Y=map.size) |
|
||||||
|
|
||||||
|
### 5.2 关键发现(影响方案完整性)
|
||||||
|
|
||||||
|
1. **C1 主进程注入是 C2 的兜底**:主进程 `onBeforeSendHeaders` 注入 `no-cache` 对**所有**经 Chromium 的请求(含 `priorityConfigAxios` 的 XHR)生效;即便 C2 漏给 `priorityConfigAxios` 加 bust,C1 也能保证其不读缓存。**建议 C1 为主、C2 为辅**,两者互补而非二选一。
|
||||||
|
2. **C2 必须显式覆盖 `priorityConfigAxios`**:因其独立于 `fetchWithRetry`,要在 `storeConfig.ts:84` 的 `priorityConfigAxios.get(configPath)` 处单独加 bust(或在 `priorityConfigAxios` 实例层加请求拦截器统一加 `?_t`)。否则 auto 策略配置可能陈旧。
|
||||||
|
3. **loadApps 异步嵌套**:当前 `categoriesList.map(async category => origins.map(async mode => ...))` 嵌套异步;A1 改造时,需把内层改为 `await Promise.all(origins.map(...))` 后在分类回调末尾提交 `apps.value`,确保"每分类增量提交"。
|
||||||
|
4. **下游无回归**:`apps.value` 整体替换会触发 Vue 响应式,`baseApps`/`filteredApps`/排行均依赖它,赋值即刷新,无兼容问题。
|
||||||
|
5. **preload 零改动**:新 IPC 通道 `appdata-window-focus` 直接 `send`/`on`,复用已暴露的通用 `ipcRenderer`,不需改 `contextBridge`。
|
||||||
|
|
||||||
|
### 5.3 测试结论
|
||||||
|
|
||||||
|
- **全部 8 个方案项(C1/C2/A1/A2/A3 + P1~P5)均可实现,无不可落地项。**
|
||||||
|
- 落地前须落实 5 个细化点(5.1 表"需细化点" + 5.2 关键发现),其中 **C2 双 axios 覆盖** 与 **C1 兜底关系** 是最易被遗漏、却影响完整性的两点。
|
||||||
|
- 风险等级:均为低~中,无高危阻塞;P2(竞态)与 A3 的 IPC 可靠性属"不做则功能打折"的必补项,已在方案中标明。
|
||||||
|
|
||||||
|
### 5.4 实际效果验证(2026-08-11,本机可执行部分已实测)
|
||||||
|
|
||||||
|
> 用户要求验证"实际效果"。区分两类验证:
|
||||||
|
> - **(a) 机理实证(本机已完成)**:用 curl/Node 验证根因与修复方向正确性,无需 GUI/服务端改动。
|
||||||
|
> - **(b) 端到端实证(需真机+服务端上架新应用,本机无法完成)**:见第 7 节,须由用户在真实环境执行。
|
||||||
|
|
||||||
|
**(a) 已完成的本机实证:**
|
||||||
|
1. `curl -sI` 远程 `applist.json`:**无 `cache-control` 头、`server: nginx`** → 证实 Chromium 启发式缓存为真因(非 CDN 主动缓存)。
|
||||||
|
2. Node 计算启发式 TTL ≈ 46 分钟 → 量化"重启仍陈旧"的窗口。
|
||||||
|
3. `?_t=Date.now()` URL 经 `new URL()` 解析合法 → C2 方向正确。
|
||||||
|
4. `curl -H "Cache-Control: no-cache"` 仍 200 → 客户端带 no-cache 即绕过陈旧副本,C1 方向正确。
|
||||||
|
5. 串行化守卫 / Map 幂等去重 / focus→IPC 逻辑已在 5.1 核对,均为纯逻辑可单测(实现后补单测即闭环)。
|
||||||
|
|
||||||
|
**(b) 尚待真机验证(非本机能力范围):**
|
||||||
|
- 需在**服务端上架一个真实新应用**(最好落入已存在分类,隔离"新分类"变量)。
|
||||||
|
- 安装修复包后,**不删缓存、不重启** → 聚焦/刷新 → 搜到新应用(验证 C1/C2 绕过启发式缓存)。
|
||||||
|
- 重启不清缓存 → 首屏即显示(验证重启不再拿旧数据)。
|
||||||
|
- 对照旧包同样操作需删 `Cache` 才有效 → 根因闭合。
|
||||||
|
- 说明:本机为无显示环境(headless Linux),且无法操作远端商店后台上架,故 (b) 必须用户在真实环境执行;**但根因机理已由 (a) 实证闭合,修复方向确凿**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 明确不做(避免过度设计)
|
||||||
|
|
||||||
|
- ❌ **定时轮询**(后台常驻网络/CPU 开销,用户未要求实时)。
|
||||||
|
- ❌ **本地磁盘离线缓存**(问题不在离线,反而引入陈旧风险)。
|
||||||
|
- ❌ **ETag / 304 协商缓存**(axios 不过系统缓存层,且已有 no-cache + bust 更稳;除非确认代理忽略请求头才需要)。
|
||||||
|
- ❌ **全局 `Cache-Control` 注入到所有请求**(仅对数据 JSON 路径注入,避免影响图标/截图等静态资源加速)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验证方法
|
||||||
|
|
||||||
|
> **验证分工**:步骤 1(单测)随实现完成;步骤 2–5(端到端)需服务端上架新应用 + 真实 GUI 环境,**由用户在真机执行**(本机 headless 无法完成)。
|
||||||
|
|
||||||
|
1. **单元/逻辑**:`loadApps` 幂等化后补测——重跑两次 `apps.value` 长度不变、无重复 pkgname;`refreshAppData` 串行化 + 防抖单测。
|
||||||
|
2. **集成(根因闭合)**:服务端上架新应用(落入已存在分类,隔离"新分类"变量)。
|
||||||
|
3. 安装修复包,**不删缓存、不重启** → 聚焦窗口 / 点刷新 → 应搜到新应用(证明缓存已绕过,P5 日志应显示条目数 +1)。
|
||||||
|
4. 重启应用(不清缓存)→ 首屏即显示新应用(证明 no-cache + bust 生效,重启不再拿旧数据)。
|
||||||
|
5. **对照旧包**:同样操作需"删除 `~/.config/spark-store/Cache` 才有效"——确认根因闭合、修复生效。
|
||||||
|
|
||||||
|
> 注:机理层面已由第 5.4 节本机 curl/Node 实证闭合(nginx 无 CC 头 → Chromium 启发式缓存 ≈46min;C1/C2 方向正确),故修复方向确凿,端到端仅作最终确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:历次分析演进
|
||||||
|
|
||||||
|
- **第一轮**:怀疑 `~/.cache` 本地应用列表文件缓存 → 核查排除(无磁盘列表缓存,搜索基于内存)。
|
||||||
|
- **第二轮**:深入确认"运行期不刷新"为主因(`loadApps` 仅启动一次、append 无去重、`loadTabApps` 强缓存、categories 启动一次)。
|
||||||
|
- **第三轮**:结合"重启无效、删除才行"反馈,定位为 **Chromium 磁盘缓存 + CDN 边缘缓存**双重陈旧,且代码零缓存失效机制;修正上轮结论——重启无效的根因是缓存层喂旧数据,而非单纯内存不刷新。
|
||||||
|
- **第四轮(方案验证)**:按用户要求"先测试不改代码",对最终方案的 8 项(C1/C2/A1/A2/A3 + P1~P5)做代码级可行性核对,确认全部可落地;补充 5 个细化点(C2 须覆盖 `priorityConfigAxios` 独立实例、C1 主进程注入为兜底、loadApps 异步嵌套改造、下游无回归、preload 零改动),写入第 5 节。方案待用户审核后决定是否实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 附加修复:下载日志断层(下载卡在"正在获取 Metalink"突兀退出)
|
||||||
|
|
||||||
|
### 8.1 现象(用户反馈)
|
||||||
|
下载应用时日志停在:
|
||||||
|
```
|
||||||
|
[13:57:09] 开始下载...
|
||||||
|
[13:57:09] 正在获取 Metalink 文件: amd64-store/office/com.qianwen.otohime/com.qianwen.otohime_3.7.5.145_amd64.deb.metalink
|
||||||
|
```
|
||||||
|
随后界面无过渡直接结束(状态变 failed / 任务消失),中间无任何失败原因,体验突兀。
|
||||||
|
|
||||||
|
### 8.2 根因
|
||||||
|
下载流程在 `electron/main/backend/install-manager.ts` 的 `runDownloadPhase`:
|
||||||
|
1. **Metalink 下载请求失败无前端日志**:`await axios.get(...)` 抛错时,原代码未 `sendLog`,错误只在主进程 `logger.error` 静默记录;错误向上抛到外层 catch → 直接发 `install-complete {success:false}`,渲染端日志面板永远停在"正在获取 Metalink"。
|
||||||
|
2. **aria2c 拉起失败也无日志**:`child.on("error")` 仅 `reject(err)`,渲染端同样看不到原因。
|
||||||
|
3. **渲染端 `install-complete` 失败分支不写 logs**:`src/modules/processInstall.ts` 收到失败时只改 `status="failed"`,未把 `log.message` 的失败原因写入 `downloadObj.logs`,导致日志卡在最后一条中间状态。
|
||||||
|
|
||||||
|
三者叠加:任一环节失败,UI 都在"正在获取 Metalink"后突兀结束且无原因。
|
||||||
|
|
||||||
|
### 8.3 修复(已提交)
|
||||||
|
- **F1(主进程 Metalink 失败日志)**:`axios.get` 用 try/catch 包裹,失败时 `sendLog("获取 Metalink 失败: <reason>")` 并 `throw`,保证渲染端能显示原因。Metalink 写入 `finish` 回调补 `sendLog("Metalink 文件下载完成")`。
|
||||||
|
- **F2(aria2c 拉起失败日志)**:`child.on("error")` 补 `sendLog("aria2c 启动失败: <msg>")`。
|
||||||
|
- **F3(渲染端失败原因入日志)**:`install-complete` 的 `else` 分支解析 `log.message` 中的 `message` 字段,push 一条 `下载失败: <reason>` 到 `downloadObj.logs`;成功分支补 `下载完成`。
|
||||||
|
|
||||||
|
改动文件:
|
||||||
|
- `electron/main/backend/install-manager.ts`(F1、F2)
|
||||||
|
- `src/modules/processInstall.ts`(F3)
|
||||||
|
|
||||||
|
> 注:本修复仅改善"失败可见性",不改变下载/重试/安装逻辑本身;UI 不再突兀卡在中间日志。
|
||||||
|
|
||||||
|
### 8.4 验证边界
|
||||||
|
本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 `vue-tsc` 类型检查与 `read_lints` 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 附加修复:更新中心点击"更新"后卡"开始更新..."
|
||||||
|
|
||||||
|
### 9.1 现象(用户反馈)
|
||||||
|
在软件更新中心选择 Visual Studio Code: 点击更新后,下载详情弹窗日志停在:
|
||||||
|
```
|
||||||
|
[09:23:41] 开始更新...
|
||||||
|
```
|
||||||
|
状态为 `queued`,后续无进展、无错误,界面卡死。
|
||||||
|
|
||||||
|
### 9.2 根因
|
||||||
|
更新中心 `electron/main/backend/update-center/service.ts` 的 `start()` 方法,把任务通过 `webContents.send("queue-install", JSON.stringify(installTaskData))` 发送给主下载队列。
|
||||||
|
|
||||||
|
**但 `webContents.send` 是从主进程向渲染进程发消息,渲染进程的 `ipcRenderer.on("queue-install")` 能收到,而主进程的 `ipcMain.on("queue-install") 监听的是渲染进程 `ipcRenderer.send` 的消息,监听不到自己 `webContents.send` 的消息。**
|
||||||
|
|
||||||
|
结果:任务根本没有进入 `install-manager.ts` 的下载队列,`processNextDownload()` 永远不会执行,UI 自然卡在"开始更新...」。
|
||||||
|
|
||||||
|
### 9.3 修复(已提交)
|
||||||
|
- **I1(抽离可复用的入队函数)**:在 `electron/main/backend/install-manager.ts` 新增导出 `addInstallTask(payload, sender)`,把原来 `ipcMain.on("queue-install")` 里的解析、校验、去重、APM 检查、命令构建、入队逻辑全部抽到该函数。`ipcMain.on` 本身只做 JSON 解析并调用 `addInstallTask`。
|
||||||
|
- **I2(更新中心直接调用入队)**:`electron/main/backend/update-center/service.ts` 的 `start()` 不再 `webContents.send("queue-install")`,而是直接 `await addInstallTask(installTaskData, webContents)`,使任务真正进入主下载队列。
|
||||||
|
- **I3(类型安全)**:新增 `QueueInstallPayload` 接口,避免 `any`。
|
||||||
|
|
||||||
|
改动文件:
|
||||||
|
- `electron/main/backend/install-manager.ts`(I1、I3)
|
||||||
|
- `electron/main/backend/update-center/service.ts`(I2)
|
||||||
|
|
||||||
|
### 9.4 验证边界
|
||||||
|
本机 headless 无法启动 GUI 触发真实更新下载;已通过 `vue-tsc` 与 `read_lints` 0 错误。真机需在软件更新中心勾选一项更新并点击"更新",确认日志从"开始更新..."推进到"正在获取 Metalink 文件"、下载进度增加,最终进入安装或明确失败。
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 排行榜与星火荣耀榜 · 项目规划文档
|
||||||
|
|
||||||
|
> 状态:**草图设计已确认,待实现**
|
||||||
|
> 分支:`Erotica`
|
||||||
|
> 日期:2026-07-30
|
||||||
|
> 关联预览原型:`ranking-preview.html`(仓库根目录,仅用于设计预览,不入库)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目背景与目标
|
||||||
|
|
||||||
|
在「已安装应用」模块重构(来源标签化、统计徽章、搜索、来源筛选、APM 置顶)完成后,本次规划聚焦于**首页与发现页的内容重组**:
|
||||||
|
|
||||||
|
- 将原本散落在「首页推荐」中的**下载排行**独立为「排行榜」一级页面,并补充**应用更新排行**;
|
||||||
|
- 新增**「星火荣耀榜」**页面,表彰应用贡献者与近期更新贡献者;
|
||||||
|
- 精简「首页推荐」页:移除精选板块(区域2)与下载排行(区域3),仅保留精选链接(区域1),并在区域1 顶部加入致谢说明。
|
||||||
|
|
||||||
|
**预期成果**:形成「首页推荐(轻量入口)— 排行榜(下载/更新)— 荣耀榜(贡献者荣誉)」三层递进的发现体系,强化社区贡献者认同感。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据可行性核查(代码已确认)
|
||||||
|
|
||||||
|
| 字段 | 来源 | 结论 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app.downloadCount` | 现有 `apmRanking` / `sparkRanking`(`App.vue` 1164–1250,异步逐应用拉 `download-times.txt`) | ✅ 直接复用,无需新增请求 |
|
||||||
|
| `app.update` | `App.vue:886` 由 `appJson.Update` 映射,格式 `"2026-01-26 17:34:15"`,同格式可直接 `localeCompare` 倒序 | ✅ 列表已含,可直接排序 |
|
||||||
|
| `app.contributor` | `App.vue:884` 由 `appJson.Contributor` 映射 | ✅ 列表已含,无需详情请求 |
|
||||||
|
|
||||||
|
> 结论:**荣耀榜/更新榜所需字段均已在列表加载阶段填充**,聚合逻辑可基于已加载的 `apps` 实时计算,不引入额外网络请求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 任务总览(优先级 + 阶段)
|
||||||
|
|
||||||
|
| 任务ID | 名称 | 类型 | 优先级 | 阶段 | 关联模块 |
|
||||||
|
|--------|------|------|--------|------|----------|
|
||||||
|
| F1 | 排行榜页(RankingView) | 新功能 | P0 | 待实现 | App.vue, AppSidebar, HomeView(移除区域3) |
|
||||||
|
| F1.1 | 应用下载排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | 复用 apmRanking/sparkRanking |
|
||||||
|
| F1.2 | 应用更新排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | apps(update 字段) |
|
||||||
|
| F2 | 星火荣耀榜页(HonorView) | 新功能 | P0 | 待实现 | App.vue, AppSidebar |
|
||||||
|
| F2.1 | 贡献荣耀榜(按来源聚合 contributor) | 新功能 | P0 | 待实现 | apps(contributor 字段) |
|
||||||
|
| F2.2 | 更新荣耀榜(最近更新应用 contributor 聚合) | 新功能 | P0 | 待实现 | apps(update+contributor) |
|
||||||
|
| F2.3 | 致谢说明卡片 | 新功能 | P1 | 待实现 | HonorView / HomeView 复用 |
|
||||||
|
| F3 | 侧边栏导航入口 | 新功能 | P0 | 待实现 | AppSidebar.vue, App.vue |
|
||||||
|
| M1 | 首页推荐页改造(去区域2/3,区域1加致谢) | 修改现有 | P0 | 待实现 | HomeView.vue |
|
||||||
|
| M2 | App.vue 视图路由与数据编排 | 修改现有 | P0 | 待实现 | App.vue |
|
||||||
|
| M3 | 榜单聚合工具函数 | 重构/提取 | P1 | 待优化 | 新增 util 或 App.vue 内 computed |
|
||||||
|
| O1 | 边加载边更新体验优化 | 优化 | P2 | 待优化 | HonorView(computed + watch) |
|
||||||
|
| O2 | 榜单条数/来源可配置 | 优化 | P2 | 待优化 | RankingView/HonorView props |
|
||||||
|
| O3 | 空状态与加载失败兜底 | 优化 | P1 | 待优化 | RankingView/HonorView |
|
||||||
|
| R1 | 类型安全与 lint 修复 | 修复 | P1 | 待修复 | 全部新增组件 |
|
||||||
|
| R2 | 与现有列表加载/更新逻辑的一致性校验 | 修复 | P1 | 待修复 | App.vue, install-manager |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 详细任务分解
|
||||||
|
|
||||||
|
### 4.1 新功能模块
|
||||||
|
|
||||||
|
#### F1 · 排行榜页(RankingView.vue)
|
||||||
|
|
||||||
|
**F1.1 应用下载排行**
|
||||||
|
- **改动描述**:新增 `RankingView` 组件,区块一展示「应用下载排行」,内部按来源分为 Spark 下载榜 / APM 下载榜两栏,各取下载量前 10 名。复用现有 `apmRanking` / `sparkRanking` 数据(由 `App.vue` 传入),列表项沿用 `AppCard` 的 `compact` 样式,并在左侧加排名序号(前 3 名用金/银/铜配色)。
|
||||||
|
- **预期目标**:与现有首页下载排行视觉一致,且独立成页后信息更聚焦。
|
||||||
|
- **关联模块**:`App.vue`(传入 `apmRanking`/`sparkRanking`)、`AppCard.vue`、`AppSidebar.vue`(入口)。
|
||||||
|
- **备注**:当前首页区域3 的展示可直接迁移,避免重复实现。
|
||||||
|
|
||||||
|
**F1.2 应用更新排行**
|
||||||
|
- **改动描述**:区块二展示「应用更新排行」,按来源分为 Spark 更新榜 / APM 更新榜两栏,各取 `app.update` 倒序前 10 名;列表项复用 `AppCard` compact 样式,副信息显示版本与更新时间(替换下载量徽章)。
|
||||||
|
- **预期目标**:让用户快速了解近期活跃更新的应用。
|
||||||
|
- **关联模块**:`App.vue`(`apps` 全量数据)、`AppCard.vue`。
|
||||||
|
- **备注**:排序用 `update.localeCompare` 倒序(格式统一可行)。
|
||||||
|
|
||||||
|
#### F2 · 星火荣耀榜页(HonorView.vue)
|
||||||
|
|
||||||
|
**F2.1 贡献荣耀榜**
|
||||||
|
- **改动描述**:区块一展示「贡献荣耀榜」,按来源分为 Spark / APM 两栏,各取 `app.contributor` 出现次数前 10 名(聚合基于已加载 `apps`,实时计算)。列表项显示排名、贡献者名(去除 `<email>` 后缀)、上榜应用数徽章。
|
||||||
|
- **预期目标**:表彰对星火生态贡献最多的开发者/维护者。
|
||||||
|
- **关联模块**:`App.vue`(`apps`)、`HonorView.vue`。
|
||||||
|
|
||||||
|
**F2.2 更新荣耀榜**
|
||||||
|
- **改动描述**:区块二展示「更新荣耀榜」,按来源分为 Spark / APM 两栏;取最近更新(按 `update` 倒序)的前 30 个应用,聚合其 `contributor` 出现次数,取前 10 名展示。
|
||||||
|
- **预期目标**:突出近期持续维护应用的贡献者。
|
||||||
|
- **关联模块**:`App.vue`(`apps`)、`HonorView.vue`。
|
||||||
|
- **备注**:与 F1.2 同源(最近更新应用列表),可共享计算。
|
||||||
|
|
||||||
|
**F2.3 致谢说明卡片**
|
||||||
|
- **改动描述**:在荣耀榜页顶部加入美化后的致谢卡片(渐变琥珀背景 + 图标 + 文案「致每一位星火贡献者…🎉」);同一卡片样式也用于首页推荐区域1(见 M1)。
|
||||||
|
- **预期目标**:强化社区归属感,视觉与整体风格统一。
|
||||||
|
- **关联模块**:`HonorView.vue`、`HomeView.vue`(复用同一段模板/组件)。
|
||||||
|
|
||||||
|
#### F3 · 侧边栏导航入口
|
||||||
|
|
||||||
|
- **改动描述**:在 `AppSidebar.vue` 新增「排行榜」「荣耀榜」两个一级入口,点击分别 `selectTab('ranking')` / `selectTab('honor')`,激活态沿用 `.sidebar-tab-active` 样式。
|
||||||
|
- **预期目标**:用户可从侧边栏直达新页面。
|
||||||
|
- **关联模块**:`AppSidebar.vue`、`App.vue`(`activeTab` 状态)。
|
||||||
|
|
||||||
|
### 4.2 需要修改的现有模块
|
||||||
|
|
||||||
|
#### M1 · 首页推荐页改造(HomeView.vue)
|
||||||
|
|
||||||
|
- **改动描述**:移除模板中的**区域2·精选板块**(`recommendSections` 应用列表区块)与**区域3·下载排行**(`apmRanking`/`sparkRanking` 区块)及其对应 props(`recommendSections`、`apmRanking`、`sparkRanking`、`rankingLoading`);在区域1(精选链接 `homelinks` 网格)顶部插入致谢说明卡片(F2.3 同款)。
|
||||||
|
- **预期目标**:首页回归轻量入口定位,下载排行/精选板块职责移交排行榜页与「全部应用」。
|
||||||
|
- **关联模块**:`HomeView.vue`、`App.vue`(停止向 HomeView 传 ranking/recommend 数据,或保留 recommend 供「全部应用」使用——需确认 recommendSections 是否仍被其他入口复用)。
|
||||||
|
- **备注**:⚠️ 需确认 `recommendSections` 是否仅首页使用;若「全部应用」等也依赖,则不能简单删除数据加载。
|
||||||
|
|
||||||
|
#### M2 · App.vue 视图路由与数据编排
|
||||||
|
|
||||||
|
- **改动描述**:主内容区新增 `v-else-if="activeTab === 'ranking'"` → `<RankingView>`、`v-else-if="activeTab === 'honor'"` → `<HonorView>`;将现有 `apmRanking`/`sparkRanking` 改传 `RankingView`;荣耀榜所需的全量 `apps` 以 `computed` 形式实时传入 `HonorView`(支撑"边加载边更新")。
|
||||||
|
- **预期目标**:打通新页面路由与数据流。
|
||||||
|
- **关联模块**:`App.vue`、`RankingView.vue`、`HonorView.vue`。
|
||||||
|
|
||||||
|
#### M3 · 榜单聚合工具函数(待优化阶段)
|
||||||
|
|
||||||
|
- **改动描述**:将"按来源取 Top N""聚合 contributor 次数"等逻辑提取为独立 `util` 函数(如 `src/modules/ranking.ts`),供 RankingView/HonorView 复用,避免组件中重复实现。
|
||||||
|
- **预期目标**:提升可维护性、便于单元测试。
|
||||||
|
- **关联模块**:新增 `src/modules/ranking.ts`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阶段划分与执行顺序
|
||||||
|
|
||||||
|
### 阶段一 · 待实现(P0,核心交付)
|
||||||
|
1. **M2** App.vue 路由骨架 + **F3** 侧边栏入口(打通页面切换)
|
||||||
|
2. **F1** RankingView(F1.1 下载榜 → F1.2 更新榜)
|
||||||
|
3. **F2** HonorView(F2.1 贡献榜 → F2.2 更新榜 → F2.3 致谢)
|
||||||
|
4. **M1** HomeView 首页改造(去区域2/3 + 区域1 致谢)
|
||||||
|
|
||||||
|
### 阶段二 · 待优化(P1–P2,体验打磨)
|
||||||
|
- **O3** 空状态/加载失败兜底(榜单为空、apps 未加载时友好提示)
|
||||||
|
- **M3 / O2** 聚合工具提取、榜单条数可配置(当前固定 10)
|
||||||
|
- **O1** 边加载边更新:用 `computed` + `watch(apps)` 实时刷新,进入荣耀榜即开始聚合,无需手动定时器
|
||||||
|
|
||||||
|
### 阶段三 · 待修复(P1,质量保障)
|
||||||
|
- **R1** 全部新增组件通过 `vue-tsc` 类型检查与 ESLint(严格模式,禁用 `any`)
|
||||||
|
- **R2** 校验与现有列表加载(`loadCategories`/`refreshInstalledApps`)、更新逻辑的一致性,避免重复遍历导致的性能问题(全量 `apps` 聚合建议在 `computed` 中记忆化)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与待确认项
|
||||||
|
|
||||||
|
1. **推荐数据归属**:`recommendSections` 是否仅首页使用?若「全部应用」等入口复用,M1 删除区域2 时需保留数据加载逻辑,仅移除模板渲染。
|
||||||
|
2. **荣耀榜数据范围**:确认基于"已加载 `apps`"即可满足需求(用户已确认"只基于已加载应用"),无需分页/全量后端接口。
|
||||||
|
3. **榜单条数**:当前统一为 10 条(用户确认),后续是否需要在 UI 上可切换(O2)。
|
||||||
|
4. **贡献者字段格式**:`contributor` 形如 `name<email>`,聚合与展示时需统一去除 `<...>` 取显示名;多个贡献者是否以 `;` 分隔需抽样确认。
|
||||||
|
5. **storeFilter 影响**:下载榜已按 Spark/APM 分榜;更新榜/荣耀榜按来源分布已实现。若 `storeFilter==='spark'` 仅显示 Spark 榜,需在主内容区做来源过滤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
- [ ] 侧边栏出现「排行榜」「荣耀榜」入口,点击正确切换页面
|
||||||
|
- [ ] 排行榜页:下载榜(Spark/APM)与更新榜(Spark/APM)各显示 Top 10,视觉与现有 AppCard 一致
|
||||||
|
- [ ] 荣耀榜页:贡献荣耀榜、更新荣耀榜各按来源 Top 10,且随 `apps` 加载实时更新;顶部致谢卡片正确显示
|
||||||
|
- [ ] 首页推荐:仅保留区域1(精选链接)+ 致谢卡片,区域2/3 已移除
|
||||||
|
- [ ] 全部新增/修改通过 `npm run lint` 与 `vue-tsc` 类型检查
|
||||||
|
- [ ] 打包(`dpkg-buildpackage`)成功,功能在 GUI 环境自测通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档基于 `ranking-preview.html` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。*
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# 测试记录文档(Testing Records)
|
||||||
|
|
||||||
|
> 用途:每次**修改代码后打包测试**都要在此追加一条记录,避免再次出现「改了 URL/逻辑后更新功能异常」这类回归问题。
|
||||||
|
> 维护约定(用户 2026-08-13 明确要求):
|
||||||
|
> 1. **每次修改代码都必须打包测试**(开发/自测统一用 `./scripts/test-build.sh`,产物带 `-test` 标签)。
|
||||||
|
> 2. 打包前先在此文档新增一条记录:填写修改功能、建议测试方式、预期结果。
|
||||||
|
> 3. 打包完成(真机或构建通过后)**回填测试结果**(通过 / 失败 + 现象 + 修复动作)。
|
||||||
|
> 4. 涉及更新中心 / 下载 / 安装 / metalink / 网络 URL 等改动时,必须包含「更新中心实际勾选升级一个应用,观察日志是否推进到下载与安装」这一回归项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录格式(复制此模板填写)
|
||||||
|
|
||||||
|
```
|
||||||
|
### [版本号] 日期 — 一句话主题
|
||||||
|
- 修改功能:
|
||||||
|
- 涉及文件:
|
||||||
|
- 建议测试方式:
|
||||||
|
- 预期结果:
|
||||||
|
- 构建验证:vue-tsc / 打包(PASS / FAIL)
|
||||||
|
- 测试结果:(待回填)
|
||||||
|
- 真机现象:
|
||||||
|
- 结论:通过 / 失败
|
||||||
|
- 失败修复:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 历史记录
|
||||||
|
|
||||||
|
### [5.2.1.31-test] 2026-08-13 — 更新中心 hold 锁定标签 + 强制安装 + 修复安装失败误报"下载完成"
|
||||||
|
- 修改功能:
|
||||||
|
1. 更新中心识别 `apt-mark hold` 锁定的包:默认不可选中,显示「已锁定」标签;提供「强制安装」开关,开启后 spark 源 ssinstall 追加 `--allow-change-held-packages`。
|
||||||
|
2. 修复安装失败误报:ssinstall 放弃安装时仍以退出码 0 结束,原逻辑误判成功导致 UI 显示「下载完成」;现检测 `放弃安装`/`dry-run测试仍然失败` 等标志判失败,并按任务来源显示「更新完成/更新失败」或「安装完成/安装失败」。
|
||||||
|
- 涉及文件:
|
||||||
|
- 后端:`update-center/types.ts`、`update-center/index.ts`、`update-center/service.ts`、`install-manager.ts`
|
||||||
|
- 前端:`typedefinition.ts`、`modules/updateCenter.ts`、`modules/processInstall.ts`、`components/update-center/UpdateCenterItem.vue`、`components/update-center/UpdateCenterList.vue`、`components/UpdateCenterModal.vue`
|
||||||
|
- 建议测试方式:
|
||||||
|
1. 更新中心打开,确认被 hold 的包(如 `code`)显示「已锁定」、复选框禁用、顶部提示条出现。
|
||||||
|
2. 勾选其它普通更新项 → 开始更新 → 观察日志推进到「正在获取 Metalink」→ 下载进度 → 安装完成(不是「下载完成」)。
|
||||||
|
3. 打开 `code` 的「强制安装」开关 → 复选框可用、标签变「将强制」→ 勾选并升级 → 观察是否正常升级(需本机 `code` 被 hold 才能验证)。
|
||||||
|
4. 回归:用「更新中心实际升级一个普通应用」验证 metalink/下载/安装全链路未被本次改动破坏(此前 28-test 因 metalinkUrl 域名误杀出过同类问题)。
|
||||||
|
- 预期结果:hold 项默认不可选;强制开关可单独升级 hold 包;安装失败时明确显示「安装失败」而非「下载完成」;普通更新链路正常。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.31-test_amd64.deb`。
|
||||||
|
- 测试结果:真机验证发现"失效"现象(见下方 32-test 修复说明)。
|
||||||
|
- 真机现象:用户装 31-test 后从更新中心点更新,日志仅 `[14:28:23] 开始更新...` 一行后无进展。
|
||||||
|
- 结论:非代码 bug,是 31 新增的 hold 拦截**正确行为**——用户测试的包(code)被 `apt-mark hold` 锁定,31 默认拦截它;但用户未开启该行的「强制安装」开关就点开始更新,后端 `start()` 把它过滤掉后**静默 return**,前端无任何提示,造成"只有一行日志"的观感。
|
||||||
|
- 失败修复:32-test 修复后端 `start()` 静默拦截问题——对被 hold 且未强制的选中项,明确向前端发送 `install-complete` 失败通知(提示"已在更新中心默认跳过,请开启强制安装后重试"),不再静默卡住。
|
||||||
|
|
||||||
|
### [5.2.1.32-test] 2026-08-13 — 修复被 hold 包未强制时更新中心"静默卡开始更新"
|
||||||
|
- 修改功能:后端 `update-center/service.ts` 的 `start()` 不再对"被锁定(hold)且未开启强制安装"的选中项静默跳过。改为:先分离可启动项与被锁拦截项;对被锁拦截项向前端发送明确的 `install-complete` 失败通知(携带提示文案);仅对真正可启动的项调用 `addInstallTask`。前端 `processInstall.ts` 的 install-complete handler 会将失败解析为「更新失败:...」,用户能看到原因而非停在「开始更新...」。
|
||||||
|
- 涉及文件:`update-center/service.ts`(仅后端;前端复用已有的 install-complete 失败显示逻辑,无需改)。
|
||||||
|
- 建议测试方式:
|
||||||
|
1. 更新中心勾选一个**普通(未 hold)**的更新项 → 开始更新 → 日志应推进到「正在获取 Metalink」→ 下载进度 → 更新完成(确认 31 的 hold 改动未破坏普通更新链路)。
|
||||||
|
2. 勾选一个**被 hold** 的包(如 `code`)且**不开**其「强制安装」开关 → 开始更新 → 日志应显示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,而非只有「开始更新...」。
|
||||||
|
3. 开启该 hold 包的「强制安装」开关 → 勾选并升级 → 应正常进入下载/安装(验证强制路径)。
|
||||||
|
- 预期结果:普通更新正常;hold 未强制时给出明确失败提示而非静默卡住;hold 强制后可正常升级。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.32-test_amd64.deb`。
|
||||||
|
- 测试结果:失败。
|
||||||
|
- 真机现象:用户装 32-test 后,对 `code` 开启「强制安装」开关再点「更新选中」,日志仍只有 `[14:59:28] 开始更新...` 一行后无进展;同时「更新选中」后该包从更新中心列表消失,未保留。
|
||||||
|
- 失败原因:
|
||||||
|
1. `service.ts` 的 `start()` 中 `taskIdByKey` 只存储了 `task.id`(数字),后续读取 `taskIdByKey.get(taskKey)?.forceHeld` 永远为 `undefined`,导致「强制安装」开关状态丢失;被 hold 包即使开启强制仍被当作未强制拦截。
|
||||||
|
2. held 阻断通知代码里 `webContents` 在通知循环之后才获取,导致通知实际未发出(但 32 中因原因 1 已把强制项也拦截,所以主要表现为卡住)。
|
||||||
|
3. `start()` 启动任务后主动从 `currentItems` 过滤掉已启动项,导致用户点击「更新选中」后包立刻从更新中心消失。
|
||||||
|
- 失败修复:见 33-test 记录。
|
||||||
|
|
||||||
|
### [5.2.1.33-test] 2026-08-13 — 修复强制安装开关失效 + 更新选中后列表消失 + 工具栏布局优化
|
||||||
|
- 修改功能:
|
||||||
|
1. 修复 `update-center/service.ts` 中 `taskIdByKey` 仅保存 `id` 导致 `forceHeld` 丢失的 bug:改为保存完整 `UpdateCenterStartTask` 对象,确保「强制安装」开关状态能正确传递到 `addInstallTask`。
|
||||||
|
2. 提前获取 `webContents` 到 held 阻断通知之前,确保被 hold 且未强制的项能向前端发送明确的 `install-complete` 失败通知。
|
||||||
|
3. 移除 `start()` 中启动后从更新中心 `items` 删除已选包的逻辑,保持列表不变,等待用户刷新或任务完成后再消失。
|
||||||
|
4. 优化 `UpdateCenterToolbar.vue`:将「全选」复选框和已选计数移到「更新选中」按钮同一行,缩短选择-执行操作路径。
|
||||||
|
- 涉及文件:
|
||||||
|
- 后端:`electron/main/backend/update-center/service.ts`
|
||||||
|
- 前端:`src/components/update-center/UpdateCenterToolbar.vue`
|
||||||
|
- 建议测试方式:
|
||||||
|
1. 更新中心勾选普通未 hold 包 → 开始更新 → 日志推进到「正在获取 Metalink」→ 下载/安装完成(非「下载完成」、非卡死)。
|
||||||
|
2. 被 hold 包(如 `code`)不开「强制安装」→ 开始更新 → 明确提示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,不只有「开始更新...」。
|
||||||
|
3. 被 hold 包开启「强制安装」→ 勾选并升级 → 应正常进入下载/安装流程(ssinstall 命令带 `--allow-change-held-packages`)。
|
||||||
|
4. 点击「更新选中」后,已选包仍保留在更新中心列表中,不会立即消失。
|
||||||
|
5. 观察更新中心工具栏:「全选」复选框、已选计数、「更新选中」按钮在同一行右侧,操作便捷。
|
||||||
|
- 预期结果:强制安装真正生效;hold 未强制有明确失败提示;普通更新链路正常;更新选中后列表保留;工具栏操作更紧凑。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.33-test_amd64.deb`。
|
||||||
|
- 测试结果:通过(用户确认优化与更新处理正确)。
|
||||||
|
- 真机现象:用户装 33-test 后确认强制安装开关生效、hold 未强制有明确失败提示、普通更新链路正常。
|
||||||
|
- 结论:通过
|
||||||
|
- 遗留反馈:选中执行更新后,包进入下载列表却仍显示在更新中心(33-test 误删了"从列表移除已启动项"逻辑);且全选按钮在无可选项时仍可点、无提示。两处已在 34-test 修复。
|
||||||
|
|
||||||
|
### [5.2.1.34-test] 2026-08-13 — 恢复更新后从列表移除 + 无可选项时全选禁用提示
|
||||||
|
- 修改功能:
|
||||||
|
1. 恢复 33-test 误删的逻辑:更新中心「更新选中」启动任务后,已启动项从更新中心 `items` 移除(被 hold 未强制拦截的项不在此列,仍保留供用户开强制重试)。更新中心只展示待更新项,避免与下载队列重复显示。
|
||||||
|
2. `modules/updateCenter.ts` 新增 `selectableCount` computed(可选项数 = 未被忽略且非"held 未强制"的项数)。
|
||||||
|
3. `UpdateCenterToolbar.vue`:当 `selectableCount === 0`(全是 hold 未强制、全是忽略项、或完全没有可更新软件)时,全选复选框 `:disabled` 且整体置灰,右侧文案改为「无可更新项」并带提示 title。
|
||||||
|
- 涉及文件:
|
||||||
|
- 后端:`electron/main/backend/update-center/service.ts`
|
||||||
|
- 前端:`src/modules/updateCenter.ts`、`src/components/UpdateCenterModal.vue`、`src/components/update-center/UpdateCenterToolbar.vue`
|
||||||
|
- 建议测试方式:
|
||||||
|
1. 勾选若干普通更新项 → 更新选中 → 这些包应从更新中心列表消失,仅出现在下载队列(之前 33-test 会残留,现恢复)。
|
||||||
|
2. 当列表里所有项都是 hold 未强制 / 已忽略,或列表为空时:全选复选框应禁用置灰、文案显示「无可更新项」,点击无反应;「更新选中」按钮也因无选中而禁用。
|
||||||
|
- 预期结果:更新选中后不在更新中心残留;无可选项时全选禁用并提示。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.34-test_amd64.deb`。
|
||||||
|
- 测试结果:待真机回填(本机无 GUI)。34-test 用户已确认更新后从列表移除、全选禁用提示均生效;但反馈开启强制后 code 仍更新失败(见 35-test 根因)。
|
||||||
|
- 真机现象:
|
||||||
|
- 结论:通过 / 失败
|
||||||
|
- 失败修复:
|
||||||
|
|
||||||
|
### [5.2.1.35-test] 2026-08-13 — 修复强制安装在 ssinstall 本地文件模式误加 apt 标志
|
||||||
|
- 修改功能:`install-manager.ts` 中 spark 源的 ssinstall 命令构建逻辑。此前对**本地 .deb 文件模式**(`metalinkUrl && filename`)也追加了 `--allow-change-held-packages`,但该选项不是 ssinstall 支持的参数(ssinstall 用法 `ssinstall [选项] <deb路径>`,选项仅自身专用),会被其透传给内部 `dirname`/`basename` 导致参数解析失败、包名丢失,进而 `E: 无法定位软件包` / `Package manager quit with exit code` 失败。
|
||||||
|
- 本地 .deb 文件模式:改走 `dpkg` 直接安装,dpkg 安装本地文件**不受 apt `hold` 限制**(hold 仅拦截 `apt install <包名>` 从仓库升级),故**不再追加**该标志。
|
||||||
|
- 仓库模式(`ssinstall pkgname`):从 apt 仓库拉取,hold 会拦截,保留 `forceHeld` 时追加 `--allow-change-held-packages`。
|
||||||
|
- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端)。
|
||||||
|
- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应正常进入 ssinstall 本地 .deb 安装,不再出现 `dirname/basename 未识别的选项` 与 `无法定位软件包`,最终「更新完成」。
|
||||||
|
- 预期结果:强制安装本地 .deb 不再因误加 apt 标志而失败;普通(未 hold)与仓库模式行为不变。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.35-test_amd64.deb`。
|
||||||
|
- 测试结果:失败(用户 15:55 日志)。
|
||||||
|
- 真机现象:开 code 强制 → 更新 → `E: 在更改保留软件包的同时使用了 -y 选项,但没有搭配 --allow-change-held-packages.` → `dry-run测试仍然失败,放弃安装` → 更新失败。
|
||||||
|
- 根因:实测证实 ssinstall 本地 .deb 模式在 `dpkg -i` 失败后兜底 `aptss install <deb> -yfq`,aptss/apt 对 held 包用 `-y` 强制要求 `--allow-change-held-packages`;而 ssinstall 不识别该参数(透传给 dirname/basename 崩溃),故 35-test 去掉标志后仍被 apt 拒绝。35-test 方案(依赖 dpkg 不拦 hold)不成立。
|
||||||
|
- 失败修复:见 36-test(改用 unhold/hold 包裹策略)。
|
||||||
|
|
||||||
|
### [5.2.1.36-test] 2026-08-13 — 强制安装改用 apt-mark unhold/hold 包裹(绕开 ssinstall 不支持该标志)
|
||||||
|
- 修改功能:`install-manager.ts` 的强制安装(forceHeld)策略从"传 `--allow-change-held-packages` 给 ssinstall"改为"安装前 `pkexec apt-mark unhold`、安装后(finally)`pkexec apt-mark hold` 恢复锁定"。
|
||||||
|
- 新增模块函数 `runAptMark(action, pkgname)`:`checkSuperUserCommand()` 取 pkexec,直接用 `pkexec apt-mark <hold|unhold> pkgname`(**不经过 shell-caller.sh**,因其白名单仅放行 apm/aptss/ssinstall,否则报"拒绝执行")。
|
||||||
|
- `runInstallPhase`:`try` 开头若 `task.forceHeld && task.origin==='spark'` 先 `runAptMark("unhold")`,`finally` 中无论成败都 `runAptMark("hold")` 恢复原锁定状态。
|
||||||
|
- 移除 spark 源 ssinstall 命令中任何 `--allow-change-held-packages`(ssinstall 不识别)。
|
||||||
|
- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端;forceHeld 字段链路 service.ts→QueueInstallPayload→task 已就绪)。
|
||||||
|
- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应出现「正在解除系统锁定(hold)...」→ ssinstall 本地安装成功 → 最终「更新完成」;另查 `apt-mark showhold` 确认安装后 code 仍被锁定(状态恢复)。
|
||||||
|
- 预期结果:强制安装 held 包成功;安装后 hold 状态自动恢复,不破坏用户原本的锁定。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.36-test_amd64.deb`。
|
||||||
|
- 测试结果:未真机验证即发现更优方案(见 37-test)。36-test 的 `runAptMark` 用 `pkexec apt-mark` 会弹额外权限框,且 apt-mark 不在 policykit 免密 exec.path 内可能直接失败。
|
||||||
|
- 失败修复:见 37-test(合并进商店已有免密 pkexec)。
|
||||||
|
|
||||||
|
### [5.2.1.37-test] 2026-08-13 — 强制安装合并进免密 pkexec(不重复请求权限)
|
||||||
|
- 修改功能:用户指出"商店已有提权配置,unhold/hold 不必再请求权限"。调查确认 policykit 规则(`extras/store.spark-app.spark-store.policy`、`pkg/.../ssinstall.policy`)仅对 `exec.path=/opt/spark-store/extras/shell-caller.sh` 与 `/usr/local/bin/ssinstall` 设 `allow_any=yes`(免密)。`pkexec apt-mark` 不在任何 policy 的 exec.path → 弹额外密码框且可能失败。
|
||||||
|
- `extras/shell-caller.sh` 新增 `force-ssinstall` 分支:在一次已提权的 pkexec 会话内执行 `apt-mark unhold <pkg>` → `ssinstall <deb> [额外参数] --native` → `apt-mark hold <pkg>`;unhold 失败仅警告不阻断,hold 无论成败都恢复;参数用双引号防注入、空参校验。
|
||||||
|
- `install-manager.ts`:`forceHeld && origin==='spark' && 本地 .deb` 时,构造命令走 `shell-caller force-ssinstall <pkg> <deb> --delete-after-install --no-create-desktop-entry`(即复用 ssinstall 那条免密 pkexec,仅一次权限请求);移除 36-test 的 `runAptMark` helper 及 runInstallPhase 的 try/finally 单独 pkexec apt-mark 调用。
|
||||||
|
- 涉及文件:`extras/shell-caller.sh`、`electron/main/backend/install-manager.ts`。
|
||||||
|
- 建议测试方式:开启 `code`(被 hold)「强制安装」→ 勾选升级 → 应**仅弹一次**权限框(与平时 ssinstall 一致)→ 日志 ssinstall 本地安装成功 → 更新完成;装后 `apt-mark showhold` 确认 code 仍锁定(已自动恢复)。
|
||||||
|
- 预期结果:强制安装成功且只请求一次权限;hold 状态自动恢复。
|
||||||
|
- 构建验证:vue-tsc 通过(exit 0);`bash -n shell-caller.sh` 语法 OK;`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.37-test_amd64.deb`。
|
||||||
|
- 测试结果:待真机回填(本机无 GUI)。
|
||||||
|
- 真机现象:
|
||||||
|
- 结论:通过 / 失败
|
||||||
|
- 失败修复:
|
||||||
|
|
||||||
|
### [5.2.1.29-test / 5.2.1.30-test] 2026-08-13 — 修复更新中心卡"开始更新..." + metalinkUrl 域名误杀清理
|
||||||
|
- 修改功能:
|
||||||
|
1. 29-test 修复 metalinkUrl 校验写死 `erotica.spark-app.store` 误杀真实 CDN 域 `d.spark-app.store`:放宽为 `*.spark-app.store` 全子域 + https + path.posix.normalize 折叠 `./` + 拒绝 `..` 越界。
|
||||||
|
2. 30-test 清理调试期噪声([debug] 日志、FILENAME_PATTERN 拆分、stream error 监听、processInstall 自监听),保留真正修复。
|
||||||
|
- 涉及文件:`install-manager.ts`、`processInstall.ts`
|
||||||
|
- 建议测试方式:更新中心勾选并升级 `trae-cn` 等普通应用,观察日志推进到「正在获取 Metalink 文件」→ 下载进度。
|
||||||
|
- 预期结果:更新中心更新功能恢复正常,不再卡「开始更新...」或「下载失败: Metalink URL 不合法」。
|
||||||
|
- 构建验证:vue-tsc 通过;打包成功(29/30-test)。
|
||||||
|
- 测试结果:用户确认 29-test 修复有效。
|
||||||
|
- 真机现象:29-test 更新中心升级 trae-cn 正常推进到下载阶段。
|
||||||
|
- 结论:通过
|
||||||
|
|
||||||
|
### [5.2.1.26-test ~ 5.2.1.28-test] 2026-08-13 — 更新中心"更新"卡死的多次排查
|
||||||
|
- 修改功能:
|
||||||
|
1. 26-test:抽离 `addInstallTask(payload, sender)`,`service.ts` 的 `start()` 直接调用(修复 `webContents.send("queue-install")` 主进程自环 bug,任务从未入队);并加 FILENAME_PATTERN、各 return 分支补 install-complete 通知、metalink 流错误监听、[debug] 日志(部分属调试噪声,后续 30-test 回退)。
|
||||||
|
2. 28-test:暴露真正根因 metalinkUrl 域名校验误杀 `d.spark-app.store`(见 29-test 修复)。
|
||||||
|
- 涉及文件:`install-manager.ts`、`update-center/service.ts`
|
||||||
|
- 建议测试方式:更新中心实际升级应用 + 普通安装对照。
|
||||||
|
- 预期结果:更新中心任务能真正进入下载队列并推进。
|
||||||
|
- 构建验证:vue-tsc 通过;打包成功。
|
||||||
|
- 测试结果:
|
||||||
|
- 28-test 日志首次暴露 metalinkUrl 域名误杀(见上方 29-test 修复)。
|
||||||
|
- 结论:根因定位完成,29-test 修复通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试 checklist(每次打包必做核心项)
|
||||||
|
|
||||||
|
- [ ] `vue-tsc --noEmit` 通过,`scripts/test-build.sh` 打包成功
|
||||||
|
- [ ] **更新中心**:勾选并升级一个普通应用,日志推进 开始更新 → 正在获取 Metalink → 下载进度 → 安装完成(确认不是「下载完成」且非「开始更新...」卡死)
|
||||||
|
- [ ] **普通安装**:应用详情页安装一个应用,确认链路正常
|
||||||
|
- [ ] **忽略功能**:更新中心忽略/取消忽略一项,确认沉底与不可选
|
||||||
|
- [ ] **hold/强制**(若改动更新中心):被 hold 包默认不可选、强制开关可单独升级
|
||||||
|
- [ ] 安装失败时 UI 明确显示「安装失败」而非「下载完成」
|
||||||
@@ -12,6 +12,32 @@ import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
|
|||||||
|
|
||||||
const logger = pino({ name: "install-manager" });
|
const logger = pino({ name: "install-manager" });
|
||||||
|
|
||||||
|
// 包名白名单:仅允许合法包名字符,杜绝命令注入(spawn 用 shell:false 仍须校验)。
|
||||||
|
const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/;
|
||||||
|
|
||||||
|
// 文件名(.deb)白名单:按 dpkg 规则允许包名/版本号合法字符。
|
||||||
|
// 含 epoch 冒号(steam-launcher_1:1.0.0.87_amd64.deb)与排序标记 ~(1.0~beta),
|
||||||
|
// 以及字母数字、. _ + -。路径遍历由下方 basename/".." 检查兜底。
|
||||||
|
// 注意字符组内 : 与 ~ 须用 \- 等转义字符隔离,避免形成正则区间(如 [:~] 会误匹配大量符号)。
|
||||||
|
const FILENAME_PATTERN = /^[A-Za-z0-9._+:\-~]+$/;
|
||||||
|
|
||||||
|
// 解析并校验应用类 IPC 的 payload(可能是旧版字符串或对象)。
|
||||||
|
// 返回规范化后的 { pkgname, origin },pkgname 非法时返回 null。
|
||||||
|
const parseAppPayload = (
|
||||||
|
payload: unknown,
|
||||||
|
): { pkgname: string; origin: "spark" | "apm" } | null => {
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
if (!PKGNAME_PATTERN.test(payload)) return null;
|
||||||
|
return { pkgname: payload, origin: "spark" };
|
||||||
|
}
|
||||||
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
|
const p = payload as Record<string, unknown>;
|
||||||
|
const pkgname = typeof p.pkgname === "string" ? p.pkgname : "";
|
||||||
|
if (!PKGNAME_PATTERN.test(pkgname)) return null;
|
||||||
|
const origin: "spark" | "apm" = p.origin === "apm" ? "apm" : "spark";
|
||||||
|
return { pkgname, origin };
|
||||||
|
};
|
||||||
|
|
||||||
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
|
const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => {
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
const noApm = argv.includes("--no-apm");
|
const noApm = argv.includes("--no-apm");
|
||||||
@@ -30,6 +56,18 @@ const isOriginEnabled = (
|
|||||||
return storeFilter === "both" || storeFilter === origin;
|
return storeFilter === "both" || storeFilter === origin;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface QueueInstallPayload {
|
||||||
|
id: number;
|
||||||
|
pkgname: string;
|
||||||
|
metalinkUrl?: string;
|
||||||
|
filename?: string;
|
||||||
|
origin?: "spark" | "apm";
|
||||||
|
upgradeOnly?: boolean;
|
||||||
|
retry?: boolean;
|
||||||
|
// 强制安装被系统锁定(apt-mark hold)的包:spark 源安装时追加 --allow-change-held-packages
|
||||||
|
forceHeld?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type InstallTask = {
|
type InstallTask = {
|
||||||
id: number;
|
id: number;
|
||||||
pkgname: string;
|
pkgname: string;
|
||||||
@@ -42,7 +80,9 @@ type InstallTask = {
|
|||||||
metalinkUrl?: string;
|
metalinkUrl?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
origin: "spark" | "apm";
|
origin: "spark" | "apm";
|
||||||
|
upgradeOnly?: boolean;
|
||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
|
forceHeld?: boolean;
|
||||||
phase: "queued-download" | "downloading" | "queued-install" | "installing";
|
phase: "queued-download" | "downloading" | "queued-install" | "installing";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,35 +302,170 @@ const parseUpgradableList = (output: string) => {
|
|||||||
return apps;
|
return apps;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for download requests from renderer process
|
/**
|
||||||
ipcMain.on("queue-install", async (event, download_json) => {
|
* 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。
|
||||||
const download =
|
* 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。
|
||||||
typeof download_json === "string"
|
* @returns 是否成功入队
|
||||||
? JSON.parse(download_json)
|
*/
|
||||||
: download_json;
|
export const addInstallTask = async (
|
||||||
const { id, pkgname, metalinkUrl, filename, origin } = download || {};
|
download: QueueInstallPayload,
|
||||||
|
sender: WebContents | null,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const webContents = sender;
|
||||||
|
const { id, pkgname, filename, origin, upgradeOnly, forceHeld } = download;
|
||||||
|
// metalinkUrl 可能在下方校验时被规范化(折叠 ./ 段),故用 let
|
||||||
|
let { metalinkUrl } = download;
|
||||||
|
|
||||||
if (!id || !pkgname) {
|
if (!id || !pkgname) {
|
||||||
logger.warn("passed arguments missing id or pkgname");
|
logger.warn("addInstallTask: passed arguments missing id or pkgname");
|
||||||
return;
|
webContents?.send("install-complete", {
|
||||||
|
id: id ?? 0,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: "任务参数缺失 id 或 pkgname",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包名白名单校验:杜绝命令注入(spawn shell:false 仍须校验,且包名不含路径字符)。
|
||||||
|
if (!PKGNAME_PATTERN.test(pkgname)) {
|
||||||
|
logger.warn(`addInstallTask invalid pkgname: ${pkgname}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `包名包含非法字符: ${pkgname}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 文件名(.deb)白名单校验:允许 Debian 包合法字符(含 epoch 冒号),并拒绝路径遍历
|
||||||
|
//("/" 与 ".." 段),防止写入下载目录之外的位置。
|
||||||
|
if (filename) {
|
||||||
|
if (!FILENAME_PATTERN.test(filename) || filename.includes("..") || path.basename(filename) !== filename) {
|
||||||
|
logger.warn(`addInstallTask invalid filename: ${filename}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `文件名包含非法字符: ${filename}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。
|
||||||
|
// 纵深防御:仅允许 https 协议、官方域名(含任意子域 *.spark-app.store)的绝对 URL,
|
||||||
|
// 或以 "/" 开头的相对路径(后续由 axios baseURL 拼接官方域名)。
|
||||||
|
// 先解析并规范化 path 中的 "./" 段(浏览器/Node 实际请求时会自动折叠,此处显式处理,
|
||||||
|
// 避免把合法的 "./" 误判为非法),再拒绝 ".." 路径遍历越界与外部域名(防 SSRF)。
|
||||||
|
if (metalinkUrl) {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
// 相对路径以官方域为基进行解析
|
||||||
|
parsed = new URL(metalinkUrl, "https://erotica.spark-app.store");
|
||||||
|
} catch {
|
||||||
|
logger.warn(`addInstallTask invalid metalinkUrl (parse failed): ${metalinkUrl}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `Metalink URL 不合法: ${metalinkUrl}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
logger.warn(`addInstallTask invalid metalinkUrl (not https): ${metalinkUrl}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `Metalink URL 协议不合法: ${metalinkUrl}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅允许官方域名(主域 spark-app.store 及其任意子域,如 erotica/d/store 等)
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const isOfficial =
|
||||||
|
host === "spark-app.store" || host.endsWith(".spark-app.store");
|
||||||
|
if (!isOfficial) {
|
||||||
|
logger.warn(`addInstallTask invalid metalinkUrl (host not official): ${host}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `Metalink URL 域名不合法: ${metalinkUrl}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 path:折叠 "." / ".." 段,拒绝越界路径遍历
|
||||||
|
const normalizedPath = path.posix.normalize(parsed.pathname);
|
||||||
|
if (normalizedPath.split("/").includes("..")) {
|
||||||
|
logger.warn(`addInstallTask invalid metalinkUrl (path traversal): ${metalinkUrl}`);
|
||||||
|
webContents?.send("install-complete", {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `Metalink URL 含路径遍历: ${metalinkUrl}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parsed.pathname = normalizedPath;
|
||||||
|
// 用规范化后的 URL 继续(去除 "./" 段,与实际请求行为一致)
|
||||||
|
metalinkUrl = parsed.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
|
logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
|
||||||
|
|
||||||
const webContents = event.sender;
|
|
||||||
|
|
||||||
// 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载
|
// 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载
|
||||||
if (!download.retry) {
|
if (!download.retry) {
|
||||||
const existingTask = Array.from(tasks.values()).find(
|
const existingTask = Array.from(tasks.values()).find(
|
||||||
(t) => t.pkgname === pkgname && t.origin === origin,
|
(t) => t.pkgname === pkgname && t.origin === origin,
|
||||||
);
|
);
|
||||||
if (existingTask) {
|
if (existingTask) {
|
||||||
webContents.send("install-log", {
|
webContents?.send("install-log", {
|
||||||
id,
|
id,
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`,
|
message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`,
|
||||||
});
|
});
|
||||||
webContents.send("install-complete", {
|
webContents?.send("install-complete", {
|
||||||
id,
|
id,
|
||||||
success: false,
|
success: false,
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
@@ -301,20 +476,25 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
stderr: "",
|
stderr: "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const superUserCmd = await checkSuperUserCommand();
|
const superUserCmd = await checkSuperUserCommand();
|
||||||
let execCommand = "";
|
let execCommand = "";
|
||||||
const execParams = [];
|
const execParams: string[] = [];
|
||||||
const downloadDir = `/tmp/spark-store/download/${pkgname}`;
|
const downloadDir = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`spark-store-${process.pid}`,
|
||||||
|
"download",
|
||||||
|
pkgname,
|
||||||
|
);
|
||||||
|
|
||||||
// APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM
|
// APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM
|
||||||
if (origin === "apm") {
|
if (origin === "apm") {
|
||||||
const hasApm = await checkApmAvailable();
|
const hasApm = await checkApmAvailable();
|
||||||
if (!hasApm) {
|
if (!hasApm) {
|
||||||
webContents.send("trigger-apm-install-dialog");
|
webContents?.send("trigger-apm-install-dialog");
|
||||||
webContents.send("install-complete", {
|
webContents?.send("install-complete", {
|
||||||
id,
|
id,
|
||||||
success: false,
|
success: false,
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
@@ -325,7 +505,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
stderr: "",
|
stderr: "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,13 +514,29 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||||
|
|
||||||
if (metalinkUrl && filename) {
|
if (metalinkUrl && filename) {
|
||||||
|
const localDeb = `${downloadDir}/${filename}`;
|
||||||
|
if (forceHeld) {
|
||||||
|
// 强制安装被系统锁定(hold)的包:合并为单次免密 pkexec(复用商店已有的
|
||||||
|
// shell-caller 提权配置),由 shell-caller 的 force-ssinstall 分支内部完成
|
||||||
|
// apt-mark unhold → ssinstall 本地 .deb → apt-mark hold,避免单独 pkexec apt-mark
|
||||||
|
// 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。
|
||||||
|
execParams.push(
|
||||||
|
"force-ssinstall",
|
||||||
|
pkgname,
|
||||||
|
localDeb,
|
||||||
|
"--delete-after-install",
|
||||||
|
"--no-create-desktop-entry",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 本地 .deb 文件模式:已下载的 .deb 通过 ssinstall 安装。
|
||||||
execParams.push(
|
execParams.push(
|
||||||
"ssinstall",
|
"ssinstall",
|
||||||
`${downloadDir}/${filename}`,
|
localDeb,
|
||||||
"--delete-after-install",
|
"--delete-after-install",
|
||||||
"--no-create-desktop-entry",
|
"--no-create-desktop-entry",
|
||||||
"--native",
|
"--native",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
execParams.push(
|
execParams.push(
|
||||||
"ssinstall",
|
"ssinstall",
|
||||||
@@ -358,7 +554,14 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
execParams.push("apm");
|
execParams.push("apm");
|
||||||
|
|
||||||
if (metalinkUrl && filename) {
|
if (metalinkUrl && filename) {
|
||||||
execParams.push("ssinstall", `${downloadDir}/${filename}`);
|
// 防御性深度校验:即便 PKGNAME_PATTERN 已挡掉路径遍历字符,仍用 path.basename
|
||||||
|
// 确保 filename 为纯文件名、不含目录分量(belt-and-suspenders)
|
||||||
|
const safeFilename = path.basename(filename);
|
||||||
|
if (safeFilename !== filename) {
|
||||||
|
logger.warn(`ssinstall filename contains path traversal: ${filename}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
execParams.push("ssinstall", path.join(downloadDir, safeFilename));
|
||||||
} else {
|
} else {
|
||||||
execParams.push("install", "-y", pkgname);
|
execParams.push("install", "-y", pkgname);
|
||||||
}
|
}
|
||||||
@@ -376,15 +579,39 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
metalinkUrl,
|
metalinkUrl,
|
||||||
filename,
|
filename,
|
||||||
origin: origin || "apm",
|
origin: origin || "apm",
|
||||||
|
upgradeOnly: Boolean(upgradeOnly),
|
||||||
|
forceHeld: Boolean(forceHeld),
|
||||||
phase: metalinkUrl ? "queued-download" : "queued-install",
|
phase: metalinkUrl ? "queued-download" : "queued-install",
|
||||||
};
|
};
|
||||||
tasks.set(id, task);
|
tasks.set(id, task);
|
||||||
processNextDownload();
|
processNextDownload();
|
||||||
processNextInstall();
|
processNextInstall();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for download requests from renderer process
|
||||||
|
ipcMain.on("queue-install", async (event, download_json) => {
|
||||||
|
let download: unknown;
|
||||||
|
try {
|
||||||
|
download =
|
||||||
|
typeof download_json === "string"
|
||||||
|
? JSON.parse(download_json)
|
||||||
|
: download_json;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "queue-install: invalid JSON payload, ignoring task");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addInstallTask(download as QueueInstallPayload, event.sender);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cancel Handler
|
// Cancel Handler
|
||||||
ipcMain.on("cancel-install", (event, id) => {
|
ipcMain.on("cancel-install", (event, id) => {
|
||||||
|
// 防御性输入校验:id 应为整数,避免 Map 以非预期键查找导致逻辑异常
|
||||||
|
if (typeof id !== "number" || !Number.isInteger(id)) {
|
||||||
|
logger.warn(`cancel-install: invalid id type: ${typeof id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const task = tasks.get(id);
|
const task = tasks.get(id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
@@ -417,9 +644,22 @@ ipcMain.on("cancel-install", (event, id) => {
|
|||||||
const isRunning = task.phase === "downloading" || task.phase === "installing";
|
const isRunning = task.phase === "downloading" || task.phase === "installing";
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
// 运行中的任务:终止进程,由对应的阶段处理器在 finally 中清理计数器与队列
|
// 运行中的任务:先发 SIGTERM 优雅终止;若 5s 内未退出,降级 SIGKILL 强制杀除,
|
||||||
task.download_process?.kill();
|
// 避免子进程忽略 SIGTERM 变成僵尸进程(改进项:进程取消需 SIGKILL 降级处理)。
|
||||||
task.install_process?.kill();
|
const forceKill = (proc: ChildProcess | null, label: string) => {
|
||||||
|
if (!proc || proc.killed) return;
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
const pid = proc.pid;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (proc && !proc.killed) {
|
||||||
|
logger.warn(`任务 ${id} 的 ${label} 进程(${pid}) 未响应 SIGTERM,发送 SIGKILL`);
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
forceKill(task.download_process, "下载");
|
||||||
|
forceKill(task.install_process, "安装");
|
||||||
|
// 由对应的阶段处理器在 finally 中清理计数器与队列
|
||||||
} else {
|
} else {
|
||||||
// 排队中的任务(未开始执行):直接清理并调度
|
// 排队中的任务(未开始执行):直接清理并调度
|
||||||
tasks.delete(id);
|
tasks.delete(id);
|
||||||
@@ -496,21 +736,31 @@ async function runDownloadPhase(task: InstallTask) {
|
|||||||
|
|
||||||
sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`);
|
sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`);
|
||||||
|
|
||||||
const response = await axios.get(task.metalinkUrl, {
|
let response: Awaited<ReturnType<typeof axios.get>>;
|
||||||
|
try {
|
||||||
|
response = await axios.get(task.metalinkUrl, {
|
||||||
baseURL: "https://erotica.spark-app.store",
|
baseURL: "https://erotica.spark-app.store",
|
||||||
responseType: "stream",
|
responseType: "stream",
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Metalink 请求失败(网络/404/超时等):明确回传渲染端,避免 UI 停在"正在获取"后突兀退出
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error(`Task ${id} Metalink 下载请求失败: ${reason}`);
|
||||||
|
sendLog(`获取 Metalink 失败: ${reason}`);
|
||||||
|
throw new Error(`获取 Metalink 失败: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
const writer = fs.createWriteStream(metalinkPath);
|
const writer = fs.createWriteStream(metalinkPath);
|
||||||
response.data.pipe(writer);
|
response.data.pipe(writer);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
writer.on("finish", resolve);
|
writer.on("finish", () => {
|
||||||
|
sendLog("Metalink 文件下载完成");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
writer.on("error", reject);
|
writer.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
sendLog("Metalink 文件下载完成");
|
|
||||||
|
|
||||||
// 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住
|
// 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住
|
||||||
const existingFiles = fs.readdirSync(downloadDir);
|
const existingFiles = fs.readdirSync(downloadDir);
|
||||||
for (const file of existingFiles) {
|
for (const file of existingFiles) {
|
||||||
@@ -624,6 +874,7 @@ async function runDownloadPhase(task: InstallTask) {
|
|||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
clearInterval(timeoutChecker);
|
clearInterval(timeoutChecker);
|
||||||
|
sendLog(`aria2c 启动失败: ${err.message}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -674,6 +925,7 @@ async function runDownloadPhase(task: InstallTask) {
|
|||||||
/**
|
/**
|
||||||
* 安装阶段:执行安装命令,安装一次只允许一个。
|
* 安装阶段:执行安装命令,安装一次只允许一个。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function runInstallPhase(task: InstallTask) {
|
async function runInstallPhase(task: InstallTask) {
|
||||||
const { webContents, id } = task;
|
const { webContents, id } = task;
|
||||||
|
|
||||||
@@ -759,9 +1011,32 @@ async function runInstallPhase(task: InstallTask) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Completion
|
// Completion
|
||||||
const success = result.code === 0;
|
// ssinstall(spark 源)在 dry-run 失败放弃安装时仍可能以退出码 0 结束,
|
||||||
|
// 需从输出识别明确的失败标志,避免误判为安装成功(否则前端会显示「完成」)。
|
||||||
|
let success = result.code === 0;
|
||||||
|
let failureDetail = "";
|
||||||
|
if (success && task.origin === "spark") {
|
||||||
|
const failMarkers = [
|
||||||
|
"放弃安装",
|
||||||
|
"dry-run测试仍然失败",
|
||||||
|
"包管理器以错误代码退出",
|
||||||
|
"Package manager quit with exit code",
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
failMarkers.some(
|
||||||
|
(marker) =>
|
||||||
|
result.stdout.includes(marker) || result.stderr.includes(marker),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
success = false;
|
||||||
|
failureDetail =
|
||||||
|
"ssinstall 放弃安装(可能因软件包被系统锁定 hold,需在更新中心开启「强制安装」)";
|
||||||
|
}
|
||||||
|
}
|
||||||
const msgObj = {
|
const msgObj = {
|
||||||
message: success ? "安装完成" : `安装失败,退出码 ${result.code}`,
|
message: success
|
||||||
|
? "安装完成"
|
||||||
|
: `安装失败:${failureDetail || `退出码 ${result.code}`}`,
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
};
|
};
|
||||||
@@ -769,9 +1044,9 @@ async function runInstallPhase(task: InstallTask) {
|
|||||||
if (success) {
|
if (success) {
|
||||||
logger.info(msgObj);
|
logger.info(msgObj);
|
||||||
|
|
||||||
// 安装成功后,如果是APM安装的,就调用createApmDesktopShortcut
|
// 安装成功后,如果是APM新安装任务,就调用createApmDesktopShortcut
|
||||||
// 这个函数负责处理桌面快捷方式,它自己会读取设置并且决定要不要创建
|
// 升级任务不创建桌面快捷方式;这个函数自己会读取设置并决定要不要创建
|
||||||
if (task.origin === "apm" && task.pkgname) {
|
if (task.origin === "apm" && task.pkgname && !task.upgradeOnly) {
|
||||||
try {
|
try {
|
||||||
await createApmDesktopShortcut(task.pkgname, sendLog);
|
await createApmDesktopShortcut(task.pkgname, sendLog);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -812,15 +1087,13 @@ async function runInstallPhase(task: InstallTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
ipcMain.handle("check-installed", async (_event, payload: unknown) => {
|
||||||
ipcMain.handle("check-installed", async (_event, payload: any) => {
|
const parsed = parseAppPayload(payload);
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
if (!parsed) {
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
logger.warn("check-installed invalid payload");
|
||||||
|
|
||||||
if (!pkgname) {
|
|
||||||
logger.warn("check-installed missing pkgname");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
|
|
||||||
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
|
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
|
||||||
|
|
||||||
@@ -887,13 +1160,12 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
|
|||||||
|
|
||||||
ipcMain.on("remove-installed", async (_event, payload) => {
|
ipcMain.on("remove-installed", async (_event, payload) => {
|
||||||
const webContents = _event.sender;
|
const webContents = _event.sender;
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
const parsed = parseAppPayload(payload);
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
if (!parsed) {
|
||||||
|
logger.warn("remove-installed invalid payload");
|
||||||
if (!pkgname) {
|
|
||||||
logger.warn("remove-installed missing pkgname");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
|
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
|
||||||
|
|
||||||
let execCommand = "";
|
let execCommand = "";
|
||||||
@@ -994,8 +1266,16 @@ ipcMain.handle(
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (origin === "spark") {
|
if (origin === "spark") {
|
||||||
// 如果提供了包名列表,只检查这些包的安装状态(优化版)
|
// 显式传入了包名列表(可能是空数组):只检查这些包的安装状态(优化版)
|
||||||
if (pkgnameList && pkgnameList.length > 0) {
|
if (Array.isArray(pkgnameList)) {
|
||||||
|
if (pkgnameList.length === 0) {
|
||||||
|
// 商店目录尚未加载或该来源没有任何可枚举的包时,
|
||||||
|
// 直接返回空列表,避免退化为“全量扫描整个系统”后再被渲染端全部跳过,
|
||||||
|
// 否则会误报“已安装应用为空”。
|
||||||
|
logger.info("Spark 包名列表为空,跳过已安装检查");
|
||||||
|
return { success: true, apps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
|
`使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`,
|
||||||
);
|
);
|
||||||
@@ -1037,7 +1317,7 @@ ipcMain.handle(
|
|||||||
return { success: true, apps: installedApps };
|
return { success: true, apps: installedApps };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退到全量扫描模式(未提供包名列表时)
|
// 回退到全量扫描模式(仅当调用方未传入 pkgnameList 时,例如旧版直接调用)
|
||||||
logger.info("使用全量扫描模式获取所有 Spark 已安装包");
|
logger.info("使用全量扫描模式获取所有 Spark 已安装包");
|
||||||
const { code, stdout } = await runCommandCapture("dpkg-query", [
|
const { code, stdout } = await runCommandCapture("dpkg-query", [
|
||||||
"-W",
|
"-W",
|
||||||
@@ -1231,15 +1511,18 @@ ipcMain.handle("show-apm-install-dialog", async (event) => {
|
|||||||
return { success: false, cancelled: true };
|
return { success: false, cancelled: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
ipcMain.handle(
|
||||||
ipcMain.handle("uninstall-installed", async (_event, payload: any) => {
|
"uninstall-installed",
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
async (
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
_event,
|
||||||
|
payload: unknown,
|
||||||
if (!pkgname) {
|
): Promise<{ success: boolean; message?: string }> => {
|
||||||
logger.warn("uninstall-installed missing pkgname");
|
const parsed = parseAppPayload(payload);
|
||||||
return { success: false, message: "missing pkgname" };
|
if (!parsed) {
|
||||||
|
logger.warn("uninstall-installed invalid payload");
|
||||||
|
return { success: false, message: "invalid payload" };
|
||||||
}
|
}
|
||||||
|
const { pkgname, origin } = parsed;
|
||||||
|
|
||||||
const superUserCmd = await checkSuperUserCommand();
|
const superUserCmd = await checkSuperUserCommand();
|
||||||
const execCommand = superUserCmd || SHELL_CALLER_PATH;
|
const execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||||
@@ -1269,15 +1552,31 @@ ipcMain.handle("uninstall-installed", async (_event, payload: any) => {
|
|||||||
? "卸载完成"
|
? "卸载完成"
|
||||||
: stderr || stdout || `卸载失败,退出码 ${code}`,
|
: stderr || stdout || `卸载失败,退出码 ${code}`,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface LaunchAppPayload {
|
||||||
ipcMain.handle("launch-app", async (_event, payload: any) => {
|
pkgname: string;
|
||||||
|
origin?: "spark" | "apm";
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"launch-app",
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
payload: LaunchAppPayload,
|
||||||
|
): Promise<{ success: boolean; message?: string }> => {
|
||||||
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
|
||||||
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
const origin = typeof payload === "string" ? "spark" : payload.origin;
|
||||||
|
|
||||||
if (!pkgname) {
|
if (
|
||||||
logger.warn("No pkgname provided for launch-app");
|
!pkgname ||
|
||||||
|
typeof pkgname !== "string" ||
|
||||||
|
!PKGNAME_PATTERN.test(pkgname) ||
|
||||||
|
pkgname.length > 256
|
||||||
|
) {
|
||||||
|
logger.warn(`Invalid pkgname provided for launch-app: ${pkgname}`);
|
||||||
|
return { success: false, message: "Invalid package name" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let execCommand = "/opt/spark-store/extras/app-launcher";
|
let execCommand = "/opt/spark-store/extras/app-launcher";
|
||||||
@@ -1298,4 +1597,7 @@ ipcMain.handle("launch-app", async (_event, payload: any) => {
|
|||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
}).unref();
|
}).unref();
|
||||||
});
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ export function registerSubmitterHandlers(
|
|||||||
width: 800,
|
width: 800,
|
||||||
height: 900,
|
height: 900,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"),
|
icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"),
|
||||||
@@ -972,6 +973,24 @@ export function registerSubmitterHandlers(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-git-name", async () => {
|
||||||
|
try {
|
||||||
|
const { exec } = await import("node:child_process");
|
||||||
|
const util = await import("util");
|
||||||
|
const execAsync = util.promisify(exec);
|
||||||
|
const { stdout } = await execAsync("git config user.name");
|
||||||
|
const name = stdout.trim();
|
||||||
|
logger.info({ name }, "[Submitter] Git name retrieved");
|
||||||
|
return { success: true, data: name || "" };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
"[Submitter] Failed to get git name, not a git repo or git not installed",
|
||||||
|
);
|
||||||
|
return { success: false, data: "" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-tags-list", async () => {
|
ipcMain.handle("get-tags-list", async () => {
|
||||||
try {
|
try {
|
||||||
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list";
|
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list";
|
||||||
@@ -1402,8 +1421,9 @@ export function registerSubmitterHandlers(
|
|||||||
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
|
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
|
||||||
const debArch = String(dataObj.arch || "amd64");
|
const debArch = String(dataObj.arch || "amd64");
|
||||||
const pkgVersion = String(dataObj.version || "0.0.0");
|
const pkgVersion = String(dataObj.version || "0.0.0");
|
||||||
const debPkgName = String(dataObj.pkgname || "unknown");
|
const debPkgName = String(dataObj.pkgname || "unknown").toLowerCase();
|
||||||
const formFileName = `${debPkgName}_${pkgVersion}_${debArch}.deb`;
|
const formFileName =
|
||||||
|
`${debPkgName}_${pkgVersion}_${debArch}.deb`.toLowerCase();
|
||||||
logger.info(
|
logger.info(
|
||||||
{ formFileName, debArch },
|
{ formFileName, debArch },
|
||||||
"[Submitter] Constructed file_name",
|
"[Submitter] Constructed file_name",
|
||||||
@@ -1671,7 +1691,7 @@ export function registerSubmitterHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataObj = formData as Record<string, unknown>;
|
const dataObj = formData as Record<string, unknown>;
|
||||||
const pkgname = String(dataObj.pkgname || "");
|
const pkgname = String(dataObj.pkgname || "").toLowerCase();
|
||||||
const packageName = String(dataObj.name || "");
|
const packageName = String(dataObj.name || "");
|
||||||
const packageVersion = String(dataObj.version || "");
|
const packageVersion = String(dataObj.version || "");
|
||||||
const packageCategory = String(dataObj.category || "");
|
const packageCategory = String(dataObj.category || "");
|
||||||
@@ -1862,7 +1882,8 @@ export function registerSubmitterHandlers(
|
|||||||
"[Submitter] Deb metadata parsed",
|
"[Submitter] Deb metadata parsed",
|
||||||
);
|
);
|
||||||
|
|
||||||
const packDebFileName = `${debPkgName}_${debVersion}_${debArch}.deb`;
|
const packDebFileName =
|
||||||
|
`${debPkgName}_${debVersion}_${debArch}.deb`.toLowerCase();
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
from: debFilePath,
|
from: debFilePath,
|
||||||
@@ -1913,7 +1934,7 @@ export function registerSubmitterHandlers(
|
|||||||
sendPackageProgress("tar", 85, "正在打包 tar.gz...");
|
sendPackageProgress("tar", 85, "正在打包 tar.gz...");
|
||||||
logger.info("[Submitter] Starting tar...");
|
logger.info("[Submitter] Starting tar...");
|
||||||
|
|
||||||
const tarFileName = `${pkgname}-${storeArch}.tar.gz`;
|
const tarFileName = `${pkgname.toLowerCase()}-${storeArch.toLowerCase()}.tar.gz`;
|
||||||
const tarOutputPath = path.join(baseTempDir, tarFileName);
|
const tarOutputPath = path.join(baseTempDir, tarFileName);
|
||||||
try {
|
try {
|
||||||
await execAsync(
|
await execAsync(
|
||||||
@@ -1938,7 +1959,9 @@ export function registerSubmitterHandlers(
|
|||||||
sendPackageProgress("done", 100, "打包完成!");
|
sendPackageProgress("done", 100, "打包完成!");
|
||||||
|
|
||||||
logger.info({ baseTempDir }, "[Submitter] Opening folder");
|
logger.info({ baseTempDir }, "[Submitter] Opening folder");
|
||||||
await shell.openPath(baseTempDir);
|
shell.openPath(baseTempDir).catch((err) => {
|
||||||
|
logger.warn({ err }, "[Submitter] Failed to open folder");
|
||||||
|
});
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
import { BrowserWindow, ipcMain } from "electron";
|
import { BrowserWindow, ipcMain } from "electron";
|
||||||
|
|
||||||
|
const logger = pino({ name: "updateCenter" });
|
||||||
|
|
||||||
import { SHELL_CALLER_PATH } from "../shared-installer";
|
import { SHELL_CALLER_PATH } from "../shared-installer";
|
||||||
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser";
|
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser";
|
||||||
import {
|
import {
|
||||||
@@ -165,9 +168,9 @@ const loadAptssItemMetadata = async (
|
|||||||
| { item: UpdateCenterItem; warning?: undefined }
|
| { item: UpdateCenterItem; warning?: undefined }
|
||||||
| { item: null; warning: string }
|
| { item: null; warning: string }
|
||||||
> => {
|
> => {
|
||||||
console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
|
logger.debug(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
|
||||||
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
|
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
|
`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,11 +178,11 @@ const loadAptssItemMetadata = async (
|
|||||||
printUrisCommand.command,
|
printUrisCommand.command,
|
||||||
printUrisCommand.args,
|
printUrisCommand.args,
|
||||||
);
|
);
|
||||||
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
|
logger.debug(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
|
`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
|
`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -188,18 +191,18 @@ const loadAptssItemMetadata = async (
|
|||||||
metadataResult,
|
metadataResult,
|
||||||
);
|
);
|
||||||
if (commandError) {
|
if (commandError) {
|
||||||
console.log(`[DEBUG] APTSS metadata error: ${commandError}`);
|
logger.debug(`[DEBUG] APTSS metadata error: ${commandError}`);
|
||||||
return { item: null, warning: commandError };
|
return { item: null, warning: commandError };
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = parsePrintUrisOutput(metadataResult.stdout);
|
const metadata = parsePrintUrisOutput(metadataResult.stdout);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
console.log(`[DEBUG] APTSS parsed metadata:`, {
|
logger.debug(`[DEBUG] APTSS parsed metadata:`, {
|
||||||
...metadata,
|
...metadata,
|
||||||
downloadUrl: `${metadata.downloadUrl}.metalink`,
|
downloadUrl: `${metadata.downloadUrl}.metalink`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
|
logger.debug(`[DEBUG] APTSS parsed metadata:`, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
@@ -378,7 +381,7 @@ export const loadUpdateCenterItems = async (
|
|||||||
storeFilter: StoreFilter = "both",
|
storeFilter: StoreFilter = "both",
|
||||||
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||||
): Promise<UpdateCenterLoadItemsResult> => {
|
): Promise<UpdateCenterLoadItemsResult> => {
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`,
|
`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`,
|
||||||
);
|
);
|
||||||
const [sparkEnabled, apmEnabled] = await Promise.all([
|
const [sparkEnabled, apmEnabled] = await Promise.all([
|
||||||
@@ -389,7 +392,7 @@ export const loadUpdateCenterItems = async (
|
|||||||
? isCommandAvailable(runCommand, "apm")
|
? isCommandAvailable(runCommand, "apm")
|
||||||
: Promise.resolve(false),
|
: Promise.resolve(false),
|
||||||
]);
|
]);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
|
`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,16 +418,16 @@ export const loadUpdateCenterItems = async (
|
|||||||
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
|
`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
|
`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
|
`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
|
`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -452,11 +455,11 @@ export const loadUpdateCenterItems = async (
|
|||||||
apmEnabled && apmResult.code === 0
|
apmEnabled && apmResult.code === 0
|
||||||
? parseApmUpgradableOutput(apmResult.stdout)
|
? parseApmUpgradableOutput(apmResult.stdout)
|
||||||
: [];
|
: [];
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
|
`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`,
|
||||||
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] parsed apmItems count=${apmItems.length}`,
|
`[UpdateCenter] parsed apmItems count=${apmItems.length}`,
|
||||||
apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`),
|
||||||
);
|
);
|
||||||
@@ -467,7 +470,7 @@ export const loadUpdateCenterItems = async (
|
|||||||
: "",
|
: "",
|
||||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||||
);
|
);
|
||||||
console.log(`[UpdateCenter] installedSources size=${installedSources.size}`);
|
logger.debug(`[UpdateCenter] installedSources size=${installedSources.size}`);
|
||||||
|
|
||||||
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
|
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
|
||||||
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
|
aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]),
|
||||||
@@ -481,11 +484,11 @@ export const loadUpdateCenterItems = async (
|
|||||||
? enrichApmItems(categorizedApmItems, runCommand)
|
? enrichApmItems(categorizedApmItems, runCommand)
|
||||||
: Promise.resolve({ items: [], warnings: [] }),
|
: Promise.resolve({ items: [], warnings: [] }),
|
||||||
]);
|
]);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
|
`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`,
|
||||||
enrichedAptssItems.warnings,
|
enrichedAptssItems.warnings,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
|
`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`,
|
||||||
enrichedApmItems.warnings,
|
enrichedApmItems.warnings,
|
||||||
);
|
);
|
||||||
@@ -495,15 +498,19 @@ export const loadUpdateCenterItems = async (
|
|||||||
enrichItemIcons(enrichedApmItems.items),
|
enrichItemIcons(enrichedApmItems.items),
|
||||||
installedSources,
|
installedSources,
|
||||||
);
|
);
|
||||||
console.log(
|
logger.debug(
|
||||||
`[UpdateCenter] mergedItems count=${mergedItems.length}`,
|
`[UpdateCenter] mergedItems count=${mergedItems.length}`,
|
||||||
mergedItems.map(
|
mergedItems.map(
|
||||||
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
|
(i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 标记被系统锁定(apt-mark hold)的包:这类包被 apt 拒绝升级(除非 --allow-change-held-packages)。
|
||||||
|
// 更新中心据此默认禁用其勾选,并提示用户单独开启「强制安装」。
|
||||||
|
const heldItems = await markHeldPackages(mergedItems, runCommand);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: mergedItems,
|
items: heldItems,
|
||||||
warnings: [
|
warnings: [
|
||||||
...warnings,
|
...warnings,
|
||||||
...enrichedAptssItems.warnings,
|
...enrichedAptssItems.warnings,
|
||||||
@@ -512,6 +519,167 @@ export const loadUpdateCenterItems = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 通过 `apt-mark showhold` 获取系统锁定的包集合,给可升级项中匹配者打 held 标记。
|
||||||
|
// 仅 spark(aptss/deb)源受 apt hold 影响;apm 源不经过 apt,无需标记。
|
||||||
|
const markHeldPackages = async (
|
||||||
|
items: UpdateCenterItem[],
|
||||||
|
runCommand: UpdateCenterCommandRunner,
|
||||||
|
): Promise<UpdateCenterItem[]> => {
|
||||||
|
const sparkItems = items.filter((item) => item.source === "aptss");
|
||||||
|
if (sparkItems.length === 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runCommand("apt-mark", ["showhold"]);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
// 查询失败不阻断更新列表,仅跳过 hold 标记
|
||||||
|
console.warn(`[UpdateCenter] apt-mark showhold failed: ${result.stderr}`);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heldSet = new Set(
|
||||||
|
result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (heldSet.size === 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) =>
|
||||||
|
item.source === "aptss" && heldSet.has(item.pkgname)
|
||||||
|
? { ...item, held: true }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 子进程超时(毫秒):网络慢/镜像源卡死时,避免命令永久挂起
|
||||||
|
const SYSTEM_UPDATE_COMMAND_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
|
// 带超时保护的命令执行:超时杀掉子进程并 resolve,防止调用方永久冻结
|
||||||
|
const runCommandWithTimeout = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ code: number; stdout: string; stderr: string }> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const child = spawn(command, args, { shell: false, env: process.env });
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// 忽略杀进程异常
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
code: -1,
|
||||||
|
stdout,
|
||||||
|
stderr: `${stderr}\n[timeout] command exceeded ${SYSTEM_UPDATE_COMMAND_TIMEOUT_MS}ms`,
|
||||||
|
});
|
||||||
|
}, SYSTEM_UPDATE_COMMAND_TIMEOUT_MS);
|
||||||
|
const finish = (result: {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
child.stdout?.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (err) =>
|
||||||
|
finish({ code: -1, stdout, stderr: err.message }),
|
||||||
|
);
|
||||||
|
child.on("close", (code) => finish({ code: code ?? -1, stdout, stderr }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新软件源(aptss ssupdate + apm update),提权执行。
|
||||||
|
// 供更新中心 IPC 与“启动后空闲预刷新”复用,单一逻辑来源。
|
||||||
|
export const runSystemUpdateSources = async (
|
||||||
|
storeFilter: StoreFilter = "both",
|
||||||
|
): Promise<{ aptss?: string; apm?: string }> => {
|
||||||
|
logger.debug(
|
||||||
|
`[UpdateCenter] runSystemUpdateSources called with storeFilter=${storeFilter}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: { aptss?: string; apm?: string } = {};
|
||||||
|
const isSourceEnabled = (
|
||||||
|
filter: StoreFilter,
|
||||||
|
source: "spark" | "apm",
|
||||||
|
): boolean => filter === "both" || filter === source;
|
||||||
|
|
||||||
|
if (isSourceEnabled(storeFilter, "spark")) {
|
||||||
|
const whichResult = await runCommandWithTimeout("which", ["aptss"]);
|
||||||
|
const aptssAvailable =
|
||||||
|
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
||||||
|
if (aptssAvailable) {
|
||||||
|
logger.debug("[UpdateCenter] Running: pkexec shell-caller aptss ssupdate");
|
||||||
|
const superUserCmd = await findExecutable(
|
||||||
|
SUPER_USER_COMMAND_CANDIDATES[0],
|
||||||
|
);
|
||||||
|
if (superUserCmd) {
|
||||||
|
const result = await runCommandWithTimeout(superUserCmd, [
|
||||||
|
SHELL_CALLER_PATH,
|
||||||
|
"aptss",
|
||||||
|
"ssupdate",
|
||||||
|
]);
|
||||||
|
results.aptss =
|
||||||
|
result.code === 0
|
||||||
|
? "ok"
|
||||||
|
: `failed: ${result.stderr.substring(0, 200)}`;
|
||||||
|
logger.debug("[UpdateCenter] aptss ssupdate result:", results.aptss);
|
||||||
|
} else {
|
||||||
|
results.aptss = "failed: pkexec not found";
|
||||||
|
console.warn("[UpdateCenter] pkexec not found, skipping aptss update");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.aptss = "skipped: aptss not installed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSourceEnabled(storeFilter, "apm")) {
|
||||||
|
const whichResult = await runCommandWithTimeout("which", ["apm"]);
|
||||||
|
const apmAvailable =
|
||||||
|
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
||||||
|
if (apmAvailable) {
|
||||||
|
logger.debug("[UpdateCenter] Running: pkexec shell-caller apm update");
|
||||||
|
const superUserCmd = await findExecutable(
|
||||||
|
SUPER_USER_COMMAND_CANDIDATES[0],
|
||||||
|
);
|
||||||
|
if (superUserCmd) {
|
||||||
|
const result = await runCommandWithTimeout(superUserCmd, [
|
||||||
|
SHELL_CALLER_PATH,
|
||||||
|
"apm",
|
||||||
|
"update",
|
||||||
|
]);
|
||||||
|
results.apm =
|
||||||
|
result.code === 0
|
||||||
|
? "ok"
|
||||||
|
: `failed: ${result.stderr.substring(0, 200)}`;
|
||||||
|
logger.debug("[UpdateCenter] apm update result:", results.apm);
|
||||||
|
} else {
|
||||||
|
results.apm = "failed: pkexec not found";
|
||||||
|
console.warn("[UpdateCenter] pkexec not found, skipping apm update");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.apm = "skipped: apm not installed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
export const registerUpdateCenterIpc = (
|
export const registerUpdateCenterIpc = (
|
||||||
ipc: Pick<typeof ipcMain, "handle">,
|
ipc: Pick<typeof ipcMain, "handle">,
|
||||||
service: Pick<
|
service: Pick<
|
||||||
@@ -528,111 +696,8 @@ export const registerUpdateCenterIpc = (
|
|||||||
): void => {
|
): void => {
|
||||||
ipc.handle(
|
ipc.handle(
|
||||||
"update-center-run-system-update",
|
"update-center-run-system-update",
|
||||||
async (_event, storeFilter: StoreFilter = "both") => {
|
async (_event, storeFilter: StoreFilter = "both") =>
|
||||||
console.log(
|
runSystemUpdateSources(storeFilter),
|
||||||
`[UpdateCenter] update-center-run-system-update called with storeFilter=${storeFilter}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: { aptss?: string; apm?: string } = {};
|
|
||||||
|
|
||||||
const runCommand = (
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
): Promise<{ code: number; stdout: string; stderr: string }> =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
shell: false,
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
child.stdout?.on("data", (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
child.stderr?.on("data", (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
child.on("error", (err) =>
|
|
||||||
resolve({ code: -1, stdout, stderr: err.message }),
|
|
||||||
);
|
|
||||||
child.on("close", (code) =>
|
|
||||||
resolve({ code: code ?? -1, stdout, stderr }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSourceEnabled = (
|
|
||||||
filter: StoreFilter,
|
|
||||||
source: "spark" | "apm",
|
|
||||||
): boolean => filter === "both" || filter === source;
|
|
||||||
|
|
||||||
// aptss update — 需要提权
|
|
||||||
if (isSourceEnabled(storeFilter, "spark")) {
|
|
||||||
const whichResult = await runCommand("which", ["aptss"]);
|
|
||||||
const aptssAvailable =
|
|
||||||
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
|
||||||
if (aptssAvailable) {
|
|
||||||
console.log(
|
|
||||||
"[UpdateCenter] Running: pkexec shell-caller aptss ssupdate",
|
|
||||||
);
|
|
||||||
const superUserCmd = await findExecutable(
|
|
||||||
SUPER_USER_COMMAND_CANDIDATES[0],
|
|
||||||
);
|
|
||||||
if (superUserCmd) {
|
|
||||||
const result = await runCommand(superUserCmd, [
|
|
||||||
SHELL_CALLER_PATH,
|
|
||||||
"aptss",
|
|
||||||
"ssupdate",
|
|
||||||
]);
|
|
||||||
results.aptss =
|
|
||||||
result.code === 0
|
|
||||||
? "ok"
|
|
||||||
: `failed: ${result.stderr.substring(0, 200)}`;
|
|
||||||
console.log("[UpdateCenter] aptss ssupdate result:", results.aptss);
|
|
||||||
} else {
|
|
||||||
results.aptss = "failed: pkexec not found";
|
|
||||||
console.warn(
|
|
||||||
"[UpdateCenter] pkexec not found, skipping aptss update",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.aptss = "skipped: aptss not installed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// apm update — 也需要提权
|
|
||||||
if (isSourceEnabled(storeFilter, "apm")) {
|
|
||||||
const whichResult = await runCommand("which", ["apm"]);
|
|
||||||
const apmAvailable =
|
|
||||||
whichResult.code === 0 && whichResult.stdout.trim().length > 0;
|
|
||||||
if (apmAvailable) {
|
|
||||||
console.log("[UpdateCenter] Running: pkexec shell-caller apm update");
|
|
||||||
const superUserCmd = await findExecutable(
|
|
||||||
SUPER_USER_COMMAND_CANDIDATES[0],
|
|
||||||
);
|
|
||||||
if (superUserCmd) {
|
|
||||||
const result = await runCommand(superUserCmd, [
|
|
||||||
SHELL_CALLER_PATH,
|
|
||||||
"apm",
|
|
||||||
"update",
|
|
||||||
]);
|
|
||||||
results.apm =
|
|
||||||
result.code === 0
|
|
||||||
? "ok"
|
|
||||||
: `failed: ${result.stderr.substring(0, 200)}`;
|
|
||||||
console.log("[UpdateCenter] apm update result:", results.apm);
|
|
||||||
} else {
|
|
||||||
results.apm = "failed: pkexec not found";
|
|
||||||
console.warn(
|
|
||||||
"[UpdateCenter] pkexec not found, skipping apm update",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.apm = "skipped: apm not installed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.handle(
|
ipc.handle(
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { runAria2Download, type Aria2DownloadResult } from "./download";
|
import { runAria2Download, type Aria2DownloadResult } from "./download";
|
||||||
import { installPackage } from "../shared-installer";
|
import { installPackage } from "../shared-installer";
|
||||||
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
|
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
|
||||||
import type { UpdateCenterItem } from "./types";
|
import type { UpdateCenterItem } from "./types";
|
||||||
|
|
||||||
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
|
const DEFAULT_DOWNLOAD_ROOT = join(
|
||||||
|
tmpdir(),
|
||||||
|
`spark-store-${process.pid}`,
|
||||||
|
"update-center",
|
||||||
|
);
|
||||||
|
|
||||||
export interface InstallUpdateItemOptions {
|
export interface InstallUpdateItemOptions {
|
||||||
item: UpdateCenterItem;
|
item: UpdateCenterItem;
|
||||||
|
|||||||
@@ -210,7 +210,11 @@ const parseUpgradableOutput = (
|
|||||||
const arch = tokens[2] ?? "";
|
const arch = tokens[2] ?? "";
|
||||||
const currentVersion =
|
const currentVersion =
|
||||||
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
|
trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? "";
|
||||||
if (!pkgname || nextVersion === currentVersion) {
|
// 仅当包名缺失或当前版本解析失败时才跳过。
|
||||||
|
// 注意:不再因 nextVersion === currentVersion 而跳过——aptss 已判定该项为
|
||||||
|
// upgradable,应信任上游判断;否则当仓库元数据出现"同版本重新发布"等情况时,
|
||||||
|
// 真实的更新项会被无声隐藏,导致"软件更新"列表空白。
|
||||||
|
if (!pkgname || !currentVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { BrowserWindow } from "electron";
|
import { BrowserWindow } from "electron";
|
||||||
|
import {
|
||||||
|
addInstallTask,
|
||||||
|
type QueueInstallPayload,
|
||||||
|
} from "../install-manager";
|
||||||
import {
|
import {
|
||||||
IGNORE_CONFIG_PATH,
|
IGNORE_CONFIG_PATH,
|
||||||
applyIgnoredEntries,
|
applyIgnoredEntries,
|
||||||
@@ -67,6 +71,8 @@ export interface UpdateCenterIgnorePayload {
|
|||||||
export interface UpdateCenterStartTask {
|
export interface UpdateCenterStartTask {
|
||||||
taskKey: string;
|
taskKey: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
// 强制安装被系统锁定(apt-mark hold)的包
|
||||||
|
forceHeld?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCenterService {
|
export interface UpdateCenterService {
|
||||||
@@ -115,6 +121,7 @@ const toState = (
|
|||||||
migrationSource: item.migrationSource,
|
migrationSource: item.migrationSource,
|
||||||
migrationTarget: item.migrationTarget,
|
migrationTarget: item.migrationTarget,
|
||||||
aptssVersion: item.aptssVersion,
|
aptssVersion: item.aptssVersion,
|
||||||
|
held: item.held,
|
||||||
})),
|
})),
|
||||||
tasks: [], // 不再展示任务日志
|
tasks: [], // 不再展示任务日志
|
||||||
warnings: [...snapshot.warnings],
|
warnings: [...snapshot.warnings],
|
||||||
@@ -217,15 +224,10 @@ export const createUpdateCenterService = (
|
|||||||
},
|
},
|
||||||
async start(tasks) {
|
async start(tasks) {
|
||||||
const snapshot = queue.getSnapshot();
|
const snapshot = queue.getSnapshot();
|
||||||
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
|
const taskByKey = new Map(
|
||||||
const selectedItems = snapshot.items.filter(
|
tasks.map((task) => [task.taskKey, task] as const),
|
||||||
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取主窗口的 webContents
|
// 获取主窗口的 webContents
|
||||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||||
const webContents = mainWindow?.webContents;
|
const webContents = mainWindow?.webContents;
|
||||||
@@ -235,22 +237,63 @@ export const createUpdateCenterService = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前 items
|
// 分类:可启动项 vs 被锁定(held)且未开启强制安装的项。
|
||||||
let currentItems = snapshot.items;
|
// 被锁定的项需用户单独开启「强制安装」才能升级,否则明确告知失败,避免静默卡在「开始更新」。
|
||||||
|
const startableItems: typeof snapshot.items = [];
|
||||||
|
const heldBlocked: Array<{
|
||||||
|
item: (typeof snapshot.items)[number];
|
||||||
|
id: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const item of selectedItems) {
|
for (const item of snapshot.items) {
|
||||||
const updateTaskId = taskIdByKey.get(getTaskKey(item));
|
const updateTask = taskByKey.get(getTaskKey(item));
|
||||||
if (!updateTaskId) {
|
if (!updateTask || item.ignored) continue;
|
||||||
|
if (item.held === true && !updateTask.forceHeld) {
|
||||||
|
heldBlocked.push({ item, id: updateTask.id });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
startableItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对「被锁定未强制」的选中项,向前端发送明确失败通知(而非静默跳过)
|
||||||
|
for (const blocked of heldBlocked) {
|
||||||
|
webContents.send("install-complete", {
|
||||||
|
id: blocked.id,
|
||||||
|
success: false,
|
||||||
|
time: Date.now(),
|
||||||
|
exitCode: -1,
|
||||||
|
message: JSON.stringify({
|
||||||
|
message: `软件包 ${blocked.item.pkgname} 被系统锁定(hold),已在更新中心默认跳过。如需升级,请在该软件行开启「强制安装」开关后重试。`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startableItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前 items 的副本,启动成功后从更新中心列表移除已交出的项,
|
||||||
|
// 避免同一包同时出现在更新中心与下载队列(更新中心只展示待更新的项)。
|
||||||
|
let currentItems = snapshot.items;
|
||||||
|
|
||||||
|
for (const item of startableItems) {
|
||||||
|
const updateTask = taskByKey.get(getTaskKey(item));
|
||||||
|
if (!updateTask) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: updateTaskId, forceHeld } = updateTask;
|
||||||
|
|
||||||
// 构建 metalink URL
|
// 构建 metalink URL
|
||||||
const metalinkUrl = item.downloadUrl
|
const metalinkUrl = item.downloadUrl
|
||||||
? `${item.downloadUrl}.metalink`
|
? `${item.downloadUrl}.metalink`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 发送到主下载队列
|
// 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端,
|
||||||
const installTaskData = {
|
// 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。
|
||||||
|
const installTaskData: QueueInstallPayload = {
|
||||||
id: updateTaskId,
|
id: updateTaskId,
|
||||||
pkgname: item.pkgname,
|
pkgname: item.pkgname,
|
||||||
metalinkUrl,
|
metalinkUrl,
|
||||||
@@ -258,20 +301,18 @@ export const createUpdateCenterService = (
|
|||||||
upgradeOnly: true,
|
upgradeOnly: true,
|
||||||
origin: item.source === "apm" ? "apm" : "spark",
|
origin: item.source === "apm" ? "apm" : "spark",
|
||||||
retry: false,
|
retry: false,
|
||||||
|
forceHeld: item.held === true ? forceHeld : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 通过 IPC 发送到主下载队列
|
await addInstallTask(installTaskData, webContents);
|
||||||
webContents.send("queue-install", JSON.stringify(installTaskData));
|
|
||||||
|
|
||||||
// 从更新中心的 items 中移除该应用(不再显示在更新列表中)
|
// 启动成功后从更新中心列表移除该项(被 hold 未强制拦截的项不会进入此处,仍保留)。
|
||||||
currentItems = currentItems.filter(
|
currentItems = currentItems.filter(
|
||||||
(i) => getTaskKey(i) !== getTaskKey(item),
|
(i) => getTaskKey(i) !== getTaskKey(item),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新队列中的 items
|
|
||||||
queue.setItems(currentItems);
|
queue.setItems(currentItems);
|
||||||
|
|
||||||
emit();
|
emit();
|
||||||
},
|
},
|
||||||
async cancel(taskKey) {
|
async cancel(taskKey) {
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export interface UpdateCenterItem {
|
|||||||
migrationSource?: UpdateSource;
|
migrationSource?: UpdateSource;
|
||||||
migrationTarget?: UpdateSource;
|
migrationTarget?: UpdateSource;
|
||||||
aptssVersion?: string;
|
aptssVersion?: string;
|
||||||
|
// 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省
|
||||||
|
updateTime?: number;
|
||||||
|
// 是否被系统锁定(apt-mark hold)。被锁定的包默认不可批量选中,需用户单独开启「强制安装」
|
||||||
|
held?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+309
-14
@@ -4,9 +4,11 @@ import {
|
|||||||
ipcMain,
|
ipcMain,
|
||||||
Menu,
|
Menu,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
net,
|
||||||
shell,
|
shell,
|
||||||
Tray,
|
Tray,
|
||||||
nativeTheme,
|
nativeTheme,
|
||||||
|
screen,
|
||||||
session,
|
session,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -18,7 +20,10 @@ import { handleCommandLine } from "./deeplink.js";
|
|||||||
import { isLoaded } from "../global.js";
|
import { isLoaded } from "../global.js";
|
||||||
import { tasks } from "./backend/install-manager.js";
|
import { tasks } from "./backend/install-manager.js";
|
||||||
import { sendTelemetryOnce } from "./backend/telemetry.js";
|
import { sendTelemetryOnce } from "./backend/telemetry.js";
|
||||||
import { initializeUpdateCenter } from "./backend/update-center/index.js";
|
import {
|
||||||
|
initializeUpdateCenter,
|
||||||
|
runSystemUpdateSources,
|
||||||
|
} from "./backend/update-center/index.js";
|
||||||
import {
|
import {
|
||||||
getMainWindowCloseAction,
|
getMainWindowCloseAction,
|
||||||
type MainWindowCloseGuardState,
|
type MainWindowCloseGuardState,
|
||||||
@@ -72,6 +77,13 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
import "./backend/install-manager.js";
|
import "./backend/install-manager.js";
|
||||||
import "./handle-url-scheme.js";
|
import "./handle-url-scheme.js";
|
||||||
|
|
||||||
|
// 关闭 Linux 的覆盖式(overlay)滚动条,强制使用经典滚动条,
|
||||||
|
// 否则 GTK overlay 滚动条会忽略渲染进程的 ::-webkit-scrollbar 颜色,
|
||||||
|
// 导致暗色模式下滚动条始终是原生灰色。必须在 app ready 前设置。
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
app.commandLine.appendSwitch("disable-features", "OverlayScrollbar");
|
||||||
|
}
|
||||||
|
|
||||||
const logger = pino({ name: "index.ts" });
|
const logger = pino({ name: "index.ts" });
|
||||||
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
|
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
|
||||||
|
|
||||||
@@ -89,6 +101,10 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
|||||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
||||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||||
|
|
||||||
|
// 进程专属临时目录,避免多实例/残留进程互相影响
|
||||||
|
// 退出时由 will-quit 统一清理
|
||||||
|
export const TEMP_BASE = path.join(os.tmpdir(), `spark-store-${process.pid}`);
|
||||||
|
|
||||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||||
? path.join(process.env.APP_ROOT, "public")
|
? path.join(process.env.APP_ROOT, "public")
|
||||||
: RENDERER_DIST;
|
: RENDERER_DIST;
|
||||||
@@ -129,7 +145,9 @@ logger.info("User Agent: " + getUserAgent());
|
|||||||
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
|
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
|
||||||
function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
|
function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
|
||||||
if (process.arch === "loong64") {
|
if (process.arch === "loong64") {
|
||||||
// Currently loong64 only have spark support
|
// Currently loong64 only have spark support,
|
||||||
|
// 但用户显式传入 --no-spark 时应允许回退到 apm
|
||||||
|
if (process.argv.includes("--no-spark")) return "apm";
|
||||||
return "spark";
|
return "spark";
|
||||||
} else {
|
} else {
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
@@ -146,6 +164,24 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
|||||||
getStoreFilterFromArgv(),
|
getStoreFilterFromArgv(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 渲染端在窗口尺寸变化时(包括无边框窗口鼠标拉边角)经此保存当前窗口尺寸
|
||||||
|
ipcMain.handle("save-window-bounds", (): boolean => {
|
||||||
|
if (win && !win.isDestroyed()) scheduleSaveBounds(win);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染端(设置页)切换界面整体缩放系数,经此实时应用到主窗口。
|
||||||
|
// factor 限定在 0.5~3 之间,避免极端值导致界面不可用。
|
||||||
|
ipcMain.handle("set-zoom-factor", (_event, factor: unknown): boolean => {
|
||||||
|
if (typeof factor !== "number" || !Number.isFinite(factor)) return false;
|
||||||
|
const clamped = Math.min(3, Math.max(0.5, factor));
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.setZoomFactor(clamped);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||||
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
||||||
|
|
||||||
@@ -256,9 +292,10 @@ const requestApplicationExit = (): void => {
|
|||||||
app.quit();
|
app.quit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showAndFocusMainWindow = (): void => {
|
const showAndFocusMainWindow = async (): Promise<void> => {
|
||||||
if (!win || win.isDestroyed()) {
|
if (!win || win.isDestroyed()) {
|
||||||
createWindow();
|
// 等待窗口创建完成,创建失败时调用方可通过异常感知
|
||||||
|
await createWindow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,11 +307,144 @@ const showAndFocusMainWindow = (): void => {
|
|||||||
win.focus();
|
win.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸
|
||||||
|
const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 };
|
||||||
|
const MIN_WINDOW_SIZE = { width: 800, height: 500 };
|
||||||
|
// 超过该尺寸的窗口(通常为全屏/最大化状态)在恢复时回退到默认尺寸,避免「启动即全屏、还原按钮失效」
|
||||||
|
const OVERSIZED_WINDOW_THRESHOLD = { width: 1600, height: 900 };
|
||||||
|
|
||||||
|
interface WindowState {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
maximized?: boolean;
|
||||||
|
/** 界面整体缩放系数(Electron webContents.setZoomFactor),默认 1 */
|
||||||
|
zoomFactor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowStatePath(): string {
|
||||||
|
// 延迟到调用时再取 userData,避免在 app ready 之前调用 app.getPath 出错
|
||||||
|
return path.join(app.getPath("userData"), "window-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外
|
||||||
|
function isVisible(bounds: WindowState): boolean {
|
||||||
|
// 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包,
|
||||||
|
// 消除 x/y/width/height 的 “可能为未定义” 告警
|
||||||
|
const { x, y, width, height } = bounds;
|
||||||
|
if (
|
||||||
|
x === undefined ||
|
||||||
|
y === undefined ||
|
||||||
|
width === undefined ||
|
||||||
|
height === undefined
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
return displays.some((display) => {
|
||||||
|
const w = display.workArea;
|
||||||
|
const horizontally = x < w.x + w.width && x + width > w.x;
|
||||||
|
const vertically = y < w.y + w.height && y + height > w.y;
|
||||||
|
return horizontally && vertically;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWindowState(): WindowState {
|
||||||
|
try {
|
||||||
|
const file = getWindowStatePath();
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as WindowState;
|
||||||
|
if (
|
||||||
|
parsed.width !== undefined &&
|
||||||
|
parsed.height !== undefined &&
|
||||||
|
parsed.width >= MIN_WINDOW_SIZE.width &&
|
||||||
|
parsed.height >= MIN_WINDOW_SIZE.height &&
|
||||||
|
isVisible(parsed)
|
||||||
|
) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
logger.warn({ parsed }, "已保存的窗口状态无效,使用默认尺寸");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, "读取窗口状态失败,使用默认尺寸");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWindowState(state: WindowState): void {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
|
||||||
|
logger.info({ state }, "已保存窗口状态");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, "保存窗口状态失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveBoundsTimer: NodeJS.Timeout | null = null;
|
||||||
|
function flushSaveBounds(): void {
|
||||||
|
if (saveBoundsTimer) {
|
||||||
|
clearTimeout(saveBoundsTimer);
|
||||||
|
saveBoundsTimer = null;
|
||||||
|
}
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
const { width, height, x, y } = win.getBounds();
|
||||||
|
saveWindowState({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
maximized: win.isMaximized(),
|
||||||
|
zoomFactor: win.webContents.getZoomFactor(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scheduleSaveBounds(winInstance: BrowserWindow): void {
|
||||||
|
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||||
|
saveBoundsTimer = setTimeout(() => {
|
||||||
|
if (winInstance.isDestroyed()) return;
|
||||||
|
const { width, height, x, y } = winInstance.getBounds();
|
||||||
|
saveWindowState({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
maximized: winInstance.isMaximized(),
|
||||||
|
zoomFactor: winInstance.webContents.getZoomFactor(),
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用退出前立即持久化(防抖 400ms 可能在快速关闭时丢失最后一次状态)
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
flushSaveBounds();
|
||||||
|
});
|
||||||
|
|
||||||
async function createWindow() {
|
async function createWindow() {
|
||||||
|
const saved = loadWindowState();
|
||||||
|
// 恢复时:若上次窗口过大(>1600x900,通常为全屏/最大化),回退到默认尺寸并居中,
|
||||||
|
// 避免「启动即全屏、还原按钮失效」;其余情况保留上次记录的实际尺寸(并居中)。
|
||||||
|
// 放大/还原仍交由标题栏按钮控制。
|
||||||
|
const oversized =
|
||||||
|
(saved.width ?? 0) > OVERSIZED_WINDOW_THRESHOLD.width ||
|
||||||
|
(saved.height ?? 0) > OVERSIZED_WINDOW_THRESHOLD.height;
|
||||||
|
const restoredWidth = oversized
|
||||||
|
? DEFAULT_WINDOW_SIZE.width
|
||||||
|
: Math.max(saved.width ?? DEFAULT_WINDOW_SIZE.width, MIN_WINDOW_SIZE.width);
|
||||||
|
const restoredHeight = oversized
|
||||||
|
? DEFAULT_WINDOW_SIZE.height
|
||||||
|
: Math.max(
|
||||||
|
saved.height ?? DEFAULT_WINDOW_SIZE.height,
|
||||||
|
MIN_WINDOW_SIZE.height,
|
||||||
|
);
|
||||||
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
title: "星火应用商店",
|
title: "星火应用商店",
|
||||||
width: 1366,
|
width: restoredWidth,
|
||||||
height: 768,
|
height: restoredHeight,
|
||||||
|
center: true,
|
||||||
|
minWidth: MIN_WINDOW_SIZE.width,
|
||||||
|
minHeight: MIN_WINDOW_SIZE.height,
|
||||||
frame: false,
|
frame: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
||||||
@@ -290,6 +460,34 @@ async function createWindow() {
|
|||||||
});
|
});
|
||||||
win = mainWindow;
|
win = mainWindow;
|
||||||
|
|
||||||
|
// 启动即应用持久化的界面缩放系数(默认 1 = 100%)。
|
||||||
|
// 必须在 loadURL/loadFile 前设置,使首帧即按目标缩放渲染,避免闪烁。
|
||||||
|
const savedZoom =
|
||||||
|
typeof saved.zoomFactor === "number" &&
|
||||||
|
saved.zoomFactor >= 0.5 &&
|
||||||
|
saved.zoomFactor <= 3
|
||||||
|
? saved.zoomFactor
|
||||||
|
: 1;
|
||||||
|
mainWindow.webContents.setZoomFactor(savedZoom);
|
||||||
|
logger.info({ zoomFactor: savedZoom }, "已应用界面缩放系数");
|
||||||
|
|
||||||
|
// 设计意图(非缺陷,勿改为自动恢复 maximized):
|
||||||
|
// 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过
|
||||||
|
// OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、
|
||||||
|
// 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中,
|
||||||
|
// 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段
|
||||||
|
// (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。
|
||||||
|
logger.info(
|
||||||
|
{ saved, restoredWidth, restoredHeight, oversized },
|
||||||
|
"已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
|
||||||
|
// 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存
|
||||||
|
mainWindow.on("moved", () => scheduleSaveBounds(mainWindow));
|
||||||
|
mainWindow.on("maximize", () => scheduleSaveBounds(mainWindow));
|
||||||
|
mainWindow.on("unmaximize", () => scheduleSaveBounds(mainWindow));
|
||||||
|
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
// #298
|
// #298
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||||
@@ -308,9 +506,18 @@ async function createWindow() {
|
|||||||
logger.info("Renderer process is ready.");
|
logger.info("Renderer process is ready.");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make all links open with the browser, not with the application
|
// 外部链接统一交给系统默认浏览器打开。
|
||||||
|
// 仅限制协议为 http/https,避免 file:/javascript: 等危险协议被打开;
|
||||||
|
// 不再限制域名,保证应用官网与首页推荐的任意链接均可正常访问。
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (url.startsWith("https:")) shell.openExternal(url);
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||||
|
shell.openExternal(url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 无效 URL,拒绝打开
|
||||||
|
}
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
||||||
@@ -323,6 +530,17 @@ async function createWindow() {
|
|||||||
|
|
||||||
mainWindow.on("close", (event) => {
|
mainWindow.on("close", (event) => {
|
||||||
if (allowAppExit) {
|
if (allowAppExit) {
|
||||||
|
// 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发)
|
||||||
|
// 先清除尚未触发的防抖定时器,避免旧定时器随后覆盖本次同步写入
|
||||||
|
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||||
|
const { width, height, x, y } = mainWindow.getBounds();
|
||||||
|
saveWindowState({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
maximized: mainWindow.isMaximized(),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +702,15 @@ app.whenReady().then(() => {
|
|||||||
// Set User-Agent for client
|
// Set User-Agent for client
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
details.requestHeaders["User-Agent"] = getUserAgent();
|
details.requestHeaders["User-Agent"] = getUserAgent();
|
||||||
|
// 数据 JSON(applist / categories / priority-config / sidebar-config 等)禁用客户端缓存。
|
||||||
|
// nginx 默认不发 Cache-Control,Chromium 会按启发式 TTL(≈(now-LastModified)/10)复用陈旧副本,
|
||||||
|
// 导致服务端上新应用后商店内仍搜不到、且重启无效(仅删 Cache 目录才生效)。
|
||||||
|
// 注意:C2 会给 URL 追加 ?_t=... 版本戳,故需按 query 前的 pathname 判断,而非 endsWith(".json")。
|
||||||
|
const dataUrlPath = details.url.split("?")[0];
|
||||||
|
if (dataUrlPath.endsWith(".json")) {
|
||||||
|
details.requestHeaders["Cache-Control"] = "no-cache";
|
||||||
|
details.requestHeaders["Pragma"] = "no-cache";
|
||||||
|
}
|
||||||
callback({ cancel: false, requestHeaders: details.requestHeaders });
|
callback({ cancel: false, requestHeaders: details.requestHeaders });
|
||||||
});
|
});
|
||||||
createWindow();
|
createWindow();
|
||||||
@@ -491,7 +718,67 @@ app.whenReady().then(() => {
|
|||||||
initializeUpdateCenter();
|
initializeUpdateCenter();
|
||||||
// 启动后执行一次遥测(仅 Linux,不阻塞)
|
// 启动后执行一次遥测(仅 Linux,不阻塞)
|
||||||
sendTelemetryOnce(getAppVersion());
|
sendTelemetryOnce(getAppVersion());
|
||||||
|
|
||||||
|
// 注册“渲染进程首页加载完成”信号:收到后立即开始后台刷新软件源,
|
||||||
|
// 趁系统负载不高时提前刷新 aptss/apm 源,用户稍后打开“软件更新”即可秒出。
|
||||||
|
// 同时保留一个兜底定时器,防止渲染进程未发信号时完全不刷新。
|
||||||
|
ipcMain.on("update-center-trigger-prefetch", () => {
|
||||||
|
startSourcePreRefreshOnce();
|
||||||
});
|
});
|
||||||
|
setTimeout(startSourcePreRefreshOnce, PRE_REFRESH_FALLBACK_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动后空闲预刷新软件源(带重试),不阻塞启动流程
|
||||||
|
// 与 src/modules/updateCenter.ts 的 backgroundRefresh 重试为【对称设计】,非代码遗漏:
|
||||||
|
// 主进程负责“启动预热”,前端负责“打开兜底”,
|
||||||
|
// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。
|
||||||
|
const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000];
|
||||||
|
const PRE_REFRESH_MAX_RETRIES = 3;
|
||||||
|
const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑
|
||||||
|
const PRE_REFRESH_TIMEOUT_MS = 60_000; // 单次预刷新整体超时,避免 pkexec 卡死挂起
|
||||||
|
let preRefreshStarted = false;
|
||||||
|
|
||||||
|
// 按重试次数取退避毫秒(越界时回退到最大间隔)
|
||||||
|
const getPreRefreshBackoffDelay = (attempt: number): number =>
|
||||||
|
PRE_REFRESH_BACKOFF_MS[attempt - 1] ??
|
||||||
|
PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1];
|
||||||
|
|
||||||
|
// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。
|
||||||
|
// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置),
|
||||||
|
// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。
|
||||||
|
const startSourcePreRefreshOnce = (attempt = 1): void => {
|
||||||
|
if (preRefreshStarted) return;
|
||||||
|
preRefreshStarted = true;
|
||||||
|
|
||||||
|
const run = (): void => {
|
||||||
|
// 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑)
|
||||||
|
if (!net.isOnline()) {
|
||||||
|
logger.info("[UpdateCenter] 预刷新跳过:当前离线");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error("pre-refresh timeout")),
|
||||||
|
PRE_REFRESH_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.race([runSystemUpdateSources("both"), timeoutPromise])
|
||||||
|
.then((results) => {
|
||||||
|
console.log("[UpdateCenter] pre-refresh done:", results);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[UpdateCenter] pre-refresh failed:", error);
|
||||||
|
if (attempt < PRE_REFRESH_MAX_RETRIES) {
|
||||||
|
const delay = getPreRefreshBackoffDelay(attempt);
|
||||||
|
setTimeout(() => startSourcePreRefreshOnce(attempt + 1), delay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
win = null;
|
win = null;
|
||||||
@@ -500,17 +787,17 @@ app.on("window-all-closed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
app.on("second-instance", () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
// Clean up temp dir
|
// 清理本进程专属临时目录(PID 隔离,不影响其他实例)
|
||||||
logger.info("Cleaning up temp dir");
|
logger.info("Cleaning up temp dir");
|
||||||
fs.rmSync("/tmp/spark-store/", { recursive: true, force: true });
|
fs.rmSync(TEMP_BASE, { recursive: true, force: true });
|
||||||
logger.info("Done, exiting");
|
logger.info("Done, exiting");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -557,7 +844,7 @@ app.whenReady().then(() => {
|
|||||||
{
|
{
|
||||||
label: "显示主界面",
|
label: "显示主界面",
|
||||||
click: () => {
|
click: () => {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -576,11 +863,19 @@ app.whenReady().then(() => {
|
|||||||
win.hide();
|
win.hide();
|
||||||
win.setSkipTaskbar(true);
|
win.setSkipTaskbar(true);
|
||||||
} else {
|
} else {
|
||||||
showAndFocusMainWindow();
|
void showAndFocusMainWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('window:state', () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
|
if (!win) return 'normal'
|
||||||
|
if (win.isMaximized()) return 'maximized'
|
||||||
|
if (win.isMinimized()) return 'minimized'
|
||||||
|
return 'normal'
|
||||||
|
})
|
||||||
|
|
||||||
// New window example arg: new windows url
|
// New window example arg: new windows url
|
||||||
// ipcMain.handle('open-win', (_, arg) => {
|
// ipcMain.handle('open-win', (_, arg) => {
|
||||||
// const childWindow = new BrowserWindow({
|
// const childWindow = new BrowserWindow({
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
|
import {
|
||||||
|
ipcRenderer,
|
||||||
|
contextBridge,
|
||||||
|
webUtils,
|
||||||
|
type IpcRendererEvent,
|
||||||
|
} from "electron";
|
||||||
|
|
||||||
type StoreFilter = "spark" | "apm" | "both";
|
type StoreFilter = "spark" | "apm" | "both";
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ type WindowControlBridge = {
|
|||||||
minimize: () => void;
|
minimize: () => void;
|
||||||
toggleMaximize: () => void;
|
toggleMaximize: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
|
state: () => Promise<| 'normal' | 'maximized' | 'minimized'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||||
@@ -97,10 +103,15 @@ contextBridge.exposeInMainWorld("apm_store", {
|
|||||||
})(),
|
})(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("electronUtils", {
|
||||||
|
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||||
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("windowControls", {
|
contextBridge.exposeInMainWorld("windowControls", {
|
||||||
minimize: () => ipcRenderer.send("window-control-minimize"),
|
minimize: () => ipcRenderer.send("window-control-minimize"),
|
||||||
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
|
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
|
||||||
close: () => ipcRenderer.send("window-control-close"),
|
close: () => ipcRenderer.send("window-control-close"),
|
||||||
|
state: () => ipcRenderer.invoke("window:state"),
|
||||||
} satisfies WindowControlBridge);
|
} satisfies WindowControlBridge);
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("updateCenter", {
|
contextBridge.exposeInMainWorld("updateCenter", {
|
||||||
|
|||||||
+27
-1
@@ -85,6 +85,32 @@ case "$command_type" in
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
"force-ssinstall")
|
||||||
|
# 强制安装被系统锁定(apt-mark hold)的包:在一次已提权的 pkexec 会话内
|
||||||
|
# 完成「解除锁定 → ssinstall 本地 .deb → 恢复锁定」,避免单独 pkexec apt-mark
|
||||||
|
# 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。
|
||||||
|
# 用法:force-ssinstall <pkgname> <deb路径> [ssinstall 额外参数...]
|
||||||
|
pkg="$2"
|
||||||
|
deb="$3"
|
||||||
|
shift 3
|
||||||
|
# 仅放行单包名(与前端 PKGNAME_PATTERN 一致,双引号防注入)
|
||||||
|
if [[ -z "$pkg" || -z "$deb" ]]; then
|
||||||
|
echo "错误:force-ssinstall 缺少包名或 deb 路径参数。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# 安装前解除系统锁定(失败仅警告,不阻断)
|
||||||
|
apt-mark unhold "$pkg" 2>&1 || echo "警告:解除系统锁定 $pkg 失败,将继续尝试安装"
|
||||||
|
# 安装本地 .deb(--native 由本分支统一追加)
|
||||||
|
/usr/bin/ssinstall "$deb" "$@" --native 2>&1
|
||||||
|
exit_code=$?
|
||||||
|
# 安装后无论如何恢复系统锁定,保持用户原本的 hold 状态
|
||||||
|
apt-mark hold "$pkg" 2>&1 || echo "警告:恢复系统锁定 $pkg 失败,请手动检查"
|
||||||
|
if [[ "$exit_code" != "0" ]]; then
|
||||||
|
echo "安装失败,可尝试安装对应的 APM 版本应用;若无对应的 APM 版本应用,可提交用户反馈"
|
||||||
|
fi
|
||||||
|
exit $exit_code
|
||||||
|
;;
|
||||||
|
|
||||||
"aptss")
|
"aptss")
|
||||||
# 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认
|
# 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认
|
||||||
if [[ "$2" == "remove" ]]; then
|
if [[ "$2" == "remove" ]]; then
|
||||||
@@ -158,7 +184,7 @@ case "$command_type" in
|
|||||||
elif [[ "$2" == "ssupdate" ]]; then
|
elif [[ "$2" == "ssupdate" ]]; then
|
||||||
/usr/bin/aptss "${@:2}" -y 2>&1
|
/usr/bin/aptss "${@:2}" -y 2>&1
|
||||||
exit_code=$?
|
exit_code=$?
|
||||||
exit $?
|
exit $exit_code
|
||||||
else
|
else
|
||||||
# 非 remove/install 命令,拒绝执行
|
# 非 remove/install 命令,拒绝执行
|
||||||
echo "拒绝执行 aptss 白名单外的指令"
|
echo "拒绝执行 aptss 白名单外的指令"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spark-store",
|
"name": "spark-store",
|
||||||
"version": "5.2.1-0",
|
"version": "5.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spark-store",
|
"name": "spark-store",
|
||||||
"version": "5.2.1-0",
|
"version": "5.3.1",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "spark-store",
|
"name": "spark-store",
|
||||||
"version": "5.2.1-0",
|
"version": "5.3.1",
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
"description": "Client for Spark App Store",
|
"description": "Client for Spark App Store",
|
||||||
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
|
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Spark Store update notifier
|
Description=Spark Store update notifier
|
||||||
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
|
After=apt-daily.service network.target network-online.target systemd-networkd.service NetworkManager.service connman.service
|
||||||
|
# 设置为 1 小时的时间窗口
|
||||||
|
StartLimitIntervalSec=1h
|
||||||
|
# 最大允许失败次数为 3 次
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=simple
|
||||||
RemainAfterExit=yes
|
|
||||||
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
|
ExecStart=/opt/durapps/spark-store/bin/update-upgrade/ss-update-notifier.sh
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=15 # 可以设置为更长的重试间隔,比如 15 秒或 30 秒
|
# 可以设置为更长的重试间隔,比如 15 秒或 30 秒
|
||||||
StartLimitIntervalSec=1h # 设置为 1 小时的时间窗口
|
RestartSec=15
|
||||||
StartLimitBurst=3 # 最大允许失败次数为 3 次
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
Description=Spark Store update notifier
|
Description=Spark Store update notifier
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=*-*-* 6,18:00
|
OnCalendar=*-*-* 6,12,18:00
|
||||||
RandomizedDelaySec=12h
|
RandomizedDelaySec=30m
|
||||||
Persistent=true
|
Persistent=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 测试打包脚本
|
||||||
|
#
|
||||||
|
# 用途:本地测试打包时,自动将 deb 版本号(位于 debian/changelog 顶部)的
|
||||||
|
# 最后一段数字 +1,并追加 "-test" 标签,随后执行 dpkg-buildpackage。
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# 当前 debian/changelog 顶部版本为 5.2.1.0
|
||||||
|
# 运行一次 -> 5.2.1.1-test(产物 spark-store_5.2.1.1-test_amd64.deb)
|
||||||
|
# 再运行一次 -> 5.2.1.2-test
|
||||||
|
#
|
||||||
|
# 注意:
|
||||||
|
# - 此脚本仅用于本地测试,不会修改 package.json(其 version 为 semver 格式,
|
||||||
|
# 而 deb 测试版本如 5.3.0.8-test 会被 electron-builder 拒绝)。
|
||||||
|
# - 为保证打包后「关于」页面显示版本与 deb 版本一致,脚本会临时向
|
||||||
|
# electron-builder.yml 追加 extraMetadata.version(electron-builder 会将其写入
|
||||||
|
# app.asar 内的 package.json,Electron 的 app.getVersion() 即从中读取),
|
||||||
|
# 打包完成后自动恢复,异常退出时由 trap 兜底恢复。
|
||||||
|
# - 每次运行都会改写 debian/changelog,属于未跟踪的工作区改动,按需自行还原。
|
||||||
|
# - 如需恢复正式版本,将 debian/changelog 顶部版本改回即可。
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 定位仓库根目录(脚本位于 scripts/ 下)
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
CHANGELOG="debian/changelog"
|
||||||
|
|
||||||
|
if [ ! -f "$CHANGELOG" ]; then
|
||||||
|
echo "错误:未找到 $CHANGELOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 读取 changelog 首行并解析版本号,形如:
|
||||||
|
# spark-store (5.2.1.0) UNRELEASED; urgency=medium
|
||||||
|
# spark-store (5.2.1.1-test) UNRELEASED; urgency=medium
|
||||||
|
top_line="$(head -n1 "$CHANGELOG")"
|
||||||
|
# 提取括号内的版本号,避免使用 [[ =~ ]] 处理字面括号引发的语法问题
|
||||||
|
old_ver="$(printf '%s' "$top_line" | sed -E 's/^[^ ]+ \(([^)]+)\).*/\1/')"
|
||||||
|
if [[ -z "$old_ver" || "$old_ver" == "$top_line" ]]; then
|
||||||
|
echo "错误:无法从 $CHANGELOG 首行解析版本号: $top_line" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 分离上游版本与 Debian 修订号:
|
||||||
|
# Debian 版本格式为 "上游版本-修订号",修订号以最后一个 '-' 分隔。
|
||||||
|
# 上游版本只允许点号(如 5.2.1.0),'-test' 这类标签属于修订号,每次测试覆盖为固定 "test"。
|
||||||
|
if [[ "$old_ver" == *-* ]]; then
|
||||||
|
upstream="${old_ver%-*}"
|
||||||
|
else
|
||||||
|
upstream="$old_ver"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 上游版本形如 X.Y.Z.W,将最后一个点号分隔的数字段 +1
|
||||||
|
if [[ "$upstream" =~ ^(.*)\.([0-9]+)$ ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
last="${BASH_REMATCH[2]}"
|
||||||
|
# 10# 强制按十进制解析,避免以 0 开头的段被当作八进制
|
||||||
|
new_last=$((10#$last + 1))
|
||||||
|
new_upstream="${prefix}.${new_last}"
|
||||||
|
else
|
||||||
|
echo "错误:上游版本号不符合 X.Y.Z.W 格式: $upstream" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
new_ver="${new_upstream}-test"
|
||||||
|
|
||||||
|
echo "版本号: ${old_ver} -> ${new_ver}"
|
||||||
|
|
||||||
|
# 仅替换首行括号内的版本号(转义点号,避免被正则当作任意字符)
|
||||||
|
old_ver_esc="${old_ver//./\\.}"
|
||||||
|
sed -i -E "1s/\\(${old_ver_esc}\\)/(${new_ver})/" "$CHANGELOG"
|
||||||
|
|
||||||
|
# Electron 二进制下载镜像(本机到 GitHub 不通,使用 npmmirror 镜像)
|
||||||
|
export ELECTRON_MIRROR="${ELECTRON_MIRROR:-https://registry.npmmirror.com/-/binary/electron/}"
|
||||||
|
|
||||||
|
# 同步应用内版本号:临时向 electron-builder.yml 追加 extraMetadata.version。
|
||||||
|
# electron-builder 会把它合并进 app.asar 内的 package.json,
|
||||||
|
# 「关于」页面经 IPC 调用的 app.getVersion() 即读取该值。
|
||||||
|
EB_YML="electron-builder.yml"
|
||||||
|
EB_YML_BAK="${EB_YML}.bak-test-build"
|
||||||
|
|
||||||
|
# 清理上次异常中断可能残留的 extraMetadata 块(追加内容始终位于文件末尾)
|
||||||
|
if grep -q "^extraMetadata:" "$EB_YML"; then
|
||||||
|
echo "清理 $EB_YML 中残留的 extraMetadata 块"
|
||||||
|
sed -i '/^extraMetadata:/,$d' "$EB_YML"
|
||||||
|
fi
|
||||||
|
cp "$EB_YML" "$EB_YML_BAK"
|
||||||
|
restore_eb_yml() {
|
||||||
|
mv -f "$EB_YML_BAK" "$EB_YML" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap restore_eb_yml EXIT
|
||||||
|
printf 'extraMetadata:\n version: "%s"\n' "$new_ver" >> "$EB_YML"
|
||||||
|
|
||||||
|
# 清理旧的 electron-builder 产物,避免 release/ 下多个版本目录被 make install
|
||||||
|
# 的 release/*/linux*-unpacked/* 通配一并拷入 deb,造成内容污染
|
||||||
|
rm -rf release/
|
||||||
|
|
||||||
|
echo "开始测试打包(版本 ${new_ver})..."
|
||||||
|
dpkg-buildpackage -us -uc -b
|
||||||
|
|
||||||
|
# 打包成功,立即恢复 electron-builder.yml 并解除 trap
|
||||||
|
restore_eb_yml
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
echo "完成。产物: ../spark-store_${new_ver}_amd64.deb"
|
||||||
+552
-2328
File diff suppressed because it is too large
Load Diff
@@ -26,21 +26,20 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("InstalledAppsModal", () => {
|
describe("InstalledAppsModal", () => {
|
||||||
it("keeps scroll chaining inside the modal list", () => {
|
const baseProps = {
|
||||||
const { container } = render(InstalledAppsModal, {
|
|
||||||
props: {
|
|
||||||
show: true,
|
show: true,
|
||||||
apps: [],
|
apps: [] as App[],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
activeOrigin: "spark",
|
warning: "",
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
syncMessage: "",
|
syncMessage: "",
|
||||||
},
|
};
|
||||||
|
|
||||||
|
it("keeps scroll chaining inside the modal list", () => {
|
||||||
|
const { container } = render(InstalledAppsModal, {
|
||||||
|
props: baseProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("已安装应用")).toBeTruthy();
|
expect(screen.getByText("已安装应用")).toBeTruthy();
|
||||||
@@ -52,17 +51,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("renders open and detail actions for a store-backed installed app", () => {
|
it("renders open and detail actions for a store-backed installed app", () => {
|
||||||
render(InstalledAppsModal, {
|
render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [createApp()],
|
apps: [createApp()],
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,20 +60,42 @@ describe("InstalledAppsModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the spark origin tag for spark apps", () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [createApp({ origin: "spark", name: "Spark Notes" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 精确匹配应用行内的来源标签(页头统计区也有 "Spark" 文案,getAllByText 过于宽泛)
|
||||||
|
expect(screen.getByTestId("origin-tag-spark")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the APM origin tag for APM apps", () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [
|
||||||
|
createApp({
|
||||||
|
origin: "apm",
|
||||||
|
name: "APM Container",
|
||||||
|
pkgname: "amber-pm-container",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 精确匹配应用行内的来源标签(页头统计区也有 "APM" 文案,getAllByText 过于宽泛)
|
||||||
|
expect(screen.getByTestId("origin-tag-apm")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits open-app when clicking 打开", async () => {
|
it("emits open-app when clicking 打开", async () => {
|
||||||
const rendered = render(InstalledAppsModal, {
|
const rendered = render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [createApp()],
|
apps: [createApp()],
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,17 +110,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("emits open-detail when clicking 查看详情", async () => {
|
it("emits open-detail when clicking 查看详情", async () => {
|
||||||
const rendered = render(InstalledAppsModal, {
|
const rendered = render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [createApp()],
|
apps: [createApp()],
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,17 +126,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
|
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
|
||||||
render(InstalledAppsModal, {
|
render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
|
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,17 +137,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("hides 查看详情 for unknown-category apps", () => {
|
it("hides 查看详情 for unknown-category apps", () => {
|
||||||
render(InstalledAppsModal, {
|
render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [createApp({ category: "unknown" })],
|
apps: [createApp({ category: "unknown" })],
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,19 +147,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
|
|
||||||
it("requests login for cloud actions when logged out", async () => {
|
it("requests login for cloud actions when logged out", async () => {
|
||||||
const rendered = render(InstalledAppsModal, {
|
const rendered = render(InstalledAppsModal, {
|
||||||
props: {
|
props: baseProps,
|
||||||
show: true,
|
|
||||||
apps: [],
|
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: false,
|
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||||
@@ -186,17 +159,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("emits cloud sync and restore events when logged in", async () => {
|
it("emits cloud sync and restore events when logged in", async () => {
|
||||||
const rendered = render(InstalledAppsModal, {
|
const rendered = render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [],
|
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
syncing: false,
|
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,17 +174,9 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("disables sync button while syncing", () => {
|
it("disables sync button while syncing", () => {
|
||||||
render(InstalledAppsModal, {
|
render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [],
|
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
syncMessage: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,20 +186,94 @@ describe("InstalledAppsModal", () => {
|
|||||||
it("shows account sync feedback in the installed apps modal", () => {
|
it("shows account sync feedback in the installed apps modal", () => {
|
||||||
render(InstalledAppsModal, {
|
render(InstalledAppsModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
...baseProps,
|
||||||
apps: [],
|
|
||||||
loading: false,
|
|
||||||
error: "",
|
|
||||||
activeOrigin: "spark",
|
|
||||||
storeFilter: "both",
|
|
||||||
sparkAvailable: true,
|
|
||||||
apmAvailable: true,
|
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
syncing: false,
|
|
||||||
syncMessage: "同步完成",
|
syncMessage: "同步完成",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("同步完成")).toBeTruthy();
|
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters installed apps by search query (name match)", async () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [
|
||||||
|
createApp({ name: "Spark Notes", pkgname: "spark-notes" }),
|
||||||
|
createApp({
|
||||||
|
name: "Visual Studio Code",
|
||||||
|
pkgname: "code",
|
||||||
|
category: "dev",
|
||||||
|
more: "https://code.visualstudio.com/",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始两条都在
|
||||||
|
expect(screen.getByText("Spark Notes")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||||
|
await fireEvent.update(input, "code");
|
||||||
|
|
||||||
|
// "code" 只匹配到 pkgname 为 "code" 的项(名称不区分大小写)
|
||||||
|
expect(screen.queryByText("Spark Notes")).toBeNull();
|
||||||
|
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters installed apps by search query (case-insensitive)", async () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [
|
||||||
|
createApp({ name: "钉钉", pkgname: "com.alibaba.dingtalk" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||||
|
await fireEvent.update(input, "DINGTALK");
|
||||||
|
|
||||||
|
// 包名大写不区分大小写匹配
|
||||||
|
expect(screen.getByText("钉钉")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a no-match hint when search query has no results", async () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [createApp({ name: "Spark Notes" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("搜索已安装应用…");
|
||||||
|
await fireEvent.update(input, "不存在的关键字xyz");
|
||||||
|
|
||||||
|
expect(screen.queryByText("Spark Notes")).toBeNull();
|
||||||
|
expect(screen.getByText(/未找到匹配/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the search when clicking the clear button", async () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
...baseProps,
|
||||||
|
apps: [createApp()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(
|
||||||
|
"搜索已安装应用…",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await fireEvent.update(input, "code");
|
||||||
|
expect(input.value).toBe("code");
|
||||||
|
|
||||||
|
// 清除按钮存在(仅在有内容时显示)
|
||||||
|
const clearBtn = screen.getByRole("button", { name: "清除搜索" });
|
||||||
|
await fireEvent.click(clearBtn);
|
||||||
|
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
expect(screen.getByText("Spark Notes")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
--color-card-light: #ffffff;
|
--color-card-light: #ffffff;
|
||||||
--color-card-dark: #151c2c;
|
--color-card-dark: #151c2c;
|
||||||
|
|
||||||
|
--color-scrollbar-thumb-light: #94a3b8;
|
||||||
|
--color-scrollbar-thumb-dark: #15203b;
|
||||||
|
|
||||||
--shadow-glass: 0 10px 30px rgba(15,23,42,0.08);
|
--shadow-glass: 0 10px 30px rgba(15,23,42,0.08);
|
||||||
--shadow-glassDark: 0 20px 45px rgba(0,0,0,0.45);
|
--shadow-glassDark: 0 20px 45px rgba(0,0,0,0.45);
|
||||||
|
|
||||||
@@ -22,6 +25,14 @@
|
|||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background-color: var(--color-surface-light);
|
background-color: var(--color-surface-light);
|
||||||
@@ -38,12 +49,50 @@
|
|||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全局滚动条基础几何:置于 base,使 utilities 中的 .scrollbar-muted/.scrollbar-nowidth 可覆盖 */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 亮色滚动条:与暗色保持相同几何(透明边距),确保亮/暗滚动块大小一致 */
|
||||||
|
html ::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-scrollbar-thumb-light);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色滚动条:挂在 html.dark 上(root),比 surface 略亮以保留可辨识反差 (#15203b),
|
||||||
|
注意:必须用 html.dark 而非 .dark *,因为经典滚动条只作用于滚动容器自身,
|
||||||
|
.dark * 在亮色模式不命中会导致回退到原生宽度,亮/暗宽度不一致。 */
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-scrollbar-thumb-dark);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/* 弱化滚动条 */
|
||||||
.scrollbar-muted {
|
.scrollbar-muted {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #94a3b8 transparent;
|
scrollbar-color: var(--color-scrollbar-thumb-light) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-muted::-webkit-scrollbar {
|
.scrollbar-muted::-webkit-scrollbar {
|
||||||
@@ -55,10 +104,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-muted::-webkit-scrollbar-thumb {
|
.scrollbar-muted::-webkit-scrollbar-thumb {
|
||||||
background-color: #94a3b8;
|
background-color: var(--color-scrollbar-thumb-light);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark .scrollbar-muted {
|
||||||
|
scrollbar-color: var(--color-surface-dark) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .scrollbar-muted::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-surface-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 完全隐藏滚动条 */
|
||||||
.scrollbar-nowidth {
|
.scrollbar-nowidth {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ const emit = defineEmits<{
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 12px;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
@click="openDetail"
|
@click="openDetail"
|
||||||
class="group flex cursor-pointer gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand/50 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60"
|
class="group relative flex cursor-pointer transition hover:border-brand/50"
|
||||||
|
:class="
|
||||||
|
compact
|
||||||
|
? 'gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 shadow-none hover:shadow-sm dark:border-slate-800/60 dark:bg-slate-900/60'
|
||||||
|
: 'gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
|
<!-- 排名徽标(排行榜/荣耀榜使用;前 3 名金/银/铜) -->
|
||||||
<div
|
<div
|
||||||
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
|
v-if="rank"
|
||||||
|
class="pointer-events-none absolute -left-1.5 -top-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold shadow-md ring-2 ring-white transition-transform duration-200 group-hover:scale-110 dark:ring-slate-900"
|
||||||
|
:class="rankClass"
|
||||||
|
>
|
||||||
|
{{ rank }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-center overflow-hidden bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
|
||||||
|
:class="compact ? 'h-9 w-9 rounded-md' : 'h-14 w-14 rounded-xl'"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref="iconImg"
|
ref="iconImg"
|
||||||
@@ -17,7 +31,8 @@
|
|||||||
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
|
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="truncate text-base font-semibold text-slate-900 dark:text-white"
|
class="truncate font-semibold text-slate-900 dark:text-white"
|
||||||
|
:class="compact ? 'text-xs' : 'text-base'"
|
||||||
>
|
>
|
||||||
{{ app.name || "" }}
|
{{ app.name || "" }}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,12 +58,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="downloadCount"
|
||||||
|
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
|
||||||
|
>{{ formatDownloads(downloadCount) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400 leading-tight">
|
<div
|
||||||
|
class="leading-tight text-slate-500 dark:text-slate-400"
|
||||||
|
:class="compact ? 'text-xs' : 'text-sm'"
|
||||||
|
>
|
||||||
{{ app.pkgname || "" }} · {{ app.version || "" }}
|
{{ app.pkgname || "" }} · {{ app.version || "" }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight"
|
class="truncate leading-tight text-slate-500 dark:text-slate-400"
|
||||||
|
:class="compact ? 'text-[11px]' : 'text-xs'"
|
||||||
>
|
>
|
||||||
{{ description || "\u00A0" }}
|
{{ description || "\u00A0" }}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +89,12 @@ const props = defineProps<{
|
|||||||
app: App;
|
app: App;
|
||||||
// 是否显示来源标识(仅在混合模式下显示)
|
// 是否显示来源标识(仅在混合模式下显示)
|
||||||
showOrigin?: boolean;
|
showOrigin?: boolean;
|
||||||
|
// 紧凑模式(用于首页精选板块等空间受限区域)
|
||||||
|
compact?: boolean;
|
||||||
|
// 下载量(用于下载排行;>=0 时在标题行右侧显示)
|
||||||
|
downloadCount?: number;
|
||||||
|
// 排名(用于排行榜/荣耀榜,左上角显示徽标)
|
||||||
|
rank?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -111,6 +141,25 @@ const description = computed(() => {
|
|||||||
return more.substring(0, 80) + (more.length > 80 ? "..." : "");
|
return more.substring(0, 80) + (more.length > 80 ? "..." : "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 下载量格式化:>=1万 显示 "x.x万"
|
||||||
|
const formatDownloads = (n?: number): string => {
|
||||||
|
if (!n || n <= 0) return "0";
|
||||||
|
if (n >= 10000) {
|
||||||
|
const wan = n / 10000;
|
||||||
|
return `${wan.toFixed(1).replace(/\.0$/, "")}万`;
|
||||||
|
}
|
||||||
|
return n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排名徽标配色(前 3 名金/银/铜,其余灰色)
|
||||||
|
const rankClass = computed(() => {
|
||||||
|
if (props.rank === 1) return "bg-amber-400 text-white shadow-amber-400/40";
|
||||||
|
if (props.rank === 2)
|
||||||
|
return "bg-slate-300 text-slate-700 shadow-slate-400/40";
|
||||||
|
if (props.rank === 3) return "bg-amber-600 text-white shadow-amber-600/40";
|
||||||
|
return "bg-slate-500/90 text-white shadow-slate-500/30";
|
||||||
|
});
|
||||||
|
|
||||||
const openDetail = () => {
|
const openDetail = () => {
|
||||||
emit("open-detail", props.app);
|
emit("open-detail", props.app);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="app.sparkApp"
|
v-if="app.sparkApp"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="原生安装,高手自定义"
|
||||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
:class="
|
:class="
|
||||||
viewingOrigin === 'spark'
|
viewingOrigin === 'spark'
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="app.apmApp"
|
v-if="app.apmApp"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="兼容安装,省心不折腾"
|
||||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
:class="
|
:class="
|
||||||
viewingOrigin === 'apm'
|
viewingOrigin === 'apm'
|
||||||
@@ -159,13 +161,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
@click="
|
@click="onOpenClick"
|
||||||
emit(
|
|
||||||
'open-app',
|
|
||||||
displayApp?.pkgname || '',
|
|
||||||
displayApp?.origin,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<i class="fas fa-external-link-alt text-xs"></i>
|
<i class="fas fa-external-link-alt text-xs"></i>
|
||||||
<span>打开</span>
|
<span>打开</span>
|
||||||
@@ -300,7 +296,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
|
class="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
|
||||||
v-html="displayApp.more.replace(/\n/g, '<br>')"
|
v-html="sanitizeMoreContent(displayApp.more)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -506,6 +502,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 双来源安装:打开时选择 APM 还是 Spark -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="openChoiceVisible"
|
||||||
|
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
|
||||||
|
@click.self="openChoiceVisible = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div class="mb-5 flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-100 to-indigo-50 shadow-inner dark:from-sky-900/30 dark:to-indigo-800/20"
|
||||||
|
>
|
||||||
|
<i class="fas fa-external-link-alt text-xl text-sky-500"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
|
||||||
|
打开应用
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
该应用同时通过 APM 与 Spark 安装,请选择要打开的来源:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2.5 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
|
||||||
|
@click="confirmOpen('apm')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-box-open"></i>
|
||||||
|
打开 APM 版
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
|
||||||
|
@click="confirmOpen('spark')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
打开 Spark 版
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="openChoiceVisible = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -517,10 +576,28 @@ import {
|
|||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
getHybridDefaultOrigin,
|
getHybridDefaultOrigin,
|
||||||
} from "../global/storeConfig";
|
} from "../global/storeConfig";
|
||||||
|
import {
|
||||||
|
tagPriorityStrategyRef,
|
||||||
|
type TagPriorityStrategy,
|
||||||
|
} from "../global/tagPriority";
|
||||||
// 评论功能暂时关闭
|
// 评论功能暂时关闭
|
||||||
// import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
|
// import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
|
||||||
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
|
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净化后端返回的应用描述(v-html 前严格去除所有标签)。
|
||||||
|
* 使用 textContent 做 HTML 实体转义,彻底杜绝 <script>/onerror/嵌套标签/编码绕过等 XSS;
|
||||||
|
* 再将 \n 转为 <br>。<br> 是纯程序生成,不含任何用户输入,可安全放入 v-html。
|
||||||
|
*/
|
||||||
|
const sanitizeMoreContent = (raw: string): string => {
|
||||||
|
if (!raw) return "";
|
||||||
|
// 用 textContent 转义所有 HTML 特殊字符(比正则替换 /<[^>]*>/g 更安全,防嵌套/编码绕过)
|
||||||
|
const stripped = document.createElement("div");
|
||||||
|
stripped.textContent = raw;
|
||||||
|
// 将 \n 转为安全的 <br>(此时 textContent 已是转义后的纯文本字符串)
|
||||||
|
return (stripped.textContent ?? "").replace(/\n/g, "<br>");
|
||||||
|
};
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -567,30 +644,112 @@ const closeMetaModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openWebsite = (url: string) => {
|
const openWebsite = (url: string) => {
|
||||||
if (url) {
|
if (!url) return;
|
||||||
window.open(url, "_blank");
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// 仅允许 http/https 协议,杜绝 javascript:/data: 等危险协议
|
||||||
|
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 无效 URL,不打开
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据「标签优先显示策略」、本地安装状态与应用的可用来源标签,计算默认展示的来源
|
||||||
|
const computeDefaultViewingOrigin = (
|
||||||
|
app: App,
|
||||||
|
strategy: TagPriorityStrategy,
|
||||||
|
sparkInstalled: boolean,
|
||||||
|
apmInstalled: boolean,
|
||||||
|
): "spark" | "apm" => {
|
||||||
|
// 父组件显式指定展示来源(如从已安装应用页按特定来源打开)时优先级最高,
|
||||||
|
// 此时强制覆盖用户标签策略与本地安装状态;否则交由下方策略逻辑决定。
|
||||||
|
// 注意:被强制的应用(forceViewingOrigin=true)在详情页保持打开期间,
|
||||||
|
// 即使设置页切换标签策略也不会实时变更其展示来源(已安装来源高于策略,属设计行为)。
|
||||||
|
if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin;
|
||||||
|
|
||||||
|
// 非合并应用只有一个来源标签,直接展示该标签
|
||||||
|
if (!app.isMerged) return app.origin;
|
||||||
|
|
||||||
|
// 合并应用:available 为实际存在的来源标签
|
||||||
|
const available: Array<"spark" | "apm"> = [];
|
||||||
|
if (app.sparkApp) available.push("spark");
|
||||||
|
if (app.apmApp) available.push("apm");
|
||||||
|
if (available.length === 0) return app.origin; // 兜底
|
||||||
|
if (available.length === 1) return available[0]; // 仅有一个标签时默认展示该标签
|
||||||
|
|
||||||
|
// 本地只安装了某一个版本时,优先展开已安装的版本,保证打开应用时能直接使用
|
||||||
|
// 已安装来源(例如 WeChat 默认 APM,但本机只装了 Spark 版时默认展开 Spark)。
|
||||||
|
const installed: Array<"spark" | "apm"> = [];
|
||||||
|
if (sparkInstalled && app.sparkApp) installed.push("spark");
|
||||||
|
if (apmInstalled && app.apmApp) installed.push("apm");
|
||||||
|
if (installed.length === 1) return installed[0];
|
||||||
|
|
||||||
|
// 都安装 / 都未安装时,继续按用户策略或应用配置的优先级展示:
|
||||||
|
// - 按策略优先选择对应标签
|
||||||
|
const preferred: "spark" | "apm" | null =
|
||||||
|
strategy === "spark" ? "spark" : strategy === "apm" ? "apm" : null;
|
||||||
|
if (preferred && available.includes(preferred)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的
|
||||||
|
// 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内
|
||||||
|
const auto = getHybridDefaultOrigin(app);
|
||||||
|
return available.includes(auto) ? auto : available[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 进入详情页时按「标签优先显示策略」与本地安装状态计算默认来源标签。
|
||||||
|
// 同时监听 props.app、共享策略 ref 与安装状态:
|
||||||
|
// - props.app 变化(重开/切换应用)→ 始终按当前策略重算(手动切标签仅本次会话预览,不持久)
|
||||||
|
// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签
|
||||||
|
// - 安装状态异步返回(如仅 Spark 已安装)→ 已打开且未手动切标签时自动切换到已安装版本
|
||||||
|
// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持,
|
||||||
|
// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。
|
||||||
|
// 注:initTagPriorityStrategy() 已在 App.vue 启动时统一调用,此处不再重复。
|
||||||
|
const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话)
|
||||||
|
let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开"
|
||||||
watch(
|
watch(
|
||||||
|
[
|
||||||
() => props.app,
|
() => props.app,
|
||||||
(newApp: App | null) => {
|
tagPriorityStrategyRef,
|
||||||
|
() => props.sparkInstalled,
|
||||||
|
() => props.apmInstalled,
|
||||||
|
],
|
||||||
|
([newApp, strategy, sparkInstalled, apmInstalled]: [
|
||||||
|
App | null,
|
||||||
|
TagPriorityStrategy,
|
||||||
|
boolean,
|
||||||
|
boolean,
|
||||||
|
]) => {
|
||||||
isIconLoaded.value = false;
|
isIconLoaded.value = false;
|
||||||
if (newApp) {
|
if (!newApp) {
|
||||||
if (newApp.isMerged) {
|
// 详情页关闭:清空会话状态,确保下次打开按最新策略重算
|
||||||
// 若父组件已根据安装状态设置了优先展示的版本,则使用
|
lastPkgname = null;
|
||||||
// 否则根据优先级配置决定默认来源
|
manualOverride.value = false;
|
||||||
if (newApp.viewingOrigin) {
|
return;
|
||||||
viewingOrigin.value = newApp.viewingOrigin;
|
|
||||||
} else if (newApp.sparkApp) {
|
|
||||||
// 使用优先级配置决定默认来源
|
|
||||||
viewingOrigin.value = getHybridDefaultOrigin(newApp.sparkApp);
|
|
||||||
} else {
|
|
||||||
viewingOrigin.value = "apm";
|
|
||||||
}
|
}
|
||||||
|
if (newApp.pkgname !== lastPkgname) {
|
||||||
|
// 新应用(含重开后再次打开同一应用):重置手动标记,按当前策略/安装状态计算
|
||||||
|
lastPkgname = newApp.pkgname;
|
||||||
|
manualOverride.value = false;
|
||||||
|
viewingOrigin.value = computeDefaultViewingOrigin(
|
||||||
|
newApp,
|
||||||
|
strategy,
|
||||||
|
sparkInstalled,
|
||||||
|
apmInstalled,
|
||||||
|
);
|
||||||
|
} else if (manualOverride.value || newApp.forceViewingOrigin) {
|
||||||
|
// 同一应用且用户正在手动预览 / 父组件强制来源:保留当前标签,不重算
|
||||||
} else {
|
} else {
|
||||||
viewingOrigin.value = newApp.origin;
|
// 同一应用、无手动预览、策略/安装状态变化 → 实时按新状态更新默认标签
|
||||||
}
|
viewingOrigin.value = computeDefaultViewingOrigin(
|
||||||
|
newApp,
|
||||||
|
strategy,
|
||||||
|
sparkInstalled,
|
||||||
|
apmInstalled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -655,8 +814,10 @@ const installBtnText = computed(() => {
|
|||||||
const iconPath = computed(() => {
|
const iconPath = computed(() => {
|
||||||
if (!displayApp.value) return "";
|
if (!displayApp.value) return "";
|
||||||
const arch = window.apm_store.arch || "amd64";
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
// 切换来源标签时(viewingOrigin)需动态重算架构路径,确保图标/资源跟随当前查看来源,
|
||||||
|
// 而非固定使用合并应用原始 origin,避免切换源时图标与数据错位。
|
||||||
const finalArch =
|
const finalArch =
|
||||||
displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
viewingOrigin.value === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
|
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -728,6 +889,30 @@ const handleRemove = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版
|
||||||
|
const openChoiceVisible = ref(false);
|
||||||
|
const openOrigins = (app: App | null): Array<"spark" | "apm"> =>
|
||||||
|
app?.origins && app.origins.length > 0
|
||||||
|
? app.origins
|
||||||
|
: [app?.origin ?? "spark"];
|
||||||
|
|
||||||
|
const onOpenClick = () => {
|
||||||
|
const app = displayApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
const origins = openOrigins(app);
|
||||||
|
if (origins.length > 1) {
|
||||||
|
openChoiceVisible.value = true;
|
||||||
|
} else {
|
||||||
|
emit("open-app", app.pkgname, origins[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmOpen = (origin: "spark" | "apm") => {
|
||||||
|
const app = displayApp.value;
|
||||||
|
if (app) emit("open-app", app.pkgname, origin);
|
||||||
|
openChoiceVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
// 收藏功能暂时关闭
|
// 收藏功能暂时关闭
|
||||||
// const handleFavorite = () => {
|
// const handleFavorite = () => {
|
||||||
// if (!displayApp.value) return;
|
// if (!displayApp.value) return;
|
||||||
@@ -739,6 +924,8 @@ const handleRemove = () => {
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
const selectOrigin = (origin: "spark" | "apm") => {
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
|
// 手动切换标签:仅本次会话预览,不写回策略、不跨重开持久
|
||||||
|
manualOverride.value = true;
|
||||||
viewingOrigin.value = origin;
|
viewingOrigin.value = origin;
|
||||||
emit("select-origin", origin);
|
emit("select-origin", origin);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="app.sparkApp"
|
v-if="app.sparkApp"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="原生安装,高手自定义"
|
||||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
:class="
|
:class="
|
||||||
viewingOrigin === 'spark'
|
viewingOrigin === 'spark'
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="app.apmApp"
|
v-if="app.apmApp"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="兼容安装,省心不折腾"
|
||||||
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
|
||||||
:class="
|
:class="
|
||||||
viewingOrigin === 'apm'
|
viewingOrigin === 'apm'
|
||||||
|
|||||||
@@ -19,10 +19,12 @@
|
|||||||
|
|
||||||
<!-- 应用数量较少时,使用普通网格(带滚动) -->
|
<!-- 应用数量较少时,使用普通网格(带滚动) -->
|
||||||
<div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller">
|
<div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller">
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div
|
||||||
|
class="grid gap-4 px-4 py-6 sm:grid-cols-2 lg:px-10 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
|
>
|
||||||
<AppCard
|
<AppCard
|
||||||
v-for="(app, index) in apps"
|
v-for="app in apps"
|
||||||
:key="index"
|
:key="app.pkgname"
|
||||||
:app="app"
|
:app="app"
|
||||||
:show-origin="effectiveShowOrigin"
|
:show-origin="effectiveShowOrigin"
|
||||||
@open-detail="$emit('open-detail', app)"
|
@open-detail="$emit('open-detail', app)"
|
||||||
@@ -182,12 +184,17 @@ const gridRows = computed(() => {
|
|||||||
.non-virtual-scroller {
|
.non-virtual-scroller {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.scroller {
|
.scroller {
|
||||||
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
|
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.non-virtual-scroller {
|
||||||
|
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-row {
|
.grid-row {
|
||||||
@@ -205,7 +212,6 @@ const gridRows = computed(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.vue-recycle-scroller__item-view) {
|
:deep(.vue-recycle-scroller__item-view) {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 lg:hidden dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
|
|
||||||
@click="$emit('toggle-sidebar')"
|
|
||||||
title="切换侧边栏"
|
|
||||||
>
|
|
||||||
<i class="fas fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
<div class="flex w-full flex-1 items-center gap-3">
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<label for="searchBox" class="sr-only">搜索应用</label>
|
|
||||||
<i
|
|
||||||
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
|
|
||||||
></i>
|
|
||||||
<input
|
|
||||||
id="searchBox"
|
|
||||||
v-model="localSearchQuery"
|
|
||||||
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
|
|
||||||
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
|
|
||||||
@focus="handleSearchFocus"
|
|
||||||
@input="handleInput"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="localSearchQuery"
|
|
||||||
type="button"
|
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
|
|
||||||
@click="clearSearch"
|
|
||||||
title="清除搜索"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
|
|
||||||
@click="$emit('open-install-settings')"
|
|
||||||
title="安装设置"
|
|
||||||
>
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
|
|
||||||
@click="$emit('open-about')"
|
|
||||||
title="关于"
|
|
||||||
>
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
searchQuery: string;
|
|
||||||
activeTab: string;
|
|
||||||
appsCount: number;
|
|
||||||
}>();
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "update-search", query: string): void;
|
|
||||||
(e: "search-focus"): void;
|
|
||||||
(e: "open-install-settings"): void;
|
|
||||||
(e: "open-about"): void;
|
|
||||||
(e: "toggle-sidebar"): void;
|
|
||||||
(e: "spk-link", pkgname: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const localSearchQuery = ref(props.searchQuery || "");
|
|
||||||
|
|
||||||
const handleSearchFocus = () => {
|
|
||||||
emit("search-focus");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
const value = localSearchQuery.value.trim();
|
|
||||||
// 检测 SPK 分享链接: spk://store/{category}/{pkgname}
|
|
||||||
const spkMatch =
|
|
||||||
value.match(/^spk:\/\/search\/(.+)$/i) ||
|
|
||||||
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
|
|
||||||
if (spkMatch) {
|
|
||||||
const pkgname = spkMatch[1].trim();
|
|
||||||
if (pkgname) {
|
|
||||||
localSearchQuery.value = "";
|
|
||||||
emit("spk-link", pkgname);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit("update-search", localSearchQuery.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
localSearchQuery.value = "";
|
|
||||||
emit("update-search", "");
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.searchQuery,
|
|
||||||
(newVal) => {
|
|
||||||
localSearchQuery.value = newVal || "";
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
@@ -1,51 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6">
|
<div class="flex h-full flex-col gap-6">
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div ref="accountMenuRoot" class="relative min-w-0 flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full min-w-0 items-center gap-2 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
||||||
:aria-label="accountLabel"
|
|
||||||
@click="handleAccountClick"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="amberLogo"
|
|
||||||
alt="星火应用商店"
|
|
||||||
class="h-11 w-11 shrink-0 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
|
|
||||||
/>
|
|
||||||
<div data-testid="account-text" class="flex min-w-0 flex-col">
|
|
||||||
<span
|
|
||||||
class="truncate text-base font-semibold text-slate-900 dark:text-white"
|
|
||||||
>{{ accountLabel }}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate text-[10px] uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500"
|
|
||||||
>社区版</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<AccountQuickMenu
|
|
||||||
v-if="currentUser && showAccountMenu"
|
|
||||||
@open-user-management="emitAccountAction('open-user-management')"
|
|
||||||
@open-favorites="emitAccountAction('open-favorites')"
|
|
||||||
@open-forum="emitAccountAction('open-forum')"
|
|
||||||
@edit-profile="emitAccountAction('edit-profile')"
|
|
||||||
@logout="emitAccountAction('logout')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 w-10 items-center justify-center rounded-2xl text-slate-400 hover:bg-slate-100 lg:hidden dark:hover:bg-slate-800"
|
|
||||||
@click="$emit('close')"
|
|
||||||
title="关闭侧边栏"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1">
|
<div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -71,6 +25,29 @@
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 排行榜 / 荣耀榜:暂时隐藏入口(代码保留,后续按需恢复) -->
|
||||||
|
<button
|
||||||
|
v-if="false"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-tab"
|
||||||
|
:class="{ 'sidebar-tab-active': activeTab === 'ranking' }"
|
||||||
|
@click="selectTab('ranking')"
|
||||||
|
>
|
||||||
|
<span class="sidebar-tab-icon"><i class="fas fa-trophy"></i></span>
|
||||||
|
<span class="sidebar-tab-label">排行榜</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="false"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-tab"
|
||||||
|
:class="{ 'sidebar-tab-active': activeTab === 'honor' }"
|
||||||
|
@click="selectTab('honor')"
|
||||||
|
>
|
||||||
|
<span class="sidebar-tab-icon"><i class="fas fa-medal"></i></span>
|
||||||
|
<span class="sidebar-tab-label">荣耀榜</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="sidebarEntries.length > 0"
|
v-if="sidebarEntries.length > 0"
|
||||||
class="my-3 border-t border-slate-100 dark:border-slate-800"
|
class="my-3 border-t border-slate-100 dark:border-slate-800"
|
||||||
@@ -129,10 +106,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed } from "vue";
|
||||||
import AccountQuickMenu from "./AccountQuickMenu.vue";
|
|
||||||
import ThemeToggle from "./ThemeToggle.vue";
|
|
||||||
import amberLogo from "../assets/imgs/spark-store.svg";
|
|
||||||
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
|
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -162,50 +136,6 @@ const emit = defineEmits<{
|
|||||||
(e: "logout"): void;
|
(e: "logout"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showAccountMenu = ref(false);
|
|
||||||
const accountMenuRoot = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const accountLabel = computed(() => "星火应用商店");
|
|
||||||
|
|
||||||
const handleAccountClick = () => {
|
|
||||||
// 登录功能暂时关闭
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDocumentPointerDown = (event: MouseEvent) => {
|
|
||||||
if (!showAccountMenu.value) return;
|
|
||||||
const target = event.target;
|
|
||||||
if (target instanceof Node && accountMenuRoot.value?.contains(target)) return;
|
|
||||||
showAccountMenu.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("mousedown", handleDocumentPointerDown);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener("mousedown", handleDocumentPointerDown);
|
|
||||||
});
|
|
||||||
|
|
||||||
const emitAccountAction = (
|
|
||||||
action:
|
|
||||||
| "open-user-management"
|
|
||||||
| "open-favorites"
|
|
||||||
| "open-forum"
|
|
||||||
| "edit-profile"
|
|
||||||
| "logout",
|
|
||||||
) => {
|
|
||||||
showAccountMenu.value = false;
|
|
||||||
if (action === "open-user-management") emit("open-user-management");
|
|
||||||
else if (action === "open-favorites") emit("open-favorites");
|
|
||||||
else if (action === "open-forum") emit("open-forum");
|
|
||||||
else if (action === "edit-profile") emit("edit-profile");
|
|
||||||
else emit("logout");
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
emit("toggle-theme");
|
|
||||||
};
|
|
||||||
|
|
||||||
const canManageApps = computed(() => {
|
const canManageApps = computed(() => {
|
||||||
return (
|
return (
|
||||||
(props.storeFilter !== "apm" && props.sparkAvailable) ||
|
(props.storeFilter !== "apm" && props.sparkAvailable) ||
|
||||||
@@ -216,12 +146,10 @@ const canManageApps = computed(() => {
|
|||||||
const canOpenUpdateCenter = canManageApps;
|
const canOpenUpdateCenter = canManageApps;
|
||||||
|
|
||||||
const selectTab = (tab: string) => {
|
const selectTab = (tab: string) => {
|
||||||
showAccountMenu.value = false;
|
|
||||||
emit("select-tab", tab);
|
emit("select-tab", tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitSidebarAction = (action: "list" | "update" | "submit") => {
|
const emitSidebarAction = (action: "list" | "update" | "submit") => {
|
||||||
showAccountMenu.value = false;
|
|
||||||
if (action === "list") emit("list");
|
if (action === "list") emit("list");
|
||||||
else if (action === "update") emit("update");
|
else if (action === "update") emit("update");
|
||||||
else emit("submit");
|
else emit("submit");
|
||||||
@@ -233,10 +161,10 @@ const emitSidebarAction = (action: "list" | "update" | "submit") => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 0.75rem;
|
border-radius: 12px;
|
||||||
padding: 0.625rem 0.875rem;
|
padding: 10px 0.875rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const selectCategory = (category: string) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-pill {
|
.category-pill {
|
||||||
@@ -81,7 +81,7 @@ const selectCategory = (category: string) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0.375rem 0.875rem;
|
padding: 0.375rem 0.875rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 0.8125rem;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
@@ -126,7 +126,7 @@ const selectCategory = (category: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-pill-count {
|
.category-pill-count {
|
||||||
font-size: 0.6875rem;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,38 +9,41 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
v-if="show"
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4 py-6"
|
||||||
@click="handleOverlayClick"
|
@click="handleOverlayClick"
|
||||||
@wheel="onOverlayWheel"
|
@wheel="onOverlayWheel"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between p-5 pb-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
<p class="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
下载详情
|
下载详情
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||||
实时了解安装进度
|
实时了解安装进度
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<i class="fas fa-xmark"></i>
|
<i class="fas fa-xmark"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="download" class="mt-6 space-y-6">
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
|
v-if="download"
|
||||||
|
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent space-y-3 overflow-y-auto overscroll-contain px-5 pb-5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
|
class="flex items-center gap-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 shrink-0 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="download.icon"
|
:src="download.icon"
|
||||||
@@ -48,18 +51,18 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-lg font-semibold text-slate-900 dark:text-white">
|
<p class="truncate text-base font-semibold text-slate-900 dark:text-white">
|
||||||
{{ download.name }}
|
{{ download.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||||
{{ download.pkgname }} · {{ download.version }}
|
{{ download.pkgname }} · {{ download.version }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
|
class="space-y-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-slate-500">状态</span>
|
<span class="text-sm font-medium text-slate-500">状态</span>
|
||||||
@@ -80,7 +83,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="download.status === 'downloading'" class="space-y-3">
|
<div v-if="download.status === 'downloading'" class="space-y-2">
|
||||||
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
|
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-brand"
|
class="h-full rounded-full bg-brand"
|
||||||
@@ -112,36 +115,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
|
class="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-slate-200/60 p-3 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between py-1">
|
<div class="flex justify-between gap-2">
|
||||||
<span class="text-slate-400">下载源</span>
|
<span class="shrink-0 text-slate-400">下载源</span>
|
||||||
<span class="font-medium text-slate-900 dark:text-white">{{
|
<span class="truncate font-medium text-slate-900 dark:text-white">{{
|
||||||
download.origin === "spark" ? "Spark Store" : "APM Store"
|
download.origin === "spark" ? "Spark Store" : "APM Store"
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="download.startTime" class="flex justify-between py-1">
|
<div
|
||||||
<span class="text-slate-400">开始时间</span>
|
v-if="download.startTime"
|
||||||
<span>{{ formatDate(download.startTime) }}</span>
|
class="flex justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 text-slate-400">开始时间</span>
|
||||||
|
<span class="truncate">{{ formatDate(download.startTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="download.endTime" class="flex justify-between py-1">
|
<div
|
||||||
<span class="text-slate-400">完成时间</span>
|
v-if="download.endTime"
|
||||||
<span>{{ formatDate(download.endTime) }}</span>
|
class="flex justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 text-slate-400">完成时间</span>
|
||||||
|
<span class="truncate">{{ formatDate(download.endTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="download.error"
|
v-if="download.error"
|
||||||
class="flex justify-between py-1 text-rose-500"
|
class="col-span-2 flex justify-between gap-2 text-rose-500"
|
||||||
>
|
>
|
||||||
<span>错误信息</span>
|
<span class="shrink-0">错误信息</span>
|
||||||
<span class="text-right">{{ download.error }}</span>
|
<span class="truncate text-right">{{ download.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="download.logs && download.logs.length"
|
v-if="download.logs && download.logs.length"
|
||||||
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
|
class="rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="font-semibold text-slate-800 dark:text-slate-100"
|
<span class="font-semibold text-slate-800 dark:text-slate-100"
|
||||||
>下载日志</span
|
>下载日志</span
|
||||||
>
|
>
|
||||||
@@ -155,22 +164,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="max-h-48 space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
|
class="max-h-32 space-y-1.5 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(log, index) in download.logs"
|
v-for="(log, index) in download.logs"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex gap-3"
|
class="flex gap-3"
|
||||||
>
|
>
|
||||||
<span class="text-slate-400">{{
|
<span class="shrink-0 text-slate-400">{{
|
||||||
formatLogTime(log.time)
|
formatLogTime(log.time)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span>{{ log.message }}</span>
|
<span class="break-all">{{ log.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-end gap-3">
|
<div class="flex flex-wrap justify-end gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
v-if="
|
v-if="
|
||||||
download.status === 'downloading' ||
|
download.status === 'downloading' ||
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- 空队列:仅显示右下角小按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="downloads.length === 0"
|
||||||
|
type="button"
|
||||||
|
class="fixed bottom-4 right-4 z-40 inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 bg-white text-slate-500 shadow-lg transition hover:-translate-y-0.5 hover:text-brand dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-white sm:bottom-6 sm:right-6"
|
||||||
|
title="暂无下载任务"
|
||||||
|
aria-label="下载队列(空)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 有任务:展开为完整面板(默认展开,可点 chevron 折叠列表) -->
|
||||||
<div
|
<div
|
||||||
ref="queueRef"
|
v-else
|
||||||
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96"
|
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96"
|
||||||
:class="
|
|
||||||
isHidden
|
|
||||||
? 'pointer-events-none translate-y-[calc(100%+1rem)] opacity-0'
|
|
||||||
: 'translate-y-0 opacity-100'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-5 py-4"
|
class="flex items-center justify-between px-5 py-4"
|
||||||
@@ -59,14 +66,7 @@
|
|||||||
v-show="isExpanded"
|
v-show="isExpanded"
|
||||||
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
|
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
|
||||||
>
|
>
|
||||||
<div
|
<div class="space-y-2">
|
||||||
v-if="downloads.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-inbox text-3xl"></i>
|
|
||||||
<p class="mt-3 text-sm">暂无下载任务</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-for="download in downloads"
|
v-for="download in downloads"
|
||||||
:key="download.id"
|
:key="download.id"
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import type { DownloadItem } from "../global/typedefinition";
|
import type { DownloadItem } from "../global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -152,31 +152,7 @@ const emit = defineEmits<{
|
|||||||
(e: "show-detail", download: DownloadItem): void;
|
(e: "show-detail", download: DownloadItem): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(true);
|
||||||
const isHidden = ref(false);
|
|
||||||
const queueRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const onWheel = (event: WheelEvent) => {
|
|
||||||
if (queueRef.value?.contains(event.target as Node) || event.deltaY === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.deltaY > 0) {
|
|
||||||
isExpanded.value = false;
|
|
||||||
isHidden.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isHidden.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener("wheel", onWheel, { capture: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const completedDownloads = computed(() => {
|
const completedDownloads = computed(() => {
|
||||||
return props.downloads.filter((d) => d.status === "completed").length;
|
return props.downloads.filter((d) => d.status === "completed").length;
|
||||||
@@ -197,4 +173,12 @@ const clearCompleted = () => {
|
|||||||
const showDownloadDetail = (download: DownloadItem) => {
|
const showDownloadDetail = (download: DownloadItem) => {
|
||||||
emit("show-detail", download);
|
emit("show-detail", download);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从无任务到有任务时,自动展开面板("有任务时再向左边伸出")
|
||||||
|
watch(
|
||||||
|
() => props.downloads.length > 0,
|
||||||
|
(hasTasks) => {
|
||||||
|
if (hasTasks) isExpanded.value = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+50
-26
@@ -1,12 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<!--
|
||||||
|
首页视图:仅渲染"区域1 精选(links)"。
|
||||||
|
历史版本曾渲染"区域2 板块(homelist.json)"作为第二个 section,
|
||||||
|
若回滚/合并导致该 section 复活,板块应用会与侧边栏入口"装机必备/社区精品/APM 扩展"内容重复展示(双来源同数据)。
|
||||||
|
现行设计:板块已迁移至侧边栏入口(App.vue `loadHomeListEntries`),首页不再渲染板块,参见 commit 438b9baa。
|
||||||
|
-->
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
|
||||||
<!-- 初始加载状态 - 只有在完全没有数据时显示 -->
|
<!-- 初始加载状态 - 只有在完全没有数据时显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="loading && links.length === 0"
|
v-if="loading && links.length === 0"
|
||||||
class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400"
|
class="flex flex-1 flex-col items-center justify-center text-slate-500 dark:text-slate-400"
|
||||||
>
|
>
|
||||||
<i class="fas fa-spinner fa-spin text-2xl mb-3"></i>
|
<i class="fas fa-spinner fa-spin text-2xl mb-3"></i>
|
||||||
<span class="text-sm">正在加载首页内容…</span>
|
<span class="text-base">正在加载首页内容…</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="error"
|
v-else-if="error"
|
||||||
@@ -17,7 +23,7 @@
|
|||||||
<!-- 无数据时显示欢迎信息 -->
|
<!-- 无数据时显示欢迎信息 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="links.length === 0"
|
v-else-if="links.length === 0"
|
||||||
class="flex flex-col items-center justify-center py-20 text-center"
|
class="flex flex-1 flex-col items-center justify-center text-center"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="storeFilter === 'apm'"
|
v-if="storeFilter === 'apm'"
|
||||||
@@ -38,7 +44,7 @@
|
|||||||
: "欢迎来到星火应用商店"
|
: "欢迎来到星火应用商店"
|
||||||
}}
|
}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-base text-slate-500 dark:text-slate-400">
|
||||||
{{
|
{{
|
||||||
storeFilter === "apm"
|
storeFilter === "apm"
|
||||||
? "探索丰富的应用,发现更多精彩内容"
|
? "探索丰富的应用,发现更多精彩内容"
|
||||||
@@ -47,41 +53,45 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- 有数据就立即展示,图片逐步加载 -->
|
<!-- 有数据就立即展示,图片逐步加载 -->
|
||||||
<div v-else>
|
<template v-else>
|
||||||
<!-- 左上角欢迎语 -->
|
<!-- ============ 区域1 · 精选(两行网格 4×2,静态不滚动) ============ -->
|
||||||
|
<section class="shrink-0">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-200">
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-200">
|
||||||
{{
|
{{
|
||||||
storeFilter === "apm"
|
storeFilter === "apm" ? "星火应用商店 (Amber PM)" : "星火应用商店"
|
||||||
? "欢迎来到星火应用商店 (Amber PM)"
|
|
||||||
: "欢迎来到星火应用商店"
|
|
||||||
}}
|
}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
<p class="text-base text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
探索丰富的应用,发现更多精彩内容
|
探索丰富的应用,发现更多精彩内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<!-- Links 区域 -->
|
v-if="links.length > 0"
|
||||||
<div v-if="links.length > 0" class="grid gap-5 auto-fit-grid">
|
class="grid grid-cols-2 gap-4 sm:grid-cols-4"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
v-for="link in links"
|
v-for="link in links"
|
||||||
:key="link.url + link.name"
|
:key="link.url + link.name"
|
||||||
:href="link.type === '_blank' ? undefined : link.url"
|
:href="link.type === '_blank' ? undefined : link.url"
|
||||||
@click.prevent="onLinkClick(link)"
|
@click.prevent="onLinkClick(link)"
|
||||||
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
|
class="group block overflow-hidden rounded-lg transition-transform duration-300 hover:scale-[1.02]"
|
||||||
:title="link.more as string"
|
:title="link.more as string"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
|
class="relative w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="computedImgUrl(link)"
|
:src="computedImgUrl(link)"
|
||||||
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
class="block w-full transition-transform duration-500 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
@load="onImageLoad(link.url + link.name)"
|
@load="onImageLoad(link.url + link.name)"
|
||||||
@error="onImageError(link.url + link.name)"
|
@error="onImageError(link.url + link.name)"
|
||||||
:class="{
|
:class="{
|
||||||
|
'aspect-[850/260] object-cover':
|
||||||
|
!imageLoaded[link.url + link.name],
|
||||||
|
'h-auto': imageLoaded[link.url + link.name],
|
||||||
'opacity-0': !imageLoaded[link.url + link.name],
|
'opacity-0': !imageLoaded[link.url + link.name],
|
||||||
'opacity-100 transition-opacity duration-300':
|
'opacity-100 transition-opacity duration-300':
|
||||||
imageLoaded[link.url + link.name],
|
imageLoaded[link.url + link.name],
|
||||||
@@ -96,9 +106,9 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 px-1">
|
<div class="mt-1.5 px-1">
|
||||||
<div
|
<div
|
||||||
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
|
class="text-sm font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
|
||||||
>
|
>
|
||||||
{{ link.name }}
|
{{ link.name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,20 +120,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- 致谢说明(F2.3 复用):置于区域1 其他内容下方,当前按需求隐藏(代码保留) -->
|
||||||
|
<ThanksCard v-if="false" class="mt-4" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
import type { HomeLink } from "../global/typedefinition";
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
import type { HomeLink, App } from "../global/typedefinition";
|
||||||
|
import ThanksCard from "./ThanksCard.vue";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
links: HomeLink[];
|
links: HomeLink[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
storeFilter?: "spark" | "apm" | "both";
|
storeFilter?: "spark" | "apm" | "both";
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
storeFilter: "both",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "open-detail", app: App): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 图片加载状态跟踪
|
// 图片加载状态跟踪
|
||||||
@@ -152,9 +175,10 @@ const onLinkClick = (link: HomeLink) => {
|
|||||||
window.location.href = link.url;
|
window.location.href = link.url;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
// 下载量格式化:>= 1万 显示为 "x.x万"
|
||||||
|
// (download count now formatted inside AppCard via its downloadCount prop)
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Link 卡片网格 - 固定最小宽度 180px */
|
/* Link 卡片网格 - 固定最小宽度 180px */
|
||||||
@@ -168,7 +192,7 @@ const onLinkClick = (link: HomeLink) => {
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auto-fit-grid {
|
.auto-fit-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<!-- 两个榜单并排,每个榜单内容在容器内滚动 -->
|
||||||
|
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
|
||||||
|
<!-- 贡献荣耀榜 -->
|
||||||
|
<section class="flex min-h-0 flex-col">
|
||||||
|
<h2
|
||||||
|
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
贡献荣耀榜
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
|
||||||
|
:class="gridCols"
|
||||||
|
>
|
||||||
|
<div v-if="showSpark" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="spark" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingContributorRow
|
||||||
|
v-for="(item, i) in contributeSpark"
|
||||||
|
:key="item.name"
|
||||||
|
:rank="i + 1"
|
||||||
|
:name="item.name"
|
||||||
|
:count="item.count"
|
||||||
|
label="上榜应用"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="contributeSpark.length === 0"
|
||||||
|
:loading="appsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showApm" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="apm" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingContributorRow
|
||||||
|
v-for="(item, i) in contributeApm"
|
||||||
|
:key="item.name"
|
||||||
|
:rank="i + 1"
|
||||||
|
:name="item.name"
|
||||||
|
:count="item.count"
|
||||||
|
label="上榜应用"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="contributeApm.length === 0"
|
||||||
|
:loading="appsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 更新荣耀榜 -->
|
||||||
|
<section class="flex min-h-0 flex-col">
|
||||||
|
<h2
|
||||||
|
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
更新荣耀榜
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
|
||||||
|
:class="gridCols"
|
||||||
|
>
|
||||||
|
<div v-if="showSpark" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="spark" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingContributorRow
|
||||||
|
v-for="(item, i) in updateContributeSpark"
|
||||||
|
:key="item.name"
|
||||||
|
:rank="i + 1"
|
||||||
|
:name="item.name"
|
||||||
|
:count="item.count"
|
||||||
|
label="近期更新"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="updateContributeSpark.length === 0"
|
||||||
|
:loading="appsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showApm" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="apm" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingContributorRow
|
||||||
|
v-for="(item, i) in updateContributeApm"
|
||||||
|
:key="item.name"
|
||||||
|
:rank="i + 1"
|
||||||
|
:name="item.name"
|
||||||
|
:count="item.count"
|
||||||
|
label="近期更新"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="updateContributeApm.length === 0"
|
||||||
|
:loading="appsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { App } from "../global/typedefinition";
|
||||||
|
import { aggregateContributors, topUpdatedContributors } from "../modules/ranking";
|
||||||
|
import SourceLabel from "./SourceLabel.vue";
|
||||||
|
import RankingContributorRow from "./RankingContributorRow.vue";
|
||||||
|
import EmptyState from "./RankingEmptyState.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
apps: App[];
|
||||||
|
storeFilter?: "spark" | "apm" | "both";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const TOP = 10;
|
||||||
|
|
||||||
|
const showSpark = computed(() => props.storeFilter !== "apm");
|
||||||
|
const showApm = computed(() => props.storeFilter !== "spark");
|
||||||
|
const gridCols = computed(() =>
|
||||||
|
showSpark.value && showApm.value
|
||||||
|
? "grid-cols-1 sm:grid-cols-2"
|
||||||
|
: "grid-cols-1",
|
||||||
|
);
|
||||||
|
const appsLoading = computed(() => props.apps.length === 0);
|
||||||
|
|
||||||
|
const contributeSpark = computed(() =>
|
||||||
|
aggregateContributors(props.apps, "spark", TOP),
|
||||||
|
);
|
||||||
|
const contributeApm = computed(() =>
|
||||||
|
aggregateContributors(props.apps, "apm", TOP),
|
||||||
|
);
|
||||||
|
const updateContributeSpark = computed(() =>
|
||||||
|
topUpdatedContributors(props.apps, "spark"),
|
||||||
|
);
|
||||||
|
const updateContributeApm = computed(() =>
|
||||||
|
topUpdatedContributors(props.apps, "apm"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -14,71 +14,142 @@
|
|||||||
@wheel="onOverlayWheel"
|
@wheel="onOverlayWheel"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex w-full max-w-4xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
class="flex w-full max-w-4xl max-h-[85vh] flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
|
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
已安装应用
|
已安装应用
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
管理本机安装的应用程序
|
管理本机安装的应用程序
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="!loading && !error"
|
||||||
|
class="mt-2 flex flex-wrap items-center gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-wrap items-stretch overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-50/60 text-sm shadow-sm dark:border-slate-700/60 dark:bg-slate-800/40"
|
||||||
|
>
|
||||||
|
<!-- APM -->
|
||||||
|
<div
|
||||||
|
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-amber-50/80 dark:hover:bg-amber-500/10"
|
||||||
|
:class="
|
||||||
|
filterOrigin === 'apm'
|
||||||
|
? 'bg-amber-100 ring-2 ring-amber-400/50 dark:bg-amber-500/20'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
title="仅显示 APM 应用"
|
||||||
|
role="button"
|
||||||
|
@click="filterOrigin = 'apm'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-500/30"
|
||||||
|
>
|
||||||
|
<i class="fas fa-box-open text-[12px]"></i>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col leading-tight">
|
||||||
|
<span
|
||||||
|
class="text-[15px] font-bold tabular-nums text-amber-700 dark:text-amber-300"
|
||||||
|
>{{ apmCount }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium uppercase tracking-wider text-amber-600/80 dark:text-amber-400/80"
|
||||||
|
>APM</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<span
|
||||||
|
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
|
||||||
|
></span>
|
||||||
|
<!-- Spark -->
|
||||||
|
<div
|
||||||
|
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-sky-50/80 dark:hover:bg-sky-500/10"
|
||||||
|
:class="
|
||||||
|
filterOrigin === 'spark'
|
||||||
|
? 'bg-sky-100 ring-2 ring-sky-400/50 dark:bg-sky-500/20'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
title="仅显示 Spark 应用"
|
||||||
|
role="button"
|
||||||
|
@click="filterOrigin = 'spark'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-500 text-white shadow-sm shadow-sky-500/30"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bolt text-[12px]"></i>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col leading-tight">
|
||||||
|
<span
|
||||||
|
class="text-[15px] font-bold tabular-nums text-sky-700 dark:text-sky-300"
|
||||||
|
>{{ sparkCount }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium uppercase tracking-wider text-sky-600/80 dark:text-sky-400/80"
|
||||||
|
>Spark</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<span
|
||||||
|
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
|
||||||
|
></span>
|
||||||
|
<!-- 总数 -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2.5 px-3.5 py-2"
|
||||||
|
:class="
|
||||||
|
filterOrigin === 'all'
|
||||||
|
? 'bg-slate-100 ring-2 ring-slate-400/40 dark:bg-slate-700/40'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
title="显示全部已安装应用"
|
||||||
|
role="button"
|
||||||
|
@click="filterOrigin = 'all'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-slate-500 to-slate-700 text-white shadow-sm dark:from-slate-400 dark:to-slate-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cubes text-[12px]"></i>
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col leading-tight">
|
||||||
|
<span
|
||||||
|
class="text-[15px] font-bold tabular-nums text-slate-900 dark:text-white"
|
||||||
|
>{{ totalCount }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
|
||||||
|
>总数</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="relative flex-1 min-w-[200px]">
|
||||||
|
<i
|
||||||
|
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
|
||||||
|
></i>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索已安装应用…"
|
||||||
|
class="w-full rounded-2xl border border-slate-200/70 bg-slate-50/60 py-2 pl-9 pr-9 text-sm text-slate-700 placeholder-slate-400 transition focus:border-brand/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800/40 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:bg-slate-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-200/60 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||||
|
aria-label="清除搜索"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
>
|
||||||
|
<i class="fas fa-xmark text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- 云端同步功能暂时关闭
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
|
|
||||||
:disabled="syncing"
|
|
||||||
@click="handleSyncClick"
|
|
||||||
>
|
|
||||||
<i class="fas fa-cloud-arrow-up"></i>
|
|
||||||
{{ syncing ? "同步中" : "同步到账号" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
||||||
@click="handleRestoreClick"
|
|
||||||
>
|
|
||||||
<i class="fas fa-cloud-arrow-down"></i>
|
|
||||||
从账号恢复
|
|
||||||
</button>
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
v-if="showOriginSwitcher"
|
|
||||||
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-if="apmEnabled"
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
|
|
||||||
:class="
|
|
||||||
activeOrigin === 'apm'
|
|
||||||
? 'bg-brand/10 text-brand dark:bg-brand/15'
|
|
||||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
|
||||||
"
|
|
||||||
:disabled="!apmAvailable"
|
|
||||||
@click="$emit('switch-origin', 'apm')"
|
|
||||||
>
|
|
||||||
APM 软件
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="sparkEnabled"
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
|
|
||||||
:class="
|
|
||||||
activeOrigin === 'spark'
|
|
||||||
? 'bg-brand/10 text-brand dark:bg-brand/15'
|
|
||||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
|
||||||
"
|
|
||||||
@click="$emit('switch-origin', 'spark')"
|
|
||||||
>
|
|
||||||
Spark 软件
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
@@ -100,7 +171,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
|
class="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4 mb-6"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="syncMessage"
|
v-if="syncMessage"
|
||||||
@@ -120,15 +191,40 @@
|
|||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="warning"
|
||||||
|
class="rounded-2xl border border-amber-200/70 bg-amber-50/60 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-triangle-exclamation mr-1.5"></i>
|
||||||
|
{{ warning }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="apps.length === 0"
|
v-else-if="apps.length === 0"
|
||||||
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||||
>
|
>
|
||||||
暂无已安装应用
|
暂无已安装应用
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="filteredApps.length === 0"
|
||||||
|
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search mr-1.5 text-slate-400"></i>
|
||||||
|
<template v-if="searchQuery">
|
||||||
|
未找到匹配“<span
|
||||||
|
class="font-semibold text-slate-700 dark:text-slate-300"
|
||||||
|
>{{ searchQuery }}</span
|
||||||
|
>”的已安装应用
|
||||||
|
</template>
|
||||||
|
<template v-else-if="filterOrigin === 'apm'">
|
||||||
|
暂无已安装的 APM 应用
|
||||||
|
</template>
|
||||||
|
<template v-else-if="filterOrigin === 'spark'">
|
||||||
|
暂无已安装的 Spark 应用
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="app in apps"
|
v-for="app in filteredApps"
|
||||||
:key="app.pkgname"
|
:key="app.pkgname"
|
||||||
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
|
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
@@ -155,6 +251,24 @@
|
|||||||
>
|
>
|
||||||
{{ app.name }}
|
{{ app.name }}
|
||||||
</p>
|
</p>
|
||||||
|
<span
|
||||||
|
v-for="o in app.origins && app.origins.length
|
||||||
|
? app.origins
|
||||||
|
: [app.origin ?? 'spark']"
|
||||||
|
:key="o"
|
||||||
|
:data-testid="
|
||||||
|
o === 'apm' ? 'origin-tag-apm' : 'origin-tag-spark'
|
||||||
|
"
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-semibold"
|
||||||
|
:class="
|
||||||
|
o === 'apm'
|
||||||
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
|
||||||
|
: 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400'
|
||||||
|
"
|
||||||
|
:title="o === 'apm' ? 'APM 软件' : 'Spark 软件'"
|
||||||
|
>
|
||||||
|
{{ o === "apm" ? "APM" : "Spark" }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="app.isDependency"
|
v-if="app.isDependency"
|
||||||
class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400"
|
class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400"
|
||||||
@@ -179,7 +293,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
@click="$emit('open-app', app)"
|
@click="onOpenClick(app)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
打开
|
打开
|
||||||
@@ -207,32 +321,122 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 双来源安装:打开时选择 APM 还是 Spark -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="openChoiceApp"
|
||||||
|
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
|
||||||
|
@click.self="openChoiceApp = null"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div class="mb-5 flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-100 to-indigo-50 shadow-inner dark:from-sky-900/30 dark:to-indigo-800/20"
|
||||||
|
>
|
||||||
|
<i class="fas fa-external-link-alt text-xl text-sky-500"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
|
||||||
|
打开应用
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
该应用同时通过 APM 与 Spark 安装,请选择要打开的来源:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2.5 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
|
||||||
|
@click="confirmOpen('apm')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-box-open"></i>
|
||||||
|
打开 APM 版
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
|
||||||
|
@click="confirmOpen('spark')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
打开 Spark 版
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="openChoiceApp = null"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive } from "vue";
|
import { computed, reactive, ref } from "vue";
|
||||||
import { App } from "../global/typedefinition";
|
import { App } from "../global/typedefinition";
|
||||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
|
||||||
const iconErrors = reactive<Record<string, boolean>>({});
|
const iconErrors = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 仅允许从这些常见图标目录读取本地图标,避免通过 app.icons 读取任意本地文件。
|
||||||
|
// 遵循最小权限原则:只收录已知存放应用图标的目录,拒绝过于宽泛的路径前缀。
|
||||||
|
const ALLOWED_LOCAL_ICON_PREFIXES = [
|
||||||
|
"/usr/share/icons/",
|
||||||
|
"/usr/share/pixmaps/",
|
||||||
|
"/usr/lib/",
|
||||||
|
"/usr/local/share/icons/",
|
||||||
|
"/usr/local/share/pixmaps/",
|
||||||
|
"/opt/",
|
||||||
|
"/var/lib/apm/",
|
||||||
|
"/var/lib/dpkg/info/",
|
||||||
|
];
|
||||||
|
|
||||||
const getIconUrl = (app: App) => {
|
const getIconUrl = (app: App) => {
|
||||||
if (app.icons && app.icons.startsWith("/")) return `file://${app.icons}`;
|
// 本地图标:仅允许以白名单目录开头、且不含路径遍历("..")的绝对路径
|
||||||
|
if (
|
||||||
|
app.icons &&
|
||||||
|
app.icons.startsWith("/") &&
|
||||||
|
!app.icons.includes("..") &&
|
||||||
|
ALLOWED_LOCAL_ICON_PREFIXES.some((prefix) => app.icons!.startsWith(prefix))
|
||||||
|
) {
|
||||||
|
return `file://${app.icons}`;
|
||||||
|
}
|
||||||
if (!app.category || app.category === "unknown") return "";
|
if (!app.category || app.category === "unknown") return "";
|
||||||
const arch = window.apm_store.arch || "amd64";
|
const arch = window.apm_store.arch || "amd64";
|
||||||
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`;
|
return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canOpenDetail = (app: App) => {
|
// 判断应用是否可打开详情页。满足以下任一条件即可展示"查看详情":
|
||||||
|
// - 有明确分类(非 unknown):详情页可按分类加载元数据
|
||||||
|
// - 有详细描述(more) / 官网(website) / 作者(author) 任一字段:详情有内容可展示
|
||||||
|
// - 有截图(img_urls):详情页可渲染预览图
|
||||||
|
// 这些字段缺失时详情页信息过空,故隐藏入口仅保留打开/卸载。
|
||||||
|
const canOpenDetail = (app: App): boolean => {
|
||||||
|
const hasCategory = app.category !== "unknown";
|
||||||
|
const hasDescription = Boolean(app.more);
|
||||||
|
const hasWebsite = Boolean(app.website);
|
||||||
|
const hasAuthor = Boolean(app.author);
|
||||||
|
const hasScreenshots = (app.img_urls?.length ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
app.category !== "unknown" ||
|
hasCategory || hasDescription || hasWebsite || hasAuthor || hasScreenshots
|
||||||
Boolean(app.more) ||
|
|
||||||
Boolean(app.website) ||
|
|
||||||
Boolean(app.author) ||
|
|
||||||
(app.img_urls?.length ?? 0) > 0
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,21 +445,84 @@ const props = defineProps<{
|
|||||||
apps: App[];
|
apps: App[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
activeOrigin: "apm" | "spark";
|
warning: string;
|
||||||
storeFilter: "spark" | "apm" | "both";
|
|
||||||
sparkAvailable: boolean;
|
|
||||||
apmAvailable: boolean;
|
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
syncMessage: string;
|
syncMessage: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 判断某应用是否以指定来源安装:优先看 origins 集合,回退到单 origin 字段
|
||||||
|
const hasOrigin = (app: App, origin: "spark" | "apm"): boolean =>
|
||||||
|
app.origins?.includes(origin) ?? app.origin === origin;
|
||||||
|
|
||||||
|
// 来源排序优先级:APM 始终排在前面(值越小越靠前),与排序比较函数共用,避免魔法数字
|
||||||
|
const ORIGIN_PRIORITY: Record<"spark" | "apm", number> = { apm: 0, spark: 1 };
|
||||||
|
const originPriority = (app: App): number =>
|
||||||
|
hasOrigin(app, "apm") ? ORIGIN_PRIORITY.apm : ORIGIN_PRIORITY.spark;
|
||||||
|
|
||||||
|
// APM / Spark 分别统计实际安装的包数量(同一 pkgname 同时装两种来源时各计一次)
|
||||||
|
// 搜索过滤后的全量(不叠加来源筛选),用于顶部统计徽章实时同步搜索结果,
|
||||||
|
// 避免搜索时列表缩减而徽章数字仍显示全量造成误导。
|
||||||
|
const searchFilteredApps = computed(() => {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase();
|
||||||
|
if (!q) return props.apps;
|
||||||
|
return props.apps.filter(
|
||||||
|
(a) =>
|
||||||
|
a.name.toLowerCase().includes(q) || a.pkgname.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 对搜索过滤后的列表做单次遍历,同时统计 APM / Spark 各自安装数。
|
||||||
|
// 同一 pkgname 同时装两种来源时各计一次(双来源计数),避免对 props.apps 多次 filter 遍历。
|
||||||
|
const appStats = computed(() => {
|
||||||
|
let apm = 0;
|
||||||
|
let spark = 0;
|
||||||
|
for (const a of searchFilteredApps.value) {
|
||||||
|
if (hasOrigin(a, "apm")) apm++;
|
||||||
|
if (hasOrigin(a, "spark")) spark++;
|
||||||
|
}
|
||||||
|
return { apm, spark };
|
||||||
|
});
|
||||||
|
const apmCount = computed(() => appStats.value.apm);
|
||||||
|
const sparkCount = computed(() => appStats.value.spark);
|
||||||
|
// 总数 = APM 包数 + Spark 包数(不同来源视为不同包,单独计数)
|
||||||
|
const totalCount = computed(() => apmCount.value + sparkCount.value);
|
||||||
|
|
||||||
|
// 来源筛选:默认全部;点击统计徽章可在 all/apm/spark 间切换
|
||||||
|
const filterOrigin = ref<"all" | "apm" | "spark">("all");
|
||||||
|
|
||||||
|
// 搜索关键词(按名称/包名不区分大小写过滤已安装应用)
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const filteredApps = computed(() => {
|
||||||
|
// 单次遍历完成「搜索过滤 + 来源筛选」,避免多次 .filter() 创建中间数组。
|
||||||
|
// 来源判定统一用 hasOrigin(兼顾双来源 origins 数组,不能退化为 a.origin)。
|
||||||
|
const q = searchQuery.value.trim().toLowerCase();
|
||||||
|
const originFilter = filterOrigin.value;
|
||||||
|
const matched = props.apps.filter((a) => {
|
||||||
|
if (originFilter === "apm" && !hasOrigin(a, "apm")) return false;
|
||||||
|
if (originFilter === "spark" && !hasOrigin(a, "spark")) return false;
|
||||||
|
if (q) {
|
||||||
|
const nameLower = a.name.toLowerCase();
|
||||||
|
const pkgLower = a.pkgname.toLowerCase();
|
||||||
|
if (!nameLower.includes(q) && !pkgLower.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回新数组排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
|
||||||
|
return [...matched].sort((a, b) => {
|
||||||
|
const pa = originPriority(a);
|
||||||
|
const pb = originPriority(b);
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
// 同类内保持原有的字母序,体验更一致
|
||||||
|
return a.pkgname.localeCompare(b.pkgname);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "refresh"): void;
|
(e: "refresh"): void;
|
||||||
(e: "uninstall", app: App): void;
|
(e: "uninstall", app: App): void;
|
||||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
|
||||||
(e: "open-app", app: App): void;
|
(e: "open-app", app: App): void;
|
||||||
(e: "open-detail", app: App): void;
|
(e: "open-detail", app: App): void;
|
||||||
(e: "sync-to-account"): void;
|
(e: "sync-to-account"): void;
|
||||||
@@ -263,6 +530,28 @@ const emit = defineEmits<{
|
|||||||
(e: "request-login"): void;
|
(e: "request-login"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版
|
||||||
|
const openChoiceApp = ref<App | null>(null);
|
||||||
|
const openOrigins = (app: App): Array<"spark" | "apm"> =>
|
||||||
|
app.origins && app.origins.length > 0 ? app.origins : [app.origin ?? "spark"];
|
||||||
|
|
||||||
|
const onOpenClick = (app: App) => {
|
||||||
|
const origins = openOrigins(app);
|
||||||
|
if (origins.length > 1) {
|
||||||
|
openChoiceApp.value = app;
|
||||||
|
} else {
|
||||||
|
// 单来源直接打开(携带正确 origin 供 launch-app 分支)
|
||||||
|
emit("open-app", { ...app, origin: origins[0] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmOpen = (origin: "spark" | "apm") => {
|
||||||
|
if (openChoiceApp.value) {
|
||||||
|
emit("open-app", { ...openChoiceApp.value, origin });
|
||||||
|
}
|
||||||
|
openChoiceApp.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
// 云端同步功能暂时关闭
|
// 云端同步功能暂时关闭
|
||||||
// const handleSyncClick = () => {
|
// const handleSyncClick = () => {
|
||||||
// if (props.loggedIn) {
|
// if (props.loggedIn) {
|
||||||
@@ -287,16 +576,4 @@ const onOverlayWheel = (e: WheelEvent) => {
|
|||||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sparkEnabled = computed(() => {
|
|
||||||
return props.storeFilter !== "apm" && props.sparkAvailable;
|
|
||||||
});
|
|
||||||
|
|
||||||
const apmEnabled = computed(() => {
|
|
||||||
return props.storeFilter !== "spark" && props.apmAvailable;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showOriginSwitcher = computed(() => {
|
|
||||||
return sparkEnabled.value && apmEnabled.value;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group flex cursor-pointer items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
|
||||||
|
@click="openDetail"
|
||||||
|
>
|
||||||
|
<!-- 排名序号(前 3 名金/银/铜) -->
|
||||||
|
<span
|
||||||
|
class="w-5 shrink-0 text-center text-sm font-bold"
|
||||||
|
:class="rankClass"
|
||||||
|
>{{ rank }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 应用图标 -->
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="!iconFailed"
|
||||||
|
:src="iconPath"
|
||||||
|
:alt="app.name"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
@error="iconFailed = true"
|
||||||
|
/>
|
||||||
|
<i v-else class="fas fa-cube text-slate-400"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用信息 -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="truncate text-xs font-semibold text-slate-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{{ app.name || "" }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="app.origin === 'spark'"
|
||||||
|
class="rounded-md bg-orange-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
|
||||||
|
>Spark</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="rounded-md bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
>APM</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="showDownload && downloadCount"
|
||||||
|
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
|
||||||
|
>{{ formatDownloads(downloadCount) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{{ app.pkgname || "" }} · {{ app.version || ""
|
||||||
|
}}<template v-if="updateTime"> · {{ updateTime }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
import type { App } from "../global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
app: App;
|
||||||
|
rank: number;
|
||||||
|
showDownload?: boolean;
|
||||||
|
downloadCount?: number;
|
||||||
|
updateTime?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
|
||||||
|
|
||||||
|
const iconFailed = ref(false);
|
||||||
|
|
||||||
|
const iconPath = computed(() => {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const finalArch =
|
||||||
|
props.app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankClass = computed(() => {
|
||||||
|
if (props.rank === 1) return "text-amber-400";
|
||||||
|
if (props.rank === 2) return "text-slate-400";
|
||||||
|
if (props.rank === 3) return "text-amber-600";
|
||||||
|
return "text-slate-400 dark:text-slate-500";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDownloads = (n?: number): string => {
|
||||||
|
if (!n || n <= 0) return "0";
|
||||||
|
if (n >= 10000) {
|
||||||
|
const wan = n / 10000;
|
||||||
|
return `${wan.toFixed(1).replace(/\.0$/, "")}万`;
|
||||||
|
}
|
||||||
|
return n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = () => emit("open-detail", props.app);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group flex items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<!-- 排名序号(前 3 名金/银/铜) -->
|
||||||
|
<span
|
||||||
|
class="w-5 shrink-0 text-center text-sm font-bold"
|
||||||
|
:class="rankClass"
|
||||||
|
>{{ rank }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 贡献者头像 -->
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-gradient-to-b from-amber-100 to-amber-200 text-amber-600 shadow-inner dark:from-amber-900/40 dark:to-amber-800/40"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 贡献者信息 -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||||
|
<div class="truncate text-xs font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数量徽标 -->
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>{{ count }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const rankClass = computed(() => {
|
||||||
|
if (props.rank === 1) return "text-amber-400";
|
||||||
|
if (props.rank === 2) return "text-slate-400";
|
||||||
|
if (props.rank === 3) return "text-amber-600";
|
||||||
|
return "text-slate-400 dark:text-slate-500";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center gap-2 py-4 text-xs text-slate-400"
|
||||||
|
>
|
||||||
|
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>{{ loading ? (loadingText || "加载中…") : "暂无数据" }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
loading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
}>(),
|
||||||
|
{ loading: false, loadingText: "加载中…" },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<!-- 两个榜单并排,每个榜单内容在容器内滚动 -->
|
||||||
|
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
|
||||||
|
<!-- 应用下载排行 -->
|
||||||
|
<section class="flex min-h-0 flex-col">
|
||||||
|
<h2
|
||||||
|
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
应用下载排行
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
|
||||||
|
:class="gridCols"
|
||||||
|
>
|
||||||
|
<div v-if="showSpark" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="spark" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingAppRow
|
||||||
|
v-for="(app, i) in downloadSpark"
|
||||||
|
:key="app.pkgname"
|
||||||
|
:app="app"
|
||||||
|
:rank="i + 1"
|
||||||
|
show-download
|
||||||
|
:download-count="app.downloadCount"
|
||||||
|
@open-detail="onOpen"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="downloadSpark.length === 0"
|
||||||
|
:loading="rankingLoading"
|
||||||
|
loading-text="加载排行中…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showApm" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="apm" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingAppRow
|
||||||
|
v-for="(app, i) in downloadApm"
|
||||||
|
:key="app.pkgname"
|
||||||
|
:app="app"
|
||||||
|
:rank="i + 1"
|
||||||
|
show-download
|
||||||
|
:download-count="app.downloadCount"
|
||||||
|
@open-detail="onOpen"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="downloadApm.length === 0"
|
||||||
|
:loading="rankingLoading"
|
||||||
|
loading-text="加载排行中…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 应用更新排行 -->
|
||||||
|
<section class="flex min-h-0 flex-col">
|
||||||
|
<h2
|
||||||
|
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
应用更新排行
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
|
||||||
|
:class="gridCols"
|
||||||
|
>
|
||||||
|
<div v-if="showSpark" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="spark" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingAppRow
|
||||||
|
v-for="(app, i) in updateSpark"
|
||||||
|
:key="app.pkgname"
|
||||||
|
:app="app"
|
||||||
|
:rank="i + 1"
|
||||||
|
:update-time="app.update"
|
||||||
|
@open-detail="onOpen"
|
||||||
|
/>
|
||||||
|
<EmptyState v-if="updateSpark.length === 0" :loading="appsLoading" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showApm" class="flex min-h-0 flex-col">
|
||||||
|
<SourceLabel origin="apm" />
|
||||||
|
<div
|
||||||
|
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<RankingAppRow
|
||||||
|
v-for="(app, i) in updateApm"
|
||||||
|
:key="app.pkgname"
|
||||||
|
:app="app"
|
||||||
|
:rank="i + 1"
|
||||||
|
:update-time="app.update"
|
||||||
|
@open-detail="onOpen"
|
||||||
|
/>
|
||||||
|
<EmptyState v-if="updateApm.length === 0" :loading="appsLoading" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { App } from "../global/typedefinition";
|
||||||
|
import { topByUpdate } from "../modules/ranking";
|
||||||
|
import RankingAppRow from "./RankingAppRow.vue";
|
||||||
|
import SourceLabel from "./SourceLabel.vue";
|
||||||
|
import EmptyState from "./RankingEmptyState.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
apps: App[];
|
||||||
|
apmRanking: App[];
|
||||||
|
sparkRanking: App[];
|
||||||
|
rankingLoading: boolean;
|
||||||
|
storeFilter?: "spark" | "apm" | "both";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
|
||||||
|
|
||||||
|
const TOP = 10;
|
||||||
|
|
||||||
|
const showSpark = computed(() => props.storeFilter !== "apm");
|
||||||
|
const showApm = computed(() => props.storeFilter !== "spark");
|
||||||
|
const gridCols = computed(() =>
|
||||||
|
showSpark.value && showApm.value
|
||||||
|
? "grid-cols-1 sm:grid-cols-2"
|
||||||
|
: "grid-cols-1",
|
||||||
|
);
|
||||||
|
const appsLoading = computed(() => props.apps.length === 0);
|
||||||
|
|
||||||
|
const downloadSpark = computed(() => props.sparkRanking.slice(0, TOP));
|
||||||
|
const downloadApm = computed(() => props.apmRanking.slice(0, TOP));
|
||||||
|
const updateSpark = computed(() => topByUpdate(props.apps, "spark", TOP));
|
||||||
|
const updateApm = computed(() => topByUpdate(props.apps, "apm", TOP));
|
||||||
|
|
||||||
|
const onOpen = (app: App) => emit("open-detail", app);
|
||||||
|
</script>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
@click.self="closeModal"
|
@click.self="closeModal"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
class="relative flex max-h-[calc(100vh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<div
|
<div
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 设置内容 -->
|
<!-- 设置内容(可滚动,标题/底部固定) -->
|
||||||
<div class="p-6 space-y-4">
|
<div class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
<!-- 更新检测开关 -->
|
<!-- 更新检测开关 -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
|
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
|
||||||
@@ -118,6 +118,96 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签优先显示策略 -->
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
标签优先显示策略
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
进入应用详情页时默认展示的来源标签
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-3 inline-flex w-full overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="标签优先显示策略"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="opt in strategyOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="tagPriorityStrategy === opt.value"
|
||||||
|
class="flex-1 px-2 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
tagPriorityStrategy === opt.value
|
||||||
|
? 'bg-brand text-white'
|
||||||
|
: 'bg-white text-slate-500 hover:bg-slate-100 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
|
||||||
|
"
|
||||||
|
@click="selectStrategy(opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 界面缩放(整体缩放:图标 / 间距 / 布局等比变化) -->
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-teal-100 text-teal-600 dark:bg-teal-900/30 dark:text-teal-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search-plus"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
界面缩放
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
整体放大或缩小界面(图标、间距、布局等比变化)
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-3 inline-flex w-full overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="界面缩放"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="opt in uiScaleOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="uiScale === opt.value"
|
||||||
|
class="flex-1 px-2 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
uiScale === opt.value
|
||||||
|
? 'bg-brand text-white'
|
||||||
|
: 'bg-white text-slate-500 hover:bg-slate-100 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
|
||||||
|
"
|
||||||
|
@click="selectUiScale(opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部提示 -->
|
<!-- 底部提示 -->
|
||||||
@@ -135,6 +225,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import {
|
||||||
|
getTagPriorityStrategy,
|
||||||
|
setTagPriorityStrategy,
|
||||||
|
type TagPriorityStrategy,
|
||||||
|
} from "../global/tagPriority";
|
||||||
|
import {
|
||||||
|
getUiScale,
|
||||||
|
setUiScale,
|
||||||
|
uiScaleToFactor,
|
||||||
|
UI_SCALE_OPTIONS,
|
||||||
|
type UiScaleOption,
|
||||||
|
} from "../global/displaySettings";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -149,6 +251,47 @@ interface Settings {
|
|||||||
enableCreateDesktop: boolean;
|
enableCreateDesktop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签优先显示策略选项(默认选中「自动选择」)
|
||||||
|
const strategyOptions: Array<{ value: TagPriorityStrategy; label: string }> = [
|
||||||
|
{ value: "auto", label: "自动选择" },
|
||||||
|
{ value: "spark", label: "Spark 优先" },
|
||||||
|
{ value: "apm", label: "APM 优先" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 界面缩放档位选项(90% / 100% / 110% / 125% / 150%)
|
||||||
|
const uiScaleOptions = UI_SCALE_OPTIONS;
|
||||||
|
|
||||||
|
const tagPriorityStrategy = ref<TagPriorityStrategy>("auto");
|
||||||
|
|
||||||
|
// 加载标签优先显示策略(从持久化读取)
|
||||||
|
const loadTagPriority = () => {
|
||||||
|
tagPriorityStrategy.value = getTagPriorityStrategy();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择并保存策略
|
||||||
|
const selectStrategy = (value: TagPriorityStrategy) => {
|
||||||
|
tagPriorityStrategy.value = value;
|
||||||
|
setTagPriorityStrategy(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 界面整体缩放档位(Electron setZoomFactor,连图标/间距/布局等比变化)
|
||||||
|
const uiScale = ref<UiScaleOption>("100");
|
||||||
|
|
||||||
|
const loadUiScale = () => {
|
||||||
|
uiScale.value = getUiScale();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择并保存界面缩放档位(立即经 IPC 应用到主窗口)
|
||||||
|
const selectUiScale = async (value: UiScaleOption) => {
|
||||||
|
uiScale.value = value;
|
||||||
|
setUiScale(value);
|
||||||
|
try {
|
||||||
|
await window.ipcRenderer.invoke("set-zoom-factor", uiScaleToFactor(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("应用界面缩放失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const settings = ref<Settings>({
|
const settings = ref<Settings>({
|
||||||
enableUpdateCheck: true,
|
enableUpdateCheck: true,
|
||||||
enableCreateDesktop: true,
|
enableCreateDesktop: true,
|
||||||
@@ -208,6 +351,8 @@ watch(
|
|||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadTagPriority();
|
||||||
|
loadUiScale();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -215,6 +360,7 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.show) {
|
if (props.show) {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadTagPriority();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="mb-1 flex items-center gap-1 text-xs font-semibold"
|
||||||
|
:class="
|
||||||
|
origin === 'spark'
|
||||||
|
? 'text-orange-600 dark:text-orange-400'
|
||||||
|
: 'text-blue-600 dark:text-blue-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded px-1.5 py-0.5 text-[10px] font-bold uppercase"
|
||||||
|
:class="
|
||||||
|
origin === 'spark'
|
||||||
|
? 'bg-orange-100 dark:bg-orange-900/30'
|
||||||
|
: 'bg-blue-100 dark:bg-blue-900/30'
|
||||||
|
"
|
||||||
|
>{{ origin === "spark" ? "Spark" : "APM" }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ origin: "spark" | "apm" }>();
|
||||||
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="h-screen overflow-y-auto bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100"
|
class="flex h-screen flex-col overflow-hidden rounded-3xl bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 ring-1 ring-black/5 dark:ring-white/5 shadow-[inset_0_1px_3px_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="submitter-titlebar sticky top-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
|
class="submitter-titlebar shrink-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -14,6 +14,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="text-lg font-semibold">投稿应用</h1>
|
<h1 class="text-lg font-semibold">投稿应用</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="submitter-close-button inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-slate-800"
|
||||||
|
@click="openSubmissionQueue"
|
||||||
|
>
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
查看当前投稿队列
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="submitter-close-button inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
|
class="submitter-close-button inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
@@ -23,7 +32,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto mb-6">
|
||||||
<div class="p-6 max-w-2xl mx-auto">
|
<div class="p-6 max-w-2xl mx-auto">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -135,6 +146,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||||
|
>官网地址</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="formData.website"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||||
@@ -161,19 +185,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
|
||||||
>官网地址</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.website"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://example.com"
|
|
||||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 bg-white dark:bg-slate-800 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
|
||||||
@@ -215,12 +226,16 @@
|
|||||||
>截图(最多5张)</label
|
>截图(最多5张)</label
|
||||||
>
|
>
|
||||||
<p class="mb-2 text-xs text-slate-500 dark:text-slate-400">
|
<p class="mb-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
可直接 Ctrl+V 粘贴截图
|
可点击添加、拖放或直接 Ctrl+V 粘贴 PNG 截图
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="grid grid-cols-5 gap-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
class="grid grid-cols-5 gap-3 rounded-lg border-2 border-dashed border-transparent p-2 transition-colors hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@paste="handleScreenshotPaste"
|
@paste="handleScreenshotPaste"
|
||||||
|
@drop="handleScreenshotDrop"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent
|
||||||
|
@dragleave.prevent
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(screenshot, index) in formData.screenshots"
|
v-for="(screenshot, index) in formData.screenshots"
|
||||||
@@ -247,11 +262,17 @@
|
|||||||
<input
|
<input
|
||||||
ref="screenshotInput"
|
ref="screenshotInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".png,.jpg,.jpeg"
|
accept=".png"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="handleScreenshotSelect"
|
@change="handleScreenshotSelect"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="mediaError"
|
||||||
|
class="mt-2 text-sm text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ mediaError }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -536,7 +557,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
class="flex-1 px-4 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||||
@click="resetFormAndClose"
|
@click="continueSubmission"
|
||||||
>
|
>
|
||||||
继续投递
|
继续投递
|
||||||
</button>
|
</button>
|
||||||
@@ -587,7 +608,7 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/50"
|
class="absolute inset-0 bg-black/50 rounded-3xl"
|
||||||
@click="showArchPackDialog = false"
|
@click="showArchPackDialog = false"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
@@ -620,11 +641,25 @@
|
|||||||
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-emerald-500 transition-colors text-left"
|
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-emerald-500 transition-colors text-left"
|
||||||
@click="selectPackArch(arch)"
|
@click="selectPackArch(arch)"
|
||||||
>
|
>
|
||||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="
|
||||||
|
isSparkHistoryStore(arch.store)
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ isSparkHistoryStore(arch.store) ? "Spark" : "APM" }}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-slate-900 dark:text-slate-100">
|
||||||
{{ getArchDisplayName(arch.store) }}
|
{{ getArchDisplayName(arch.store) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
输出: {{ formData.pkgname }}-{{ arch.store }}.tar.gz
|
输出: {{ formData.pkgname.toLowerCase() }}-{{
|
||||||
|
arch.store
|
||||||
|
}}.tar.gz
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,7 +684,7 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/50"
|
class="absolute inset-0 bg-black/50 rounded-3xl"
|
||||||
@click="showArchDialog = false"
|
@click="showArchDialog = false"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
@@ -676,14 +711,23 @@
|
|||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<button
|
<button
|
||||||
v-for="arch in availableArchs"
|
v-for="arch in sortedArchs"
|
||||||
:key="arch.store"
|
:key="arch.store"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full p-4 rounded-lg border-2 border-slate-200 dark:border-slate-700 hover:border-blue-500 transition-colors text-left"
|
class="w-full p-4 rounded-lg border-2 transition-colors text-left"
|
||||||
|
:class="archOriginBorderClass(arch.store)"
|
||||||
@click="selectArch(arch)"
|
@click="selectArch(arch)"
|
||||||
>
|
>
|
||||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-slate-900 dark:text-slate-100">
|
||||||
{{ getArchDisplayName(arch.store) }}
|
{{ getArchDisplayName(arch.store) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="getOriginBadgeClass(arch.store)"
|
||||||
|
>
|
||||||
|
{{ getOriginLabel(arch.store) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
<div class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
版本: {{ arch.version }} | 分类: {{ arch.category }}
|
版本: {{ arch.version }} | 分类: {{ arch.category }}
|
||||||
@@ -704,6 +748,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -767,6 +812,7 @@ const isSearchingHistory = ref(false);
|
|||||||
const showArchDialog = ref(false);
|
const showArchDialog = ref(false);
|
||||||
const availableArchs = ref<HistoryArchInfo[]>([]);
|
const availableArchs = ref<HistoryArchInfo[]>([]);
|
||||||
const currentDebArch = ref("");
|
const currentDebArch = ref("");
|
||||||
|
const mediaError = ref("");
|
||||||
const iconPreview = ref("");
|
const iconPreview = ref("");
|
||||||
const iconFileName = ref("");
|
const iconFileName = ref("");
|
||||||
|
|
||||||
@@ -822,6 +868,23 @@ const isValidUrl = (url: string): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getContributorFromGit = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const nameResult = await window.ipcRenderer.invoke("get-git-name");
|
||||||
|
const emailResult = await window.ipcRenderer.invoke("get-git-email");
|
||||||
|
|
||||||
|
const name = nameResult?.success ? nameResult.data : "";
|
||||||
|
const email = emailResult?.success ? emailResult.data : "";
|
||||||
|
|
||||||
|
if (name && email) {
|
||||||
|
return `${name} <${email}>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Submitter] Failed to get git config:", error);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
const hasRequiredFields =
|
const hasRequiredFields =
|
||||||
formData.name.trim() &&
|
formData.name.trim() &&
|
||||||
@@ -864,13 +927,70 @@ const hasUserStartedFilling = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getArchDisplayName = (store: string): string => {
|
const getHistoryArch = (
|
||||||
const archMap: Record<string, string> = {
|
store: string,
|
||||||
store: "AMD64 (x86_64)",
|
): "amd64" | "arm64" | "loong64" | "other" => {
|
||||||
"aarch64-store": "ARM64 (aarch64)",
|
if (store === "store" || store.startsWith("amd64-")) return "amd64";
|
||||||
"loong64-store": "LoongArch64",
|
if (store === "aarch64-store" || store.startsWith("arm64-")) return "arm64";
|
||||||
|
if (store === "loong64-store" || store.startsWith("loong64-"))
|
||||||
|
return "loong64";
|
||||||
|
return "other";
|
||||||
};
|
};
|
||||||
return archMap[store] || store;
|
|
||||||
|
const isSparkHistoryStore = (store: string): boolean => {
|
||||||
|
// 兼容新格式(amd64-store / arm64-store / loong64-store / amd64-apm ...)
|
||||||
|
// 与旧格式(store / aarch64-store / loong64-store)
|
||||||
|
return store === "store" || store.endsWith("-store");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortHistoryArchs = (archs: HistoryArchInfo[]): HistoryArchInfo[] => {
|
||||||
|
const archOrder: Record<ReturnType<typeof getHistoryArch>, number> = {
|
||||||
|
amd64: 0,
|
||||||
|
arm64: 1,
|
||||||
|
loong64: 2,
|
||||||
|
other: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...archs].sort((a, b) => {
|
||||||
|
const sourceOrder =
|
||||||
|
Number(isSparkHistoryStore(b.store)) -
|
||||||
|
Number(isSparkHistoryStore(a.store));
|
||||||
|
if (sourceOrder !== 0) return sourceOrder;
|
||||||
|
return (
|
||||||
|
archOrder[getHistoryArch(a.store)] - archOrder[getHistoryArch(b.store)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArchDisplayName = (store: string): string => {
|
||||||
|
const arch = getHistoryArch(store);
|
||||||
|
const archMap: Record<typeof arch, string> = {
|
||||||
|
amd64: "AMD64 (x86_64)",
|
||||||
|
arm64: "ARM64 (aarch64)",
|
||||||
|
loong64: "LoongArch64",
|
||||||
|
other: store,
|
||||||
|
};
|
||||||
|
return archMap[arch];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedArchs = computed(() => sortHistoryArchs(availableArchs.value));
|
||||||
|
|
||||||
|
const getOriginLabel = (store: string): string => {
|
||||||
|
return isSparkHistoryStore(store) ? "Spark" : "APM";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginBadgeClass = (store: string): string => {
|
||||||
|
if (isSparkHistoryStore(store)) {
|
||||||
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const archOriginBorderClass = (store: string): string => {
|
||||||
|
if (isSparkHistoryStore(store)) {
|
||||||
|
return "border-slate-200 dark:border-slate-700 hover:border-blue-500";
|
||||||
|
}
|
||||||
|
return "border-slate-200 dark:border-slate-700 hover:border-amber-400";
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCategoriesPromise = ref<Promise<void> | null>(null);
|
const loadCategoriesPromise = ref<Promise<void> | null>(null);
|
||||||
@@ -1042,6 +1162,14 @@ const updateFormTags = () => {
|
|||||||
formData.tags = selectedTags.value.map((t) => t.value).join(";");
|
formData.tags = selectedTags.value.map((t) => t.value).join(";");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSubmissionQueue = (): void => {
|
||||||
|
window.open(
|
||||||
|
"https://upload.spark-app.store/",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const selectDebFile = async () => {
|
const selectDebFile = async () => {
|
||||||
const result = await window.ipcRenderer.invoke("select-deb-file");
|
const result = await window.ipcRenderer.invoke("select-deb-file");
|
||||||
if (result?.success && result.filePath) {
|
if (result?.success && result.filePath) {
|
||||||
@@ -1091,14 +1219,8 @@ const searchHistoryApp = async () => {
|
|||||||
console.log("[Submitter] availableArchs before:", availableArchs.value);
|
console.log("[Submitter] availableArchs before:", availableArchs.value);
|
||||||
console.log("[Submitter] showArchDialog before:", showArchDialog.value);
|
console.log("[Submitter] showArchDialog before:", showArchDialog.value);
|
||||||
|
|
||||||
// 按 amd64 → arm64 → loong64 顺序排序
|
availableArchs.value = sortHistoryArchs(
|
||||||
const archOrder: Record<string, number> = {
|
historyResult.data as HistoryArchInfo[],
|
||||||
store: 0,
|
|
||||||
"aarch64-store": 1,
|
|
||||||
"loong64-store": 2,
|
|
||||||
};
|
|
||||||
availableArchs.value = [...historyResult.data].sort(
|
|
||||||
(a, b) => (archOrder[a.store] ?? 99) - (archOrder[b.store] ?? 99),
|
|
||||||
);
|
);
|
||||||
console.log("[Submitter] availableArchs after:", availableArchs.value);
|
console.log("[Submitter] availableArchs after:", availableArchs.value);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -1112,7 +1234,7 @@ const searchHistoryApp = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从第一条历史记录预填名称和分类
|
// 从第一条历史记录预填名称和分类
|
||||||
const firstArch = historyResult.data[0];
|
const firstArch = availableArchs.value[0];
|
||||||
if (firstArch) {
|
if (firstArch) {
|
||||||
formData.name = firstArch.name || formData.name;
|
formData.name = firstArch.name || formData.name;
|
||||||
formData.category = firstArch.category || formData.category;
|
formData.category = firstArch.category || formData.category;
|
||||||
@@ -1183,6 +1305,15 @@ const searchHistoryApp = async () => {
|
|||||||
const parseDebFileAndSearchHistory = async (debPath: string) => {
|
const parseDebFileAndSearchHistory = async (debPath: string) => {
|
||||||
isParsingDeb.value = true;
|
isParsingDeb.value = true;
|
||||||
debParseError.value = "";
|
debParseError.value = "";
|
||||||
|
availableArchs.value = [];
|
||||||
|
showArchDialog.value = false;
|
||||||
|
formData.pkgname = "";
|
||||||
|
formData.version = "";
|
||||||
|
formData.author = "";
|
||||||
|
formData.contributor = "";
|
||||||
|
formData.website = "";
|
||||||
|
formData.description = "";
|
||||||
|
currentDebArch.value = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -1231,6 +1362,12 @@ const parseDebFileAndSearchHistory = async (debPath: string) => {
|
|||||||
JSON.stringify(formData, null, 2),
|
JSON.stringify(formData, null, 2),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gitContributor = await getContributorFromGit();
|
||||||
|
if (gitContributor) {
|
||||||
|
formData.contributor = gitContributor;
|
||||||
|
console.log("[Submitter] Contributor updated from git:", gitContributor);
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.pkgname) {
|
if (formData.pkgname) {
|
||||||
isSearchingHistory.value = true;
|
isSearchingHistory.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -1294,13 +1431,20 @@ const handleDrop = async (event: DragEvent) => {
|
|||||||
if (file.name.endsWith(".deb")) {
|
if (file.name.endsWith(".deb")) {
|
||||||
console.log("[Submitter] File is a deb package");
|
console.log("[Submitter] File is a deb package");
|
||||||
|
|
||||||
|
// 在 contextIsolation 环境下,File.path 不可用
|
||||||
|
// 使用 Electron 的 webUtils.getPathForFile() 获取真实文件系统路径
|
||||||
|
let filePath: string;
|
||||||
|
try {
|
||||||
|
filePath = window.electronUtils.getPathForFile(file);
|
||||||
|
console.log("[Submitter] File path from electronUtils:", filePath);
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
"[Submitter] electronUtils.getPathForFile failed, trying fallback",
|
||||||
|
);
|
||||||
const textUriList = event.dataTransfer?.getData("text/uri-list");
|
const textUriList = event.dataTransfer?.getData("text/uri-list");
|
||||||
console.log("[Submitter] text/uri-list:", textUriList);
|
|
||||||
|
|
||||||
const textPlain = event.dataTransfer?.getData("text/plain");
|
const textPlain = event.dataTransfer?.getData("text/plain");
|
||||||
console.log("[Submitter] text/plain:", textPlain);
|
filePath = textUriList || textPlain || "";
|
||||||
|
}
|
||||||
const filePath = file.path || textUriList || textPlain;
|
|
||||||
console.log("[Submitter] Final filePath:", filePath);
|
console.log("[Submitter] Final filePath:", filePath);
|
||||||
|
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
@@ -1339,7 +1483,9 @@ const selectArch = async (arch: HistoryArchInfo) => {
|
|||||||
|
|
||||||
formData.name = arch.name || formData.name;
|
formData.name = arch.name || formData.name;
|
||||||
formData.author = arch.author || formData.author;
|
formData.author = arch.author || formData.author;
|
||||||
formData.contributor = arch.contributor || formData.contributor;
|
if (!formData.contributor) {
|
||||||
|
formData.contributor = arch.contributor || "";
|
||||||
|
}
|
||||||
formData.website = arch.website || formData.website;
|
formData.website = arch.website || formData.website;
|
||||||
formData.category = arch.category || formData.category;
|
formData.category = arch.category || formData.category;
|
||||||
formData.description = arch.more || formData.description;
|
formData.description = arch.more || formData.description;
|
||||||
@@ -1397,10 +1543,13 @@ const selectIconFile = () => {
|
|||||||
iconFileInput.value?.click();
|
iconFileInput.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIconFileSelect = (event: Event) => {
|
const isPngFile = (file: File): boolean => {
|
||||||
const target = event.target as HTMLInputElement;
|
return (
|
||||||
const file = target.files?.[0];
|
file.type === "image/png" || (file.type === "" && /\.png$/i.test(file.name))
|
||||||
if (file) {
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readIconFile = (file: File): void => {
|
||||||
iconFileName.value = file.name;
|
iconFileName.value = file.name;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
@@ -1409,26 +1558,31 @@ const handleIconFileSelect = (event: Event) => {
|
|||||||
iconPreview.value = dataUrl;
|
iconPreview.value = dataUrl;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (isPngFile(file)) {
|
||||||
|
mediaError.value = "";
|
||||||
|
readIconFile(file);
|
||||||
|
} else {
|
||||||
|
mediaError.value = "应用图标仅支持 PNG 格式。";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIconDrop = (event: DragEvent) => {
|
const handleIconDrop = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = event.dataTransfer?.files?.[0];
|
const file = event.dataTransfer?.files?.[0];
|
||||||
if (
|
if (!file) return;
|
||||||
file &&
|
if (isPngFile(file)) {
|
||||||
(file.name.endsWith(".png") ||
|
mediaError.value = "";
|
||||||
file.name.endsWith(".jpg") ||
|
readIconFile(file);
|
||||||
file.name.endsWith(".jpeg"))
|
} else {
|
||||||
) {
|
mediaError.value = "应用图标仅支持 PNG 格式。";
|
||||||
iconFileName.value = file.name;
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const dataUrl = e.target?.result as string;
|
|
||||||
formData.iconPath = dataUrl;
|
|
||||||
iconPreview.value = dataUrl;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1437,7 +1591,14 @@ const addScreenshot = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const importScreenshots = (files: File[]) => {
|
const importScreenshots = (files: File[]) => {
|
||||||
for (const file of files) {
|
const pngFiles = files.filter(isPngFile);
|
||||||
|
if (pngFiles.length !== files.length) {
|
||||||
|
mediaError.value = "截图仅支持 PNG 格式。";
|
||||||
|
} else if (pngFiles.length > 0) {
|
||||||
|
mediaError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of pngFiles) {
|
||||||
if (formData.screenshots.length >= 5) break;
|
if (formData.screenshots.length >= 5) break;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -1455,16 +1616,18 @@ const importScreenshots = (files: File[]) => {
|
|||||||
|
|
||||||
const handleScreenshotSelect = (event: Event) => {
|
const handleScreenshotSelect = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const files = Array.from(target.files ?? []).filter((file) =>
|
importScreenshots(Array.from(target.files ?? []));
|
||||||
/\.(png|jpe?g)$/i.test(file.name),
|
|
||||||
);
|
|
||||||
importScreenshots(files);
|
|
||||||
target.value = "";
|
target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScreenshotDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
importScreenshots(Array.from(event.dataTransfer?.files ?? []));
|
||||||
|
};
|
||||||
|
|
||||||
const handleScreenshotPaste = (event: ClipboardEvent) => {
|
const handleScreenshotPaste = (event: ClipboardEvent) => {
|
||||||
const files = Array.from(event.clipboardData?.items ?? [])
|
const files = Array.from(event.clipboardData?.items ?? [])
|
||||||
.filter((item) => item.type.startsWith("image/"))
|
.filter((item) => item.kind === "file")
|
||||||
.map((item) => item.getAsFile())
|
.map((item) => item.getAsFile())
|
||||||
.filter((file): file is File => file !== null);
|
.filter((file): file is File => file !== null);
|
||||||
|
|
||||||
@@ -1497,6 +1660,7 @@ const resetForm = () => {
|
|||||||
submitSuccess.value = false;
|
submitSuccess.value = false;
|
||||||
submitError.value = "";
|
submitError.value = "";
|
||||||
debParseError.value = "";
|
debParseError.value = "";
|
||||||
|
mediaError.value = "";
|
||||||
packageSuccess.value = false;
|
packageSuccess.value = false;
|
||||||
packageError.value = "";
|
packageError.value = "";
|
||||||
packageResult.value = null;
|
packageResult.value = null;
|
||||||
@@ -1514,14 +1678,29 @@ const resetForm = () => {
|
|||||||
isSearchingHistory.value = false;
|
isSearchingHistory.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSubmitSuccessModal = () => {
|
const clearSubmitTransientState = (): void => {
|
||||||
showSubmitSuccessModal.value = false;
|
showSubmitSuccessModal.value = false;
|
||||||
resetForm();
|
submitSuccess.value = false;
|
||||||
|
isSubmitting.value = false;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
uploadStage.value = "";
|
||||||
|
uploadStageMessage.value = "";
|
||||||
|
uploadStages.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFormAndClose = () => {
|
const closeSubmitSuccessModal = () => {
|
||||||
showSubmitSuccessModal.value = false;
|
clearSubmitTransientState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueSubmission = () => {
|
||||||
|
const shouldClear = window.confirm(
|
||||||
|
"是否清空当前表单内容后继续投递?选择“取消”将保留已填写内容。",
|
||||||
|
);
|
||||||
|
if (shouldClear) {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
} else {
|
||||||
|
clearSubmitTransientState();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
@@ -1712,26 +1891,64 @@ const closeWindow = () => {
|
|||||||
window.ipcRenderer.send("close-submitter-window");
|
window.ipcRenderer.send("close-submitter-window");
|
||||||
};
|
};
|
||||||
|
|
||||||
import { onMounted, nextTick } from "vue";
|
import { onMounted, onUnmounted, nextTick } from "vue";
|
||||||
|
|
||||||
const getGitEmail = async () => {
|
const getGitInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.ipcRenderer.invoke("get-git-email");
|
const [nameResult, emailResult] = await Promise.all([
|
||||||
if (result?.success && result.data) {
|
window.ipcRenderer.invoke("get-git-name"),
|
||||||
formData.mail = result.data;
|
window.ipcRenderer.invoke("get-git-email"),
|
||||||
console.log("[Submitter] Git email auto-filled:", result.data);
|
]);
|
||||||
|
|
||||||
|
let gitName = "";
|
||||||
|
let gitEmail = "";
|
||||||
|
|
||||||
|
if (nameResult?.success && nameResult.data) {
|
||||||
|
gitName = nameResult.data;
|
||||||
|
console.log("[Submitter] Git name auto-filled:", gitName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailResult?.success && emailResult.data) {
|
||||||
|
gitEmail = emailResult.data;
|
||||||
|
console.log("[Submitter] Git email auto-filled:", gitEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 git name 和 email 合并填入 contributor 字段,格式: Name <email>
|
||||||
|
if (gitName || gitEmail) {
|
||||||
|
if (gitName && gitEmail) {
|
||||||
|
formData.contributor = `${gitName} <${gitEmail}>`;
|
||||||
|
} else if (gitName) {
|
||||||
|
formData.contributor = gitName;
|
||||||
|
} else if (gitEmail) {
|
||||||
|
formData.contributor = gitEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单独填入邮箱
|
||||||
|
if (gitEmail) {
|
||||||
|
formData.mail = gitEmail;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[Submitter] Failed to get git email:", err);
|
console.warn("[Submitter] Failed to get git info:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log("[Submitter] Component mounted, loading categories and tags");
|
console.log("[Submitter] Component mounted, loading categories and tags");
|
||||||
|
// 透明窗口:让 body/html 背景透明,圆角才能透出,否则四角是方角。
|
||||||
|
// 保存原始值,在 onUnmounted 中恢复,避免副作用泄漏到其它视图。
|
||||||
|
const prevHtmlBg = document.documentElement.style.backgroundColor;
|
||||||
|
const prevBodyBg = document.body.style.backgroundColor;
|
||||||
|
document.documentElement.style.backgroundColor = "transparent";
|
||||||
|
document.body.style.backgroundColor = "transparent";
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.documentElement.style.backgroundColor = prevHtmlBg;
|
||||||
|
document.body.style.backgroundColor = prevBodyBg;
|
||||||
|
});
|
||||||
// 先等待分类列表加载完成,避免后续竞态
|
// 先等待分类列表加载完成,避免后续竞态
|
||||||
await Promise.all([loadCategoriesList(), loadTagsList()]);
|
await Promise.all([loadCategoriesList(), loadTagsList()]);
|
||||||
// 尝试从 git 配置读取邮箱
|
// 尝试从 git 配置读取 name 和 email,填入 contributor 和 mail
|
||||||
await getGitEmail();
|
await getGitInfo();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative mb-6 overflow-hidden rounded-2xl border border-amber-200/60 bg-gradient-to-r from-amber-50 to-orange-50 p-5 shadow-sm dark:border-amber-500/20 dark:from-amber-500/10 dark:to-orange-500/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -right-4 -top-4 text-7xl text-amber-200/50 dark:text-amber-400/10"
|
||||||
|
>
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow"
|
||||||
|
>
|
||||||
|
<i class="fas fa-hand-holding-heart"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-sm font-bold text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: "致每一位星火贡献者",
|
||||||
|
message:
|
||||||
|
"星火应用商店的每一次更新、每一个应用,都离不开开发者的默默耕耘与热心用户的悉心维护。感谢所有为星火生态付出时间与精力的贡献者——是你们,让开源之光生生不息。🎉",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -58,6 +58,34 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 双来源:先让用户选择要卸载的来源 -->
|
||||||
|
<div
|
||||||
|
v-if="installOrigins.length > 1 && !uninstalling && !completed"
|
||||||
|
class="mb-1 flex w-full flex-col items-end gap-2"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
该应用同时通过 APM 与 Spark 安装,请选择要卸载的来源:
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
|
||||||
|
@click="confirmUninstall('apm')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-box-open"></i>
|
||||||
|
卸载 APM 版
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
|
||||||
|
@click="confirmUninstall('spark')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
卸载 Spark 版
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
v-if="!uninstalling"
|
v-if="!uninstalling"
|
||||||
@@ -69,10 +97,10 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!uninstalling && !completed"
|
v-if="installOrigins.length <= 1 && !uninstalling && !completed"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5"
|
class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5"
|
||||||
@click="confirmUninstall"
|
@click="confirmUninstall(installOrigins[0])"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
确认卸载
|
确认卸载
|
||||||
@@ -115,6 +143,14 @@ const logEnd = ref<HTMLElement | null>(null);
|
|||||||
const appName = computed(() => props.app?.name || "未知应用");
|
const appName = computed(() => props.app?.name || "未知应用");
|
||||||
const appPkg = computed(() => props.app?.pkgname || "");
|
const appPkg = computed(() => props.app?.pkgname || "");
|
||||||
|
|
||||||
|
// 该应用实际安装的来源集合:优先取自合并后的 origins,回退到单 origin 字段
|
||||||
|
const installOrigins = computed<Array<"spark" | "apm">>(() => {
|
||||||
|
if (props.app?.origins && props.app.origins.length > 0) {
|
||||||
|
return props.app.origins;
|
||||||
|
}
|
||||||
|
return [props.app?.origin ?? "spark"];
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
|
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
|
||||||
reset();
|
reset();
|
||||||
@@ -134,18 +170,20 @@ const reset = () => {
|
|||||||
error.value = "";
|
error.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmUninstall = () => {
|
const confirmUninstall = (origin: "spark" | "apm") => {
|
||||||
if (!appPkg.value) {
|
if (!appPkg.value) {
|
||||||
error.value = "无效的包名";
|
error.value = "无效的包名";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstalling.value = true;
|
uninstalling.value = true;
|
||||||
logs.value = ["正在请求卸载: " + appPkg.value + "..."];
|
const originLabel = origin === "apm" ? "APM" : "Spark";
|
||||||
|
logs.value = [`正在请求卸载(${originLabel}): ${appPkg.value}...`];
|
||||||
|
|
||||||
|
// 按用户选择的来源走对应的卸载方式(spark -> aptss remove;apm -> apm autoremove)
|
||||||
window.ipcRenderer.send("remove-installed", {
|
window.ipcRenderer.send("remove-installed", {
|
||||||
pkgname: appPkg.value,
|
pkgname: appPkg.value,
|
||||||
origin: props.app?.origin || "spark",
|
origin,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<UpdateCenterToolbar
|
<UpdateCenterToolbar
|
||||||
:search-query="store.searchQuery.value"
|
:search-query="store.searchQuery.value"
|
||||||
:selected-count="selectedCount"
|
:selected-count="selectedCount"
|
||||||
|
:selectable-count="store.selectableCount.value"
|
||||||
:all-selected="store.allSelected.value"
|
:all-selected="store.allSelected.value"
|
||||||
:some-selected="store.someSelected.value"
|
:some-selected="store.someSelected.value"
|
||||||
:loading="store.loading.value"
|
:loading="store.loading.value"
|
||||||
@@ -61,12 +62,24 @@
|
|||||||
>
|
>
|
||||||
正在刷新更新列表…
|
正在刷新更新列表…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="heldLockedCount > 0"
|
||||||
|
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<p class="leading-6">
|
||||||
|
检测到 {{ heldLockedCount }} 个软件被系统锁定(hold),默认不会升级。如需强制升级,请在对应软件上打开「强制安装」开关。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<UpdateCenterList
|
<UpdateCenterList
|
||||||
:items="store.filteredItems.value"
|
:items="store.filteredItems.value"
|
||||||
:tasks="store.snapshot.value.tasks"
|
:tasks="store.snapshot.value.tasks"
|
||||||
:selected-task-keys="store.selectedTaskKeys.value"
|
:selected-task-keys="store.selectedTaskKeys.value"
|
||||||
|
:forced-task-keys="store.forcedTaskKeys.value"
|
||||||
|
:apps="apps"
|
||||||
@toggle-selection="emit('toggle-selection', $event)"
|
@toggle-selection="emit('toggle-selection', $event)"
|
||||||
|
@toggle-force="store.toggleForce"
|
||||||
@ignore-item="store.ignoreItem"
|
@ignore-item="store.ignoreItem"
|
||||||
@unignore-item="store.unignoreItem"
|
@unignore-item="store.unignoreItem"
|
||||||
/>
|
/>
|
||||||
@@ -86,6 +99,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type { App } from "@/global/typedefinition";
|
||||||
import type { UpdateCenterStore } from "@/modules/updateCenter";
|
import type { UpdateCenterStore } from "@/modules/updateCenter";
|
||||||
|
|
||||||
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
|
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
|
||||||
@@ -103,10 +117,20 @@ const emit = defineEmits<{
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
store: UpdateCenterStore;
|
store: UpdateCenterStore;
|
||||||
|
apps: App[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedCount = computed(() => props.store.getSelectedItems().length);
|
const selectedCount = computed(() => props.store.getSelectedItems().length);
|
||||||
|
|
||||||
|
// 未开启强制安装的被锁定(held)软件数量,用于顶部提示
|
||||||
|
const heldLockedCount = computed(
|
||||||
|
() =>
|
||||||
|
props.store.snapshot.value.items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.held === true && !props.store.forcedTaskKeys.value.has(i.taskKey),
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
|
||||||
const onOverlayWheel = (e: WheelEvent) => {
|
const onOverlayWheel = (e: WheelEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
||||||
|
|||||||
@@ -1,45 +1,154 @@
|
|||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white px-3 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
|
class="window-titlebar sticky top-0 z-20 flex h-12 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-slate-50 px-4 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<!-- 左:移动端侧栏切换 + logo + 应用名 -->
|
||||||
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span>
|
<div class="window-titlebar-left flex min-w-0 items-center gap-2">
|
||||||
<span class="truncate text-sm font-semibold">星火应用商店</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="window-titlebar-controls flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
|
aria-label="切换侧边栏"
|
||||||
|
title="切换侧边栏"
|
||||||
|
@click="$emit('toggle-sidebar')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bars text-base"></i>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src="../assets/imgs/spark-store.svg"
|
||||||
|
class="h-8 w-8 flex-shrink-0"
|
||||||
|
alt="星火应用商店"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-base font-semibold">星火应用商店</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-orange-400 bg-orange-50 px-2 py-0.5 text-[10px] font-semibold text-orange-600 dark:border-amber-400/70 dark:bg-amber-400/10 dark:text-amber-300 dark:shadow-[0_0_8px_rgba(251,191,36,0.4)]"
|
||||||
|
>社区版</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中:搜索框(取代原 AppHeader 里的搜索) -->
|
||||||
|
<div class="window-titlebar-center min-w-0 flex-1">
|
||||||
|
<div class="relative mx-auto w-full max-w-xl">
|
||||||
|
<i
|
||||||
|
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
|
||||||
|
></i>
|
||||||
|
<input
|
||||||
|
id="searchBox"
|
||||||
|
:value="searchQuery"
|
||||||
|
class="w-full rounded-full border border-slate-200/70 bg-white/70 py-2 pl-9 pr-9 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-2 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
|
||||||
|
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="absolute flex right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
|
||||||
|
title="清除搜索"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times-circle text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右:主题 + 设置 + 关于 + 分隔符 + 窗口控制 -->
|
||||||
|
<div class="window-titlebar-controls flex shrink-0 items-center gap-1">
|
||||||
|
<ThemeToggle :theme-mode="themeMode" @toggle="$emit('toggle-theme')" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
|
aria-label="安装设置"
|
||||||
|
title="安装设置"
|
||||||
|
@click="$emit('open-install-settings')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cog text-base"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
|
aria-label="关于"
|
||||||
|
title="关于"
|
||||||
|
@click="$emit('open-about')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle text-base"></i>
|
||||||
|
</button>
|
||||||
|
<div class="mx-1 h-6 w-px bg-slate-300/70 dark:bg-slate-700/70"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fas fa-window-minimize rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
||||||
aria-label="最小化"
|
aria-label="最小化"
|
||||||
title="最小化"
|
title="最小化"
|
||||||
@click="minimize"
|
@click="minimize"
|
||||||
>
|
>
|
||||||
−
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
class="fas rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
||||||
|
:class="isMaximized ? 'fa-window-restore' : 'fa-window-maximize'"
|
||||||
aria-label="最大化或还原"
|
aria-label="最大化或还原"
|
||||||
title="最大化或还原"
|
title="最大化或还原"
|
||||||
@click="toggleMaximize"
|
@click="toggleMaximize"
|
||||||
>
|
>
|
||||||
□
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-3 py-1 text-sm transition hover:bg-red-500 hover:text-white"
|
class="fas fa-times rounded-md px-3 py-1.5 text-base transition hover:bg-red-500 hover:text-white"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ThemeToggle from "./ThemeToggle.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
searchQuery: string;
|
||||||
|
themeMode: "light" | "dark" | "auto";
|
||||||
|
isMaximized: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:searchQuery", value: string): void;
|
||||||
|
(e: "search-focus"): void;
|
||||||
|
(e: "spk-link", pkgname: string): void;
|
||||||
|
(e: "open-install-settings"): void;
|
||||||
|
(e: "open-about"): void;
|
||||||
|
(e: "toggle-theme"): void;
|
||||||
|
(e: "toggle-sidebar"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
emit("search-focus");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
const value = target.value.trim();
|
||||||
|
// 检测 SPK 分享链接: spk://search/{pkgname} 或 spk://store/{category}/{pkgname}
|
||||||
|
const spkMatch =
|
||||||
|
value.match(/^spk:\/\/search\/(.+)$/i) ||
|
||||||
|
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
|
||||||
|
if (spkMatch) {
|
||||||
|
const pkgname = spkMatch[1].trim();
|
||||||
|
if (pkgname) {
|
||||||
|
target.value = "";
|
||||||
|
emit("update:searchQuery", "");
|
||||||
|
emit("spk-link", pkgname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("update:searchQuery", target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
emit("update:searchQuery", "");
|
||||||
|
};
|
||||||
|
|
||||||
const minimize = () => {
|
const minimize = () => {
|
||||||
window.windowControls.minimize();
|
window.windowControls.minimize();
|
||||||
};
|
};
|
||||||
@@ -58,8 +167,9 @@ const close = () => {
|
|||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-titlebar-controls,
|
/* 所有可交互元素(输入框、按钮)禁用窗口拖动 */
|
||||||
.window-titlebar-controls button {
|
.window-titlebar input,
|
||||||
|
.window-titlebar button {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
|
class="relative flex items-center gap-3 rounded-xl border p-2.5 shadow-sm transition"
|
||||||
|
:class="
|
||||||
|
item.ignored === true
|
||||||
|
? 'border-slate-200/50 bg-slate-50/60 opacity-70 dark:border-slate-800/50 dark:bg-slate-900/40'
|
||||||
|
: item.held === true
|
||||||
|
? 'border-amber-300/70 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10'
|
||||||
|
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
|
class="h-4 w-4 shrink-0 rounded border-slate-300 accent-brand focus:ring-brand"
|
||||||
:checked="selected"
|
:checked="selected"
|
||||||
:disabled="item.ignored === true"
|
:disabled="isSelectionLocked"
|
||||||
@change="$emit('toggle-selection')"
|
@change="$emit('toggle-selection')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
|
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
|
||||||
|
:class="item.ignored === true || item.held === true ? 'opacity-60' : ''"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="iconSrc"
|
:src="iconSrc"
|
||||||
@@ -20,82 +28,125 @@
|
|||||||
@error="handleIconError"
|
@error="handleIconError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="font-semibold text-slate-900 dark:text-white">
|
<p
|
||||||
|
class="truncate font-semibold"
|
||||||
|
:class="
|
||||||
|
item.ignored === true
|
||||||
|
? 'text-sm text-slate-400 dark:text-slate-500'
|
||||||
|
: 'text-sm text-slate-900 dark:text-white'
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ item.displayName }}
|
{{ item.displayName }}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||||
>
|
>
|
||||||
{{ sourceLabel }}
|
{{ sourceLabel }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
v-if="item.isMigration"
|
|
||||||
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
|
|
||||||
>
|
|
||||||
将迁移到 APM
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="item.ignored === true"
|
v-if="item.ignored === true"
|
||||||
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
class="shrink-0 rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
已忽略
|
已忽略
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="item.held === true && !forced"
|
||||||
|
class="shrink-0 rounded-full bg-amber-200 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
已锁定
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="item.held === true && forced"
|
||||||
|
class="shrink-0 rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-semibold text-white dark:bg-amber-500"
|
||||||
|
>
|
||||||
|
将强制
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<p class="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
|
||||||
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
|
{{ item.packageName }} · {{ item.currentVersion }} →
|
||||||
{{ item.newVersion }}
|
{{ item.newVersion }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
v-if="item.ignored === true"
|
|
||||||
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
已忽略的更新不会加入本次任务。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="task"
|
|
||||||
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
<p>{{ statusLabel }}</p>
|
|
||||||
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div
|
||||||
|
v-if="task"
|
||||||
|
class="shrink-0 text-right text-xs font-semibold text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<p>{{ statusLabel }}</p>
|
||||||
|
<p v-if="showProgress" class="mt-0.5">{{ progressText }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="item.ignored !== true && !task"
|
||||||
|
class="flex shrink-0 flex-col items-end gap-0.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-semibold text-brand dark:text-amber-300"
|
||||||
|
>{{ timeLabel }}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!task" class="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<span class="text-[11px] font-semibold text-slate-400">{{
|
||||||
|
timeLabel
|
||||||
|
}}</span>
|
||||||
|
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 强制安装开关:仅对被系统锁定(held)的包显示 -->
|
||||||
|
<div
|
||||||
|
v-if="item.held === true && !task"
|
||||||
|
class="flex shrink-0 items-center gap-1"
|
||||||
|
:title="forced ? '已开启强制安装' : '强制安装被锁定的软件'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex h-[18px] w-[34px] items-center rounded-full transition"
|
||||||
|
:class="forced ? 'bg-brand' : 'bg-slate-300 dark:bg-slate-600'"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="forced"
|
||||||
|
aria-label="强制安装"
|
||||||
|
@click.stop="$emit('toggle-force')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition"
|
||||||
|
:class="forced ? 'translate-x-[16px]' : 'translate-x-[2px]'"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<span class="text-[10px] text-slate-400">强制</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="item.ignored === true"
|
v-if="item.ignored === true"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/80 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-slate-300/80 text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
aria-label="取消忽略"
|
aria-label="取消忽略"
|
||||||
@click.stop="$emit('unignore-item')"
|
@click.stop="$emit('unignore-item')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rotate-left"></i>
|
<i class="fas fa-rotate-left text-xs"></i>
|
||||||
取消忽略
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-amber-300/80 px-3 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
|
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-amber-600 transition hover:bg-amber-50 dark:text-amber-300 dark:hover:bg-amber-500/10"
|
||||||
aria-label="忽略更新"
|
aria-label="忽略更新"
|
||||||
@click.stop="$emit('ignore-item')"
|
@click.stop="$emit('ignore-item')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-eye-slash"></i>
|
<i class="fas fa-eye-slash text-xs"></i>
|
||||||
忽略更新
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showProgress" class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
|
v-if="showProgress"
|
||||||
|
class="absolute inset-x-0 bottom-0 h-1 overflow-hidden rounded-b-xl bg-slate-200 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
|
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
|
||||||
:style="progressStyle"
|
:style="progressStyle"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -111,6 +162,8 @@ const props = defineProps<{
|
|||||||
item: UpdateCenterItem;
|
item: UpdateCenterItem;
|
||||||
task?: UpdateCenterTaskState;
|
task?: UpdateCenterTaskState;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
// 是否已开启强制安装(仅对 held 项有意义)
|
||||||
|
forced?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const PLACEHOLDER_ICON =
|
const PLACEHOLDER_ICON =
|
||||||
@@ -121,8 +174,15 @@ defineEmits<{
|
|||||||
(e: "toggle-selection"): void;
|
(e: "toggle-selection"): void;
|
||||||
(e: "ignore-item"): void;
|
(e: "ignore-item"): void;
|
||||||
(e: "unignore-item"): void;
|
(e: "unignore-item"): void;
|
||||||
|
(e: "toggle-force"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 被锁定且未强制时,复选框禁用(与「已忽略」一样不可被选中)
|
||||||
|
const isSelectionLocked = computed(
|
||||||
|
() =>
|
||||||
|
props.item.ignored === true || (props.item.held === true && !props.forced),
|
||||||
|
);
|
||||||
|
|
||||||
const normalizeIconSrc = (icon: string): string => {
|
const normalizeIconSrc = (icon: string): string => {
|
||||||
if (/^[a-z]+:\/\//i.test(icon)) {
|
if (/^[a-z]+:\/\//i.test(icon)) {
|
||||||
return icon;
|
return icon;
|
||||||
@@ -185,4 +245,27 @@ const showProgress = computed(() => {
|
|||||||
|
|
||||||
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
|
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
|
||||||
const progressStyle = computed(() => ({ width: progressText.value }));
|
const progressStyle = computed(() => ({ width: progressText.value }));
|
||||||
|
|
||||||
|
// 相对时间(如「3天前」),无数据降级为 「—」
|
||||||
|
const timeLabel = computed(() => {
|
||||||
|
const t = props.item.updateTime;
|
||||||
|
if (!t || typeof t !== "number") return "—";
|
||||||
|
const diff = Date.now() - t;
|
||||||
|
const day = 24 * 60 * 60 * 1000;
|
||||||
|
if (diff < 0) return "刚刚";
|
||||||
|
if (diff < day) return "今天";
|
||||||
|
if (diff < 2 * day) return "昨天";
|
||||||
|
if (diff < 30 * day) return `${Math.floor(diff / day)}天前`;
|
||||||
|
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
||||||
|
return `${Math.floor(diff / (365 * day))}年前`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大小格式化(如 45 MB / 1.2 GB)
|
||||||
|
const sizeLabel = computed(() => {
|
||||||
|
const size = props.item.size;
|
||||||
|
if (!size || size <= 0) return "—";
|
||||||
|
const mb = size / (1024 * 1024);
|
||||||
|
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
||||||
|
return `${Math.round(mb)} MB`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<UpdateCenterItem
|
<UpdateCenterItem
|
||||||
v-for="item in items"
|
v-for="item in enrichedItems"
|
||||||
:key="item.taskKey"
|
:key="item.taskKey"
|
||||||
:item="item"
|
:item="item"
|
||||||
:task="taskMap.get(item.taskKey)"
|
:task="taskMap.get(item.taskKey)"
|
||||||
:selected="selectedTaskKeys.has(item.taskKey)"
|
:selected="selectedTaskKeys.has(item.taskKey)"
|
||||||
|
:forced="forcedTaskKeys.has(item.taskKey)"
|
||||||
@toggle-selection="$emit('toggle-selection', item.taskKey)"
|
@toggle-selection="$emit('toggle-selection', item.taskKey)"
|
||||||
|
@toggle-force="$emit('toggle-force', item.taskKey)"
|
||||||
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
|
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
|
||||||
@unignore-item="
|
@unignore-item="
|
||||||
$emit('unignore-item', item.packageName, item.newVersion)
|
$emit('unignore-item', item.packageName, item.newVersion)
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
App,
|
||||||
UpdateCenterItem as UpdateCenterItemModel,
|
UpdateCenterItem as UpdateCenterItemModel,
|
||||||
UpdateCenterTaskState,
|
UpdateCenterTaskState,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
@@ -39,14 +42,34 @@ const props = defineProps<{
|
|||||||
items: UpdateCenterItemModel[];
|
items: UpdateCenterItemModel[];
|
||||||
tasks: UpdateCenterTaskState[];
|
tasks: UpdateCenterTaskState[];
|
||||||
selectedTaskKeys: Set<string>;
|
selectedTaskKeys: Set<string>;
|
||||||
|
forcedTaskKeys: Set<string>;
|
||||||
|
apps: App[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: "toggle-selection", taskKey: string): void;
|
(e: "toggle-selection", taskKey: string): void;
|
||||||
|
(e: "toggle-force", taskKey: string): void;
|
||||||
(e: "ignore-item", packageName: string, newVersion: string): void;
|
(e: "ignore-item", packageName: string, newVersion: string): void;
|
||||||
(e: "unignore-item", packageName: string, newVersion: string): void;
|
(e: "unignore-item", packageName: string, newVersion: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 从商店目录 apps 的 update 字段(app.json 的 Update,如 "2026-08-08")
|
||||||
|
// 推算更新发布时间,补全到更新列表项,用于显示「X天前」。匹配不到则保留原值(降级为「—」)。
|
||||||
|
const enrichedItems = computed<UpdateCenterItemModel[]>(() => {
|
||||||
|
const updateByPkg = new Map<string, number>();
|
||||||
|
for (const app of props.apps) {
|
||||||
|
if (app.pkgname && app.update) {
|
||||||
|
const t = Date.parse(app.update);
|
||||||
|
if (!Number.isNaN(t)) updateByPkg.set(app.pkgname, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return props.items.map((item) => {
|
||||||
|
if (item.updateTime && !Number.isNaN(item.updateTime)) return item;
|
||||||
|
const t = updateByPkg.get(item.packageName);
|
||||||
|
return t ? { ...item, updateTime: t } : item;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const taskMap = computed(() => {
|
const taskMap = computed(() => {
|
||||||
return new Map(props.tasks.map((task) => [task.taskKey, task]));
|
return new Map(props.tasks.map((task) => [task.taskKey, task]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
集中管理 APM 与传统 deb 更新任务
|
集中管理 APM 与传统 deb 更新任务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
@@ -21,6 +21,37 @@
|
|||||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
|
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
|
||||||
{{ loading ? "刷新中" : "刷新" }}
|
{{ loading ? "刷新中" : "刷新" }}
|
||||||
</button>
|
</button>
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-2 select-none rounded-2xl border border-slate-200/70 px-3 py-2 transition hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||||
|
:class="{
|
||||||
|
'cursor-not-allowed opacity-50 hover:bg-transparent dark:hover:bg-transparent':
|
||||||
|
selectableCount === 0,
|
||||||
|
}"
|
||||||
|
:title="
|
||||||
|
selectableCount === 0
|
||||||
|
? '当前没有可更新项(全部被锁定未强制或已忽略)'
|
||||||
|
: '全选所有可更新项'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="selectAllRef"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand disabled:cursor-not-allowed"
|
||||||
|
:checked="allSelected"
|
||||||
|
:disabled="selectableCount === 0"
|
||||||
|
@change="$emit('toggle-select-all')"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>全选</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
class="text-sm text-slate-400 dark:text-slate-500"
|
||||||
|
:title="selectableCount === 0 ? '无可更新项' : '已选中的更新项数量'"
|
||||||
|
>
|
||||||
|
<template v-if="selectableCount === 0">无可更新项</template>
|
||||||
|
<template v-else>已选 {{ selectedCount }} 项</template>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
||||||
@@ -41,31 +72,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<label class="inline-flex cursor-pointer items-center gap-2 select-none">
|
|
||||||
<input
|
|
||||||
ref="selectAllRef"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
|
|
||||||
:checked="allSelected"
|
|
||||||
@change="$emit('toggle-select-all')"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
|
|
||||||
>全选</span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<span class="text-sm text-slate-400 dark:text-slate-500">
|
|
||||||
已选 {{ selectedCount }} 项
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="block relative">
|
<label class="block relative">
|
||||||
<span class="sr-only">搜索更新</span>
|
<span class="sr-only">搜索更新</span>
|
||||||
|
<i
|
||||||
|
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
|
||||||
|
></i>
|
||||||
<input
|
<input
|
||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
type="search"
|
|
||||||
placeholder="搜索应用或包名"
|
placeholder="搜索应用或包名"
|
||||||
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
|
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -87,6 +102,7 @@ import { ref, watch } from "vue";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
selectableCount: number;
|
||||||
allSelected: boolean;
|
allSelected: boolean;
|
||||||
someSelected: boolean;
|
someSelected: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* useAccountSync —— 登录、下载历史、云端同步/恢复、账号态联动、登出。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(requireLogin / openLoginFromPrompt / handleFlarumLogin /
|
||||||
|
* loadDownloadedHistory / refreshInstalledSyncCandidates / syncInstalledAppsToAccount /
|
||||||
|
* syncInstalledAppsNow / openRestoreFromAccount / installCloudItems /
|
||||||
|
* maybePromptInstalledSync / openUserManagement / handleLogout / openReviewUserProfile /
|
||||||
|
* 各代次守卫 / clear*State),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态来自 useAppState;currentUser / isLoggedIn 来自 authState;
|
||||||
|
* clearFavoriteState 来自 useFavorites;onDetailInstall 来自 useDownloads。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
FlarumLoginPayload,
|
||||||
|
SyncedAppListItem,
|
||||||
|
AppReview,
|
||||||
|
App,
|
||||||
|
} from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
downloadedApps,
|
||||||
|
downloadedLoading,
|
||||||
|
downloadedError,
|
||||||
|
downloadedRequestGeneration,
|
||||||
|
syncLoading,
|
||||||
|
syncStatusMessage,
|
||||||
|
syncRequestGeneration,
|
||||||
|
syncCandidateApps,
|
||||||
|
restoreLoading,
|
||||||
|
restoreError,
|
||||||
|
showRestoreModal,
|
||||||
|
restoreItems,
|
||||||
|
restoreRequestGeneration,
|
||||||
|
installedSyncPromptShown,
|
||||||
|
systemInfo,
|
||||||
|
showUserManagementModal,
|
||||||
|
isSidebarOpen,
|
||||||
|
showLoginPrompt,
|
||||||
|
showLoginModal,
|
||||||
|
currentView,
|
||||||
|
activeTab,
|
||||||
|
selectedCategory,
|
||||||
|
selectedReviewUserProfile,
|
||||||
|
showReviewUserProfileModal,
|
||||||
|
sparkAvailable,
|
||||||
|
apmAvailable,
|
||||||
|
storeFilter,
|
||||||
|
apps,
|
||||||
|
showLoginPromptMessage,
|
||||||
|
loginLoading,
|
||||||
|
loginError,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { isLoggedIn, currentUser } from "../global/authState";
|
||||||
|
import {
|
||||||
|
setInstalledSyncEnabled,
|
||||||
|
loadInstalledSyncPreference,
|
||||||
|
installedSyncEnabled,
|
||||||
|
} from "../global/accountSyncState";
|
||||||
|
import {
|
||||||
|
exchangeFlarumToken,
|
||||||
|
listDownloadedApps,
|
||||||
|
uploadSyncedAppList,
|
||||||
|
fetchSyncedAppList,
|
||||||
|
} from "../modules/backendApi";
|
||||||
|
import { requestFlarumToken } from "../modules/flarumAuth";
|
||||||
|
import {
|
||||||
|
buildSyncItems,
|
||||||
|
mergeInstalledApps,
|
||||||
|
resolveCloudInstallCandidate,
|
||||||
|
} from "../modules/appListSync";
|
||||||
|
import { isOriginEnabled } from "../modules/storeFilter";
|
||||||
|
import { clearFavoriteState } from "./useFavorites";
|
||||||
|
import { onDetailInstall } from "./useDownloads";
|
||||||
|
import { registerRequireLogin } from "./useAppDetail";
|
||||||
|
import { registerRequireLogin as registerRequireLoginFav } from "./useFavorites";
|
||||||
|
|
||||||
|
// requireLogin 供 useAppDetail / useFavorites / 本模块内部使用;登记到其它 composable
|
||||||
|
export const requireLogin = (message: string): boolean => {
|
||||||
|
if (isLoggedIn.value) return true;
|
||||||
|
showLoginPromptMessage.value = message;
|
||||||
|
showLoginPrompt.value = true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
registerRequireLogin(requireLogin);
|
||||||
|
registerRequireLoginFav(requireLogin);
|
||||||
|
|
||||||
|
const openLoginFromPrompt = () => {
|
||||||
|
showLoginPrompt.value = false;
|
||||||
|
showLoginModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDownloadedState = () => {
|
||||||
|
downloadedRequestGeneration.value += 1;
|
||||||
|
downloadedApps.value = [];
|
||||||
|
downloadedLoading.value = false;
|
||||||
|
downloadedError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRestoreState = () => {
|
||||||
|
restoreRequestGeneration.value += 1;
|
||||||
|
restoreItems.value = [];
|
||||||
|
restoreLoading.value = false;
|
||||||
|
restoreError.value = "";
|
||||||
|
showRestoreModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInstalledSyncState = () => {
|
||||||
|
syncRequestGeneration.value += 1;
|
||||||
|
syncLoading.value = false;
|
||||||
|
syncStatusMessage.value = "";
|
||||||
|
syncCandidateApps.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextDownloadedRequestGeneration = (): number => {
|
||||||
|
downloadedRequestGeneration.value += 1;
|
||||||
|
return downloadedRequestGeneration.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentDownloadedRequest = (
|
||||||
|
generation: number,
|
||||||
|
userId: number,
|
||||||
|
): boolean =>
|
||||||
|
downloadedRequestGeneration.value === generation &&
|
||||||
|
currentUser.value?.id === userId;
|
||||||
|
|
||||||
|
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
|
||||||
|
restoreRequestGeneration.value === generation &&
|
||||||
|
currentUser.value?.id === userId;
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
// 调用 authState 的 logout(此处通过注入,保持 authState 为唯一会话源)
|
||||||
|
logoutRef();
|
||||||
|
pendingDownloadRecordsClear();
|
||||||
|
clearFavoriteState();
|
||||||
|
clearDownloadedState();
|
||||||
|
clearRestoreState();
|
||||||
|
clearInstalledSyncState();
|
||||||
|
loadInstalledSyncPreference(null);
|
||||||
|
showLoginModal.value = false;
|
||||||
|
showLoginPrompt.value = false;
|
||||||
|
isSidebarOpen.value = false;
|
||||||
|
showUserManagementModal.value = false;
|
||||||
|
if (currentView.value === "favorites") {
|
||||||
|
currentView.value = "default";
|
||||||
|
activeTab.value = "home";
|
||||||
|
selectedCategory.value = "all";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
||||||
|
loginLoading.value = true;
|
||||||
|
loginError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const flarumToken = await requestFlarumToken(payload);
|
||||||
|
const session = await exchangeFlarumToken({
|
||||||
|
flarumUserId: flarumToken.userId,
|
||||||
|
flarumToken: flarumToken.token,
|
||||||
|
});
|
||||||
|
setAuthSessionRef(session);
|
||||||
|
clearInstalledSyncState();
|
||||||
|
loadInstalledSyncPreference(session.user.id);
|
||||||
|
showLoginModal.value = false;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
|
||||||
|
} finally {
|
||||||
|
loginLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDownloadedHistory = async (): Promise<void> => {
|
||||||
|
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||||
|
const userId = currentUser.value?.id;
|
||||||
|
if (userId === undefined) return;
|
||||||
|
const generation = nextDownloadedRequestGeneration();
|
||||||
|
|
||||||
|
downloadedLoading.value = true;
|
||||||
|
downloadedError.value = "";
|
||||||
|
try {
|
||||||
|
const result = await listDownloadedApps(1, 50);
|
||||||
|
if (!isCurrentDownloadedRequest(generation, userId)) return;
|
||||||
|
downloadedApps.value = result.items;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentDownloadedRequest(generation, userId)) return;
|
||||||
|
downloadedApps.value = [];
|
||||||
|
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentDownloadedRequest(generation, userId)) {
|
||||||
|
downloadedLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshInstalledSyncCandidates = async (
|
||||||
|
isCurrentRequest: () => boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const origins: Array<"spark" | "apm"> = [];
|
||||||
|
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
|
||||||
|
origins.push("spark");
|
||||||
|
}
|
||||||
|
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
|
||||||
|
origins.push("apm");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedApps: App[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
origins.map(async (origin) => {
|
||||||
|
const pkgnameList =
|
||||||
|
origin === "spark"
|
||||||
|
? apps.value
|
||||||
|
.filter((app) => app.origin === "spark")
|
||||||
|
.map((app) => app.pkgname)
|
||||||
|
: undefined;
|
||||||
|
const result = await window.ipcRenderer.invoke("list-installed", {
|
||||||
|
origin,
|
||||||
|
pkgnameList,
|
||||||
|
});
|
||||||
|
if (!result?.success) return;
|
||||||
|
|
||||||
|
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||||
|
for (const rawApp of appList) {
|
||||||
|
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||||
|
if (!isInstalledAppInfoRef(rawApp)) continue;
|
||||||
|
const appInfo = mapInstalledAppToCatalogAppRef(rawApp, origin);
|
||||||
|
if (appInfo) refreshedApps.push(appInfo);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCurrentRequest()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCandidateApps.value = mergeInstalledApps(
|
||||||
|
syncCandidateApps.value,
|
||||||
|
refreshedApps,
|
||||||
|
origins,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||||
|
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||||
|
if (syncLoading.value) return;
|
||||||
|
const userId = currentUser.value?.id;
|
||||||
|
if (userId === undefined) return;
|
||||||
|
const generation = syncRequestGeneration.value + 1;
|
||||||
|
syncRequestGeneration.value = generation;
|
||||||
|
syncLoading.value = true;
|
||||||
|
syncStatusMessage.value = "";
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshInstalledSyncCandidates(
|
||||||
|
() =>
|
||||||
|
syncRequestGeneration.value === generation &&
|
||||||
|
currentUser.value?.id === userId,
|
||||||
|
);
|
||||||
|
if (!refreshed) return;
|
||||||
|
const items = buildSyncItems(syncCandidateApps.value);
|
||||||
|
await uploadSyncedAppList({
|
||||||
|
clientArch: window.apm_store.arch || "amd64",
|
||||||
|
distro: systemInfo.value.distro,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
syncRequestGeneration.value !== generation ||
|
||||||
|
currentUser.value?.id !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadedError.value = "";
|
||||||
|
syncStatusMessage.value = "同步完成";
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
syncRequestGeneration.value !== generation ||
|
||||||
|
currentUser.value?.id !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||||
|
syncStatusMessage.value = downloadedError.value;
|
||||||
|
} finally {
|
||||||
|
if (
|
||||||
|
syncRequestGeneration.value === generation &&
|
||||||
|
currentUser.value?.id === userId
|
||||||
|
) {
|
||||||
|
syncLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInstalledAppsNow = (): void => {
|
||||||
|
void syncInstalledAppsToAccount();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRestoreFromAccount = async (): Promise<void> => {
|
||||||
|
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||||
|
const userId = currentUser.value?.id;
|
||||||
|
if (userId === undefined) return;
|
||||||
|
const generation = restoreRequestGeneration.value + 1;
|
||||||
|
restoreRequestGeneration.value = generation;
|
||||||
|
showRestoreModal.value = true;
|
||||||
|
restoreLoading.value = true;
|
||||||
|
restoreError.value = "";
|
||||||
|
restoreItems.value = [];
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshInstalledSyncCandidates(() =>
|
||||||
|
isCurrentRestoreRequest(generation, userId),
|
||||||
|
);
|
||||||
|
if (!refreshed) return;
|
||||||
|
const result = await fetchSyncedAppList();
|
||||||
|
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||||
|
restoreItems.value = result?.items || [];
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||||
|
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRestoreRequest(generation, userId)) {
|
||||||
|
restoreLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量云端安装:分批触发(每批 3 个),收集每个安装的结果并反馈失败项。
|
||||||
|
// onDetailInstall 内部本身已串行排队,这里仅限制"同时发起"的并发,避免一次注入大量任务。
|
||||||
|
const installCloudItems = async (items: SyncedAppListItem[]): Promise<void> => {
|
||||||
|
const BATCH_SIZE = 3;
|
||||||
|
let failedCount = 0;
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||||
|
const batch = items.slice(i, i + BATCH_SIZE);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (item) => {
|
||||||
|
const app = resolveCloudInstallCandidate(item, apps.value);
|
||||||
|
if (!app) return;
|
||||||
|
await onDetailInstall(app);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
failedCount += results.filter((r) => r.status === "rejected").length;
|
||||||
|
}
|
||||||
|
if (failedCount > 0) {
|
||||||
|
console.error(`批量云端安装中有 ${failedCount} 项失败`);
|
||||||
|
}
|
||||||
|
showRestoreModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybePromptInstalledSync = async (): Promise<void> => {
|
||||||
|
if (
|
||||||
|
import.meta.env.MODE === "test" ||
|
||||||
|
!isLoggedIn.value ||
|
||||||
|
installedSyncPromptShown.value ||
|
||||||
|
installedSyncEnabled.value !== null
|
||||||
|
) {
|
||||||
|
if (isLoggedIn.value && installedSyncEnabled.value === true) {
|
||||||
|
await syncInstalledAppsToAccount();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installedSyncPromptShown.value = true;
|
||||||
|
const enabled = window.confirm(
|
||||||
|
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
|
||||||
|
);
|
||||||
|
setInstalledSyncEnabled(enabled);
|
||||||
|
if (enabled) await syncInstalledAppsToAccount();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUserManagement = async () => {
|
||||||
|
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||||
|
showUserManagementModal.value = true;
|
||||||
|
isSidebarOpen.value = false;
|
||||||
|
showLoginPrompt.value = false;
|
||||||
|
await loadDownloadedHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReviewUserProfile = (review: AppReview): void => {
|
||||||
|
const current = currentUser.value;
|
||||||
|
const isCurrentUser =
|
||||||
|
review.isAuthor === true ||
|
||||||
|
(review.userId !== undefined &&
|
||||||
|
current?.id !== undefined &&
|
||||||
|
review.userId === current.id);
|
||||||
|
|
||||||
|
selectedReviewUserProfile.value = {
|
||||||
|
displayName: review.userDisplayName || "星火用户",
|
||||||
|
username: isCurrentUser ? current?.username : undefined,
|
||||||
|
avatarUrl:
|
||||||
|
review.userAvatarUrl || (isCurrentUser ? current?.avatarUrl : undefined),
|
||||||
|
coverUrl: isCurrentUser ? current?.coverUrl : undefined,
|
||||||
|
forumGroups: isCurrentUser ? current?.forumGroups : undefined,
|
||||||
|
};
|
||||||
|
showReviewUserProfileModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 以下引用通过注入方式从 authState / useInstalledApps 获取,避免循环依赖:
|
||||||
|
// logout / setAuthSession(authState)、isInstalledAppInfo / mapInstalledAppToCatalogApp
|
||||||
|
// (useInstalledApps)、pendingDownloadRecords.clear(useDownloads)
|
||||||
|
let logoutRef: () => void = () => undefined;
|
||||||
|
export const registerLogout = (fn: () => void) => {
|
||||||
|
logoutRef = fn;
|
||||||
|
};
|
||||||
|
let setAuthSessionRef: (session: unknown) => void = () => undefined;
|
||||||
|
export const registerSetAuthSession = (fn: (session: unknown) => void) => {
|
||||||
|
setAuthSessionRef = fn;
|
||||||
|
};
|
||||||
|
let isInstalledAppInfoRef: (value: unknown) => boolean = () => false;
|
||||||
|
export const registerIsInstalledAppInfo = (fn: (value: unknown) => boolean) => {
|
||||||
|
isInstalledAppInfoRef = fn;
|
||||||
|
};
|
||||||
|
let mapInstalledAppToCatalogAppRef: (
|
||||||
|
app: unknown,
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
) => App | null = () => null;
|
||||||
|
export const registerMapInstalledAppToCatalogApp = (
|
||||||
|
fn: (app: unknown, origin: "spark" | "apm") => App | null,
|
||||||
|
) => {
|
||||||
|
mapInstalledAppToCatalogAppRef = fn;
|
||||||
|
};
|
||||||
|
let pendingDownloadRecordsClear: () => void = () => undefined;
|
||||||
|
export const registerPendingDownloadRecordsClear = (fn: () => void) => {
|
||||||
|
pendingDownloadRecordsClear = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
openLoginFromPrompt,
|
||||||
|
handleLogout,
|
||||||
|
handleFlarumLogin,
|
||||||
|
loadDownloadedHistory,
|
||||||
|
refreshInstalledSyncCandidates,
|
||||||
|
syncInstalledAppsToAccount,
|
||||||
|
syncInstalledAppsNow,
|
||||||
|
openRestoreFromAccount,
|
||||||
|
installCloudItems,
|
||||||
|
maybePromptInstalledSync,
|
||||||
|
openUserManagement,
|
||||||
|
openReviewUserProfile,
|
||||||
|
clearDownloadedState,
|
||||||
|
clearRestoreState,
|
||||||
|
clearInstalledSyncState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
/**
|
||||||
|
* useAppDetail —— 应用详情弹窗的获取与打开/关闭逻辑。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(fetchAppFromStore / openDetail / openDetailFromInstalled /
|
||||||
|
* createFallbackApp / checkAppInstalled / loadScreenshots / closeDetail /
|
||||||
|
* openScreenPreview / closeScreenPreview / prevScreen / nextScreen /
|
||||||
|
* selectDetailOrigin / handleDetailRequestLogin / currentDisplayApp /
|
||||||
|
* currentReviewAppKey / currentReviewTags),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态来自 useAppState;loadFavoriteMetadataForDetail 来自 useFavorites。
|
||||||
|
*/
|
||||||
|
import { computed, nextTick } from "vue";
|
||||||
|
import type { App } from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
currentApp,
|
||||||
|
currentAppSparkInstalled,
|
||||||
|
currentAppApmInstalled,
|
||||||
|
showModal,
|
||||||
|
showPreview,
|
||||||
|
currentScreenIndex,
|
||||||
|
screenshots,
|
||||||
|
storeFilter,
|
||||||
|
favoriteFolders,
|
||||||
|
favoriteLoading,
|
||||||
|
systemInfo,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { isLoggedIn } from "../global/authState";
|
||||||
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
import { getHybridDefaultOrigin } from "../global/storeConfig";
|
||||||
|
import {
|
||||||
|
getDisplayApp,
|
||||||
|
buildReviewAppKey,
|
||||||
|
buildReviewTags,
|
||||||
|
} from "../modules/appIdentity";
|
||||||
|
import { loadFavoriteMetadataForDetail } from "./useFavorites";
|
||||||
|
import { axiosInstance } from "./useHttp";
|
||||||
|
import type { ReviewTags } from "../global/typedefinition";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
|
||||||
|
const clientArch = computed(() => window.apm_store.arch || "amd64");
|
||||||
|
|
||||||
|
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
|
||||||
|
|
||||||
|
const currentReviewAppKey = computed(() => {
|
||||||
|
if (!currentDisplayApp.value) return "";
|
||||||
|
return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentReviewTags = computed<ReviewTags | null>(() => {
|
||||||
|
if (!currentDisplayApp.value) return null;
|
||||||
|
return buildReviewTags(currentDisplayApp.value, {
|
||||||
|
clientArch: clientArch.value,
|
||||||
|
distro: systemInfo.value.distro,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 截图探测请求的中断控制器:每次打开新详情前取消旧探测,避免结果覆盖当前应用
|
||||||
|
let screenshotAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
// 从仓库获取应用详细信息的辅助函数
|
||||||
|
const fetchAppFromStore = async (
|
||||||
|
pkgname: string,
|
||||||
|
category: string,
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
): Promise<App | null> => {
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
// 路径参数需编码,避免特殊字符破坏请求路径或造成路径穿越
|
||||||
|
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent(
|
||||||
|
category,
|
||||||
|
)}/${encodeURIComponent(pkgname)}/app.json`;
|
||||||
|
// 接入 rootAbortController.signal,确保组件卸载/详情关闭时请求可被取消,
|
||||||
|
// 避免竞态与内存泄漏(onUnmounted 会 abort 该 controller)
|
||||||
|
const response = await fetch(appJsonUrl, {
|
||||||
|
signal: (await import("./useHttp")).rootAbortController.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const appJson = await response.json();
|
||||||
|
// img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组
|
||||||
|
const parsedImgUrls = (() => {
|
||||||
|
if (typeof appJson.img_urls === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(appJson.img_urls) as string[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (appJson.img_urls as string[]) || [];
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
name: appJson.Name || "",
|
||||||
|
pkgname: appJson.Pkgname || pkgname,
|
||||||
|
version: appJson.Version || "",
|
||||||
|
filename: appJson.Filename || "",
|
||||||
|
torrent_address: appJson.Torrent_address || "",
|
||||||
|
author: appJson.Author || "",
|
||||||
|
contributor: appJson.Contributor || "",
|
||||||
|
website: appJson.Website || "",
|
||||||
|
update: appJson.Update || "",
|
||||||
|
size: appJson.Size || "",
|
||||||
|
more: appJson.More || "",
|
||||||
|
tags: appJson.Tags || "",
|
||||||
|
img_urls: parsedImgUrls,
|
||||||
|
icons: appJson.icons || "",
|
||||||
|
category,
|
||||||
|
origin,
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// 组件卸载/详情关闭触发 abort 时静默返回,避免刷 AbortError 日志
|
||||||
|
if ((e as Error)?.name === "AbortError") return null;
|
||||||
|
|
||||||
|
console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包)
|
||||||
|
const openDetailFromInstalled = (app: App) => {
|
||||||
|
openDetail({ ...app, _fromInstalled: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
|
||||||
|
interface OpenDetailInput extends Partial<App> {
|
||||||
|
pkgname?: string;
|
||||||
|
category?: string;
|
||||||
|
_fromHomeView?: boolean;
|
||||||
|
_fromInstalled?: boolean;
|
||||||
|
_fromDeepLink?: boolean;
|
||||||
|
origin?: "spark" | "apm";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取远程/本地均无匹配时的回退 App 构造(两处兜底分支共用,避免重复字段映射)
|
||||||
|
const createFallbackApp = (
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
pkgname: string,
|
||||||
|
category: string,
|
||||||
|
): App => ({
|
||||||
|
name: (raw.name as string) || "",
|
||||||
|
pkgname,
|
||||||
|
version: (raw.version as string) || "",
|
||||||
|
filename: (raw.filename as string) || "",
|
||||||
|
category,
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
more: (raw.more as string) || "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
origin: (raw.origin as "spark" | "apm") || "apm",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDetail = async (app: App | OpenDetailInput) => {
|
||||||
|
// 提取 pkgname 和 category(必须存在)
|
||||||
|
const pkgname = app?.pkgname;
|
||||||
|
if (!pkgname) {
|
||||||
|
console.warn("openDetail called without pkgname");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = app.category || "unknown";
|
||||||
|
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
||||||
|
// 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型
|
||||||
|
const marker = app as OpenDetailInput;
|
||||||
|
const fromHomeView = marker._fromHomeView === true;
|
||||||
|
const fromDeepLink = marker._fromDeepLink === true;
|
||||||
|
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
||||||
|
const fromInstalled = marker._fromInstalled === true;
|
||||||
|
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
|
||||||
|
|
||||||
|
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
|
||||||
|
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
|
||||||
|
const clickedOrigin = app.origin as "spark" | "apm" | undefined;
|
||||||
|
let fullApp = filteredAppsFind(pkgname, clickedOrigin);
|
||||||
|
// 如果没找到,回退到全局 apps 中查找(同样优先 origin)
|
||||||
|
if (!fullApp) {
|
||||||
|
fullApp = apps.value.find(
|
||||||
|
(a) =>
|
||||||
|
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果)
|
||||||
|
if (!fullApp) {
|
||||||
|
fullApp =
|
||||||
|
filteredAppsFind(pkgname, undefined) ||
|
||||||
|
apps.value.find((a) => a.pkgname === pkgname);
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalApp: App;
|
||||||
|
|
||||||
|
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
|
||||||
|
if (needFetchFromStore) {
|
||||||
|
// 从 Spark 和 APM 仓库获取完整的应用信息
|
||||||
|
// 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏
|
||||||
|
let [sparkApp, apmApp] = await Promise.all([
|
||||||
|
fromInstalled || storeFilter.value !== "apm"
|
||||||
|
? fetchAppFromStore(pkgname, category, "spark")
|
||||||
|
: Promise.resolve(null),
|
||||||
|
fromInstalled || storeFilter.value !== "spark"
|
||||||
|
? fetchAppFromStore(pkgname, category, "apm")
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 已安装页:若某来源仓库拉取失败(如分类不匹配),用本地已合并的应用补全,避免丢失另一类型
|
||||||
|
if (fromInstalled && fullApp && fullApp.isMerged) {
|
||||||
|
const merged = fullApp as App;
|
||||||
|
if (!sparkApp && merged.sparkApp) sparkApp = merged.sparkApp;
|
||||||
|
if (!apmApp && merged.apmApp) apmApp = merged.apmApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建合并的应用对象
|
||||||
|
if (sparkApp || apmApp) {
|
||||||
|
// 如果两个仓库都有这个应用,创建合并对象
|
||||||
|
if (sparkApp && apmApp) {
|
||||||
|
// 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示
|
||||||
|
const defaultOrigin =
|
||||||
|
clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp)
|
||||||
|
? clickedOrigin
|
||||||
|
: getHybridDefaultOrigin(sparkApp);
|
||||||
|
finalApp = {
|
||||||
|
...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示
|
||||||
|
isMerged: true,
|
||||||
|
sparkApp: sparkApp,
|
||||||
|
apmApp: apmApp,
|
||||||
|
viewingOrigin: defaultOrigin, // 默认查看来源版本
|
||||||
|
};
|
||||||
|
} else if (sparkApp) {
|
||||||
|
finalApp = sparkApp;
|
||||||
|
} else {
|
||||||
|
finalApp = apmApp!;
|
||||||
|
}
|
||||||
|
} else if (fullApp) {
|
||||||
|
finalApp = fullApp;
|
||||||
|
} else {
|
||||||
|
// 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象
|
||||||
|
finalApp = createFallbackApp(
|
||||||
|
app as Record<string, unknown>,
|
||||||
|
pkgname,
|
||||||
|
category,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 HomeView 来源,使用原来的逻辑
|
||||||
|
if (fullApp) {
|
||||||
|
finalApp = fullApp;
|
||||||
|
} else {
|
||||||
|
// 构造一个最小可用的 App 对象
|
||||||
|
finalApp = createFallbackApp(
|
||||||
|
app as Record<string, unknown>,
|
||||||
|
pkgname,
|
||||||
|
category,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Spark/APM 安装状态,已安装的版本优先展示
|
||||||
|
let installedStateKnown = false;
|
||||||
|
if (finalApp.isMerged && (finalApp.sparkApp || finalApp.apmApp)) {
|
||||||
|
const [sparkInstalled, apmInstalled] = await Promise.all([
|
||||||
|
finalApp.sparkApp
|
||||||
|
? (window.ipcRenderer.invoke("check-installed", {
|
||||||
|
pkgname: finalApp.sparkApp.pkgname,
|
||||||
|
origin: "spark",
|
||||||
|
}) as Promise<boolean>)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
finalApp.apmApp
|
||||||
|
? (window.ipcRenderer.invoke("check-installed", {
|
||||||
|
pkgname: finalApp.apmApp.pkgname,
|
||||||
|
origin: "apm",
|
||||||
|
}) as Promise<boolean>)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
installedStateKnown = true;
|
||||||
|
|
||||||
|
// 来源默认展示规则:
|
||||||
|
// 1) 已安装页打开:按安装类型打开。
|
||||||
|
// - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖)
|
||||||
|
// - 多个来源已安装 → 按「设置的优先标签/应用优先级」打开(策略)
|
||||||
|
// 2) 其他页面:
|
||||||
|
// - 仅一个来源已安装 → 默认展示已装版本(详情页按安装状态优先,保证可直接打开)
|
||||||
|
// - 都安装/都未安装 → 按「标签优先显示策略/应用优先级」打开
|
||||||
|
// 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景,
|
||||||
|
// 其余情况都不强制,交由详情页按安装状态/用户标签策略重算。
|
||||||
|
let forceOrigin: "spark" | "apm" | undefined = undefined;
|
||||||
|
if (fromInstalled) {
|
||||||
|
const installedOrigins = (app as Record<string, unknown>).origins as
|
||||||
|
| Array<"spark" | "apm">
|
||||||
|
| undefined;
|
||||||
|
if (installedOrigins && installedOrigins.length === 1) {
|
||||||
|
// 单来源安装:按安装类型强制
|
||||||
|
forceOrigin = installedOrigins[0];
|
||||||
|
} else if (installedOrigins && installedOrigins.length > 1) {
|
||||||
|
// 多来源安装:交由策略(按设置优先标签),不强制
|
||||||
|
forceOrigin = undefined;
|
||||||
|
} else {
|
||||||
|
// origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型
|
||||||
|
if (sparkInstalled && !apmInstalled) forceOrigin = "spark";
|
||||||
|
else if (apmInstalled && !sparkInstalled) forceOrigin = "apm";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
|
||||||
|
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
|
||||||
|
finalApp.forceViewingOrigin = false;
|
||||||
|
if (
|
||||||
|
forceOrigin &&
|
||||||
|
(forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)
|
||||||
|
) {
|
||||||
|
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
|
||||||
|
finalApp.viewingOrigin = forceOrigin;
|
||||||
|
finalApp.forceViewingOrigin = true;
|
||||||
|
} else if (sparkInstalled && !apmInstalled) {
|
||||||
|
// 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(详情页会按安装状态优先)
|
||||||
|
finalApp.viewingOrigin = "spark";
|
||||||
|
} else if (apmInstalled && !sparkInstalled) {
|
||||||
|
finalApp.viewingOrigin = "apm";
|
||||||
|
} else {
|
||||||
|
// 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示。
|
||||||
|
// 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用),
|
||||||
|
// 但不置 forceViewingOrigin,详情页会按用户策略重算。
|
||||||
|
finalApp.viewingOrigin = getHybridDefaultOrigin(
|
||||||
|
finalApp.sparkApp || finalApp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已拿到安装状态时直接写入全局状态,避免详情页先按“都未安装”闪一下再切到已装版本
|
||||||
|
currentAppSparkInstalled.value = sparkInstalled;
|
||||||
|
currentAppApmInstalled.value = apmInstalled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAppForScreenshots =
|
||||||
|
finalApp.viewingOrigin !== undefined && finalApp.isMerged
|
||||||
|
? ((finalApp.viewingOrigin === "spark"
|
||||||
|
? finalApp.sparkApp
|
||||||
|
: finalApp.apmApp) ?? finalApp)
|
||||||
|
: finalApp;
|
||||||
|
|
||||||
|
currentApp.value = finalApp;
|
||||||
|
currentScreenIndex.value = 0;
|
||||||
|
// 先清空截图,避免详情页渲染时残留上一个应用的图片(新图需异步探测后才填充)。
|
||||||
|
screenshots.value = [];
|
||||||
|
// 截图按固定路径拼接 screen_1~5.png 并探测真实存在性;异步进行,详情页先打开不阻塞。
|
||||||
|
// 每次打开取消上一轮探测,避免陈旧结果覆盖当前应用。
|
||||||
|
if (screenshotAbortController) screenshotAbortController.abort();
|
||||||
|
screenshotAbortController = new AbortController();
|
||||||
|
loadScreenshots(
|
||||||
|
displayAppForScreenshots,
|
||||||
|
screenshotAbortController.signal,
|
||||||
|
).catch(() => {
|
||||||
|
// 被取消或异常已在 loadScreenshots 内处理
|
||||||
|
});
|
||||||
|
showModal.value = true;
|
||||||
|
|
||||||
|
if (!installedStateKnown) {
|
||||||
|
currentAppSparkInstalled.value = false;
|
||||||
|
currentAppApmInstalled.value = false;
|
||||||
|
checkAppInstalled(finalApp);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isLoggedIn.value &&
|
||||||
|
favoriteFolders.value.length === 0 &&
|
||||||
|
!favoriteLoading.value
|
||||||
|
) {
|
||||||
|
void loadFavoriteMetadataForDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const modal = document.querySelector(
|
||||||
|
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
|
||||||
|
);
|
||||||
|
if (modal) (modal as HTMLElement).scrollTop = 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAppInstalled = (app: App) => {
|
||||||
|
if (app.isMerged) {
|
||||||
|
if (app.sparkApp) {
|
||||||
|
window.ipcRenderer
|
||||||
|
.invoke("check-installed", {
|
||||||
|
pkgname: app.sparkApp.pkgname,
|
||||||
|
origin: "spark",
|
||||||
|
})
|
||||||
|
.then((isInstalled: boolean) => {
|
||||||
|
currentAppSparkInstalled.value = isInstalled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (app.apmApp) {
|
||||||
|
window.ipcRenderer
|
||||||
|
.invoke("check-installed", {
|
||||||
|
pkgname: app.apmApp.pkgname,
|
||||||
|
origin: "apm",
|
||||||
|
})
|
||||||
|
.then((isInstalled: boolean) => {
|
||||||
|
currentAppApmInstalled.value = isInstalled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.ipcRenderer
|
||||||
|
.invoke("check-installed", { pkgname: app.pkgname, origin: app.origin })
|
||||||
|
.then((isInstalled: boolean) => {
|
||||||
|
if (app.origin === "spark") {
|
||||||
|
currentAppSparkInstalled.value = isInstalled;
|
||||||
|
} else {
|
||||||
|
currentAppApmInstalled.value = isInstalled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 截图来源直接采用后端 app.img_urls(权威真实列表,微信 3 张即返回 3 个 URL)。
|
||||||
|
// 无需前端再 HEAD 探测 screen_1~5.png —— 既消除每次打开详情的额外请求压力,
|
||||||
|
// 又保证"实际有几张就预览几张",边界由 ScreenPreview 的 length 自动收敛。
|
||||||
|
// 截图 URL 存在性缓存:按 URL 记忆探测结果(true=存在 / false=404 等无效),
|
||||||
|
// 避免同一应用反复打开详情时重复发起 HEAD 探测,降低服务器压力。
|
||||||
|
const screenshotExistenceCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
// 探测单张截图是否真实存在:HEAD 请求,超时 3s;命中缓存直接返回。
|
||||||
|
const checkScreenshotExists = async (
|
||||||
|
url: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const cached = screenshotExistenceCache.get(url);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
try {
|
||||||
|
await axiosInstance.head(url, { timeout: 3000, signal });
|
||||||
|
screenshotExistenceCache.set(url, true);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// 请求被主动取消(用户快速切换/关闭详情触发 abort)时,不应把该 URL 误记为
|
||||||
|
// "不存在"污染缓存——否则同进程内再打开该应用会少显示图片,直到重启才恢复。
|
||||||
|
// 取消错误直接上抛,由 loadScreenshots 的 Promise.all reject 被 openDetail 吞掉,
|
||||||
|
// 既不写缓存也不覆盖 screenshots,后续成功的轮次(新 controller)会正确填充。
|
||||||
|
if (signal?.aborted) throw err;
|
||||||
|
screenshotExistenceCache.set(url, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 截图候选 URL 一律按固定规则前端拼接,最多 5 张(screen_1~5.png),并对每张探测真实存在性,
|
||||||
|
// 仅保留真实可用的 URL,保证"实际有几张就预览几张"。
|
||||||
|
// 注意:以下拼接路径(APM_STORE_BASE_URL / finalArch / category / pkgname / screen_N.png)
|
||||||
|
// 直接对应服务器静态资源布局,属于权威路径,禁止修改或替换为 app.img_urls 等其它来源,
|
||||||
|
// 否则会导致截图全部 404(历史已踩坑:后端 img_urls 不准确)。
|
||||||
|
const loadScreenshots = async (app: App, signal?: AbortSignal) => {
|
||||||
|
if (!app.category || app.category === "unknown") {
|
||||||
|
screenshots.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
candidates.push(
|
||||||
|
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/screen_${i}.png`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先按缓存/同步填充已知结果,未知项异步探测;避免详情页打开后长时间空白
|
||||||
|
const results = await Promise.all(
|
||||||
|
candidates.map((url) => checkScreenshotExists(url, signal)),
|
||||||
|
);
|
||||||
|
screenshots.value = candidates.filter((_, index) => results[index]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
showModal.value = false;
|
||||||
|
currentApp.value = null;
|
||||||
|
// 关闭详情时取消正在进行的截图探测,避免结果写入已关闭的旧应用
|
||||||
|
if (screenshotAbortController) {
|
||||||
|
screenshotAbortController.abort();
|
||||||
|
screenshotAbortController = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openScreenPreview = (index: number) => {
|
||||||
|
currentScreenIndex.value = index;
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeScreenPreview = () => {
|
||||||
|
showPreview.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevScreen = () => {
|
||||||
|
if (currentScreenIndex.value > 0) {
|
||||||
|
currentScreenIndex.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextScreen = () => {
|
||||||
|
if (currentScreenIndex.value < screenshots.value.length - 1) {
|
||||||
|
currentScreenIndex.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectDetailOrigin = (origin: "spark" | "apm") => {
|
||||||
|
if (currentApp.value?.isMerged) {
|
||||||
|
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDetailRequestLogin = (message: string) => {
|
||||||
|
requireLoginRef(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
// requireLogin 定义在 useAccountSync,这里通过函数引用注入,避免循环依赖。
|
||||||
|
// 使用模块级可赋值引用,由 useAccountSync 在初始化时登记。
|
||||||
|
let requireLoginRef: (message: string) => boolean = () => true;
|
||||||
|
export const registerRequireLogin = (fn: (message: string) => boolean) => {
|
||||||
|
requireLoginRef = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// filteredApps 由 App.vue 汇总派生(依赖多个 composable 状态),此处仅做查找辅助,
|
||||||
|
// 实际引用由 App.vue 通过 setFilteredApps 注入,保证与改造前完全一致。
|
||||||
|
let filteredAppsSource: Ref<App[]> | null = null;
|
||||||
|
export const setFilteredApps = (appsRef: Ref<App[]>) => {
|
||||||
|
filteredAppsSource = appsRef;
|
||||||
|
};
|
||||||
|
const filteredAppsFind = (
|
||||||
|
pkgname: string,
|
||||||
|
origin: "spark" | "apm" | undefined,
|
||||||
|
): App | undefined =>
|
||||||
|
filteredAppsSource?.value.find(
|
||||||
|
(a) => a.pkgname === pkgname && (!origin || a.origin === origin),
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
clientArch,
|
||||||
|
currentDisplayApp,
|
||||||
|
currentReviewAppKey,
|
||||||
|
currentReviewTags,
|
||||||
|
fetchAppFromStore,
|
||||||
|
openDetail,
|
||||||
|
openDetailFromInstalled,
|
||||||
|
createFallbackApp,
|
||||||
|
checkAppInstalled,
|
||||||
|
loadScreenshots,
|
||||||
|
closeDetail,
|
||||||
|
openScreenPreview,
|
||||||
|
closeScreenPreview,
|
||||||
|
prevScreen,
|
||||||
|
nextScreen,
|
||||||
|
selectDetailOrigin,
|
||||||
|
handleDetailRequestLogin,
|
||||||
|
};
|
||||||
|
export type { OpenDetailInput };
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* useAppState —— 跨 composable 共享的全局原始响应式状态(单例)。
|
||||||
|
*
|
||||||
|
* 设计说明(避免循环依赖与重复实例化):
|
||||||
|
* App.vue 原文件中的逻辑被拆分到多个 composable(useCatalog / useRanking /
|
||||||
|
* useAppDetail / useInstalledApps / useFavorites / useAccountSync / useDownloads)。
|
||||||
|
* 这些 composable 之间存在大量共享状态(apps / currentApp / installedApps /
|
||||||
|
* favorite* / 各类弹窗开关等)。若各自持有会出现多实例与状态不同步,并引发循环依赖。
|
||||||
|
*
|
||||||
|
* 故把"贯穿整个应用生命周期的全局原始状态"集中在本模块以单例形式导出,
|
||||||
|
* 所有 composable 与 App.vue 均从本模块取用同一份 ref 实例。
|
||||||
|
* 这与现有 global/authState.ts、global/storeConfig.ts 的单例风格一致。
|
||||||
|
*
|
||||||
|
* 本模块**只持有状态与极少量纯派生 computed**,不含任何业务逻辑/副作用,
|
||||||
|
* 确保拆分前后运行时行为完全一致(7 维度审计:功能/状态一致)。
|
||||||
|
*
|
||||||
|
* 注意:currentApp / currentAppSparkInstalled / currentAppApmInstalled 同时在
|
||||||
|
* storeConfig 中导出(供其它模块直接引用)。本模块从 storeConfig 再导出以保持单一实例。
|
||||||
|
*/
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
AppJson,
|
||||||
|
CategoryInfo,
|
||||||
|
SidebarEntry,
|
||||||
|
HomeLink,
|
||||||
|
FavoriteFolder,
|
||||||
|
FavoriteItem,
|
||||||
|
InstalledAppInfo,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
|
SystemInfo,
|
||||||
|
DownloadedAppRecord,
|
||||||
|
SyncedAppListItem,
|
||||||
|
AppReview,
|
||||||
|
ReviewUserProfile,
|
||||||
|
DownloadItem,
|
||||||
|
} from "../global/typedefinition";
|
||||||
|
|
||||||
|
// ===== 目录数据 =====
|
||||||
|
export const apps: Ref<App[]> = ref([]);
|
||||||
|
export const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||||
|
export const tabCategories: Ref<Record<string, Record<string, CategoryInfo>>> =
|
||||||
|
ref({});
|
||||||
|
export const tabApps: Ref<Record<string, App[]>> = ref({});
|
||||||
|
export const loadingTabs = ref<Set<string>>(new Set());
|
||||||
|
export const homeListUrls = ref<
|
||||||
|
Record<string, { spark?: string; apm?: string }>
|
||||||
|
>({});
|
||||||
|
export const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
||||||
|
export const sparkAvailable = ref(false);
|
||||||
|
export const apmAvailable = ref(false);
|
||||||
|
export const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||||
|
export const initialCatalogLoaded = ref(false);
|
||||||
|
export const loading = ref(true);
|
||||||
|
|
||||||
|
// ===== 首页 / 排行 =====
|
||||||
|
export const homeLinks = ref<HomeLink[]>([]);
|
||||||
|
export const homeLoading = ref(false);
|
||||||
|
export const homeError = ref("");
|
||||||
|
export const apmRanking = ref<App[]>([]);
|
||||||
|
export const sparkRanking = ref<App[]>([]);
|
||||||
|
export const rankingLoading = ref(false);
|
||||||
|
|
||||||
|
// ===== 导航 =====
|
||||||
|
export type MainView = "default" | "favorites";
|
||||||
|
export const currentView = ref<MainView>("default");
|
||||||
|
export const activeTab = ref("home");
|
||||||
|
export const selectedCategory = ref("all");
|
||||||
|
export const searchQuery = ref("");
|
||||||
|
export const isSidebarOpen = ref(false);
|
||||||
|
|
||||||
|
// ===== 详情弹窗 =====
|
||||||
|
export {
|
||||||
|
currentApp,
|
||||||
|
currentAppSparkInstalled,
|
||||||
|
currentAppApmInstalled,
|
||||||
|
} from "../global/storeConfig";
|
||||||
|
export const showModal = ref(false);
|
||||||
|
export const showPreview = ref(false);
|
||||||
|
export const currentScreenIndex = ref(0);
|
||||||
|
export const screenshots = ref<string[]>([]);
|
||||||
|
|
||||||
|
// ===== 已安装应用 =====
|
||||||
|
export const showInstalledModal = ref(false);
|
||||||
|
export const installedApps = ref<App[]>([]);
|
||||||
|
export const installedLoading = ref(false);
|
||||||
|
export const installedError = ref("");
|
||||||
|
export const installedWarning = ref("");
|
||||||
|
export const installedRefreshGeneration = ref(0);
|
||||||
|
export const installedSyncPromptShown = ref(false);
|
||||||
|
|
||||||
|
// ===== 收藏夹 =====
|
||||||
|
export const favoriteFolders = ref<FavoriteFolder[]>([]);
|
||||||
|
export const activeFavoriteFolderId = ref<number | null>(null);
|
||||||
|
export const favoriteItems = ref<FavoriteItem[]>([]);
|
||||||
|
export const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
|
||||||
|
export const showFavoriteSelector = ref(false);
|
||||||
|
export const favoriteTargetApp = ref<App | null>(null);
|
||||||
|
export const favoriteSelectorDraftFolderIds = ref<Array<
|
||||||
|
number | "default"
|
||||||
|
> | null>(null);
|
||||||
|
export const favoriteLoading = ref(false);
|
||||||
|
export const favoriteError = ref("");
|
||||||
|
export const favoriteRequestGeneration = ref(0);
|
||||||
|
|
||||||
|
// ===== 账号 / 下载历史 / 云端同步 / 恢复 =====
|
||||||
|
export const showUserManagementModal = ref(false);
|
||||||
|
export const downloadedApps = ref<DownloadedAppRecord[]>([]);
|
||||||
|
export const downloadedLoading = ref(false);
|
||||||
|
export const downloadedError = ref("");
|
||||||
|
export const downloadedRequestGeneration = ref(0);
|
||||||
|
export const syncLoading = ref(false);
|
||||||
|
export const syncStatusMessage = ref("");
|
||||||
|
export const syncRequestGeneration = ref(0);
|
||||||
|
export const syncCandidateApps = ref<App[]>([]);
|
||||||
|
export const restoreLoading = ref(false);
|
||||||
|
export const restoreError = ref("");
|
||||||
|
export const showRestoreModal = ref(false);
|
||||||
|
export const restoreItems = ref<SyncedAppListItem[]>([]);
|
||||||
|
export const restoreRequestGeneration = ref(0);
|
||||||
|
export const showLoginModal = ref(false);
|
||||||
|
export const loginLoading = ref(false);
|
||||||
|
export const loginError = ref("");
|
||||||
|
export const showLoginPrompt = ref(false);
|
||||||
|
export const loginPromptMessage = ref("请登录星火账号后继续操作。");
|
||||||
|
export const showLoginPromptMessage = loginPromptMessage;
|
||||||
|
export const showReviewUserProfileModal = ref(false);
|
||||||
|
export const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
|
||||||
|
export const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||||
|
|
||||||
|
// ===== 下载队列 =====
|
||||||
|
export { downloads } from "../global/downloadStatus";
|
||||||
|
export const showDownloadDetailModal = ref(false);
|
||||||
|
export const currentDownload: Ref<DownloadItem | null> = ref(null);
|
||||||
|
|
||||||
|
// ===== 其它弹窗 / 卸载 / 更新中心编排 =====
|
||||||
|
export const showUninstallModal = ref(false);
|
||||||
|
export const uninstallTargetApp: Ref<App | null> = ref(null);
|
||||||
|
export const showAboutModal = ref(false);
|
||||||
|
export const showSettingsModal = ref(false);
|
||||||
|
export const showApmInstallDialog = ref(false);
|
||||||
|
|
||||||
|
// 可用来源 computed(供 storeFilter 相关判断复用)
|
||||||
|
export const availableSources = computed(() => ({
|
||||||
|
spark: sparkAvailable.value,
|
||||||
|
apm: apmAvailable.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 运算符别名(仅导出类型引用,避免重复定义)
|
||||||
|
export type {
|
||||||
|
App,
|
||||||
|
AppJson,
|
||||||
|
CategoryInfo,
|
||||||
|
SidebarEntry,
|
||||||
|
HomeLink,
|
||||||
|
FavoriteFolder,
|
||||||
|
FavoriteItem,
|
||||||
|
InstalledAppInfo,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
|
SystemInfo,
|
||||||
|
DownloadedAppRecord,
|
||||||
|
SyncedAppListItem,
|
||||||
|
AppReview,
|
||||||
|
ReviewUserProfile,
|
||||||
|
DownloadItem,
|
||||||
|
};
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
/**
|
||||||
|
* useCatalog —— 应用目录 / 分类 / 侧边栏入口的数据加载。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(loadCategories / loadSidebarConfig / loadTabCategories /
|
||||||
|
* loadTabApps / loadApps / normalizeAppJson / loadHomeListEntries / loadHomeListApps /
|
||||||
|
* preloadHomeListApps / preloadSidebarTabApps),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态(apps / categories / tabCategories / tabApps / loadingTabs /
|
||||||
|
* homeListUrls / sidebarEntries / initialCatalogLoaded)来自 useAppState 单例。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
AppJson,
|
||||||
|
CategoryInfo,
|
||||||
|
SidebarEntry,
|
||||||
|
} from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
categories,
|
||||||
|
tabCategories,
|
||||||
|
tabApps,
|
||||||
|
loadingTabs,
|
||||||
|
homeListUrls,
|
||||||
|
sidebarEntries,
|
||||||
|
initialCatalogLoaded,
|
||||||
|
storeFilter,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { axiosInstance, fetchWithRetry, rootAbortController } from "./useHttp";
|
||||||
|
import { loadPriorityConfig, APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
|
||||||
|
export const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||||
|
|
||||||
|
const categoryData: Record<string, { zh: string; origins: string[] }> = {};
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const path = `/${finalArch}/categories.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(path);
|
||||||
|
const data = response.data;
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (categoryData[key]) {
|
||||||
|
if (!categoryData[key].origins.includes(mode)) {
|
||||||
|
categoryData[key].origins.push(mode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
categoryData[key] = {
|
||||||
|
zh: data[key].zh || data[key],
|
||||||
|
origins: [mode],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 读取 categories.json 失败(如某来源无此文件),静默忽略该来源
|
||||||
|
|
||||||
|
console.warn(`读取 ${mode} categories.json 失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categories.value = categoryData;
|
||||||
|
|
||||||
|
// 加载优先级配置(从 spark 目录)
|
||||||
|
await loadPriorityConfig(arch);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`读取 categories 失败:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 远程 sidebar-config.json 入口的类型守卫:在写入渲染层前拦截异常字段。
|
||||||
|
// 字段类型/长度限制防止畸形数据(如超长 name 撑破布局、非法 type 触发未知分支)。
|
||||||
|
// type 白名单必须与 global/typedefinition.ts 的 SidebarEntry.type 保持一致。
|
||||||
|
const VALID_SIDEBAR_TYPES = ["category", "search", "link", "homeList"] as const;
|
||||||
|
const isValidSidebarEntry = (e: unknown): e is SidebarEntry => {
|
||||||
|
if (typeof e !== "object" || e === null) return false;
|
||||||
|
const entry = e as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof entry.id !== "string" ||
|
||||||
|
entry.id.length === 0 ||
|
||||||
|
entry.id.length > 64
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
typeof entry.name !== "string" ||
|
||||||
|
entry.name.length === 0 ||
|
||||||
|
entry.name.length > 128
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (entry.icon !== undefined && typeof entry.icon !== "string") return false;
|
||||||
|
if (
|
||||||
|
entry.type !== undefined &&
|
||||||
|
!VALID_SIDEBAR_TYPES.includes(entry.type as (typeof VALID_SIDEBAR_TYPES)[number])
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (entry.value !== undefined && typeof entry.value !== "string") return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSidebarConfig = async () => {
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||||
|
|
||||||
|
const entryMap = new Map<string, SidebarEntry>();
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const path = `/${finalArch}/sidebar-config.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(path);
|
||||||
|
const data = response.data;
|
||||||
|
const entries = Array.isArray(data) ? data : data.entries || [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 严格校验远程配置,避免畸形/恶意字段进入渲染层(Vue 模板自动转义已兜底 XSS)
|
||||||
|
if (!isValidSidebarEntry(entry)) continue;
|
||||||
|
const existing = entryMap.get(entry.id);
|
||||||
|
if (existing) {
|
||||||
|
// 多仓库共有入口,合并来源
|
||||||
|
if (existing.origins && !existing.origins.includes(mode)) {
|
||||||
|
existing.origins.push(mode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entryMap.set(entry.id, {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
icon: entry.icon ?? "",
|
||||||
|
type: entry.type ?? "category",
|
||||||
|
value: entry.value ?? entry.id,
|
||||||
|
origins: [mode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`读取 ${mode} sidebar-config.json 失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarEntries.value = Array.from(entryMap.values());
|
||||||
|
if (sidebarEntries.value.length > 0) {
|
||||||
|
console.info(`已加载 ${sidebarEntries.value.length} 个侧边栏配置入口`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`读取 sidebar-config 失败:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAppJson = (
|
||||||
|
appJson: AppJson,
|
||||||
|
category: string,
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
): App => ({
|
||||||
|
name: appJson.Name,
|
||||||
|
pkgname: appJson.Pkgname,
|
||||||
|
version: appJson.Version,
|
||||||
|
filename: appJson.Filename,
|
||||||
|
torrent_address: appJson.Torrent_address,
|
||||||
|
author: appJson.Author,
|
||||||
|
contributor: appJson.Contributor,
|
||||||
|
website: appJson.Website,
|
||||||
|
update: appJson.Update,
|
||||||
|
size: appJson.Size,
|
||||||
|
more: appJson.More,
|
||||||
|
tags: appJson.Tags,
|
||||||
|
img_urls: (() => {
|
||||||
|
if (typeof appJson.img_urls === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(appJson.img_urls) as string[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (appJson.img_urls as string[]) || [];
|
||||||
|
})(),
|
||||||
|
icons: appJson.icons,
|
||||||
|
category: category,
|
||||||
|
origin: origin,
|
||||||
|
currentStatus: "not-installed" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadTabCategories = async () => {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||||
|
const newTabCategories: Record<string, Record<string, CategoryInfo>> = {};
|
||||||
|
|
||||||
|
// 并行加载所有侧边栏入口的子分类,减少串行等待
|
||||||
|
const categoryEntries = sidebarEntries.value.filter(
|
||||||
|
(e) => e.type === "category",
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
categoryEntries.map(async (entry) => {
|
||||||
|
const folderName = entry.value || entry.id;
|
||||||
|
const catData: Record<string, { zh: string; origins: string[] }> = {};
|
||||||
|
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
|
||||||
|
const entryModes = entry.origins?.length
|
||||||
|
? entry.origins.filter((o) => modes.includes(o))
|
||||||
|
: modes;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
entryModes.map(async (mode) => {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const path = `/${finalArch}/${folderName}/categories.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(path);
|
||||||
|
const data = response.data;
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (catData[key]) {
|
||||||
|
if (!catData[key].origins.includes(mode)) {
|
||||||
|
catData[key].origins.push(mode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
catData[key] = {
|
||||||
|
zh: data[key].zh || data[key],
|
||||||
|
origins: [mode],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 该入口没有子分类,静默忽略
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(catData).length > 0) {
|
||||||
|
newTabCategories[entry.id] = catData;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`入口 "${entry.id}" 加载到 ${Object.keys(catData).length} 个子分类`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tabCategories.value = newTabCategories;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTabApps = async (entryId: string) => {
|
||||||
|
if (tabApps.value[entryId]) return;
|
||||||
|
// 防止重复加载:如果正在加载中则跳过
|
||||||
|
if (loadingTabs.value.has(entryId)) return;
|
||||||
|
|
||||||
|
const entry = sidebarEntries.value.find((e) => e.id === entryId);
|
||||||
|
if (!entry || entry.type !== "category") return;
|
||||||
|
|
||||||
|
// 标记为加载中
|
||||||
|
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
|
||||||
|
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const allModes: Array<"spark" | "apm"> = storeFilterMode();
|
||||||
|
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
|
||||||
|
const modes = entry.origins?.length
|
||||||
|
? entry.origins.filter((o) => allModes.includes(o))
|
||||||
|
: allModes;
|
||||||
|
const folderName = entry.value || entry.id;
|
||||||
|
const subCats = tabCategories.value[entryId];
|
||||||
|
|
||||||
|
// 收集所有需要发起的请求任务(mode × 子分类),然后全并发加载
|
||||||
|
const tasks: Promise<App[]>[] = [];
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
|
||||||
|
if (subCats && Object.keys(subCats).length > 0) {
|
||||||
|
for (const [subCat, catInfo] of Object.entries(subCats)) {
|
||||||
|
if (
|
||||||
|
catInfo.origins &&
|
||||||
|
catInfo.origins.length > 0 &&
|
||||||
|
!catInfo.origins.includes(mode)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
|
||||||
|
|
||||||
|
console.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
|
||||||
|
tasks.push(
|
||||||
|
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||||
|
.then((categoryApps) =>
|
||||||
|
(categoryApps || []).map((aj) =>
|
||||||
|
normalizeAppJson(aj, subCat, mode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
console.warn(
|
||||||
|
`加载入口子分类 ${entryId}/${subCat} (${mode}) 失败:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return [] as App[];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const path = `/${finalArch}/${folderName}/applist.json`;
|
||||||
|
|
||||||
|
console.info(`加载入口目录: ${entryId} (来源: ${mode})`);
|
||||||
|
tasks.push(
|
||||||
|
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||||
|
.then((categoryApps) =>
|
||||||
|
(categoryApps || []).map((aj) =>
|
||||||
|
normalizeAppJson(aj, folderName, mode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
console.warn(`加载入口目录 ${entryId} (${mode}) 失败:`, e);
|
||||||
|
return [] as App[];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
const loadedApps = results.flat();
|
||||||
|
|
||||||
|
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
|
||||||
|
|
||||||
|
// 移除加载标记
|
||||||
|
const next = new Set(loadingTabs.value);
|
||||||
|
next.delete(entryId);
|
||||||
|
loadingTabs.value = next;
|
||||||
|
|
||||||
|
console.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadApps = async (onFirstBatch?: () => void) => {
|
||||||
|
try {
|
||||||
|
console.info("开始加载应用数据(全并发带重试)...");
|
||||||
|
|
||||||
|
const categoriesList = Object.keys(categories.value || {});
|
||||||
|
let firstBatchCallDone = false;
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
|
||||||
|
// 并发加载所有分类,每个分类自带重试机制
|
||||||
|
await Promise.all(
|
||||||
|
categoriesList.map(async (category) => {
|
||||||
|
const catInfo = categories.value[category];
|
||||||
|
if (!catInfo) return;
|
||||||
|
const origins = (catInfo.origins ||
|
||||||
|
(catInfo.origin ? [catInfo.origin] : [])) as string[];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
origins.map(async (mode) => {
|
||||||
|
try {
|
||||||
|
const finalArch =
|
||||||
|
mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
|
||||||
|
const path = `/${finalArch}/${category}/applist.json`;
|
||||||
|
|
||||||
|
console.info(`加载分类: ${category} (来源: ${mode})`);
|
||||||
|
const categoryApps = await fetchWithRetry<AppJson[]>(
|
||||||
|
path,
|
||||||
|
rootAbortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedApps = (categoryApps || []).map((appJson) =>
|
||||||
|
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 增量式更新,让用户尽快看到部分数据
|
||||||
|
// 用赋值替代 push(...),避免对响应式数组逐元素触发 re-render
|
||||||
|
apps.value = [...apps.value, ...normalizedApps];
|
||||||
|
|
||||||
|
// 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑)
|
||||||
|
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||||
|
firstBatchCallDone = true;
|
||||||
|
onFirstBatch();
|
||||||
|
// 标记初始目录加载完成,使 apps.length watcher 开始在目录变更时刷新已安装列表
|
||||||
|
initialCatalogLoaded.value = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`加载分类 ${category} 来源 ${mode} 最终失败:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确保即使全部失败也结束 loading
|
||||||
|
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||||
|
onFirstBatch();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载应用数据流程异常:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载首页推荐列表为侧边栏入口(按名称合并 spark/apm,置于分类入口上方)
|
||||||
|
export const loadHomeListEntries = async () => {
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||||
|
|
||||||
|
// 按列表名称合并各来源的 jsonUrl
|
||||||
|
const byName = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; urls: { spark?: string; apm?: string } }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// 并发拉取各来源的 homelist.json(spark/apm),缩短首页入口加载耗时;
|
||||||
|
// 各来源独立解析后合并到局部 byName,不逐个触发响应式更新。
|
||||||
|
await Promise.all(
|
||||||
|
modes.map(async (mode) => {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/homelist.json`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const lists = await res.json();
|
||||||
|
lists.forEach(
|
||||||
|
(item: { name?: string; type?: string; jsonUrl?: string }) => {
|
||||||
|
if (item.type === "appList" && item.jsonUrl) {
|
||||||
|
const name = item.name || "推荐";
|
||||||
|
const existing = byName.get(name);
|
||||||
|
if (existing) {
|
||||||
|
existing.urls[mode] = item.jsonUrl;
|
||||||
|
} else {
|
||||||
|
byName.set(name, {
|
||||||
|
name,
|
||||||
|
urls: { [mode]: item.jsonUrl } as {
|
||||||
|
spark?: string;
|
||||||
|
apm?: string;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to load ${mode} homelist.json`, e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries: SidebarEntry[] = [];
|
||||||
|
const urlsMap: Record<string, { spark?: string; apm?: string }> = {};
|
||||||
|
|
||||||
|
byName.forEach((info, name) => {
|
||||||
|
const id = `home-list-${name}`;
|
||||||
|
entries.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
icon: "fas fa-star",
|
||||||
|
type: "homeList",
|
||||||
|
});
|
||||||
|
urlsMap[id] = info.urls;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
// 首页推荐入口置于分类入口上方
|
||||||
|
sidebarEntries.value = [...entries, ...sidebarEntries.value];
|
||||||
|
homeListUrls.value = { ...homeListUrls.value, ...urlsMap };
|
||||||
|
|
||||||
|
console.info(`已加载 ${entries.length} 个首页推荐列表入口`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`加载首页推荐列表入口失败: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载首页推荐列表的应用数据(合并展示 spark+apm,按 pkgname 去重,spark 优先)
|
||||||
|
export const loadHomeListApps = async (entryId: string) => {
|
||||||
|
if (tabApps.value[entryId]) return;
|
||||||
|
// 防止重复加载:如果正在加载中则跳过
|
||||||
|
if (loadingTabs.value.has(entryId)) return;
|
||||||
|
|
||||||
|
const urls = homeListUrls.value[entryId];
|
||||||
|
if (!urls) return;
|
||||||
|
|
||||||
|
// 标记为加载中
|
||||||
|
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
|
||||||
|
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const loadedApps: App[] = [];
|
||||||
|
const seenPkgnames = new Set<string>();
|
||||||
|
|
||||||
|
const parseAppList = (
|
||||||
|
rawApps: Record<string, string>[],
|
||||||
|
mode: "spark" | "apm",
|
||||||
|
): App[] =>
|
||||||
|
rawApps.map((a) => {
|
||||||
|
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
|
||||||
|
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
|
||||||
|
const urlCategory =
|
||||||
|
(urls[mode] || "").split("/").filter(Boolean)[0] || "unknown";
|
||||||
|
const category = a.Category || a.category || urlCategory;
|
||||||
|
|
||||||
|
let img_urls: string[] = [];
|
||||||
|
const rawImgUrls = a.img_urls;
|
||||||
|
if (typeof rawImgUrls === "string") {
|
||||||
|
try {
|
||||||
|
img_urls = JSON.parse(rawImgUrls);
|
||||||
|
} catch {
|
||||||
|
img_urls = [];
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(rawImgUrls)) {
|
||||||
|
img_urls = rawImgUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: a.Name || a.name || a.Pkgname || a.pkgname || "",
|
||||||
|
pkgname: a.Pkgname || a.pkgname || "",
|
||||||
|
version: a.Version || "",
|
||||||
|
filename: a.Filename || a.filename || "",
|
||||||
|
torrent_address: a.Torrent_address || "",
|
||||||
|
author: a.Author || "",
|
||||||
|
contributor: a.Contributor || "",
|
||||||
|
website: a.Website || "",
|
||||||
|
update: a.Update || "",
|
||||||
|
size: a.Size || "",
|
||||||
|
more: a.More || a.more || "",
|
||||||
|
tags: a.Tags || "",
|
||||||
|
img_urls,
|
||||||
|
icons: a.icons || "",
|
||||||
|
category,
|
||||||
|
origin: mode,
|
||||||
|
currentStatus: "not-installed" as const,
|
||||||
|
} as App;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按优先级顺序加载:spark 优先,apm 中与 spark 同名的跳过
|
||||||
|
const modes: Array<"spark" | "apm"> = ["spark", "apm"];
|
||||||
|
for (const mode of modes) {
|
||||||
|
const jsonUrl = urls[mode];
|
||||||
|
if (!jsonUrl) continue;
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = `/${finalArch}${jsonUrl}`;
|
||||||
|
const rawApps =
|
||||||
|
(await fetchWithRetry<Record<string, string>[]>(
|
||||||
|
path,
|
||||||
|
rootAbortController.signal,
|
||||||
|
)) || [];
|
||||||
|
const apps = parseAppList(rawApps, mode);
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
|
||||||
|
seenPkgnames.add(app.pkgname);
|
||||||
|
loadedApps.push(app);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`加载首页列表 ${entryId} (${mode}) 失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
|
||||||
|
|
||||||
|
// 移除加载标记
|
||||||
|
const next = new Set(loadingTabs.value);
|
||||||
|
next.delete(entryId);
|
||||||
|
loadingTabs.value = next;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用)
|
||||||
|
export const preloadHomeListApps = (): Promise<void> => {
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
for (const entry of sidebarEntries.value) {
|
||||||
|
if (entry.type === "homeList") {
|
||||||
|
tasks.push(
|
||||||
|
loadHomeListApps(entry.id).catch((e: unknown) =>
|
||||||
|
console.warn(`预加载首页列表 ${entry.id} 失败:`, e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(tasks).then(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后)
|
||||||
|
export const preloadSidebarTabApps = (): Promise<void> => {
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
for (const entry of sidebarEntries.value) {
|
||||||
|
if (entry.type === "category") {
|
||||||
|
tasks.push(
|
||||||
|
loadTabApps(entry.id).catch((e: unknown) =>
|
||||||
|
console.warn(`预加载入口 ${entry.id} 失败:`, e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(tasks).then(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 来源模式解析:both -> [spark, apm],否则单一来源
|
||||||
|
function storeFilterMode(): Array<"spark" | "apm"> {
|
||||||
|
return storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* useDownloads —— 下载队列控制与安装触发。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(pendingDownloadRecords / onDetailInstall /
|
||||||
|
* handleInstallCompleteForDownloadRecord / onDetailRemove / onDetailFavorite /
|
||||||
|
* pauseDownload / resumeDownload / cancelDownload / retryDownload /
|
||||||
|
* clearCompletedDownloads / showDownloadDetailModalFunc / closeDownloadDetail /
|
||||||
|
* openDownloadedApp / installCompleteCallback / watchDownloadsChange 注册),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态来自 useAppState;handleInstall / handleRetry 来自 modules/processInstall;
|
||||||
|
* openFavoriteSelector 来自 useFavorites。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
DownloadItem,
|
||||||
|
DownloadResult,
|
||||||
|
DownloadedAppRecord,
|
||||||
|
} from "../global/typedefinition";
|
||||||
|
import type { IpcRendererEvent } from "electron";
|
||||||
|
import {
|
||||||
|
downloads,
|
||||||
|
currentDownload,
|
||||||
|
showDownloadDetailModal,
|
||||||
|
currentApp,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { handleInstall, handleRetry } from "../modules/processInstall";
|
||||||
|
import { watchDownloadsChange } from "../global/downloadStatus";
|
||||||
|
import { buildFavoriteAppKey, parsePackageArch } from "../modules/appIdentity";
|
||||||
|
import { openFavoriteSelector } from "./useFavorites";
|
||||||
|
import { recordDownloadedApp } from "../modules/backendApi";
|
||||||
|
import { isLoggedIn, currentUser } from "../global/authState";
|
||||||
|
|
||||||
|
interface PendingDownloadRecord {
|
||||||
|
userId: number;
|
||||||
|
appKey: string;
|
||||||
|
pkgname: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
selectedOrigin: "spark" | "apm";
|
||||||
|
version: string;
|
||||||
|
packageArch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingDownloadRecords = new Map<number, PendingDownloadRecord>();
|
||||||
|
|
||||||
|
const onDetailInstall = async (app: App) => {
|
||||||
|
const initiatingUserId = currentUser.value?.id ?? null;
|
||||||
|
const download = await handleInstall(app);
|
||||||
|
if (
|
||||||
|
!download ||
|
||||||
|
initiatingUserId === null ||
|
||||||
|
!isLoggedIn.value ||
|
||||||
|
currentUser.value?.id !== initiatingUserId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDownloadRecords.set(download.id, {
|
||||||
|
userId: initiatingUserId,
|
||||||
|
appKey: buildFavoriteAppKey(app),
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
name: app.name,
|
||||||
|
category: app.category,
|
||||||
|
selectedOrigin: app.origin,
|
||||||
|
version: app.version,
|
||||||
|
packageArch: app.arch || parsePackageArch(app.filename),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallCompleteForDownloadRecord = async (
|
||||||
|
_event: IpcRendererEvent,
|
||||||
|
result: DownloadResult,
|
||||||
|
) => {
|
||||||
|
const pendingRecord = pendingDownloadRecords.get(result.id);
|
||||||
|
if (!pendingRecord) return;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
pendingDownloadRecords.delete(result.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!result.success ||
|
||||||
|
!isLoggedIn.value ||
|
||||||
|
currentUser.value?.id !== pendingRecord.userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadRecord: Omit<DownloadedAppRecord, "id" | "downloadedAt"> = {
|
||||||
|
appKey: pendingRecord.appKey,
|
||||||
|
pkgname: pendingRecord.pkgname,
|
||||||
|
name: pendingRecord.name,
|
||||||
|
category: pendingRecord.category,
|
||||||
|
selectedOrigin: pendingRecord.selectedOrigin,
|
||||||
|
version: pendingRecord.version,
|
||||||
|
packageArch: pendingRecord.packageArch,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recordDownloadedApp(downloadRecord);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.warn({ err: error }, "记录下载应用失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDetailRemove = (app: App) => {
|
||||||
|
requestUninstallRef(app);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDetailFavorite = async (app: App) => {
|
||||||
|
await openFavoriteSelector(app);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: 目前 APM 商店不能暂停下载
|
||||||
|
const pauseDownload = (id: DownloadItem) => {
|
||||||
|
const download = downloads.value.find((d) => d.id === id.id);
|
||||||
|
if (download && download.status === "installing") {
|
||||||
|
// 'installing' matches type definition, previously 'downloading'
|
||||||
|
download.status = "paused";
|
||||||
|
download.logs.push({
|
||||||
|
time: Date.now(),
|
||||||
|
message: "下载已暂停",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: 同理,暂未实现
|
||||||
|
const resumeDownload = (id: DownloadItem) => {
|
||||||
|
const download = downloads.value.find((d) => d.id === id.id);
|
||||||
|
if (download && download.status === "paused") {
|
||||||
|
download.status = "installing"; // previously 'downloading'
|
||||||
|
download.logs.push({
|
||||||
|
time: Date.now(),
|
||||||
|
message: "继续下载...",
|
||||||
|
});
|
||||||
|
// simulateDownload(download); // removed or undefined?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDownload = (id: DownloadItem) => {
|
||||||
|
const index = downloads.value.findIndex((d) => d.id === id.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const download = downloads.value[index];
|
||||||
|
// 发送到主进程取消
|
||||||
|
window.ipcRenderer.send("cancel-install", download.id);
|
||||||
|
|
||||||
|
download.status = "failed";
|
||||||
|
download.logs.push({
|
||||||
|
time: Date.now(),
|
||||||
|
message: "下载已取消",
|
||||||
|
});
|
||||||
|
// 保留在队列中以便用户可以重试或查看日志
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryDownload = (id: DownloadItem) => {
|
||||||
|
const download = downloads.value.find((d) => d.id === id.id);
|
||||||
|
if (download && download.status === "failed") {
|
||||||
|
download.status = "queued";
|
||||||
|
download.progress = 0;
|
||||||
|
download.downloadedSize = 0;
|
||||||
|
download.logs.push({
|
||||||
|
time: Date.now(),
|
||||||
|
message: "重新开始下载...",
|
||||||
|
});
|
||||||
|
handleRetry(download);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCompletedDownloads = () => {
|
||||||
|
downloads.value = downloads.value.filter((d) => d.status !== "completed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDownloadDetailModalFunc = (download: DownloadItem) => {
|
||||||
|
currentDownload.value = download;
|
||||||
|
showDownloadDetailModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDownloadDetail = () => {
|
||||||
|
showDownloadDetailModal.value = false;
|
||||||
|
currentDownload.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
|
||||||
|
// const encodedPkg = encodeURIComponent(download.pkgname);
|
||||||
|
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
|
||||||
|
// fallbackText: `打开应用: ${download.pkgname}`
|
||||||
|
// });
|
||||||
|
window.ipcRenderer
|
||||||
|
.invoke("launch-app", { pkgname, origin })
|
||||||
|
.catch((err) => console.error("启动应用失败 (launch-app):", err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const installCompleteCallback = (pkgname?: string) => {
|
||||||
|
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
|
||||||
|
checkAppInstalledRef(currentApp.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watchDownloadsChange(installCompleteCallback);
|
||||||
|
|
||||||
|
// checkAppInstalled 由 useAppDetail 持有,通过注入引用获取,避免循环依赖
|
||||||
|
let checkAppInstalledRef: (app: App) => void = () => undefined;
|
||||||
|
export const registerCheckAppInstalled = (fn: (app: App) => void) => {
|
||||||
|
checkAppInstalledRef = fn;
|
||||||
|
};
|
||||||
|
// requestUninstall 由 useInstalledApps 持有,通过注入引用获取,避免循环依赖
|
||||||
|
let requestUninstallRef: (app: App) => void = () => undefined;
|
||||||
|
export const registerRequestUninstall = (fn: (app: App) => void) => {
|
||||||
|
requestUninstallRef = fn;
|
||||||
|
};
|
||||||
|
// 登出时清空待下载记录(原 App.vue logout 中的 pendingDownloadRecords.clear())
|
||||||
|
export const clearPendingDownloadRecords = (): void => {
|
||||||
|
pendingDownloadRecords.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// onDetailInstall 需要被 useFavorites / useAccountSync 调用,此处导出
|
||||||
|
export {
|
||||||
|
pendingDownloadRecords,
|
||||||
|
onDetailInstall,
|
||||||
|
handleInstallCompleteForDownloadRecord,
|
||||||
|
onDetailRemove,
|
||||||
|
onDetailFavorite,
|
||||||
|
pauseDownload,
|
||||||
|
resumeDownload,
|
||||||
|
cancelDownload,
|
||||||
|
retryDownload,
|
||||||
|
clearCompletedDownloads,
|
||||||
|
showDownloadDetailModalFunc,
|
||||||
|
closeDownloadDetail,
|
||||||
|
openDownloadedApp,
|
||||||
|
installCompleteCallback,
|
||||||
|
};
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* useFavorites —— 收藏夹相关的全部状态与逻辑。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(loadFavoriteFolders / loadActiveFavoriteItems /
|
||||||
|
* loadAllFavoriteItems / loadFavoriteMetadataForDetail / refreshFavorites /
|
||||||
|
* openFavoriteSelector / toFavoritePayload / saveCurrentFavoriteFolders /
|
||||||
|
* createFavoriteFolderFromSelector / openFavoriteManagement / selectFavoriteFolder /
|
||||||
|
* createFavoriteFolderFromPrompt / removeSelectedFavorites / installResolvedFavorites /
|
||||||
|
* clearFavoriteState / 代次守卫 / currentFavoriteMetadata / currentFavoriteFolderIds /
|
||||||
|
* resolvedFavoriteItems),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态来自 useAppState;clientArch 来自 useAppDetail。
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
FavoriteItem,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
|
} from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
favoriteFolders,
|
||||||
|
favoriteItems,
|
||||||
|
favoriteItemsByFolder,
|
||||||
|
favoriteLoading,
|
||||||
|
favoriteError,
|
||||||
|
favoriteRequestGeneration,
|
||||||
|
favoriteTargetApp,
|
||||||
|
favoriteSelectorDraftFolderIds,
|
||||||
|
activeFavoriteFolderId,
|
||||||
|
showFavoriteSelector,
|
||||||
|
currentView,
|
||||||
|
activeTab,
|
||||||
|
isSidebarOpen,
|
||||||
|
showLoginPrompt,
|
||||||
|
apps,
|
||||||
|
installedApps,
|
||||||
|
availableSources,
|
||||||
|
storeFilter,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { clientArch } from "./useAppDetail";
|
||||||
|
import {
|
||||||
|
listFavoriteFolders,
|
||||||
|
listFavoriteItems,
|
||||||
|
addFavoriteItem,
|
||||||
|
deleteFavoriteItem,
|
||||||
|
createFavoriteFolder,
|
||||||
|
bulkDeleteFavoriteItems,
|
||||||
|
} from "../modules/backendApi";
|
||||||
|
import { buildFavoriteAppKey } from "../modules/appIdentity";
|
||||||
|
import { resolveFavoriteItems } from "../modules/favoriteAvailability";
|
||||||
|
import { isLoggedIn } from "../global/authState";
|
||||||
|
import { refreshFavoriteInstalledApps } from "./useInstalledApps";
|
||||||
|
|
||||||
|
const nextFavoriteRequestGeneration = (): number => {
|
||||||
|
favoriteRequestGeneration.value += 1;
|
||||||
|
return favoriteRequestGeneration.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
||||||
|
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
||||||
|
|
||||||
|
const loadFavoriteFolders = async (
|
||||||
|
generation = favoriteRequestGeneration.value,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const folders = await listFavoriteFolders();
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||||
|
|
||||||
|
favoriteFolders.value = folders;
|
||||||
|
const activeFolderExists = folders.some(
|
||||||
|
(folder) => folder.id === activeFavoriteFolderId.value,
|
||||||
|
);
|
||||||
|
if (!activeFolderExists) {
|
||||||
|
activeFavoriteFolderId.value = folders[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadActiveFavoriteItems = async (
|
||||||
|
generation = favoriteRequestGeneration.value,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!activeFavoriteFolderId.value) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||||
|
favoriteItems.value = [];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const items = await listFavoriteItems(activeFavoriteFolderId.value);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||||
|
|
||||||
|
favoriteItems.value = items;
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[activeFavoriteFolderId.value]: items,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllFavoriteItems = async (
|
||||||
|
generation = favoriteRequestGeneration.value,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const folderIds = favoriteFolders.value.map((folder) => folder.id);
|
||||||
|
const entries = await Promise.all(
|
||||||
|
folderIds.map(async (folderId) => ({
|
||||||
|
folderId,
|
||||||
|
items: await listFavoriteItems(folderId),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||||
|
|
||||||
|
favoriteItemsByFolder.value = Object.fromEntries(
|
||||||
|
entries.map(({ folderId, items }) => [folderId, items]),
|
||||||
|
);
|
||||||
|
favoriteItems.value = activeFavoriteFolderId.value
|
||||||
|
? (favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? [])
|
||||||
|
: [];
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFavoriteMetadataForDetail = async (): Promise<void> => {
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
try {
|
||||||
|
const folders = await listFavoriteFolders();
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
const entries = await Promise.all(
|
||||||
|
folders.map(async (folder) => ({
|
||||||
|
folderId: folder.id,
|
||||||
|
items: await listFavoriteItems(folder.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
|
||||||
|
favoriteFolders.value = folders;
|
||||||
|
favoriteItemsByFolder.value = Object.fromEntries(
|
||||||
|
entries.map(({ folderId, items }) => [folderId, items]),
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshFavorites = async (): Promise<void> => {
|
||||||
|
const generation = nextFavoriteRequestGeneration();
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
refreshFavoriteInstalledApps(),
|
||||||
|
loadFavoriteFolders(generation),
|
||||||
|
]);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
await loadAllFavoriteItems(generation);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFavoriteSelector = async (app: App) => {
|
||||||
|
if (!requireLoginRef("收藏应用需要登录星火账号。")) return;
|
||||||
|
const generation = nextFavoriteRequestGeneration();
|
||||||
|
favoriteTargetApp.value = app;
|
||||||
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
const foldersLoaded = await loadFavoriteFolders(generation);
|
||||||
|
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||||
|
const itemsLoaded = await loadAllFavoriteItems(generation);
|
||||||
|
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||||
|
showFavoriteSelector.value = true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFavoritePayload = (
|
||||||
|
app: App,
|
||||||
|
): Omit<FavoriteItem, "id" | "createdAt"> => ({
|
||||||
|
appKey: buildFavoriteAppKey(app),
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
name: app.name,
|
||||||
|
category: app.category,
|
||||||
|
iconUrl: app.icons,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveCurrentFavoriteFolders = async (
|
||||||
|
folderIds: Array<number | "default">,
|
||||||
|
) => {
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
const app = favoriteTargetApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
try {
|
||||||
|
const numericFolderIds = folderIds.filter(
|
||||||
|
(folderId): folderId is number => typeof folderId === "number",
|
||||||
|
);
|
||||||
|
const includesFallbackDefault = folderIds.includes("default");
|
||||||
|
const nextFolderIds = new Set(numericFolderIds);
|
||||||
|
const existingByFolder = favoriteFolders.value
|
||||||
|
.map((folder) => ({
|
||||||
|
folderId: folder.id,
|
||||||
|
item: (favoriteItemsByFolder.value[folder.id] ?? []).find(
|
||||||
|
(favorite) =>
|
||||||
|
favorite.pkgname === app.pkgname &&
|
||||||
|
favorite.category === app.category,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(entry): entry is { folderId: number; item: FavoriteItem } =>
|
||||||
|
entry.item !== undefined,
|
||||||
|
);
|
||||||
|
const existingFolderIds = new Set(
|
||||||
|
existingByFolder.map((entry) => entry.folderId),
|
||||||
|
);
|
||||||
|
const payload = toFavoritePayload(app);
|
||||||
|
const addedItemPromises = numericFolderIds
|
||||||
|
.filter((folderId) => !existingFolderIds.has(folderId))
|
||||||
|
.map(async (folderId) => ({
|
||||||
|
folderId,
|
||||||
|
item: await addFavoriteItem(folderId, payload),
|
||||||
|
}));
|
||||||
|
const deletedEntries = existingByFolder.filter(
|
||||||
|
({ folderId }) => !nextFolderIds.has(folderId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [addedEntries] = await Promise.all([
|
||||||
|
Promise.all(addedItemPromises),
|
||||||
|
...(includesFallbackDefault ? [addFavoriteItem("default", payload)] : []),
|
||||||
|
...deletedEntries.map(({ folderId, item }) =>
|
||||||
|
deleteFavoriteItem(folderId, item.id),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
const nextItemsByFolder = { ...favoriteItemsByFolder.value };
|
||||||
|
deletedEntries.forEach(({ folderId, item }) => {
|
||||||
|
nextItemsByFolder[folderId] = (nextItemsByFolder[folderId] ?? []).filter(
|
||||||
|
(favorite) => favorite.id !== item.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
addedEntries.forEach(({ folderId, item }) => {
|
||||||
|
nextItemsByFolder[folderId] = [
|
||||||
|
...(nextItemsByFolder[folderId] ?? []).filter(
|
||||||
|
(favorite) =>
|
||||||
|
favorite.pkgname !== app.pkgname ||
|
||||||
|
favorite.category !== app.category,
|
||||||
|
),
|
||||||
|
item,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
favoriteItemsByFolder.value = nextItemsByFolder;
|
||||||
|
if (activeFavoriteFolderId.value) {
|
||||||
|
favoriteItems.value =
|
||||||
|
nextItemsByFolder[activeFavoriteFolderId.value] ?? [];
|
||||||
|
}
|
||||||
|
showFavoriteSelector.value = false;
|
||||||
|
favoriteTargetApp.value = null;
|
||||||
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
|
if (includesFallbackDefault) await refreshFavorites();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "更新收藏失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavoriteFolderFromSelector = async (
|
||||||
|
draftFolderIds: Array<
|
||||||
|
number | "default"
|
||||||
|
> = favoriteSelectorDraftFolderIds.value ?? [],
|
||||||
|
): Promise<void> => {
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
const name = window.prompt("请输入收藏夹名称");
|
||||||
|
const folderName = name?.trim();
|
||||||
|
if (!folderName) return;
|
||||||
|
const app = favoriteTargetApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
const folder = await createFavoriteFolder(folderName);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteFolders.value = [
|
||||||
|
...favoriteFolders.value.filter((item) => item.id !== folder.id),
|
||||||
|
folder,
|
||||||
|
];
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
|
||||||
|
};
|
||||||
|
favoriteSelectorDraftFolderIds.value = [
|
||||||
|
...new Set([...draftFolderIds, folder.id]),
|
||||||
|
];
|
||||||
|
showFavoriteSelector.value = true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFavoriteManagement = async () => {
|
||||||
|
if (!requireLoginRef("请登录后查看我的收藏。")) return;
|
||||||
|
currentView.value = "favorites";
|
||||||
|
activeTab.value = "favorites";
|
||||||
|
isSidebarOpen.value = false;
|
||||||
|
showLoginPrompt.value = false;
|
||||||
|
await refreshFavorites();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFavoriteFolder = async (folderId: number) => {
|
||||||
|
const generation = nextFavoriteRequestGeneration();
|
||||||
|
activeFavoriteFolderId.value = folderId;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await loadActiveFavoriteItems(generation);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavoriteFolderFromPrompt = async () => {
|
||||||
|
const name = window.prompt("请输入收藏夹名称");
|
||||||
|
const folderName = name?.trim();
|
||||||
|
if (!folderName) return;
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
const folder = await createFavoriteFolder(folderName);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteFolders.value = [
|
||||||
|
...favoriteFolders.value.filter((item) => item.id !== folder.id),
|
||||||
|
folder,
|
||||||
|
];
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
|
||||||
|
};
|
||||||
|
favoriteItems.value = [];
|
||||||
|
activeFavoriteFolderId.value = folder.id;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedFavorites = async (ids: number[]) => {
|
||||||
|
if (!activeFavoriteFolderId.value || ids.length === 0) return;
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[activeFavoriteFolderId.value]: (
|
||||||
|
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
|
||||||
|
).filter((favorite) => !ids.includes(favorite.id)),
|
||||||
|
};
|
||||||
|
await refreshFavorites();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteError.value = (error as Error)?.message || "移除收藏失败";
|
||||||
|
} finally {
|
||||||
|
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.selectedApp) {
|
||||||
|
await onDetailInstallRef(item.selectedApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFavoriteState = () => {
|
||||||
|
favoriteRequestGeneration.value += 1;
|
||||||
|
favoriteFolders.value = [];
|
||||||
|
activeFavoriteFolderId.value = null;
|
||||||
|
favoriteItems.value = [];
|
||||||
|
favoriteItemsByFolder.value = {};
|
||||||
|
showFavoriteSelector.value = false;
|
||||||
|
favoriteTargetApp.value = null;
|
||||||
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
|
favoriteLoading.value = false;
|
||||||
|
favoriteError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// requireLogin / onDetailInstall 分别定义在 useAccountSync / useDownloads,
|
||||||
|
// 通过模块级可赋值引用注入,避免循环依赖。
|
||||||
|
let requireLoginRef: (message: string) => boolean = () => true;
|
||||||
|
export const registerRequireLogin = (fn: (message: string) => boolean) => {
|
||||||
|
requireLoginRef = fn;
|
||||||
|
};
|
||||||
|
let onDetailInstallRef: (app: App) => Promise<void> = async () => undefined;
|
||||||
|
export const registerOnDetailInstall = (fn: (app: App) => Promise<void>) => {
|
||||||
|
onDetailInstallRef = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 这些 computed 依赖 currentDisplayApp / favorite 状态,放在本模块汇总派生。
|
||||||
|
export const currentFavoriteMetadata = computed(
|
||||||
|
(): {
|
||||||
|
favorited: boolean;
|
||||||
|
folderName: string;
|
||||||
|
} => {
|
||||||
|
// 延迟引用 currentDisplayApp,避免顶层循环导入时的初始化顺序问题
|
||||||
|
const app = currentDisplayAppRef();
|
||||||
|
if (!app) return { favorited: false, folderName: "" };
|
||||||
|
|
||||||
|
const folder = favoriteFolders.value.find((favoriteFolder) => {
|
||||||
|
const items = favoriteItemsByFolder.value[favoriteFolder.id] ?? [];
|
||||||
|
return items.some(
|
||||||
|
(favorite) =>
|
||||||
|
favorite.pkgname === app.pkgname &&
|
||||||
|
favorite.category === app.category,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!folder) return { favorited: false, folderName: "" };
|
||||||
|
|
||||||
|
return { favorited: true, folderName: folder.name.trim() };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const currentFavoriteFolderIds = computed(
|
||||||
|
(): Array<number | "default"> => {
|
||||||
|
if (favoriteSelectorDraftFolderIds.value) {
|
||||||
|
return favoriteSelectorDraftFolderIds.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = favoriteTargetApp.value ?? currentDisplayAppRef();
|
||||||
|
if (!app) return [];
|
||||||
|
|
||||||
|
return favoriteFolders.value
|
||||||
|
.filter((folder) =>
|
||||||
|
(favoriteItemsByFolder.value[folder.id] ?? []).some(
|
||||||
|
(favorite) =>
|
||||||
|
favorite.pkgname === app.pkgname &&
|
||||||
|
favorite.category === app.category,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((folder) => folder.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
||||||
|
resolveFavoriteItems(
|
||||||
|
favoriteItems.value,
|
||||||
|
apps.value,
|
||||||
|
installedApps.value,
|
||||||
|
availableSources.value,
|
||||||
|
storeFilter.value,
|
||||||
|
clientArch.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// currentDisplayApp 由 useAppDetail 持有,此处通过注入引用获取,避免循环导入
|
||||||
|
let currentDisplayAppRef: () => App | null = () => null;
|
||||||
|
export const registerCurrentDisplayApp = (fn: () => App | null) => {
|
||||||
|
currentDisplayAppRef = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadFavoriteFolders,
|
||||||
|
loadActiveFavoriteItems,
|
||||||
|
loadAllFavoriteItems,
|
||||||
|
loadFavoriteMetadataForDetail,
|
||||||
|
refreshFavorites,
|
||||||
|
openFavoriteSelector,
|
||||||
|
toFavoritePayload,
|
||||||
|
saveCurrentFavoriteFolders,
|
||||||
|
createFavoriteFolderFromSelector,
|
||||||
|
openFavoriteManagement,
|
||||||
|
selectFavoriteFolder,
|
||||||
|
createFavoriteFolderFromPrompt,
|
||||||
|
removeSelectedFavorites,
|
||||||
|
installResolvedFavorites,
|
||||||
|
clearFavoriteState,
|
||||||
|
nextFavoriteRequestGeneration,
|
||||||
|
isCurrentFavoriteRequest,
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* useHttp —— 全局 HTTP 工具(axios 实例 + 带重试请求 + 超时包装)。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移,逻辑零改动,仅改为导出供各 composable 复用。
|
||||||
|
* 含缓存穿透拦截器(createCacheBusterInterceptor),与之前的审计加固一致。
|
||||||
|
*/
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
import { createCacheBusterInterceptor } from "../global/cacheBusterInterceptor";
|
||||||
|
|
||||||
|
// Axios 全局配置
|
||||||
|
export const axiosInstance = axios.create({
|
||||||
|
baseURL: APM_STORE_BASE_URL,
|
||||||
|
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
|
||||||
|
});
|
||||||
|
|
||||||
|
// C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳,
|
||||||
|
// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳,
|
||||||
|
// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。
|
||||||
|
// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||||||
|
axiosInstance.interceptors.request.use(createCacheBusterInterceptor());
|
||||||
|
|
||||||
|
// 5xx / 网络错误 / 超时重试;4xx(如 404)快速失败
|
||||||
|
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
||||||
|
|
||||||
|
// 计算重试延迟:优先尊重服务端 Retry-After 头;429 限流用指数退避(封顶 30s);
|
||||||
|
// 其余 5xx 用固定延迟。避免对限流场景频繁重试浪费带宽。
|
||||||
|
const calculateRetryDelay = (
|
||||||
|
status: number | undefined,
|
||||||
|
attempt: number,
|
||||||
|
retryAfterHeader?: string,
|
||||||
|
fixedDelayMs = 500,
|
||||||
|
): number => {
|
||||||
|
if (retryAfterHeader) {
|
||||||
|
const seconds = Number.parseInt(retryAfterHeader, 10);
|
||||||
|
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return Math.min(1000 * 2 ** attempt, 30000);
|
||||||
|
}
|
||||||
|
return fixedDelayMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWithRetry = async <T>(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 2,
|
||||||
|
retryDelayMs = 500,
|
||||||
|
): Promise<T | null> => {
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const resp = await axiosInstance.get<T>(path, { signal });
|
||||||
|
return resp.data;
|
||||||
|
} catch (err) {
|
||||||
|
const ae = err as AxiosError;
|
||||||
|
const status = ae.response?.status;
|
||||||
|
// 4xx(含 404)快速失败,不重试
|
||||||
|
if (status && status < 500 && status !== 429) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 仅对网络错误 / 5xx / 429 重试
|
||||||
|
const retryable =
|
||||||
|
!status || RETRYABLE_STATUS.has(status) || status === 429;
|
||||||
|
if (!retryable || attempt === retries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const retryAfter = ae.response?.headers?.["retry-after"] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const delay = calculateRetryDelay(
|
||||||
|
status,
|
||||||
|
attempt,
|
||||||
|
retryAfter,
|
||||||
|
retryDelayMs,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全局请求取消控制器(目录加载期间可整体取消)
|
||||||
|
export const rootAbortController = new AbortController();
|
||||||
|
|
||||||
|
// 给 Promise 包一层超时,超时即 reject。finally 中清理定时器,避免泄漏。
|
||||||
|
export const withTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
label = "operation",
|
||||||
|
): Promise<T> => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} 超时 (${ms}ms)`)), ms);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* useInstalledApps —— 已安装应用列表的查询、合并、卸载触发逻辑。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(isInstalledAppInfo / LIST_INSTALLED_TIMEOUT_MS /
|
||||||
|
* resolveInstalledOrigins / refreshInstalledApps / mapInstalledAppToCatalogApp /
|
||||||
|
* refreshFavoriteInstalledApps / requestUninstall / openInstalledModal /
|
||||||
|
* closeInstalledModal / uninstallInstalledApp / installedCloudKeys /
|
||||||
|
* installedCloudPackageKeys),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态来自 useAppState;withTimeout / rootAbortController 来自 useHttp。
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { App, InstalledAppInfo } from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
installedApps,
|
||||||
|
installedLoading,
|
||||||
|
installedError,
|
||||||
|
installedWarning,
|
||||||
|
installedRefreshGeneration,
|
||||||
|
showInstalledModal,
|
||||||
|
sparkAvailable,
|
||||||
|
apmAvailable,
|
||||||
|
storeFilter,
|
||||||
|
showUninstallModal,
|
||||||
|
uninstallTargetApp,
|
||||||
|
syncCandidateApps,
|
||||||
|
availableSources,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { withTimeout } from "./useHttp";
|
||||||
|
import {
|
||||||
|
isOriginUsable,
|
||||||
|
isOriginEnabled,
|
||||||
|
getEffectiveStoreFilter,
|
||||||
|
} from "../modules/storeFilter";
|
||||||
|
import { cloudItemKey, cloudPackageKey } from "../modules/appListSync";
|
||||||
|
import { removeDownloadItem } from "../global/downloadStatus";
|
||||||
|
|
||||||
|
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
return typeof v.pkgname === "string" && typeof v.origin === "string";
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_INSTALLED_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
|
||||||
|
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
|
||||||
|
const origins: Array<"spark" | "apm"> = [];
|
||||||
|
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
|
||||||
|
origins.push("spark");
|
||||||
|
}
|
||||||
|
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
|
||||||
|
origins.push("apm");
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshInstalledApps = async () => {
|
||||||
|
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
|
||||||
|
if (!showInstalledModal.value) return;
|
||||||
|
installedLoading.value = true;
|
||||||
|
installedError.value = "";
|
||||||
|
installedWarning.value = "";
|
||||||
|
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
|
||||||
|
const generation = ++installedRefreshGeneration.value;
|
||||||
|
try {
|
||||||
|
const origins = resolveInstalledOrigins();
|
||||||
|
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
|
||||||
|
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark,
|
||||||
|
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
|
||||||
|
const sparkPkgnameList = apps.value
|
||||||
|
.filter((a) => a.origin === "spark")
|
||||||
|
.map((a) => a.pkgname);
|
||||||
|
const effectiveOrigins = origins.filter(
|
||||||
|
(o) => o !== "spark" || sparkPkgnameList.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (effectiveOrigins.length === 0) {
|
||||||
|
installedApps.value = [];
|
||||||
|
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
|
||||||
|
installedError.value =
|
||||||
|
apps.value.length === 0
|
||||||
|
? "正在加载应用目录,请稍候…"
|
||||||
|
: "当前系统不可用应用管理功能";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行查询每个 origin 的已安装应用:
|
||||||
|
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
|
||||||
|
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
effectiveOrigins.map((origin) =>
|
||||||
|
withTimeout(
|
||||||
|
window.ipcRenderer.invoke("list-installed", {
|
||||||
|
origin,
|
||||||
|
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
|
||||||
|
}),
|
||||||
|
LIST_INSTALLED_TIMEOUT_MS,
|
||||||
|
`${origin} list-installed`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
|
||||||
|
if (generation !== installedRefreshGeneration.value) return;
|
||||||
|
if (!showInstalledModal.value) return;
|
||||||
|
|
||||||
|
const combinedApps: App[] = [];
|
||||||
|
// 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合
|
||||||
|
const originsByPkg = new Map<string, Set<"spark" | "apm">>();
|
||||||
|
const failedOrigins: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < effectiveOrigins.length; i++) {
|
||||||
|
const origin = effectiveOrigins[i];
|
||||||
|
const settled = results[i];
|
||||||
|
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
|
||||||
|
if (settled.status !== "fulfilled" || !settled.value?.success) {
|
||||||
|
failedOrigins.push(origin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = settled.value;
|
||||||
|
|
||||||
|
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||||
|
for (const rawApp of appList) {
|
||||||
|
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
|
||||||
|
if (!isInstalledAppInfo(rawApp)) continue;
|
||||||
|
const app = rawApp;
|
||||||
|
|
||||||
|
// Find matching remote app to enrich data. We look exactly for that origin.
|
||||||
|
let appInfo = apps.value.find(
|
||||||
|
(a) => a.pkgname === app.pkgname && a.origin === origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (origin === "spark" && !appInfo) {
|
||||||
|
// Only show Spark packages that exist in the App Store catalogue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appInfo) {
|
||||||
|
appInfo.flags = app.flags;
|
||||||
|
appInfo.arch = app.arch;
|
||||||
|
appInfo.currentStatus = "installed";
|
||||||
|
appInfo.isDependency = app.isDependency;
|
||||||
|
} else {
|
||||||
|
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
|
||||||
|
appInfo = {
|
||||||
|
name: app.name || app.pkgname,
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
version: app.version,
|
||||||
|
category: "unknown",
|
||||||
|
tags: "",
|
||||||
|
more: "",
|
||||||
|
filename: "",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: app.icon || "",
|
||||||
|
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
|
||||||
|
currentStatus: "installed",
|
||||||
|
arch: app.arch,
|
||||||
|
flags: app.flags,
|
||||||
|
isDependency: app.isDependency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 合并同一 pkgname 在多个来源的安装记录为单条,避免列表出现重复项(相同 pkgname 键冲突)
|
||||||
|
const existingIdx = combinedApps.findIndex(
|
||||||
|
(a) => a.pkgname === appInfo.pkgname,
|
||||||
|
);
|
||||||
|
if (existingIdx === -1) {
|
||||||
|
combinedApps.push(appInfo);
|
||||||
|
}
|
||||||
|
// 记录该 pkgname 当前这一来源,供卸载时判断走 APM 还是 Spark
|
||||||
|
const originSet =
|
||||||
|
originsByPkg.get(appInfo.pkgname) ?? new Set<"spark" | "apm">();
|
||||||
|
originSet.add(origin);
|
||||||
|
originsByPkg.set(appInfo.pkgname, originSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将来源集合回写到每条已安装应用,供卸载时判断应走 APM 还是 Spark
|
||||||
|
for (const app of combinedApps) {
|
||||||
|
const set = originsByPkg.get(app.pkgname);
|
||||||
|
if (set) app.origins = Array.from(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
installedApps.value = combinedApps;
|
||||||
|
|
||||||
|
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
|
||||||
|
if (failedOrigins.length > 0) {
|
||||||
|
const labels = failedOrigins
|
||||||
|
.map((o) => (o === "spark" ? "Spark" : "APM"))
|
||||||
|
.join("、");
|
||||||
|
if (combinedApps.length > 0) {
|
||||||
|
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
|
||||||
|
} else {
|
||||||
|
installedError.value = `读取${labels}已安装应用失败`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (generation !== installedRefreshGeneration.value) return;
|
||||||
|
if (!showInstalledModal.value) return;
|
||||||
|
installedApps.value = [];
|
||||||
|
installedError.value = (error as Error)?.message || "读取已安装应用失败";
|
||||||
|
} finally {
|
||||||
|
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
|
||||||
|
if (generation === installedRefreshGeneration.value) {
|
||||||
|
installedLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapInstalledAppToCatalogApp = (
|
||||||
|
app: InstalledAppInfo,
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
): App | null => {
|
||||||
|
const appInfo = apps.value.find(
|
||||||
|
(catalogApp) =>
|
||||||
|
catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (origin === "spark" && !appInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appInfo) {
|
||||||
|
appInfo.flags = app.flags;
|
||||||
|
appInfo.arch = app.arch;
|
||||||
|
appInfo.currentStatus = "installed";
|
||||||
|
appInfo.isDependency = app.isDependency;
|
||||||
|
return appInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: app.name || app.pkgname,
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
version: app.version,
|
||||||
|
category: "unknown",
|
||||||
|
tags: "",
|
||||||
|
more: "",
|
||||||
|
filename: "",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: app.icon || "",
|
||||||
|
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
|
||||||
|
currentStatus: "installed",
|
||||||
|
arch: app.arch,
|
||||||
|
flags: app.flags,
|
||||||
|
isDependency: app.isDependency,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshFavoriteInstalledApps = async (): Promise<void> => {
|
||||||
|
const origins: Array<"spark" | "apm"> = [];
|
||||||
|
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
|
||||||
|
origins.push("spark");
|
||||||
|
}
|
||||||
|
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
|
||||||
|
origins.push("apm");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedApps: App[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
origins.map(async (origin) => {
|
||||||
|
const pkgnameList =
|
||||||
|
origin === "spark"
|
||||||
|
? apps.value
|
||||||
|
.filter((app) => app.origin === "spark")
|
||||||
|
.map((app) => app.pkgname)
|
||||||
|
: undefined;
|
||||||
|
const result = await window.ipcRenderer.invoke("list-installed", {
|
||||||
|
origin,
|
||||||
|
pkgnameList,
|
||||||
|
});
|
||||||
|
if (!result?.success) return;
|
||||||
|
|
||||||
|
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||||
|
for (const rawApp of appList) {
|
||||||
|
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||||
|
if (!isInstalledAppInfo(rawApp)) continue;
|
||||||
|
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
|
||||||
|
if (appInfo) refreshedApps.push(appInfo);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshedKeys = new Set(
|
||||||
|
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
|
||||||
|
);
|
||||||
|
installedApps.value = [
|
||||||
|
...installedApps.value.filter(
|
||||||
|
(app) =>
|
||||||
|
!origins.includes(app.origin) &&
|
||||||
|
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
|
||||||
|
),
|
||||||
|
...refreshedApps,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestUninstall = (app: App) => {
|
||||||
|
uninstallTargetApp.value = app;
|
||||||
|
showUninstallModal.value = true;
|
||||||
|
removeDownloadItem(app.pkgname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInstalledModal = () => {
|
||||||
|
if (
|
||||||
|
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInstalledModal.value = true;
|
||||||
|
refreshInstalledApps();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInstalledModal = () => {
|
||||||
|
showInstalledModal.value = false;
|
||||||
|
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
|
||||||
|
installedError.value = "";
|
||||||
|
installedWarning.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uninstallInstalledApp = (app: App) => {
|
||||||
|
requestUninstall(app);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用在更新中心/详情页的安装状态变更后,刷新已安装列表(由 App.vue 编排调用)
|
||||||
|
const onUninstallSuccess = () => {
|
||||||
|
if (showInstalledModal.value) {
|
||||||
|
refreshInstalledApps();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已安装应用与云端同步候选用 key 集合(供恢复模态判断已装项)
|
||||||
|
const installedCloudKeys = computed(
|
||||||
|
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const installedCloudPackageKeys = computed(
|
||||||
|
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
isInstalledAppInfo,
|
||||||
|
LIST_INSTALLED_TIMEOUT_MS,
|
||||||
|
resolveInstalledOrigins,
|
||||||
|
refreshInstalledApps,
|
||||||
|
mapInstalledAppToCatalogApp,
|
||||||
|
refreshFavoriteInstalledApps,
|
||||||
|
requestUninstall,
|
||||||
|
openInstalledModal,
|
||||||
|
closeInstalledModal,
|
||||||
|
uninstallInstalledApp,
|
||||||
|
onUninstallSuccess,
|
||||||
|
installedCloudKeys,
|
||||||
|
installedCloudPackageKeys,
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* useRanking —— 首页区域(links + 推荐列表入口)与下载排行榜数据。
|
||||||
|
*
|
||||||
|
* 从原 App.vue 原样搬移(loadHome / loadRanking / fetchDownloadCount / 缓存逻辑 /
|
||||||
|
* publishRanking),逻辑零改动。
|
||||||
|
*
|
||||||
|
* 共享状态(apps / homeLinks / apmRanking / sparkRanking / rankingLoading /
|
||||||
|
* homeLoading / homeError / storeFilter)来自 useAppState 单例。
|
||||||
|
*/
|
||||||
|
import type { App, HomeLink } from "../global/typedefinition";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
homeLinks,
|
||||||
|
homeLoading,
|
||||||
|
homeError,
|
||||||
|
apmRanking,
|
||||||
|
sparkRanking,
|
||||||
|
rankingLoading,
|
||||||
|
storeFilter,
|
||||||
|
} from "./useAppState";
|
||||||
|
import { rootAbortController } from "./useHttp";
|
||||||
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
|
|
||||||
|
const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1";
|
||||||
|
const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
|
||||||
|
|
||||||
|
interface CachedCount {
|
||||||
|
count: number;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadCountCache = new Map<string, CachedCount>();
|
||||||
|
|
||||||
|
const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`;
|
||||||
|
|
||||||
|
const loadCacheFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const data = JSON.parse(raw) as Record<string, CachedCount>;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) {
|
||||||
|
downloadCountCache.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupted cache
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCacheToStorage = () => {
|
||||||
|
try {
|
||||||
|
const obj: Record<string, CachedCount> = {};
|
||||||
|
downloadCountCache.forEach((v, k) => {
|
||||||
|
obj[k] = v;
|
||||||
|
});
|
||||||
|
localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj));
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDownloadCount = async (app: App): Promise<number> => {
|
||||||
|
const key = cacheKey(app);
|
||||||
|
const cached = downloadCountCache.get(key);
|
||||||
|
if (cached) return cached.count;
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`,
|
||||||
|
{ signal: rootAbortController.signal },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return 0;
|
||||||
|
const text = (await resp.text()).trim();
|
||||||
|
const n = parseInt(text, 10);
|
||||||
|
const count = Number.isFinite(n) ? n : 0;
|
||||||
|
downloadCountCache.set(key, { count, ts: Date.now() });
|
||||||
|
return count;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishRanking = (results: App[]) => {
|
||||||
|
apmRanking.value = results
|
||||||
|
.filter((a) => a.origin === "apm")
|
||||||
|
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
|
||||||
|
.slice(0, 10);
|
||||||
|
sparkRanking.value = results
|
||||||
|
.filter((a) => a.origin === "spark")
|
||||||
|
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
|
||||||
|
.slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
let rankingGeneration = 0;
|
||||||
|
|
||||||
|
const loadRanking = async () => {
|
||||||
|
if (apps.value.length === 0) return;
|
||||||
|
const gen = ++rankingGeneration;
|
||||||
|
rankingLoading.value = true;
|
||||||
|
const all = apps.value.slice();
|
||||||
|
const CONCURRENCY = 15;
|
||||||
|
const results: App[] = [];
|
||||||
|
for (let i = 0; i < all.length; i += CONCURRENCY) {
|
||||||
|
if (gen !== rankingGeneration) {
|
||||||
|
saveCacheToStorage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batch = all.slice(i, i + CONCURRENCY);
|
||||||
|
const settled = await Promise.all(
|
||||||
|
batch.map(async (app) => ({
|
||||||
|
...app,
|
||||||
|
downloadCount: await fetchDownloadCount(app),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
results.push(...settled);
|
||||||
|
// 边拉边发:每批完成后立即发布增量排名
|
||||||
|
if (gen === rankingGeneration) publishRanking(results);
|
||||||
|
}
|
||||||
|
if (gen !== rankingGeneration) {
|
||||||
|
saveCacheToStorage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rankingLoading.value = false;
|
||||||
|
saveCacheToStorage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行
|
||||||
|
loadCacheFromStorage();
|
||||||
|
|
||||||
|
// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪
|
||||||
|
|
||||||
|
const loadHome = async () => {
|
||||||
|
homeLoading.value = true;
|
||||||
|
homeError.value = "";
|
||||||
|
homeLinks.value = [];
|
||||||
|
try {
|
||||||
|
const arch = window.apm_store.arch || "amd64";
|
||||||
|
const modes: Array<"spark" | "apm"> =
|
||||||
|
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
|
||||||
|
|
||||||
|
// 按名称去重,spark 优先:同名链接 spark 覆盖 apm
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
|
// 并行请求各来源的 homelinks.json,缩短首页加载耗时
|
||||||
|
const modeResults = await Promise.all(
|
||||||
|
modes.map(async (mode) => {
|
||||||
|
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/homelinks.json`);
|
||||||
|
if (res.ok) return { mode, raw: (await res.json()) as unknown };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to load ${mode} homelinks.json`, e);
|
||||||
|
}
|
||||||
|
return { mode, raw: undefined };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { mode, raw } of modeResults) {
|
||||||
|
if (!raw) continue;
|
||||||
|
// 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误)
|
||||||
|
const links = Array.isArray(raw)
|
||||||
|
? (raw.filter((x) => x && typeof x === "object") as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[])
|
||||||
|
: [];
|
||||||
|
for (const l of links) {
|
||||||
|
// 远程数据不可信,使用 typeof 运行时守卫替代 `as string` 断言,
|
||||||
|
// 避免非字符串字段(如数字/对象)被注入状态导致下游显示异常。
|
||||||
|
const name =
|
||||||
|
typeof l.Name === "string"
|
||||||
|
? l.Name
|
||||||
|
: typeof l.name === "string"
|
||||||
|
? l.name
|
||||||
|
: "";
|
||||||
|
if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑
|
||||||
|
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
|
||||||
|
// 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供),
|
||||||
|
// 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。
|
||||||
|
const url =
|
||||||
|
typeof l.Url === "string"
|
||||||
|
? l.Url
|
||||||
|
: typeof l.url === "string"
|
||||||
|
? l.url
|
||||||
|
: "";
|
||||||
|
if (!url) continue;
|
||||||
|
const icon =
|
||||||
|
typeof l.Icon === "string"
|
||||||
|
? l.Icon
|
||||||
|
: typeof l.icon === "string"
|
||||||
|
? l.icon
|
||||||
|
: "";
|
||||||
|
seenNames.add(name);
|
||||||
|
// 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态
|
||||||
|
const safeLink: HomeLink = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
more: typeof l.more === "string" ? l.more : undefined,
|
||||||
|
imgUrl: typeof l.imgUrl === "string" ? l.imgUrl : undefined,
|
||||||
|
type: typeof l.type === "string" ? l.type : undefined,
|
||||||
|
origin: mode,
|
||||||
|
};
|
||||||
|
homeLinks.value.push(safeLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
homeError.value = (error as Error)?.message || "加载首页失败";
|
||||||
|
} finally {
|
||||||
|
homeLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { loadHome, loadRanking };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存穿透拦截器工厂:为所有 .json GET 请求追加 ?_t=${timestamp} 版本戳,
|
||||||
|
* 穿透 CDN/浏览器缓存,确保新应用上架后能立即被搜到(解决"上架后搜不到")。
|
||||||
|
*
|
||||||
|
* 为避免同一次会话内每次请求都生成新戳导致缓存完全失效(频繁重复请求、浪费带宽),
|
||||||
|
* 引入 TTL:在 ttlMs 窗口内复用同一时间戳;超过窗口才刷新。
|
||||||
|
* 默认 5 分钟,兼顾"及时感知新数据"与"不过度击穿缓存"。
|
||||||
|
*
|
||||||
|
* 注意:这是有意为之的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||||||
|
*/
|
||||||
|
export function createCacheBusterInterceptor(ttlMs = 5 * 60 * 1000) {
|
||||||
|
let cachedTimestamp: number | null = null;
|
||||||
|
|
||||||
|
return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
||||||
|
if (
|
||||||
|
config.method?.toLowerCase() === "get" &&
|
||||||
|
typeof config.url === "string"
|
||||||
|
) {
|
||||||
|
const reqPath = config.url.split("?")[0];
|
||||||
|
if (reqPath.endsWith(".json")) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedTimestamp === null || now - cachedTimestamp > ttlMs) {
|
||||||
|
cachedTimestamp = now;
|
||||||
|
}
|
||||||
|
const sep = config.url.includes("?") ? "&" : "?";
|
||||||
|
config.url = `${config.url}${sep}_t=${cachedTimestamp}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 显示设置:界面整体缩放(Electron webContents.setZoomFactor)
|
||||||
|
*
|
||||||
|
* 通过 Electron 的 setZoomFactor 整体缩放渲染内容,连图标、间距、布局
|
||||||
|
* 一起等比放大/缩小(逻辑分辨率变化),不会破坏布局;与单纯改字号相比
|
||||||
|
* 更稳定,故本应用只提供此一种显示放大入口。
|
||||||
|
*
|
||||||
|
* 实际缩放由主进程 setZoomFactor 执行;渲染端仅负责持久化档位偏好,
|
||||||
|
* 并在启动时把档位换算的系数通过 IPC 告知主进程。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UiScaleOption = "90" | "100" | "110" | "125" | "150";
|
||||||
|
|
||||||
|
// 档位 → 缩放系数(与 Electron setZoomFactor 一致,1 = 100%)
|
||||||
|
const UI_SCALE_MAP: Record<UiScaleOption, number> = {
|
||||||
|
"90": 0.9,
|
||||||
|
"100": 1,
|
||||||
|
"110": 1.1,
|
||||||
|
"125": 1.25,
|
||||||
|
"150": 1.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UI_SCALE_OPTIONS: Array<{
|
||||||
|
value: UiScaleOption;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "90", label: "90%" },
|
||||||
|
{ value: "100", label: "100%" },
|
||||||
|
{ value: "110", label: "110%" },
|
||||||
|
{ value: "125", label: "125%" },
|
||||||
|
{ value: "150", label: "150%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UI_SCALE_STORAGE_KEY = "spark-store-ui-scale";
|
||||||
|
const DEFAULT_UI_SCALE: UiScaleOption = "100";
|
||||||
|
|
||||||
|
const isValidUiScale = (v: unknown): v is UiScaleOption =>
|
||||||
|
typeof v === "string" && v in UI_SCALE_MAP;
|
||||||
|
|
||||||
|
/** 读取持久化的界面缩放档位(无/非法时回退默认「100%」) */
|
||||||
|
export const getUiScale = (): UiScaleOption => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY);
|
||||||
|
if (isValidUiScale(raw)) return raw;
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时忽略,使用默认
|
||||||
|
}
|
||||||
|
return DEFAULT_UI_SCALE;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将档位换算为 Electron 缩放系数 */
|
||||||
|
export const uiScaleToFactor = (option: UiScaleOption): number =>
|
||||||
|
UI_SCALE_MAP[option] ?? UI_SCALE_MAP[DEFAULT_UI_SCALE];
|
||||||
|
|
||||||
|
/** 选择并持久化界面缩放档位(不在此直接调用主进程,由调用方经 IPC 应用) */
|
||||||
|
export const setUiScale = (option: UiScaleOption): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(UI_SCALE_STORAGE_KEY, option);
|
||||||
|
} catch {
|
||||||
|
// 持久化失败时仍已在会话内选定,忽略写入错误
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动时初始化:从持久化恢复档位,并经 IPC 告知主进程应用缩放。
|
||||||
|
* 需在 App.vue onMounted 调用,主进程 createWindow 阶段已自有兜底(默认 1),
|
||||||
|
* 此处确保渲染端偏好生效。
|
||||||
|
*/
|
||||||
|
export const initUiScale = async (): Promise<void> => {
|
||||||
|
const factor = uiScaleToFactor(getUiScale());
|
||||||
|
try {
|
||||||
|
await window.ipcRenderer.invoke("set-zoom-factor", factor);
|
||||||
|
} catch {
|
||||||
|
// 主进程 IPC 不可用时忽略(主进程启动兜底已设为 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
+101
-90
@@ -1,9 +1,23 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
import type { App, StoreMode } from "./typedefinition";
|
import type { App, StoreMode } from "./typedefinition";
|
||||||
|
import { createCacheBusterInterceptor } from "./cacheBusterInterceptor";
|
||||||
|
|
||||||
export const APM_STORE_BASE_URL: string =
|
export const APM_STORE_BASE_URL: string =
|
||||||
import.meta.env.VITE_APM_STORE_BASE_URL || "";
|
import.meta.env.VITE_APM_STORE_BASE_URL || "";
|
||||||
|
|
||||||
|
// 加载优先级配置专用客户端:与渲染进程其余请求共用同一套 HTTP 客户端,
|
||||||
|
// 避免 Electron 渲染进程中全局 fetch 失败导致配置始终为空、auto 模式全部回退 APM。
|
||||||
|
const priorityConfigAxios = axios.create({
|
||||||
|
baseURL: APM_STORE_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// C2:priority-config.json 走独立 axios 实例(不经过 axiosInstance),
|
||||||
|
// 复用共享缓存穿透拦截器(带 TTL 复用戳,避免同会话频繁击穿缓存)。
|
||||||
|
// 与 C1(axiosInstance 的拦截器)为同一工厂生成,确保穿透策略与 TTL 行为一致。
|
||||||
|
priorityConfigAxios.interceptors.request.use(createCacheBusterInterceptor());
|
||||||
|
|
||||||
export const APM_STORE_STATS_BASE_URL: string =
|
export const APM_STORE_STATS_BASE_URL: string =
|
||||||
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
||||||
|
|
||||||
@@ -29,7 +43,10 @@ export const showApmInstallDialog = ref(false);
|
|||||||
|
|
||||||
export const currentStoreMode = ref<StoreMode>("hybrid");
|
export const currentStoreMode = ref<StoreMode>("hybrid");
|
||||||
|
|
||||||
// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)
|
// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)。
|
||||||
|
// 设计意图:社区版当前主推 APM 来源(星火 APM 为新架构、deb 为传统来源),
|
||||||
|
// 故默认优先级设为 "apm"。非临时回归,属既定产品方向;如后续调整需产品确认。
|
||||||
|
// TODO: 产品确认默认 APM 优先是否长期保持。
|
||||||
export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm";
|
export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm";
|
||||||
|
|
||||||
// 优先级规则配置接口
|
// 优先级规则配置接口
|
||||||
@@ -62,14 +79,6 @@ export let dynamicPriorityConfig: PriorityRules = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 标记是否已从服务器加载配置
|
|
||||||
export let isPriorityConfigLoaded = false;
|
|
||||||
|
|
||||||
// 标记服务器上是否存在配置文件
|
|
||||||
// false = 没有配置文件,默认优先 Spark
|
|
||||||
// true = 有配置文件,按配置规则判断(配置为空则默认优先 APM)
|
|
||||||
export let hasPriorityConfigFile = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从服务器加载优先级配置
|
* 从服务器加载优先级配置
|
||||||
* 配置文件路径: ${arch}-store/priority-config.json (放在 spark 下)
|
* 配置文件路径: ${arch}-store/priority-config.json (放在 spark 下)
|
||||||
@@ -77,15 +86,16 @@ export let hasPriorityConfigFile = false;
|
|||||||
*/
|
*/
|
||||||
export async function loadPriorityConfig(arch: string): Promise<void> {
|
export async function loadPriorityConfig(arch: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const configUrl = `${APM_STORE_BASE_URL}/${arch}-store/priority-config.json`;
|
const configPath = `/${arch}-store/priority-config.json`;
|
||||||
const response = await fetch(configUrl, {
|
console.log(
|
||||||
method: "GET",
|
`[PriorityConfig] 开始加载优先级配置: ${APM_STORE_BASE_URL}${configPath}`,
|
||||||
cache: "no-cache",
|
);
|
||||||
});
|
const response = await priorityConfigAxios.get(configPath);
|
||||||
|
const config = response.data;
|
||||||
if (response.ok) {
|
// 顶层结构校验:畸形数据(非对象/为数组/null)直接走失败回退,避免访问属性抛错
|
||||||
const config = await response.json();
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||||
hasPriorityConfigFile = true;
|
throw new Error("Invalid priority-config format: expected object");
|
||||||
|
}
|
||||||
// 支持新旧两种配置格式
|
// 支持新旧两种配置格式
|
||||||
if (config.sparkPriority || config.apmPriority) {
|
if (config.sparkPriority || config.apmPriority) {
|
||||||
// 新格式:双向配置
|
// 新格式:双向配置
|
||||||
@@ -116,27 +126,31 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
isPriorityConfigLoaded = true;
|
// 仅打印规则计数而非完整配置,避免潜在敏感信息泄露(诊断用)
|
||||||
|
const ruleCount = (r: {
|
||||||
|
pkgnames: string[];
|
||||||
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
}) => r.pkgnames.length + r.categories.length + r.tags.length;
|
||||||
console.log(
|
console.log(
|
||||||
"[PriorityConfig] 已从服务器加载优先级配置:",
|
`[PriorityConfig] 已从服务器加载优先级配置: spark ${ruleCount(
|
||||||
dynamicPriorityConfig,
|
dynamicPriorityConfig.sparkPriority,
|
||||||
|
)} 条, apm ${ruleCount(dynamicPriorityConfig.apmPriority)} 条`,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// 配置文件不存在,默认优先 Spark
|
|
||||||
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
|
|
||||||
hasPriorityConfigFile = false;
|
|
||||||
resetPriorityConfig();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 获取失败,默认优先 Spark
|
// 获取失败(含 404:服务器无配置文件),默认优先 APM。
|
||||||
console.warn("[PriorityConfig] 加载配置失败,使用默认 Spark 优先:", error);
|
// 注:此处为配置缺失/加载失败,dynamicPriorityConfig 重置为空规则,
|
||||||
hasPriorityConfigFile = false;
|
// 由 getHybridDefaultOrigin 回退 HYBRID_DEFAULT_PRIORITY(默认 APM)。
|
||||||
|
console.warn(
|
||||||
|
`[PriorityConfig] 加载配置失败(${APM_STORE_BASE_URL}/${arch}-store/priority-config.json),使用默认 APM 优先:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
resetPriorityConfig();
|
resetPriorityConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置优先级配置为默认值
|
* 重置优先级配置为默认值(配置缺失/加载失败时调用)
|
||||||
*/
|
*/
|
||||||
function resetPriorityConfig(): void {
|
function resetPriorityConfig(): void {
|
||||||
dynamicPriorityConfig = {
|
dynamicPriorityConfig = {
|
||||||
@@ -151,73 +165,70 @@ function resetPriorityConfig(): void {
|
|||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
isPriorityConfigLoaded = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取混合模式下应用的默认优先来源
|
* 按优先级配置规则判断单个应用标识是否命中「优先来源」。
|
||||||
* 判断优先级(从高到低):
|
* 命中则返回 "apm" / "spark",未命中返回 null。
|
||||||
* 1. apmPriority.pkgnames - 强制优先 APM
|
* 规则优先级(从高到低):apmPriority.pkgnames > sparkPriority.pkgnames >
|
||||||
* 2. sparkPriority.pkgnames - 强制优先 Spark
|
* apmPriority.categories > sparkPriority.categories > apmPriority.tags > sparkPriority.tags
|
||||||
* 3. apmPriority.categories - 该分类优先 APM
|
*/
|
||||||
* 4. sparkPriority.categories - 该分类优先 Spark
|
function matchPriority(app: App): "apm" | "spark" | null {
|
||||||
* 5. apmPriority.tags - 包含标签优先 APM
|
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
|
||||||
* 6. sparkPriority.tags - 包含标签优先 Spark
|
|
||||||
* 7. 默认行为:
|
if (apmPriority.pkgnames.includes(app.pkgname)) return "apm";
|
||||||
* - 没有配置文件 → 优先 Spark
|
if (sparkPriority.pkgnames.includes(app.pkgname)) return "spark";
|
||||||
* - 有配置文件但规则为空 → 优先 APM
|
if (apmPriority.categories.includes(app.category)) return "apm";
|
||||||
|
if (sparkPriority.categories.includes(app.category)) return "spark";
|
||||||
|
|
||||||
|
if (app.tags && apmPriority.tags.length > 0) {
|
||||||
|
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
|
||||||
|
for (const ruleTag of apmPriority.tags) {
|
||||||
|
if (appTags.includes(ruleTag.toLowerCase())) return "apm";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.tags && sparkPriority.tags.length > 0) {
|
||||||
|
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
|
||||||
|
for (const ruleTag of sparkPriority.tags) {
|
||||||
|
if (appTags.includes(ruleTag.toLowerCase())) return "spark";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取混合模式下应用的默认优先来源(即「应用配置优先级」)。
|
||||||
|
* 对合并应用,依次用顶层标识、sparkApp、apmApp 去匹配优先级配置,
|
||||||
|
* 任一来源命中即生效(避免顶层 pkgname 取自某一子版而漏掉另一子版的规则)。
|
||||||
|
* 若两子版方向相反命中,遵从 apmPriority「例外优先」语义让 apm 胜出。
|
||||||
|
* 均不命中时回退到 HYBRID_DEFAULT_PRIORITY(默认优先 APM)。
|
||||||
* @param app 应用信息
|
* @param app 应用信息
|
||||||
* @returns "apm" 或 "spark"
|
* @returns "apm" 或 "spark"
|
||||||
*/
|
*/
|
||||||
export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
|
export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
|
||||||
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
|
const result = matchPriority(app);
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
// 1. 检查 APM 优先的包名(最高优先级)
|
// 合并应用:两个子版分别匹配,任一命中即采用。
|
||||||
if (apmPriority.pkgnames.includes(app.pkgname)) {
|
// 若子版自身 category 为空(如部分入口构造的 fallback 应用),
|
||||||
return "apm";
|
// 回退使用顶层 app.category 参与分类规则匹配,避免漏匹配。
|
||||||
|
if (app.isMerged) {
|
||||||
|
const fallbackCategory = app.category;
|
||||||
|
// 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配
|
||||||
|
const toCandidate = (sub: App): App =>
|
||||||
|
sub.category ? sub : { ...sub, category: fallbackCategory };
|
||||||
|
const sparkHit = app.sparkApp
|
||||||
|
? matchPriority(toCandidate(app.sparkApp))
|
||||||
|
: null;
|
||||||
|
const apmHit = app.apmApp ? matchPriority(toCandidate(app.apmApp)) : null;
|
||||||
|
|
||||||
|
// apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中
|
||||||
|
//(sparkApp 命中 spark 且 apmApp 命中 apm)时,按配置语义让 apm 胜出。
|
||||||
|
if (sparkHit === "spark" && apmHit === "apm") return "apm";
|
||||||
|
if (sparkHit) return sparkHit;
|
||||||
|
if (apmHit) return apmHit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Spark 优先的包名
|
// 默认行为:与 HYBRID_DEFAULT_PRIORITY 保持一致(默认优先 APM)
|
||||||
if (sparkPriority.pkgnames.includes(app.pkgname)) {
|
return HYBRID_DEFAULT_PRIORITY;
|
||||||
return "spark";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查 APM 优先的分类
|
|
||||||
if (apmPriority.categories.includes(app.category)) {
|
|
||||||
return "apm";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 检查 Spark 优先的分类
|
|
||||||
if (sparkPriority.categories.includes(app.category)) {
|
|
||||||
return "spark";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 检查 APM 优先的标签
|
|
||||||
if (app.tags && apmPriority.tags.length > 0) {
|
|
||||||
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
|
|
||||||
for (const ruleTag of apmPriority.tags) {
|
|
||||||
if (appTags.includes(ruleTag.toLowerCase())) {
|
|
||||||
return "apm";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 检查 Spark 优先的标签
|
|
||||||
if (app.tags && sparkPriority.tags.length > 0) {
|
|
||||||
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
|
|
||||||
for (const ruleTag of sparkPriority.tags) {
|
|
||||||
if (appTags.includes(ruleTag.toLowerCase())) {
|
|
||||||
return "spark";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 默认行为
|
|
||||||
if (!hasPriorityConfigFile) {
|
|
||||||
// 没有配置文件 → 默认优先 Spark
|
|
||||||
return "spark";
|
|
||||||
} else {
|
|
||||||
// 有配置文件但规则为空 → 默认优先 APM
|
|
||||||
return "apm";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 标签优先显示策略
|
||||||
|
//
|
||||||
|
// 用于在「应用详情页」决定进入时默认展示的来源(Spark / APM)标签。
|
||||||
|
// 该配置为纯前端显示偏好(选中的标签即安装时对应的来源),不涉及后端安装脚本,
|
||||||
|
// 因此使用 localStorage 持久化并通过共享 ref 在设置页与详情页之间共享。
|
||||||
|
//
|
||||||
|
// 策略取值:
|
||||||
|
// - "auto" : 自动选择 —— 按应用配置的优先级(getHybridDefaultOrigin)自动选中
|
||||||
|
// - "spark" : Spark 优先 —— 默认选中 Spark 标签;若无 Spark 标签则回退优先级策略
|
||||||
|
// - "apm" : APM 优先 —— 默认选中 APM 标签;若无 APM 标签则回退优先级策略
|
||||||
|
// 注:当应用仅有一个来源标签时,无论策略如何都默认展示该标签。
|
||||||
|
// 注:若本地仅安装了某一个来源版本,详情页会优先展开已安装版本,此策略在都安装/都未安装时生效。
|
||||||
|
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export type TagPriorityStrategy = "auto" | "spark" | "apm";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "spark-store-tag-priority-strategy";
|
||||||
|
|
||||||
|
const VALID_STRATEGIES: TagPriorityStrategy[] = ["auto", "spark", "apm"];
|
||||||
|
|
||||||
|
// 共享响应式引用:设置页写入、详情页读取,保证跨组件同步
|
||||||
|
const strategy = ref<TagPriorityStrategy>("auto");
|
||||||
|
|
||||||
|
// 导出共享 ref,供详情页以响应式方式 watch(设置变化实时生效)
|
||||||
|
export const tagPriorityStrategyRef = strategy;
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
const readFromStorage = (): TagPriorityStrategy => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored && (VALID_STRATEGIES as string[]).includes(stored)) {
|
||||||
|
return stored as TagPriorityStrategy;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时静默降级为默认值
|
||||||
|
}
|
||||||
|
return "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次从持久化读取(仅执行一次),可在应用启动时调用
|
||||||
|
export const initTagPriorityStrategy = (): void => {
|
||||||
|
if (loaded) return;
|
||||||
|
strategy.value = readFromStorage();
|
||||||
|
loaded = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步获取当前策略(确保已加载过持久化值)
|
||||||
|
export const getTagPriorityStrategy = (): TagPriorityStrategy => {
|
||||||
|
if (!loaded) {
|
||||||
|
strategy.value = readFromStorage();
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
return strategy.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置并持久化策略
|
||||||
|
export const setTagPriorityStrategy = (value: TagPriorityStrategy): void => {
|
||||||
|
if (!(VALID_STRATEGIES as string[]).includes(value)) return;
|
||||||
|
strategy.value = value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时仅更新内存态,忽略写入失败
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -115,6 +115,9 @@ export interface App {
|
|||||||
sparkApp?: App; // Optional reference to the spark version
|
sparkApp?: App; // Optional reference to the spark version
|
||||||
apmApp?: App; // Optional reference to the apm version
|
apmApp?: App; // Optional reference to the apm version
|
||||||
viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal
|
viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal
|
||||||
|
forceViewingOrigin?: boolean; // true 表示 viewingOrigin 为父组件显式指定(如从已安装页按特定来源打开),优先级高于用户标签策略;未设置或 false 时由标签策略决定默认展示
|
||||||
|
origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项)
|
||||||
|
downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAppItem {
|
export interface UpdateAppItem {
|
||||||
@@ -153,6 +156,10 @@ export interface UpdateCenterItem {
|
|||||||
migrationSource?: UpdateSource;
|
migrationSource?: UpdateSource;
|
||||||
migrationTarget?: UpdateSource;
|
migrationTarget?: UpdateSource;
|
||||||
aptssVersion?: string;
|
aptssVersion?: string;
|
||||||
|
// 更新发布时间(毫秒时间戳),用于列表显示「X天前」;暂无数据时前端降级为「—」
|
||||||
|
updateTime?: number;
|
||||||
|
// 是否被系统锁定(apt-mark hold)。被锁定项默认不可批量选中,需用户单独开启强制安装
|
||||||
|
held?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCenterTaskState {
|
export interface UpdateCenterTaskState {
|
||||||
@@ -170,6 +177,8 @@ export interface UpdateCenterTaskState {
|
|||||||
export interface UpdateCenterStartTask {
|
export interface UpdateCenterStartTask {
|
||||||
taskKey: string;
|
taskKey: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
// 强制安装被系统锁定(apt-mark hold)的包
|
||||||
|
forceHeld?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCenterSnapshot {
|
export interface UpdateCenterSnapshot {
|
||||||
@@ -223,18 +232,19 @@ export interface CategoryInfo {
|
|||||||
zh: string;
|
zh: string;
|
||||||
origins?: string[];
|
origins?: string[];
|
||||||
origin?: "spark" | "apm";
|
origin?: "spark" | "apm";
|
||||||
[k: string]: unknown;
|
// 保留扩展点,避免使用宽泛的索引签名削弱类型安全
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeLink {
|
export interface HomeLink {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
// 数据源(homelinks.json)并不提供 icon 字段,图片统一由 imgUrl 提供,故设为可选
|
||||||
|
icon?: string;
|
||||||
more?: string;
|
more?: string;
|
||||||
imgUrl?: string;
|
imgUrl?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
origin?: "spark" | "apm";
|
origin?: "spark" | "apm";
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarEntry {
|
export interface SidebarEntry {
|
||||||
|
|||||||
@@ -219,10 +219,29 @@ window.ipcRenderer.on("install-log", (_event, log: InstallLog) => {
|
|||||||
window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
|
window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
|
||||||
const downloadObj = downloads.value.find((d) => d.id === log.id);
|
const downloadObj = downloads.value.find((d) => d.id === log.id);
|
||||||
if (downloadObj) {
|
if (downloadObj) {
|
||||||
|
// 更新中心来源的任务用「更新」语义,其余(普通安装)用「安装」语义,
|
||||||
|
// 避免把安装结果误报为「下载完成」。
|
||||||
|
const isUpdate = (downloadObj.source ?? "").toLowerCase().includes("update");
|
||||||
|
const doneLabel = isUpdate ? "更新完成" : "安装完成";
|
||||||
|
const failVerb = isUpdate ? "更新失败" : "安装失败";
|
||||||
|
|
||||||
if (log.success) {
|
if (log.success) {
|
||||||
downloadObj.status = "completed";
|
downloadObj.status = "completed";
|
||||||
|
downloadObj.logs.push({ time: Date.now(), message: doneLabel });
|
||||||
} else {
|
} else {
|
||||||
downloadObj.status = "failed";
|
downloadObj.status = "failed";
|
||||||
|
// 将失败原因写入日志,避免 UI 停在"正在获取 Metalink"等中间日志后突兀结束
|
||||||
|
let reason = "未知错误";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(log.message);
|
||||||
|
reason = parsed?.message || reason;
|
||||||
|
} catch {
|
||||||
|
reason = log.message || reason;
|
||||||
|
}
|
||||||
|
downloadObj.logs.push({
|
||||||
|
time: Date.now(),
|
||||||
|
message: `${failVerb}: ${reason}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 排行榜与荣耀榜聚合工具
|
||||||
|
* - 下载排行:由 App.vue 预计算好的 apmRanking / sparkRanking 直接传入视图,本模块不重复拉取。
|
||||||
|
* - 更新排行 / 贡献荣耀榜 / 更新荣耀榜:基于已加载的 `apps` 实时聚合,无额外网络请求。
|
||||||
|
*/
|
||||||
|
import type { App } from "../global/typedefinition";
|
||||||
|
|
||||||
|
/** 榜单统一条数(需求确认:10 条) */
|
||||||
|
export const TOP_N = 10;
|
||||||
|
|
||||||
|
/** 更新荣耀榜取「最近更新」应用的窗口大小(用于聚合这部分贡献者) */
|
||||||
|
export const RECENT_UPDATE_WINDOW = 30;
|
||||||
|
|
||||||
|
export type AppOrigin = "spark" | "apm";
|
||||||
|
|
||||||
|
export interface ContributorRank {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 contributor 字段。
|
||||||
|
* 原始格式形如 `name<email>`,可能存在多个贡献者以 `;` / `;` 分隔。
|
||||||
|
* 聚合与展示统一去除 `<...>` 取显示名。
|
||||||
|
*/
|
||||||
|
export function parseContributors(raw: string | undefined): string[] {
|
||||||
|
if (!raw || typeof raw !== "string") return [];
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const part of raw.split(/[;;]/)) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
// 去除 <...> 包裹的邮箱,取显示名;长度 1~50 过滤空字符串/异常超长
|
||||||
|
const name = trimmed.replace(/<[^>]*>/g, "").trim();
|
||||||
|
if (name.length > 0 && name.length <= 50) names.push(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对已过滤来源的应用列表,按 contributor 出现次数计数 */
|
||||||
|
function countContributors(apps: App[]): Map<string, number> {
|
||||||
|
const counter = new Map<string, number>();
|
||||||
|
for (const app of apps) {
|
||||||
|
for (const name of parseContributors(app.contributor)) {
|
||||||
|
counter.set(name, (counter.get(name) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRanks(counter: Map<string, number>, topN: number): ContributorRank[] {
|
||||||
|
return [...counter.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||||
|
.slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 贡献荣耀榜:按来源统计每个贡献者的上榜应用数,取 Top N */
|
||||||
|
export function aggregateContributors(
|
||||||
|
apps: App[],
|
||||||
|
origin: AppOrigin,
|
||||||
|
topN: number = TOP_N,
|
||||||
|
): ContributorRank[] {
|
||||||
|
const filtered = apps.filter((a) => a.origin === origin);
|
||||||
|
return toRanks(countContributors(filtered), topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用更新排行:按 `update` 字段倒序,取某来源的 Top N */
|
||||||
|
export function topByUpdate(
|
||||||
|
apps: App[],
|
||||||
|
origin: AppOrigin,
|
||||||
|
topN: number = TOP_N,
|
||||||
|
): App[] {
|
||||||
|
// 按 update 字段倒序;后端日期格式可能不严格一致(如 2026-08-10 / 2026/08/10 /
|
||||||
|
// 带时分秒),用时间戳数字比较比 localeCompare 字符串比较更鲁棒。
|
||||||
|
// 无效日期 getTime() 为 NaN,统一按 0 处理,避免 NaN 参与比较导致乱序。
|
||||||
|
const toTime = (s: string | undefined): number => {
|
||||||
|
const t = new Date(s || "").getTime();
|
||||||
|
return Number.isFinite(t) ? t : 0;
|
||||||
|
};
|
||||||
|
return apps
|
||||||
|
.filter((a) => a.origin === origin && a.update)
|
||||||
|
.sort((a, b) => toTime(b.update) - toTime(a.update))
|
||||||
|
.slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新荣耀榜:取某来源「最近更新」窗口内的应用,聚合其贡献者,取 Top N */
|
||||||
|
export function topUpdatedContributors(
|
||||||
|
apps: App[],
|
||||||
|
origin: AppOrigin,
|
||||||
|
recentN: number = RECENT_UPDATE_WINDOW,
|
||||||
|
topN: number = TOP_N,
|
||||||
|
): ContributorRank[] {
|
||||||
|
// topByUpdate 内部已按 origin 过滤,无需再次 filter
|
||||||
|
const recent = topByUpdate(apps, origin, recentN);
|
||||||
|
return toRanks(countContributors(recent), topN);
|
||||||
|
}
|
||||||
+127
-21
@@ -24,10 +24,12 @@ export interface UpdateCenterStore {
|
|||||||
showMigrationConfirm: Ref<boolean>;
|
showMigrationConfirm: Ref<boolean>;
|
||||||
searchQuery: Ref<string>;
|
searchQuery: Ref<string>;
|
||||||
selectedTaskKeys: Ref<Set<string>>;
|
selectedTaskKeys: Ref<Set<string>>;
|
||||||
|
forcedTaskKeys: Ref<Set<string>>;
|
||||||
snapshot: Ref<UpdateCenterSnapshot>;
|
snapshot: Ref<UpdateCenterSnapshot>;
|
||||||
filteredItems: ComputedRef<UpdateCenterItem[]>;
|
filteredItems: ComputedRef<UpdateCenterItem[]>;
|
||||||
allSelected: ComputedRef<boolean>;
|
allSelected: ComputedRef<boolean>;
|
||||||
someSelected: ComputedRef<boolean>;
|
someSelected: ComputedRef<boolean>;
|
||||||
|
selectableCount: ComputedRef<number>;
|
||||||
bind: () => void;
|
bind: () => void;
|
||||||
unbind: () => void;
|
unbind: () => void;
|
||||||
open: (storeFilter?: StoreFilter) => Promise<void>;
|
open: (storeFilter?: StoreFilter) => Promise<void>;
|
||||||
@@ -35,6 +37,7 @@ export interface UpdateCenterStore {
|
|||||||
ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
|
ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
|
||||||
unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
|
unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
|
||||||
toggleSelection: (taskKey: string) => void;
|
toggleSelection: (taskKey: string) => void;
|
||||||
|
toggleForce: (taskKey: string) => void;
|
||||||
toggleSelectAll: () => void;
|
toggleSelectAll: () => void;
|
||||||
getSelectedItems: () => UpdateCenterItem[];
|
getSelectedItems: () => UpdateCenterItem[];
|
||||||
closeNow: () => void;
|
closeNow: () => void;
|
||||||
@@ -60,21 +63,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
const showMigrationConfirm = ref(false);
|
const showMigrationConfirm = ref(false);
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const selectedTaskKeys = ref(new Set<string>());
|
const selectedTaskKeys = ref(new Set<string>());
|
||||||
|
// 强制安装集合:仅当前会话内有效(刷新/重开更新中心即重置),不持久化。
|
||||||
|
// 仅对被系统锁定(held)的项有意义:开启后该 held 项可被单独选中并升级。
|
||||||
|
const forcedTaskKeys = ref(new Set<string>());
|
||||||
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
|
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
|
||||||
let lastStoreFilter: StoreFilter = "both";
|
let lastStoreFilter: StoreFilter = "both";
|
||||||
|
|
||||||
|
// 判断某 item 是否可被选中:未被忽略,且(未被锁定 或 已开启强制安装)
|
||||||
|
const isSelectable = (item: UpdateCenterItem): boolean => {
|
||||||
|
if (item.ignored === true) return false;
|
||||||
|
if (item.held === true && !forcedTaskKeys.value.has(item.taskKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const resetSessionState = (): void => {
|
const resetSessionState = (): void => {
|
||||||
showCloseConfirm.value = false;
|
showCloseConfirm.value = false;
|
||||||
showMigrationConfirm.value = false;
|
showMigrationConfirm.value = false;
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
selectedTaskKeys.value = new Set();
|
selectedTaskKeys.value = new Set();
|
||||||
|
forcedTaskKeys.value = new Set();
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
|
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
|
||||||
const selectableTaskKeys = new Set(
|
const selectableTaskKeys = new Set(
|
||||||
nextSnapshot.items
|
nextSnapshot.items.filter(isSelectable).map((item) => item.taskKey),
|
||||||
.filter((item) => item.ignored !== true)
|
|
||||||
.map((item) => item.taskKey),
|
|
||||||
);
|
);
|
||||||
selectedTaskKeys.value = new Set(
|
selectedTaskKeys.value = new Set(
|
||||||
[...selectedTaskKeys.value].filter((taskKey) =>
|
[...selectedTaskKeys.value].filter((taskKey) =>
|
||||||
@@ -85,12 +99,20 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectableItems = computed(() =>
|
const selectableItems = computed(() =>
|
||||||
snapshot.value.items.filter((item) => item.ignored !== true),
|
snapshot.value.items.filter(isSelectable),
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const query = searchQuery.value.trim();
|
const query = searchQuery.value.trim();
|
||||||
return snapshot.value.items.filter((item) => matchesSearch(item, query));
|
const matched = snapshot.value.items.filter((item) =>
|
||||||
|
matchesSearch(item, query),
|
||||||
|
);
|
||||||
|
// 已忽略项沉底:可选(含已强制的锁定项)在前、不可选(已忽略 / 未强制的锁定项)在后,
|
||||||
|
// 各自保持原有顺序
|
||||||
|
return [
|
||||||
|
...matched.filter(isSelectable),
|
||||||
|
...matched.filter((item) => !isSelectable(item)),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const allSelected = computed(() => {
|
const allSelected = computed(() => {
|
||||||
@@ -109,6 +131,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 可选项数量:被忽略 / 被锁定未强制的项不计入。用于全选按钮可用性判断。
|
||||||
|
const selectableCount = computed(() => selectableItems.value.length);
|
||||||
|
|
||||||
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
|
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
|
||||||
applySnapshot(nextSnapshot);
|
applySnapshot(nextSnapshot);
|
||||||
};
|
};
|
||||||
@@ -133,30 +158,27 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
isBound = false;
|
isBound = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = async (storeFilter: StoreFilter = "both"): Promise<void> => {
|
// 刷新软件源(不加载列表):网络慢/卡死由主进程超时保护,不会永久挂起
|
||||||
lastStoreFilter = storeFilter;
|
const runSystemUpdate = async (storeFilter: StoreFilter): Promise<void> => {
|
||||||
resetSessionState();
|
|
||||||
isOpen.value = true;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
try {
|
||||||
const nextSnapshot = await window.updateCenter.open(storeFilter);
|
await window.ipcRenderer.invoke(
|
||||||
applySnapshot(nextSnapshot);
|
"update-center-run-system-update",
|
||||||
} finally {
|
storeFilter,
|
||||||
loading.value = false;
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[UpdateCenter] system update failed", error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 主动刷新:先刷新源再加载列表(用户点击刷新按钮时使用,期望即时结果)
|
||||||
const refresh = async (
|
const refresh = async (
|
||||||
storeFilter: StoreFilter = lastStoreFilter,
|
storeFilter: StoreFilter = lastStoreFilter,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
lastStoreFilter = storeFilter;
|
lastStoreFilter = storeFilter;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 先运行系统更新(aptss update / apm update),确保本地包信息最新
|
await runSystemUpdate(storeFilter);
|
||||||
await window.ipcRenderer.invoke(
|
|
||||||
"update-center-run-system-update",
|
|
||||||
storeFilter,
|
|
||||||
);
|
|
||||||
const nextSnapshot = await window.updateCenter.refresh(storeFilter);
|
const nextSnapshot = await window.updateCenter.refresh(storeFilter);
|
||||||
applySnapshot(nextSnapshot);
|
applySnapshot(nextSnapshot);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -164,6 +186,60 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 打开更新中心:先用缓存秒开列表(不卡 UI),再后台刷新源并重新加载。
|
||||||
|
// 后台刷新带指数退避重试;窗口关闭即停,避免无效重试。
|
||||||
|
// 与 electron/main/index.ts 的预刷新重试为【对称设计】,非代码遗漏:
|
||||||
|
// 前端负责“打开更新中心兜底”,主进程负责“启动预热”,
|
||||||
|
// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。
|
||||||
|
const MAX_BACKGROUND_RETRIES = 3;
|
||||||
|
const BACKGROUND_BACKOFF_MS = [2000, 4000, 8000];
|
||||||
|
|
||||||
|
// 按重试次数取退避毫秒(越界时回退到最大间隔)
|
||||||
|
const getBackoffDelay = (attempt: number): number =>
|
||||||
|
BACKGROUND_BACKOFF_MS[attempt - 1] ??
|
||||||
|
BACKGROUND_BACKOFF_MS[BACKGROUND_BACKOFF_MS.length - 1];
|
||||||
|
|
||||||
|
const backgroundRefresh = async (
|
||||||
|
storeFilter: StoreFilter,
|
||||||
|
attempt: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!isOpen.value) return; // 窗口已关闭,停止重试
|
||||||
|
try {
|
||||||
|
await runSystemUpdate(storeFilter);
|
||||||
|
const nextSnapshot = await window.updateCenter.refresh(storeFilter);
|
||||||
|
if (!isOpen.value) return;
|
||||||
|
applySnapshot(nextSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= MAX_BACKGROUND_RETRIES) {
|
||||||
|
console.warn(
|
||||||
|
`[UpdateCenter] background refresh failed after ${MAX_BACKGROUND_RETRIES} attempts`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = getBackoffDelay(attempt);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void backgroundRefresh(storeFilter, attempt + 1);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = async (storeFilter: StoreFilter = "both"): Promise<void> => {
|
||||||
|
lastStoreFilter = storeFilter;
|
||||||
|
resetSessionState();
|
||||||
|
isOpen.value = true;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 1. 先加载缓存,立即显示列表(秒开,不阻塞于网络刷新)
|
||||||
|
const cachedSnapshot = await window.updateCenter.open(storeFilter);
|
||||||
|
applySnapshot(cachedSnapshot);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
// 2. 后台异步刷新源并在完成后更新列表(失败自动重试)
|
||||||
|
void backgroundRefresh(storeFilter, 1);
|
||||||
|
};
|
||||||
|
|
||||||
const ignoreItem = async (
|
const ignoreItem = async (
|
||||||
packageName: string,
|
packageName: string,
|
||||||
newVersion: string,
|
newVersion: string,
|
||||||
@@ -182,7 +258,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
const item = snapshot.value.items.find(
|
const item = snapshot.value.items.find(
|
||||||
(entry) => entry.taskKey === taskKey,
|
(entry) => entry.taskKey === taskKey,
|
||||||
);
|
);
|
||||||
if (!item || item.ignored === true) {
|
if (!item || !isSelectable(item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +272,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
selectedTaskKeys.value = nextSelection;
|
selectedTaskKeys.value = nextSelection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 切换「强制安装」开关(仅对被系统锁定的包有意义)。
|
||||||
|
// 关闭强制时,若该包已被选中则同步取消其选中态。
|
||||||
|
const toggleForce = (taskKey: string): void => {
|
||||||
|
const item = snapshot.value.items.find(
|
||||||
|
(entry) => entry.taskKey === taskKey,
|
||||||
|
);
|
||||||
|
if (!item || item.held !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextForced = new Set(forcedTaskKeys.value);
|
||||||
|
if (nextForced.has(taskKey)) {
|
||||||
|
nextForced.delete(taskKey);
|
||||||
|
// 取消强制后该包不可再被选中,移出选中集
|
||||||
|
if (selectedTaskKeys.value.has(taskKey)) {
|
||||||
|
const nextSelection = new Set(selectedTaskKeys.value);
|
||||||
|
nextSelection.delete(taskKey);
|
||||||
|
selectedTaskKeys.value = nextSelection;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextForced.add(taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
forcedTaskKeys.value = nextForced;
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSelectAll = (): void => {
|
const toggleSelectAll = (): void => {
|
||||||
const selectable = selectableItems.value;
|
const selectable = selectableItems.value;
|
||||||
if (allSelected.value) {
|
if (allSelected.value) {
|
||||||
@@ -208,7 +310,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
const getSelectedItems = (): UpdateCenterItem[] => {
|
const getSelectedItems = (): UpdateCenterItem[] => {
|
||||||
return snapshot.value.items.filter(
|
return snapshot.value.items.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
|
selectedTaskKeys.value.has(item.taskKey) && isSelectable(item),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,6 +372,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
startTasks.push({
|
startTasks.push({
|
||||||
taskKey: item.taskKey,
|
taskKey: item.taskKey,
|
||||||
id: downloadId,
|
id: downloadId,
|
||||||
|
forceHeld: item.held === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -293,10 +396,12 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
showMigrationConfirm,
|
showMigrationConfirm,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTaskKeys,
|
selectedTaskKeys,
|
||||||
|
forcedTaskKeys,
|
||||||
snapshot,
|
snapshot,
|
||||||
filteredItems,
|
filteredItems,
|
||||||
allSelected,
|
allSelected,
|
||||||
someSelected,
|
someSelected,
|
||||||
|
selectableCount,
|
||||||
bind,
|
bind,
|
||||||
unbind,
|
unbind,
|
||||||
open,
|
open,
|
||||||
@@ -304,6 +409,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
ignoreItem,
|
ignoreItem,
|
||||||
unignoreItem,
|
unignoreItem,
|
||||||
toggleSelection,
|
toggleSelection,
|
||||||
|
toggleForce,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
getSelectedItems,
|
getSelectedItems,
|
||||||
closeNow,
|
closeNow,
|
||||||
|
|||||||
Vendored
+4
@@ -22,6 +22,9 @@ declare global {
|
|||||||
};
|
};
|
||||||
windowControls: WindowControlBridge;
|
windowControls: WindowControlBridge;
|
||||||
updateCenter: UpdateCenterBridge;
|
updateCenter: UpdateCenterBridge;
|
||||||
|
electronUtils: {
|
||||||
|
getPathForFile: (file: File) => string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ interface WindowControlBridge {
|
|||||||
minimize: () => void;
|
minimize: () => void;
|
||||||
toggleMaximize: () => void;
|
toggleMaximize: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
|
state: () => Promise<| 'normal' | 'maximized' | 'minimized'>
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPC channel type definitions
|
// IPC channel type definitions
|
||||||
|
|||||||
+5
-5
@@ -16,11 +16,11 @@ load_transhell
|
|||||||
if [[ "$IS_APM_ENV" = "" ]] ;then
|
if [[ "$IS_APM_ENV" = "" ]] ;then
|
||||||
case $(arch) in
|
case $(arch) in
|
||||||
x86_64 | i686 | i386)
|
x86_64 | i686 | i386)
|
||||||
STORE_URL="store"
|
STORE_URL="amd64-store"
|
||||||
STORE_LIST_URL=""
|
STORE_LIST_URL=""
|
||||||
;;
|
;;
|
||||||
aarch64)
|
aarch64)
|
||||||
STORE_URL="aarch64-store"
|
STORE_URL="arm64-store"
|
||||||
STORE_LIST_URL="-aarch64"
|
STORE_LIST_URL="-aarch64"
|
||||||
;;
|
;;
|
||||||
loongarch64)
|
loongarch64)
|
||||||
@@ -62,13 +62,13 @@ is_empty_dir(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update_list(){
|
function update_list(){
|
||||||
curl --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list"
|
curl -L --progress-bar -o /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list "${SPARK_DOWNLOAD_SERVER_URL}/${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list"
|
||||||
log.info "${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list update done"
|
log.info "${UPSTREAM_CATOGARY}${STORE_LIST_URL}.list update done"
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_conf(){
|
function update_conf(){
|
||||||
mkdir -p /tmp/aptss-conf/
|
mkdir -p /tmp/aptss-conf/
|
||||||
curl --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf"
|
curl -L --progress-bar -o /tmp/aptss-conf/apt-fast.conf "${SPARK_DOWNLOAD_SERVER_URL}/apt-fast.conf"
|
||||||
log.info "apt-fast.conf update done"
|
log.info "apt-fast.conf update done"
|
||||||
chmod -R 755 /tmp/aptss-conf
|
chmod -R 755 /tmp/aptss-conf
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ update_conf
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages" ];then
|
if [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ] && [ ! -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ];then
|
||||||
|
|
||||||
mkdir -p /tmp/aptss-conf/
|
mkdir -p /tmp/aptss-conf/
|
||||||
echo -e "\e[1;32m${TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST}\e[0m"
|
echo -e "\e[1;32m${TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST}\e[0m"
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ if grep -q "ID=aosc" /etc/os-release; then
|
|||||||
ARGS="$ARGS --no-spark"
|
ARGS="$ARGS --no-spark"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 检查龙GPU,添加 --disable-gpu
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "loongarch64" ] || [ "$ARCH" = "loong64" ]; then
|
||||||
|
is_loonggpu=$(lspci -s $(basename $(readlink $(grep -l connected /sys/class/drm/card*/*/status 2>/dev/null | head -1 | grep -o 'card[0-9]*' | xargs -I{} echo /sys/class/drm/{}/device))) | grep -qi loongson && echo "Found" || echo "NotFound")
|
||||||
|
if [ "$is_loonggpu" = "Found" ]; then
|
||||||
|
echo "检测到龙GPU"
|
||||||
|
ARGS="$ARGS --disable-gpu"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
|
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
|
||||||
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
|
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
|
||||||
|
|
||||||
+8
-5
@@ -62,11 +62,11 @@ export DEBIAN_FRONTEND=noninteractive
|
|||||||
if [[ "$IS_APM_ENV" = "" ]] ;then
|
if [[ "$IS_APM_ENV" = "" ]] ;then
|
||||||
case $(arch) in
|
case $(arch) in
|
||||||
x86_64 | i686 | i386)
|
x86_64 | i686 | i386)
|
||||||
STORE_URL="store"
|
STORE_URL="amd64-store"
|
||||||
STORE_LIST_URL=""
|
STORE_LIST_URL=""
|
||||||
;;
|
;;
|
||||||
aarch64)
|
aarch64)
|
||||||
STORE_URL="aarch64-store"
|
STORE_URL="arm64-store"
|
||||||
STORE_LIST_URL="-aarch64"
|
STORE_LIST_URL="-aarch64"
|
||||||
;;
|
;;
|
||||||
loongarch64)
|
loongarch64)
|
||||||
@@ -195,10 +195,13 @@ function hash_check() {
|
|||||||
# 检查可能的仓库位置
|
# 检查可能的仓库位置
|
||||||
if [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ]; then
|
if [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages" ]; then
|
||||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages"
|
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_${STORE_URL}_Packages"
|
||||||
elif [ -e "/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages" ]; then
|
elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages" ]; then
|
||||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/d.store.deepinos.org.cn_${STORE_URL}_Packages"
|
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_store_Packages"
|
||||||
|
elif [ -e "/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages" ]; then
|
||||||
|
PACKAGES_DATA_PATH="/var/lib/aptss/lists/${SPARK_DOWNLOAD_SERVER_URL_NO_PROTOCOL}_aarch64-store_Packages"
|
||||||
else
|
else
|
||||||
PACKAGES_DATA_PATH="/var/lib/aptss/lists/mirrors.sdu.edu.cn_spark-store-repository_${STORE_URL}_Packages"
|
echo "ERROR:NO_PACKAGE_FILE_FOUND!"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "正在运行包验证..."
|
echo "正在运行包验证..."
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>更新中心 · hold/强制安装 设计草图</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--brand:#0071e3; --brand-dark:#0066cc; --brand-soft:#409cff;
|
||||||
|
--amber:#f59e0b; --amber-soft:#fbbf24;
|
||||||
|
--bg:#f5f7fb; --card:#fff; --line:#e2e8f0; --txt:#0f172a; --sub:#64748b;
|
||||||
|
--danger:#ef4444;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;font-family:"Inter","system-ui","Microsoft YaHei",sans-serif;}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--txt);padding:32px;}
|
||||||
|
h1{font-size:20px;margin:0 0 4px;}
|
||||||
|
.hint{color:var(--sub);font-size:13px;margin-bottom:24px;line-height:1.6;}
|
||||||
|
.modal{width:100%;max-width:960px;margin:0 auto;background:var(--card);border:1px solid var(--line);
|
||||||
|
border-radius:24px;overflow:hidden;box-shadow:0 20px 50px rgba(15,23,42,.12);}
|
||||||
|
.toolbar{display:flex;align-items:center;gap:12px;padding:18px 24px;border-bottom:1px solid var(--line);}
|
||||||
|
.toolbar .title{font-weight:700;font-size:16px;}
|
||||||
|
.toolbar .spacer{flex:1;}
|
||||||
|
.btn{border:1px solid var(--line);background:#fff;border-radius:12px;padding:8px 14px;font-size:13px;cursor:pointer;}
|
||||||
|
.btn-primary{background:var(--brand);color:#fff;border-color:var(--brand);}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||||
|
.warn{margin:16px 24px;background:#fffbeb;border:1px solid #fde68a;color:#92400e;
|
||||||
|
border-radius:16px;padding:12px 16px;font-size:13px;line-height:1.6;}
|
||||||
|
.list{padding:8px 16px 20px;}
|
||||||
|
.item{position:relative;display:flex;align-items:center;gap:12px;border:1px solid var(--line);
|
||||||
|
border-radius:16px;padding:12px 16px;margin-top:10px;}
|
||||||
|
.item.held{background:#fffdf5;border-color:#fde68a;}
|
||||||
|
.item.sel{background:rgba(0,113,227,.04);}
|
||||||
|
.chk{width:18px;height:18px;accent-color:var(--brand);}
|
||||||
|
.icon{width:40px;height:40px;border-radius:10px;background:#eef2f7;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:18px;}
|
||||||
|
.main{flex:1;min-width:0;}
|
||||||
|
.name-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
|
||||||
|
.name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.tag{font-size:10px;font-weight:600;border-radius:999px;padding:2px 8px;}
|
||||||
|
.tag-src{background:#eef2f7;color:#475569;}
|
||||||
|
.tag-held{background:#fef3c7;color:#b45309;}
|
||||||
|
.tag-ign{background:#e2e8f0;color:#64748b;}
|
||||||
|
.ver{font-size:12px;color:var(--sub);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.right{text-align:right;font-size:12px;color:#475569;white-space:nowrap;}
|
||||||
|
.right .t{color:var(--brand);font-weight:600;font-size:11px;}
|
||||||
|
.right .s{color:#94a3b8;font-size:10px;}
|
||||||
|
.actions{display:flex;align-items:center;gap:6px;}
|
||||||
|
.iconbtn{width:32px;height:32px;border-radius:10px;border:1px solid var(--line);background:#fff;
|
||||||
|
display:flex;align-items:center;justify-content:center;cursor:pointer;color:#475569;font-size:13px;}
|
||||||
|
.iconbtn.amber{color:var(--amber);}
|
||||||
|
.iconbtn.amber:hover{background:#fffbeb;}
|
||||||
|
|
||||||
|
/* 强制安装:开关样式 */
|
||||||
|
.force{display:flex;align-items:center;gap:6px;}
|
||||||
|
.switch{position:relative;width:34px;height:18px;border-radius:999px;background:#cbd5e1;transition:.2s;flex:none;}
|
||||||
|
.switch.on{background:var(--brand);}
|
||||||
|
.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:.2s;}
|
||||||
|
.switch.on::after{left:18px;}
|
||||||
|
.force-label{font-size:11px;color:var(--sub);}
|
||||||
|
|
||||||
|
/* 底部说明条 */
|
||||||
|
.legend{margin:0 24px 20px;font-size:12px;color:var(--sub);line-height:1.8;
|
||||||
|
border-top:1px dashed var(--line);padding-top:14px;}
|
||||||
|
.legend b{color:var(--txt);}
|
||||||
|
|
||||||
|
/* 布局说明块 */
|
||||||
|
.notes{max-width:960px;margin:28px auto 0;background:#fff;border:1px solid var(--line);
|
||||||
|
border-radius:16px;padding:18px 22px;font-size:13px;line-height:1.8;color:#334155;}
|
||||||
|
.notes h2{font-size:15px;margin:0 0 10px;}
|
||||||
|
.notes code{background:#f1f5f9;padding:1px 6px;border-radius:6px;font-size:12px;color:#0f172a;}
|
||||||
|
.notes li{margin-bottom:6px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>更新中心 — hold 标签 & 强制安装(设计草图)</h1>
|
||||||
|
<p class="hint">
|
||||||
|
说明:与现有「忽略」机制一致 —— 被系统 <code>apt-mark hold</code> 的包在更新中心默认<b>不可选中</b>(复选框禁用),
|
||||||
|
并在软件上显示「已锁定 / hold」标签;同时该软件行提供<b>「强制安装」开关</b>,打开后该包可单独被选中并升级
|
||||||
|
(后端对 spark 源追加 <code>--allow-change-held-packages</code>)。其他功能(忽略、批量升级、进度)不变。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="modal">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="title">更新中心</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn">全选</button>
|
||||||
|
<button class="btn">刷新</button>
|
||||||
|
<button class="btn" disabled>开始更新 (2)</button>
|
||||||
|
<button class="btn">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
⚠ 检测到 1 个软件被系统锁定(hold),默认不会升级。如需强制升级,请在对应软件上打开「强制安装」。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
<!-- 普通可升级项 -->
|
||||||
|
<div class="item sel">
|
||||||
|
<input type="checkbox" class="chk" checked />
|
||||||
|
<div class="icon">📦</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="name-row">
|
||||||
|
<span class="name">deepin-terminal</span>
|
||||||
|
<span class="tag tag-src">传统deb</span>
|
||||||
|
</div>
|
||||||
|
<div class="ver">6.0.1 → 6.0.2</div>
|
||||||
|
</div>
|
||||||
|
<div class="right"><div class="t">今天</div><div class="s">48 MB</div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="iconbtn amber" title="忽略更新">👁̶</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 被 hold 的软件:默认禁用复选框 + 显示 hold 标签 + 提供强制安装开关 -->
|
||||||
|
<div class="item held">
|
||||||
|
<input type="checkbox" class="chk" disabled title="已被系统锁定,需开启强制安装" />
|
||||||
|
<div class="icon">📦</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="name-row">
|
||||||
|
<span class="name">code</span>
|
||||||
|
<span class="tag tag-src">传统deb</span>
|
||||||
|
<span class="tag tag-held">已锁定</span>
|
||||||
|
</div>
|
||||||
|
<div class="ver">1.133.0 → 1.133.1</div>
|
||||||
|
</div>
|
||||||
|
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<!-- 强制安装开关 -->
|
||||||
|
<div class="force" title="强制安装被锁定的软件">
|
||||||
|
<div class="switch"></div>
|
||||||
|
<span class="force-label">强制</span>
|
||||||
|
</div>
|
||||||
|
<div class="iconbtn amber" title="忽略更新">👁̶</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已开启强制安装的 hold 软件:复选框可用,标签变为「将强制」 -->
|
||||||
|
<div class="item held sel">
|
||||||
|
<input type="checkbox" class="chk" checked />
|
||||||
|
<div class="icon">📦</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="name-row">
|
||||||
|
<span class="name">code</span>
|
||||||
|
<span class="tag tag-src">传统deb</span>
|
||||||
|
<span class="tag tag-held">将强制</span>
|
||||||
|
</div>
|
||||||
|
<div class="ver">1.133.0 → 1.133.1</div>
|
||||||
|
</div>
|
||||||
|
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="force" title="强制安装被锁定的软件">
|
||||||
|
<div class="switch on"></div>
|
||||||
|
<span class="force-label">强制</span>
|
||||||
|
</div>
|
||||||
|
<div class="iconbtn amber" title="忽略更新">👁̶</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已忽略项(沉底,仅作对照) -->
|
||||||
|
<div class="item" style="opacity:.7;">
|
||||||
|
<input type="checkbox" class="chk" disabled />
|
||||||
|
<div class="icon">📦</div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="name-row">
|
||||||
|
<span class="name">old-app</span>
|
||||||
|
<span class="tag tag-src">APM</span>
|
||||||
|
<span class="tag tag-ign">已忽略</span>
|
||||||
|
</div>
|
||||||
|
<div class="ver">2.1 → 2.2</div>
|
||||||
|
</div>
|
||||||
|
<div class="right"><div class="t">30天前</div><div class="s">—</div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="iconbtn" title="取消忽略">↺</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<b>图例:</b>
|
||||||
|
① <b>普通项</b>:可正常勾选升级;
|
||||||
|
② <b>已锁定(hold)项</b>:复选框禁用、显示「已锁定」黄色标签,与「已忽略」一样沉底、不可随「全选」选中;
|
||||||
|
③ 每行末的 <b>「强制」开关</b> 仅对被锁定的软件显示,打开后复选框启用、标签变「将强制」,可被单独勾选升级;
|
||||||
|
④ <b>忽略</b>按钮(👁̶)对所有可操作项保留,逻辑不变。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<h2>实现逻辑(动手前请确认此方案)</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>数据来源</b>:后端 <code>service.ts</code> 构建 <code>UpdateCenterItem</code> 时,调用 <code>apt-mark showhold</code>(或 <code>dpkg</code>)检测该包是否被 hold,新增字段 <code>held?: boolean</code>;前端类型同步加该字段。</li>
|
||||||
|
<li><b>默认不可选</b>:与 <code>ignored</code> 一致——被 hold 的项的复选框 <code>:disabled</code>,<code>toggleSelection</code>/<code>selectableItems</code>/<code>filteredItems</code>/<code>start()</code> 中把它当作不可选(<code>held === true</code> 同 <code>ignored === true</code> 处理)。</li>
|
||||||
|
<li><b>强制开关</b>:前端 store 维护一个 <code>forcedTaskKeys: Set<string></code>;开关打开的项,即使 <code>held</code> 也允许被 <code>selected</code>。<code>start()</code> 时把 <code>forced</code> 标记带入 <code>QueueInstallPayload</code>。</li>
|
||||||
|
<li><b>后端执行</b>:<code>install-manager.ts</code> 的 <code>addInstallTask</code> 收到 <code>forceHeld: true</code> 且 <code>origin==="spark"</code> 时,给 <code>ssinstall</code> 追加 <code>--allow-change-held-packages</code>(仅 spark 源;apm 源不涉及 apt hold)。</li>
|
||||||
|
<li><b>状态文案修复(顺带)</b>:前次日志「下载完成」误把安装失败当成功;本次如需一并修,可在 <code>processInstall.ts</code> 区分下载/安装完成文案,并让 <code>install-complete</code> 的 success 真实反映 apt 退出码(ssinstall 放弃安装却返回 0 的问题需另行处理)。<em>此项是否做,等你指示。</em></li>
|
||||||
|
<li><b>「强制」状态不持久化</b>:与选中态一样,仅本次弹窗会话内有效,刷新/重开恢复默认(避免用户忘记关掉强制)。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user