mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
refactor: 基于 upstream/Erotica 重新整合本地功能(来源选择/合并详情/计数/窗口/首页)
将本地在 upstream !408(已安装应用合并+统计)之上的后续改动重新整合到最新 upstream/Erotica: - 已安装应用:卸载/打开按 APM/Spark 来源选择;计数总数=APM+Spark 包数(双来源计为独立包) - 查看详情:已安装页复用所有应用页合并详情视图(单来源优先已装类型,双来源自动模式) - 窗口:恢复阈值改为 1600x900,过大回退默认尺寸并居中,不自动最大化 - 首页推荐:致谢卡片移至其他内容下方,加大标题/卡片/致谢间距 - 隐藏排行榜/荣耀榜侧栏入口(代码保留,v-if=false) - 新增 scripts/test-build.sh 测试打包脚本(版本末段+1 加 -test)并更新 AGENTS.md 冲突文件(index.ts/App.vue/InstalledAppsModal.vue)均取本地完整实现(为上游版本的超集)。
This commit is contained in:
@@ -65,3 +65,6 @@ debian/files
|
||||
debian/tmp/
|
||||
debian/.debhelper/
|
||||
debian/spark-store/
|
||||
|
||||
# AI working memory (跨会话上下文,不进入上游)
|
||||
.codebuddy/
|
||||
|
||||
@@ -695,6 +695,23 @@ npm run build:rpm # 仅构建 RPM 包
|
||||
- `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:**
|
||||
|
||||
@@ -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` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。*
|
||||
+23
-9
@@ -289,6 +289,8 @@ const showAndFocusMainWindow = (): void => {
|
||||
// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸
|
||||
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;
|
||||
@@ -375,12 +377,24 @@ function scheduleSaveBounds(winInstance: BrowserWindow): void {
|
||||
|
||||
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({
|
||||
title: "星火应用商店",
|
||||
width: saved.width ?? DEFAULT_WINDOW_SIZE.width,
|
||||
height: saved.height ?? DEFAULT_WINDOW_SIZE.height,
|
||||
x: saved.x,
|
||||
y: saved.y,
|
||||
width: restoredWidth,
|
||||
height: restoredHeight,
|
||||
center: true,
|
||||
minWidth: MIN_WINDOW_SIZE.width,
|
||||
minHeight: MIN_WINDOW_SIZE.height,
|
||||
frame: false,
|
||||
@@ -398,11 +412,11 @@ async function createWindow() {
|
||||
});
|
||||
win = mainWindow;
|
||||
|
||||
// 恢复上一次的最大化状态
|
||||
if (saved.maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
logger.info({ saved }, "已恢复窗口状态");
|
||||
// 不再自动恢复最大化状态(避免无法还原);启动即居中显示,过大窗口已回退默认尺寸
|
||||
logger.info(
|
||||
{ saved, restoredWidth, restoredHeight, oversized },
|
||||
"已恢复窗口状态(过大窗口回退默认尺寸并居中)",
|
||||
);
|
||||
|
||||
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
|
||||
// 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/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(electron-builder 的 dir 产物版本)。
|
||||
# - 每次运行都会改写 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/}"
|
||||
|
||||
echo "开始测试打包(版本 ${new_ver})..."
|
||||
dpkg-buildpackage -us -uc -b
|
||||
|
||||
echo "完成。产物: ../spark-store_${new_ver}_amd64.deb"
|
||||
+277
-44
@@ -4,7 +4,15 @@
|
||||
v-else
|
||||
class="flex h-screen flex-col overflow-hidden bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100"
|
||||
>
|
||||
<WindowTitleBar />
|
||||
<WindowTitleBar
|
||||
:search-query="searchQuery"
|
||||
@update:search-query="handleSearchInput"
|
||||
@search-focus="handleSearchFocus"
|
||||
@open-install-settings="handleOpenInstallSettings"
|
||||
@open-about="openAboutModal"
|
||||
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
|
||||
@spk-link="handleSpkLink"
|
||||
/>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
@@ -46,21 +54,6 @@
|
||||
</aside>
|
||||
|
||||
<main class="flex h-full min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="sticky top-10 z-30 shrink-0 border-b border-slate-200/70 bg-slate-50 px-4 py-4 lg:px-10 dark:border-slate-800/70 dark:bg-slate-950"
|
||||
>
|
||||
<AppHeader
|
||||
:search-query="searchQuery"
|
||||
:active-tab="activeTab"
|
||||
:apps-count="filteredApps.length"
|
||||
@update-search="handleSearchInput"
|
||||
@search-focus="handleSearchFocus"
|
||||
@open-install-settings="handleOpenInstallSettings"
|
||||
@open-about="openAboutModal"
|
||||
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
|
||||
@spk-link="handleSpkLink"
|
||||
/>
|
||||
</div>
|
||||
<CategoryBar
|
||||
v-if="
|
||||
currentView === 'default' &&
|
||||
@@ -90,12 +83,35 @@
|
||||
@open-detail="openDetail"
|
||||
/>
|
||||
<template v-else-if="activeTab === 'home'">
|
||||
<div class="h-full overflow-y-auto pr-2 scrollbar-nowidth">
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<HomeView
|
||||
:links="homeLinks"
|
||||
:loading="homeLoading"
|
||||
:error="homeError"
|
||||
:store-filter="storeFilter"
|
||||
@open-detail="handleAppCardOpenDetail"
|
||||
@select-section="selectTab"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab === 'ranking'">
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<RankingView
|
||||
:apps="apps"
|
||||
:apm-ranking="apmRanking"
|
||||
:spark-ranking="sparkRanking"
|
||||
:ranking-loading="rankingLoading"
|
||||
:store-filter="storeFilter"
|
||||
@open-detail="handleAppCardOpenDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab === 'honor'">
|
||||
<div class="h-full min-h-0 overflow-hidden">
|
||||
<HonorView
|
||||
:apps="apps"
|
||||
:store-filter="storeFilter"
|
||||
@open-detail="handleAppCardOpenDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -181,7 +197,7 @@
|
||||
@close="closeInstalledModal"
|
||||
@refresh="refreshInstalledApps"
|
||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||
@open-detail="openDetail"
|
||||
@open-detail="openDetailFromInstalled"
|
||||
@uninstall="uninstallInstalledApp"
|
||||
@sync-to-account="syncInstalledAppsToAccount"
|
||||
@restore-from-account="openRestoreFromAccount"
|
||||
@@ -288,9 +304,10 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import pino from "pino";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppHeader from "./components/AppHeader.vue";
|
||||
import AppGrid from "./components/AppGrid.vue";
|
||||
import HomeView from "./components/HomeView.vue";
|
||||
import RankingView from "./components/RankingView.vue";
|
||||
import HonorView from "./components/HonorView.vue";
|
||||
import CategoryBar from "./components/CategoryBar.vue";
|
||||
import AppDetailModal from "./components/AppDetailModal.vue";
|
||||
import ScreenPreview from "./components/ScreenPreview.vue";
|
||||
@@ -904,6 +921,11 @@ const fetchAppFromStore = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包)
|
||||
const openDetailFromInstalled = (app: App) => {
|
||||
openDetail({ ...app, _fromInstalled: true });
|
||||
};
|
||||
|
||||
const openDetail = async (app: App | Record<string, unknown>) => {
|
||||
// 提取 pkgname 和 category(必须存在)
|
||||
const pkgname = (app as Record<string, unknown>).pkgname as string;
|
||||
@@ -912,17 +934,36 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
||||
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
||||
const fromHomeView = (app as Record<string, unknown>)._fromHomeView === true;
|
||||
const fromDeepLink = (app as Record<string, unknown>)._fromDeepLink === true;
|
||||
const needFetchFromStore = fromHomeView || fromDeepLink;
|
||||
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
||||
const fromInstalled = (app as Record<string, unknown>)._fromInstalled === true;
|
||||
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
|
||||
if (!pkgname) {
|
||||
console.warn("openDetail: 缺少 pkgname", app);
|
||||
return;
|
||||
}
|
||||
|
||||
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
|
||||
let fullApp = filteredApps.value.find((a) => a.pkgname === pkgname);
|
||||
// 如果没找到,回退到全局 apps 中查找
|
||||
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
|
||||
const clickedOrigin = (app as Record<string, unknown>).origin as
|
||||
| "spark"
|
||||
| "apm"
|
||||
| undefined;
|
||||
let fullApp = filteredApps.value.find(
|
||||
(a) =>
|
||||
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
||||
);
|
||||
// 如果没找到,回退到全局 apps 中查找(同样优先 origin)
|
||||
if (!fullApp) {
|
||||
fullApp = apps.value.find((a) => a.pkgname === pkgname);
|
||||
fullApp = apps.value.find(
|
||||
(a) =>
|
||||
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
||||
);
|
||||
}
|
||||
// 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果)
|
||||
if (!fullApp) {
|
||||
fullApp =
|
||||
filteredApps.value.find((a) => a.pkgname === pkgname) ||
|
||||
apps.value.find((a) => a.pkgname === pkgname);
|
||||
}
|
||||
|
||||
let finalApp: App;
|
||||
@@ -930,27 +971,38 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
||||
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
|
||||
if (needFetchFromStore) {
|
||||
// 从 Spark 和 APM 仓库获取完整的应用信息
|
||||
const [sparkApp, apmApp] = await Promise.all([
|
||||
storeFilter.value !== "apm"
|
||||
// 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏
|
||||
let [sparkApp, apmApp] = await Promise.all([
|
||||
fromInstalled || storeFilter.value !== "apm"
|
||||
? fetchAppFromStore(pkgname, category, "spark")
|
||||
: Promise.resolve(null),
|
||||
storeFilter.value !== "spark"
|
||||
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) {
|
||||
// 根据优先级配置决定默认显示哪个版本
|
||||
const defaultOrigin = getHybridDefaultOrigin(sparkApp);
|
||||
// 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示
|
||||
const defaultOrigin =
|
||||
clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp)
|
||||
? clickedOrigin
|
||||
: getHybridDefaultOrigin(sparkApp);
|
||||
finalApp = {
|
||||
...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示
|
||||
isMerged: true,
|
||||
sparkApp: sparkApp,
|
||||
apmApp: apmApp,
|
||||
viewingOrigin: defaultOrigin, // 默认查看优先级高的版本
|
||||
viewingOrigin: defaultOrigin, // 默认查看来源版本
|
||||
};
|
||||
} else if (sparkApp) {
|
||||
finalApp = sparkApp;
|
||||
@@ -1027,12 +1079,38 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
||||
}) as Promise<boolean>)
|
||||
: Promise.resolve(false),
|
||||
]);
|
||||
if (sparkInstalled && !apmInstalled) {
|
||||
// 优先遵从点击来源 origin(未安装时、已安装时都应以用户点击的版本为准)
|
||||
let preferred = (app as Record<string, unknown>).origin as
|
||||
| "spark"
|
||||
| "apm"
|
||||
| undefined;
|
||||
if (fromInstalled) {
|
||||
// 已安装页:依据实际安装来源决定默认展示
|
||||
const installedOrigins = (app as Record<string, unknown>).origins as
|
||||
| Array<"spark" | "apm">
|
||||
| undefined;
|
||||
if (installedOrigins && installedOrigins.length > 0) {
|
||||
if (
|
||||
installedOrigins.includes("spark") &&
|
||||
installedOrigins.includes("apm")
|
||||
) {
|
||||
// 两种类型都已安装:保持所有应用页的自动显示模式(不强制指定来源)
|
||||
preferred = undefined;
|
||||
} else {
|
||||
// 仅安装一种类型:优先显示已安装的类型(另一类型仍可在来源切换中查看)
|
||||
preferred = installedOrigins[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preferred && (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp)) {
|
||||
finalApp.viewingOrigin = preferred;
|
||||
} else if (sparkInstalled && !apmInstalled) {
|
||||
// 无点击来源时:仅一个仓库已安装则优先展示已安装版本
|
||||
finalApp.viewingOrigin = "spark";
|
||||
} else if (apmInstalled && !sparkInstalled) {
|
||||
finalApp.viewingOrigin = "apm";
|
||||
} else {
|
||||
// 若都安装或都未安装,根据优先级配置决定默认展示
|
||||
// 都安装/都未安装且未指定来源:按优先级配置决定默认展示
|
||||
finalApp.viewingOrigin = getHybridDefaultOrigin(
|
||||
finalApp.sparkApp || finalApp,
|
||||
);
|
||||
@@ -1134,6 +1212,125 @@ const homeLinks = ref<HomeLink[]>([]);
|
||||
const homeLoading = ref(false);
|
||||
const homeError = ref("");
|
||||
|
||||
// 首页三区域:区域2 板块(已迁移至「全部应用」等分类入口,首页不再展示)
|
||||
const apmRanking = ref<App[]>([]);
|
||||
const sparkRanking = ref<App[]>([]);
|
||||
|
||||
// 区域3 · 下载排行(全站):从全量应用目录逐应用拉取 download-times.txt,
|
||||
// 按 origin 分别排 APM / Spark 两榜。用代次守卫避免竞态。
|
||||
// 优化:localStorage 缓存(1 小时 TTL)避免重复请求;并发 48;每个批次完成后立即发布
|
||||
// 增量排名,边拉边显;fetchDownloadCount 优先命中缓存。
|
||||
let rankingGeneration = 0;
|
||||
const rankingLoading = ref(false);
|
||||
|
||||
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`,
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
const loadRanking = async () => {
|
||||
if (apps.value.length === 0) return;
|
||||
const gen = ++rankingGeneration;
|
||||
rankingLoading.value = true;
|
||||
const all = apps.value.slice();
|
||||
const CONCURRENCY = 64;
|
||||
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 = "";
|
||||
@@ -1332,7 +1529,22 @@ const loadHomeListApps = async (entryId: string) => {
|
||||
logger.info(`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
|
||||
};
|
||||
|
||||
// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢
|
||||
// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用)
|
||||
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) =>
|
||||
logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
};
|
||||
|
||||
// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后)
|
||||
const preloadSidebarTabApps = (): Promise<void> => {
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const entry of sidebarEntries.value) {
|
||||
@@ -1342,12 +1554,6 @@ const preloadSidebarTabApps = (): Promise<void> => {
|
||||
logger.warn(`预加载入口 ${entry.id} 失败: ${e}`),
|
||||
),
|
||||
);
|
||||
} else if (entry.type === "homeList") {
|
||||
tasks.push(
|
||||
loadHomeListApps(entry.id).catch((e: unknown) =>
|
||||
logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
@@ -1421,8 +1627,15 @@ const handleStartSelectedUpdates = async () => {
|
||||
};
|
||||
|
||||
const confirmMigrationStart = async () => {
|
||||
// 确认即关闭对话框(UX:确认动作立即生效),再执行异步迁移启动
|
||||
updateCenterStore.showMigrationConfirm.value = false;
|
||||
try {
|
||||
await updateCenterStore.startSelected();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`启动迁移失败 (startSelected): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openInstalledModal = () => {
|
||||
@@ -1506,6 +1719,8 @@ const refreshInstalledApps = async () => {
|
||||
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++) {
|
||||
@@ -1564,8 +1779,25 @@ const refreshInstalledApps = async () => {
|
||||
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;
|
||||
@@ -2977,18 +3209,19 @@ onMounted(async () => {
|
||||
loading.value = true;
|
||||
homeLoading.value = true;
|
||||
|
||||
// 先加载全部应用,再预加载侧边栏入口
|
||||
// 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载
|
||||
await Promise.all([
|
||||
loadHome(),
|
||||
new Promise<void>((resolve) => {
|
||||
preloadHomeListApps(),
|
||||
loadApps(() => {
|
||||
loading.value = false;
|
||||
resolve();
|
||||
});
|
||||
loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新
|
||||
}),
|
||||
]);
|
||||
|
||||
// 全部应用加载完成后再预加载侧边栏入口
|
||||
// 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪
|
||||
loadRanking();
|
||||
|
||||
// 其余分类入口预加载(用户点击分类时才需要,延后)
|
||||
preloadSidebarTabApps().then(() => {
|
||||
logger.info("侧边栏入口预加载完成");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
@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
|
||||
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
|
||||
ref="iconImg"
|
||||
@@ -17,7 +31,8 @@
|
||||
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<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 || "" }}
|
||||
</div>
|
||||
@@ -43,12 +58,21 @@
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-if="downloadCount"
|
||||
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
|
||||
>{{ formatDownloads(downloadCount) }}</span
|
||||
>
|
||||
</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 || "" }}
|
||||
</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" }}
|
||||
</div>
|
||||
@@ -65,6 +89,12 @@ const props = defineProps<{
|
||||
app: App;
|
||||
// 是否显示来源标识(仅在混合模式下显示)
|
||||
showOrigin?: boolean;
|
||||
// 紧凑模式(用于首页精选板块等空间受限区域)
|
||||
compact?: boolean;
|
||||
// 下载量(用于下载排行;>=0 时在标题行右侧显示)
|
||||
downloadCount?: number;
|
||||
// 排名(用于排行榜/荣耀榜,左上角显示徽标)
|
||||
rank?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -111,6 +141,27 @@ const description = computed(() => {
|
||||
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 = () => {
|
||||
emit("open-detail", props.app);
|
||||
};
|
||||
|
||||
@@ -161,13 +161,7 @@
|
||||
<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"
|
||||
@click="
|
||||
emit(
|
||||
'open-app',
|
||||
displayApp?.pkgname || '',
|
||||
displayApp?.origin,
|
||||
)
|
||||
"
|
||||
@click="onOpenClick"
|
||||
>
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
<span>打开</span>
|
||||
@@ -392,6 +386,7 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
|
||||
<!-- 元数据详情弹窗 -->
|
||||
<Transition
|
||||
enter-active-class="duration-200 ease-out"
|
||||
@@ -507,6 +502,69 @@
|
||||
</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="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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -730,6 +788,28 @@ 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 = () => {
|
||||
// if (!displayApp.value) return;
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
<!-- 应用数量较少时,使用普通网格(带滚动) -->
|
||||
<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
|
||||
v-for="app in apps"
|
||||
:key="app.pkgname"
|
||||
|
||||
@@ -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>
|
||||
@@ -71,6 +71,29 @@
|
||||
>
|
||||
</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
|
||||
v-if="sidebarEntries.length > 0"
|
||||
class="my-3 border-t border-slate-100 dark:border-slate-800"
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<div
|
||||
@@ -53,14 +66,7 @@
|
||||
v-show="isExpanded"
|
||||
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
|
||||
>
|
||||
<div
|
||||
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 class="space-y-2">
|
||||
<div
|
||||
v-for="download in downloads"
|
||||
:key="download.id"
|
||||
@@ -130,7 +136,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { DownloadItem } from "../global/typedefinition";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -146,7 +152,7 @@ const emit = defineEmits<{
|
||||
(e: "show-detail", download: DownloadItem): void;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isExpanded = ref(true);
|
||||
|
||||
const completedDownloads = computed(() => {
|
||||
return props.downloads.filter((d) => d.status === "completed").length;
|
||||
@@ -167,4 +173,12 @@ const clearCompleted = () => {
|
||||
const showDownloadDetail = (download: DownloadItem) => {
|
||||
emit("show-detail", download);
|
||||
};
|
||||
|
||||
// 从无任务到有任务时,自动展开面板("有任务时再向左边伸出")
|
||||
watch(
|
||||
() => props.downloads.length > 0,
|
||||
(hasTasks) => {
|
||||
if (hasTasks) isExpanded.value = true;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
+49
-25
@@ -1,9 +1,15 @@
|
||||
<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
|
||||
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>
|
||||
<span class="text-sm">正在加载首页内容…</span>
|
||||
@@ -17,7 +23,7 @@
|
||||
<!-- 无数据时显示欢迎信息 -->
|
||||
<div
|
||||
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
|
||||
v-if="storeFilter === 'apm'"
|
||||
@@ -47,41 +53,45 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- 有数据就立即展示,图片逐步加载 -->
|
||||
<div v-else>
|
||||
<!-- 左上角欢迎语 -->
|
||||
<template v-else>
|
||||
<!-- ============ 区域1 · 精选(两行网格 4×2,静态不滚动) ============ -->
|
||||
<section class="shrink-0">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
<h1 class="text-sm font-bold text-slate-800 dark:text-slate-200">
|
||||
{{
|
||||
storeFilter === "apm"
|
||||
? "欢迎来到星火应用商店 (Amber PM)"
|
||||
: "欢迎来到星火应用商店"
|
||||
storeFilter === "apm" ? "星火应用商店 (Amber PM)" : "星火应用商店"
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
<p class="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
探索丰富的应用,发现更多精彩内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Links 区域 -->
|
||||
<div v-if="links.length > 0" class="grid gap-5 auto-fit-grid">
|
||||
<div
|
||||
v-if="links.length > 0"
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.url + link.name"
|
||||
:href="link.type === '_blank' ? undefined : link.url"
|
||||
@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"
|
||||
>
|
||||
<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
|
||||
: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"
|
||||
decoding="async"
|
||||
@load="onImageLoad(link.url + link.name)"
|
||||
@error="onImageError(link.url + link.name)"
|
||||
: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-100 transition-opacity duration-300':
|
||||
imageLoaded[link.url + link.name],
|
||||
@@ -96,34 +106,47 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 px-1">
|
||||
<div class="mt-1.5 px-1">
|
||||
<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-[13px] font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
|
||||
>
|
||||
{{ link.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
|
||||
class="text-[11px] text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
|
||||
>
|
||||
{{ link.more }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 致谢说明(F2.3 复用):置于区域1 其他内容下方 -->
|
||||
<ThanksCard class="mt-4" />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
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";
|
||||
|
||||
defineProps<{
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
links: HomeLink[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
// 下载量格式化:>= 1万 显示为 "x.x万"
|
||||
// (download count now formatted inside AppCard via its downloadCount prop)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Link 卡片网格 - 固定最小宽度 180px */
|
||||
|
||||
@@ -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>
|
||||
@@ -249,20 +249,20 @@
|
||||
{{ app.name }}
|
||||
</p>
|
||||
<span
|
||||
v-if="app.origin === 'apm'"
|
||||
data-testid="origin-tag-apm"
|
||||
class="rounded-md bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
|
||||
title="APM 软件"
|
||||
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 软件'"
|
||||
>
|
||||
APM
|
||||
</span>
|
||||
<span
|
||||
v-else-if="app.origin === 'spark'"
|
||||
data-testid="origin-tag-spark"
|
||||
class="rounded-md bg-sky-100 px-2 py-0.5 text-[11px] font-semibold text-sky-700 dark:bg-sky-500/20 dark:text-sky-400"
|
||||
title="Spark 软件"
|
||||
>
|
||||
Spark
|
||||
{{ o === "apm" ? "APM" : "Spark" }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.isDependency"
|
||||
@@ -288,7 +288,7 @@
|
||||
<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"
|
||||
@click="$emit('open-app', app)"
|
||||
@click="onOpenClick(app)"
|
||||
>
|
||||
<i class="fas fa-play"></i>
|
||||
打开
|
||||
@@ -316,6 +316,75 @@
|
||||
</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>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -374,13 +443,19 @@ const props = defineProps<{
|
||||
syncMessage: string;
|
||||
}>();
|
||||
|
||||
const apmCount = computed(
|
||||
() => props.apps.filter((a) => a.origin === "apm").length,
|
||||
// 判断某应用是否以指定来源安装:优先看 origins 集合,回退到单 origin 字段
|
||||
const hasOrigin = (app: App, origin: "spark" | "apm"): boolean =>
|
||||
app.origins?.includes(origin) ?? app.origin === origin;
|
||||
|
||||
// APM / Spark 分别统计实际安装的包数量(同一 pkgname 同时装两种来源时各计一次)
|
||||
const apmCount = computed(() =>
|
||||
props.apps.filter((a) => hasOrigin(a, "apm")).length,
|
||||
);
|
||||
const sparkCount = computed(
|
||||
() => props.apps.filter((a) => a.origin === "spark").length,
|
||||
const sparkCount = computed(() =>
|
||||
props.apps.filter((a) => hasOrigin(a, "spark")).length,
|
||||
);
|
||||
const totalCount = computed(() => props.apps.length);
|
||||
// 总数 = APM 包数 + Spark 包数(不同来源视为不同包,单独计数)
|
||||
const totalCount = computed(() => apmCount.value + sparkCount.value);
|
||||
|
||||
// 来源筛选:默认全部;点击统计徽章可在 all/apm/spark 间切换
|
||||
const filterOrigin = ref<"all" | "apm" | "spark">("all");
|
||||
@@ -401,16 +476,16 @@ const filteredApps = computed(() => {
|
||||
|
||||
// 2. 再按来源筛选(默认 all = 不过滤)
|
||||
if (filterOrigin.value === "apm") {
|
||||
list = list.filter((a) => a.origin === "apm");
|
||||
list = list.filter((a) => hasOrigin(a, "apm"));
|
||||
} else if (filterOrigin.value === "spark") {
|
||||
list = list.filter((a) => a.origin === "spark");
|
||||
list = list.filter((a) => hasOrigin(a, "spark"));
|
||||
}
|
||||
|
||||
// 3. 排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
|
||||
// 返回新数组,避免修改原始 props.apps
|
||||
return [...list].sort((a, b) => {
|
||||
const aApm = a.origin === "apm" ? 0 : 1;
|
||||
const bApm = b.origin === "apm" ? 0 : 1;
|
||||
const aApm = hasOrigin(a, "apm") ? 0 : 1;
|
||||
const bApm = hasOrigin(b, "apm") ? 0 : 1;
|
||||
if (aApm !== bApm) return aApm - bApm;
|
||||
// 同类内保持原有的字母序,体验更一致
|
||||
return a.pkgname.localeCompare(b.pkgname);
|
||||
@@ -429,6 +504,28 @@ const emit = defineEmits<{
|
||||
(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 = () => {
|
||||
// if (props.loggedIn) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
</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">
|
||||
<button
|
||||
v-if="!uninstalling"
|
||||
@@ -69,10 +97,10 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!uninstalling && !completed"
|
||||
v-if="installOrigins.length <= 1 && !uninstalling && !completed"
|
||||
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"
|
||||
@click="confirmUninstall"
|
||||
@click="confirmUninstall(installOrigins[0])"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
确认卸载
|
||||
@@ -115,6 +143,14 @@ const logEnd = ref<HTMLElement | null>(null);
|
||||
const appName = computed(() => props.app?.name || "未知应用");
|
||||
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 = () => {
|
||||
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
|
||||
reset();
|
||||
@@ -134,18 +170,20 @@ const reset = () => {
|
||||
error.value = "";
|
||||
};
|
||||
|
||||
const confirmUninstall = () => {
|
||||
const confirmUninstall = (origin: "spark" | "apm") => {
|
||||
if (!appPkg.value) {
|
||||
error.value = "无效的包名";
|
||||
return;
|
||||
}
|
||||
|
||||
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", {
|
||||
pkgname: appPkg.value,
|
||||
origin: props.app?.origin || "spark",
|
||||
origin,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,76 @@
|
||||
<template>
|
||||
<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">
|
||||
<img
|
||||
src="../assets/imgs/spark-store.svg"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
alt="星火应用商店"
|
||||
/>
|
||||
<span class="truncate text-sm font-semibold">星火应用商店</span>
|
||||
</div>
|
||||
|
||||
<div class="window-titlebar-controls flex items-center gap-1">
|
||||
<!-- 左:移动端侧栏切换 + logo + 应用名 -->
|
||||
<div class="window-titlebar-left flex min-w-0 items-center gap-2">
|
||||
<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>
|
||||
</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 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">
|
||||
<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="rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
||||
aria-label="最小化"
|
||||
title="最小化"
|
||||
@click="minimize"
|
||||
@@ -23,7 +79,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
||||
class="rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
|
||||
aria-label="最大化或还原"
|
||||
title="最大化或还原"
|
||||
@click="toggleMaximize"
|
||||
@@ -44,6 +100,47 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
searchQuery: string;
|
||||
}>();
|
||||
|
||||
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-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 = () => {
|
||||
window.windowControls.minimize();
|
||||
};
|
||||
@@ -62,8 +159,9 @@ const close = () => {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.window-titlebar-controls,
|
||||
.window-titlebar-controls button {
|
||||
/* 所有可交互元素(输入框、按钮)禁用窗口拖动 */
|
||||
.window-titlebar input,
|
||||
.window-titlebar button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface App {
|
||||
sparkApp?: App; // Optional reference to the spark version
|
||||
apmApp?: App; // Optional reference to the apm version
|
||||
viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal
|
||||
origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项)
|
||||
downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析)
|
||||
}
|
||||
|
||||
export interface UpdateAppItem {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 排行榜与荣耀榜聚合工具
|
||||
* - 下载排行:由 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) return [];
|
||||
const names: string[] = [];
|
||||
for (const part of raw.split(/[;;]/)) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const name = trimmed.replace(/<[^>]*>/g, "").trim();
|
||||
if (name) 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[] {
|
||||
return apps
|
||||
.filter((a) => a.origin === origin && a.update)
|
||||
.sort((a, b) => (b.update || "").localeCompare(a.update || ""))
|
||||
.slice(0, topN);
|
||||
}
|
||||
|
||||
/** 更新荣耀榜:取某来源「最近更新」窗口内的应用,聚合其贡献者,取 Top N */
|
||||
export function topUpdatedContributors(
|
||||
apps: App[],
|
||||
origin: AppOrigin,
|
||||
recentN: number = RECENT_UPDATE_WINDOW,
|
||||
topN: number = TOP_N,
|
||||
): ContributorRank[] {
|
||||
const recent = topByUpdate(apps, origin, recentN).filter(
|
||||
(a) => a.origin === origin,
|
||||
);
|
||||
return toRanks(countContributors(recent), topN);
|
||||
}
|
||||
Reference in New Issue
Block a user