diff --git a/.gitignore b/.gitignore index 380dcd59..e478a394 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ debian/files debian/tmp/ debian/.debhelper/ debian/spark-store/ + +# AI working memory (跨会话上下文,不进入上游) +.codebuddy/ diff --git a/AGENTS.md b/AGENTS.md index 48e4cb46..4c317933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:** diff --git a/docs/ranking-honor-plan.md b/docs/ranking-honor-plan.md new file mode 100644 index 00000000..9c649175 --- /dev/null +++ b/docs/ranking-honor-plan.md @@ -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`,实时计算)。列表项显示排名、贡献者名(去除 `` 后缀)、上榜应用数徽章。 +- **预期目标**:表彰对星火生态贡献最多的开发者/维护者。 +- **关联模块**:`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'"` → ``、`v-else-if="activeTab === 'honor'"` → ``;将现有 `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`,聚合与展示时需统一去除 `<...>` 取显示名;多个贡献者是否以 `;` 分隔需抽样确认。 +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` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。* diff --git a/electron/main/index.ts b/electron/main/index.ts index 7911d5d7..6d6e1dc5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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 兜底保存 diff --git a/scripts/test-build.sh b/scripts/test-build.sh new file mode 100755 index 00000000..8346bafe --- /dev/null +++ b/scripts/test-build.sh @@ -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" diff --git a/src/App.vue b/src/App.vue index f8b1a4ef..f37712a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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" > - +
@@ -46,21 +54,6 @@
-
- -
+ + @@ -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) => { // 提取 pkgname 和 category(必须存在) const pkgname = (app as Record).pkgname as string; @@ -912,17 +934,36 @@ const openDetail = async (app: App | Record) => { // 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息) const fromHomeView = (app as Record)._fromHomeView === true; const fromDeepLink = (app as Record)._fromDeepLink === true; - const needFetchFromStore = fromHomeView || fromDeepLink; + // 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型) + const fromInstalled = (app as Record)._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).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) => { // 来自 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) => { }) as Promise) : Promise.resolve(false), ]); - if (sparkInstalled && !apmInstalled) { + // 优先遵从点击来源 origin(未安装时、已安装时都应以用户点击的版本为准) + let preferred = (app as Record).origin as + | "spark" + | "apm" + | undefined; + if (fromInstalled) { + // 已安装页:依据实际安装来源决定默认展示 + const installedOrigins = (app as Record).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([]); const homeLoading = ref(false); const homeError = ref(""); +// 首页三区域:区域2 板块(已迁移至「全部应用」等分类入口,首页不再展示) +const apmRanking = ref([]); +const sparkRanking = ref([]); + +// 区域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(); + +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; + 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 = {}; + 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 => { + 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 => { + const tasks: Promise[] = []; + 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 => { const tasks: Promise[] = []; for (const entry of sidebarEntries.value) { @@ -1342,12 +1554,6 @@ const preloadSidebarTabApps = (): Promise => { 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; - await updateCenterStore.startSelected(); + 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>(); const failedOrigins: string[] = []; for (let i = 0; i < effectiveOrigins.length; i++) { @@ -1564,10 +1779,27 @@ const refreshInstalledApps = async () => { isDependency: app.isDependency, }; } - combinedApps.push(appInfo); + // 合并同一 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 @@ -2977,18 +3209,19 @@ onMounted(async () => { loading.value = true; homeLoading.value = true; - // 先加载全部应用,再预加载侧边栏入口 + // 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载 await Promise.all([ loadHome(), - new Promise((resolve) => { - loadApps(() => { - loading.value = false; - resolve(); - }); + preloadHomeListApps(), + loadApps(() => { + loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新 }), ]); - // 全部应用加载完成后再预加载侧边栏入口 + // 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪 + loadRanking(); + + // 其余分类入口预加载(用户点击分类时才需要,延后) preloadSidebarTabApps().then(() => { logger.info("侧边栏入口预加载完成"); }); diff --git a/src/components/AppCard.vue b/src/components/AppCard.vue index e266ea8d..82de7541 100644 --- a/src/components/AppCard.vue +++ b/src/components/AppCard.vue @@ -1,10 +1,24 @@
+ {{ formatDownloads(downloadCount) }} -
+
{{ app.pkgname || "" }} · {{ app.version || "" }}
{{ description || "\u00A0" }}
@@ -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); }; diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 3ef111c2..27c65ea2 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -161,13 +161,7 @@
+ + + + +
+
+
+
+ +
+
+

+ 打开应用 +

+

+ 该应用同时通过 APM 与 Spark 安装,请选择要打开的来源: +

+
+
+
+ + +
+
+ +
+
+
+
@@ -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; diff --git a/src/components/AppGrid.vue b/src/components/AppGrid.vue index a2dcefcb..7b1ac177 100644 --- a/src/components/AppGrid.vue +++ b/src/components/AppGrid.vue @@ -19,7 +19,9 @@
-
+
-
-
- -
-
- - - - -
- - -
-
-
- - - diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index a6820f41..d18270da 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -71,6 +71,29 @@ > + + + + +
+ + + +
-
- -

暂无下载任务

-
-
+
diff --git a/src/components/HomeView.vue b/src/components/HomeView.vue index f850a6ee..a1438141 100644 --- a/src/components/HomeView.vue +++ b/src/components/HomeView.vue @@ -1,9 +1,15 @@
- +// 下载量格式化:>= 1万 显示为 "x.x万" +// (download count now formatted inside AppCard via its downloadCount prop) + diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index dba750dc..6d87aba1 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -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 { diff --git a/src/modules/ranking.ts b/src/modules/ranking.ts new file mode 100644 index 00000000..0b8cb658 --- /dev/null +++ b/src/modules/ranking.ts @@ -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`,可能存在多个贡献者以 `;` / `;` 分隔。 + * 聚合与展示统一去除 `<...>` 取显示名。 + */ +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 { + const counter = new Map(); + 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, 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); +}