fix: 标签优先显示策略 auto 模式按服务端优先级配置展示

- auto 模式用合并应用自身(含 sparkApp/apmApp)匹配 priority-config.json,
  不再仅取单一子版,避免漏匹配。
- openDetail 区分「已安装页显式来源强制」与「其他页面按策略」;
  非强制分支清除 forceViewingOrigin 粘性标志,避免跨入口状态泄漏。
- AppDetailModal 按 TagPriorityStrategy 响应式重算默认来源;
  手动切标签仅本次会话预览,不持久。
- storeConfig.loadPriorityConfig 改用 axios(与渲染进程一致),
  修复 Electron 下全局 fetch 失败导致配置为空、auto 全部回退 APM 的问题。
- 首页推荐列表 parseAppList 从 jsonUrl 提取分类,避免 category 误为 unknown。
This commit is contained in:
xiyidaiwa
2026-08-07 18:21:39 +08:00
parent 88499a56f8
commit b3eb1d01d9
6 changed files with 188 additions and 149 deletions
+7 -11
View File
@@ -308,21 +308,17 @@ function getWindowStatePath(): string {
// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外
function isVisible(bounds: WindowState): boolean {
if (
bounds.width === undefined ||
bounds.height === undefined ||
bounds.x === undefined ||
bounds.y === undefined
) {
// 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包,
// 消除 x/y/width/height 的 “可能为未定义” 告警
const { x, y, width, height } = bounds;
if (x === undefined || y === undefined || width === undefined || height === undefined) {
return false;
}
const displays = screen.getAllDisplays();
return displays.some((display) => {
const { x, y, width, height } = display.workArea;
const horizontally =
bounds.x < x + width && bounds.x + bounds.width > x;
const vertically =
bounds.y < y + height && bounds.y + bounds.height > y;
const w = display.workArea;
const horizontally = x < w.x + w.width && x + width > w.x;
const vertically = y < w.y + w.height && y + height > w.y;
return horizontally && vertically;
});
}
+35 -22
View File
@@ -1095,38 +1095,46 @@ const openDetail = async (app: App | OpenDetailInput) => {
}) as Promise<boolean>)
: Promise.resolve(false),
]);
// 优先遵从点击来源 origin(未安装时、已安装时都应以用户点击的版本为准)
let preferred = (app as Record<string, unknown>).origin as
| "spark"
| "apm"
| undefined;
// 来源默认展示规则:
// 1) 已安装页打开:按安装类型打开。
// - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖)
// - 多个来源已安装 → 按「设置的优先标签」打开(策略)
// 2) 其他页面:一律按「设置的优先标签」打开(策略 > 服务端混合默认),不强制。
// 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景,
// 其余情况都不强制,交由详情页按用户标签策略重算。
let forceOrigin: "spark" | "apm" | undefined = undefined;
if (fromInstalled) {
// 已安装页:依据实际安装来源决定默认展示
const installedOrigins = (app as Record<string, unknown>).origins as
| Array<"spark" | "apm">
| undefined;
if (installedOrigins && installedOrigins.length > 0) {
if (
installedOrigins.includes("spark") &&
installedOrigins.includes("apm")
) {
// 两种类型都已安装:保持所有应用页的自动显示模式(不强制指定来源)
preferred = undefined;
} else {
// 仅安装一种类型:优先显示已安装的类型(另一类型仍可在来源切换中查看)
preferred = installedOrigins[0];
}
if (installedOrigins && installedOrigins.length === 1) {
// 单来源安装:按安装类型强制
forceOrigin = installedOrigins[0];
} else if (installedOrigins && installedOrigins.length > 1) {
// 多来源安装:交由策略(按设置优先标签),不强制
forceOrigin = undefined;
} else {
// origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型
if (sparkInstalled && !apmInstalled) forceOrigin = "spark";
else if (apmInstalled && !sparkInstalled) forceOrigin = "apm";
}
}
if (preferred && (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp)) {
finalApp.viewingOrigin = preferred;
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
finalApp.forceViewingOrigin = false;
if (forceOrigin && (forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)) {
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
finalApp.viewingOrigin = forceOrigin;
finalApp.forceViewingOrigin = true;
} else if (sparkInstalled && !apmInstalled) {
// 无点击来源时:仅一个仓库已安装则优先展示已装版本
// 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(仍受用户策略覆盖)
finalApp.viewingOrigin = "spark";
} else if (apmInstalled && !sparkInstalled) {
finalApp.viewingOrigin = "apm";
} else {
// 都安装/都未安装且未指定来源:按优先级配置决定默认展示
// 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示
// 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用),
// 但不置 forceViewingOrigin,详情页会按用户策略重算。
finalApp.viewingOrigin = getHybridDefaultOrigin(
finalApp.sparkApp || finalApp,
);
@@ -1496,7 +1504,12 @@ const loadHomeListApps = async (entryId: string) => {
mode: "spark" | "apm",
): App[] =>
rawApps.map((a) => {
const category = a.Category || a.category || "unknown";
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
const urlCategory = (urls[mode] || "")
.split("/")
.filter(Boolean)[0] || "unknown";
const category = a.Category || a.category || urlCategory;
let img_urls: string[] = [];
const rawImgUrls = a.img_urls;
+43 -9
View File
@@ -578,7 +578,8 @@ import {
getHybridDefaultOrigin,
} from "../global/storeConfig";
import {
getTagPriorityStrategy,
tagPriorityStrategyRef,
initTagPriorityStrategy,
type TagPriorityStrategy,
} from "../global/tagPriority";
// 评论功能暂时关闭
@@ -641,8 +642,9 @@ const computeDefaultViewingOrigin = (
app: App,
strategy: TagPriorityStrategy,
): "spark" | "apm" => {
// 父组件显式指定展示来源(如从已安装应用页按特定来源打开)优先级最高
if (app.viewingOrigin) return app.viewingOrigin;
// 父组件显式指定展示来源(如从已安装应用页按特定来源打开)优先级最高
// 此时强制覆盖用户标签策略;否则交由下方策略逻辑决定。
if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin;
// 非合并应用只有一个来源标签,直接展示该标签
if (!app.isMerged) return app.origin;
@@ -661,19 +663,49 @@ const computeDefaultViewingOrigin = (
return preferred;
}
// 回退到应用配置的优先级策略(getHybridDefaultOrigin),并约束在可用标签范围内
const auto = getHybridDefaultOrigin(app.sparkApp ?? app.apmApp ?? app);
// 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的
// 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内
const auto = getHybridDefaultOrigin(app);
console.log(
`[PriorityConfig] auto 决策 pkgname=${app.pkgname} category=${app.category} sparkTags=${app.sparkApp?.tags ?? ""} apmTags=${app.apmApp?.tags ?? ""} -> ${auto}`,
);
return available.includes(auto) ? auto : available[0];
};
// 进入详情页时按「标签优先显示策略」计算默认来源标签。
// 同时监听 props.app 与共享策略 ref
// - props.app 变化(重开/切换应用)→ 始终按当前策略重算(手动切标签仅本次会话预览,不持久)
// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签
// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持,
// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。
initTagPriorityStrategy();
const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话)
let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开"
watch(
() => props.app,
(newApp: App | null) => {
[() => props.app, tagPriorityStrategyRef],
([newApp]: [App | null, TagPriorityStrategy]) => {
isIconLoaded.value = false;
if (newApp) {
if (!newApp) {
// 详情页关闭:清空会话状态,确保下次打开按最新策略重算
lastPkgname = null;
manualOverride.value = false;
return;
}
if (newApp.pkgname !== lastPkgname) {
// 新应用(含重开后再次打开同一应用):重置手动标记,按当前策略计算
lastPkgname = newApp.pkgname;
manualOverride.value = false;
viewingOrigin.value = computeDefaultViewingOrigin(
newApp,
getTagPriorityStrategy(),
tagPriorityStrategyRef.value,
);
} else if (manualOverride.value || newApp.forceViewingOrigin) {
// 同一应用且用户正在手动预览 / 父组件强制来源:保留当前标签,不重算
} else {
// 同一应用、无手动预览、策略变化 → 实时按新策略更新默认标签
viewingOrigin.value = computeDefaultViewingOrigin(
newApp,
tagPriorityStrategyRef.value,
);
}
},
@@ -845,6 +877,8 @@ const confirmOpen = (origin: "spark" | "apm") => {
// };
const selectOrigin = (origin: "spark" | "apm") => {
// 手动切换标签:仅本次会话预览,不写回策略、不跨重开持久
manualOverride.value = true;
viewingOrigin.value = origin;
emit("select-origin", origin);
};
+99 -107
View File
@@ -1,9 +1,17 @@
import { ref } from "vue";
import axios from "axios";
import type { App, StoreMode } from "./typedefinition";
export const APM_STORE_BASE_URL: string =
import.meta.env.VITE_APM_STORE_BASE_URL || "";
// 加载优先级配置专用客户端:与渲染进程其余请求共用同一套 HTTP 客户端,
// 避免 Electron 渲染进程中全局 fetch 失败导致配置始终为空、auto 模式全部回退 APM。
const priorityConfigAxios = axios.create({
baseURL: APM_STORE_BASE_URL,
timeout: 10000,
});
export const APM_STORE_STATS_BASE_URL: string =
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
@@ -77,59 +85,54 @@ export let hasPriorityConfigFile = false;
*/
export async function loadPriorityConfig(arch: string): Promise<void> {
try {
const configUrl = `${APM_STORE_BASE_URL}/${arch}-store/priority-config.json`;
const response = await fetch(configUrl, {
method: "GET",
cache: "no-cache",
});
if (response.ok) {
const config = await response.json();
hasPriorityConfigFile = true;
// 支持新旧两种配置格式
if (config.sparkPriority || config.apmPriority) {
// 新格式:双向配置
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.sparkPriority?.pkgnames || [],
categories: config.sparkPriority?.categories || [],
tags: config.sparkPriority?.tags || [],
},
apmPriority: {
pkgnames: config.apmPriority?.pkgnames || [],
categories: config.apmPriority?.categories || [],
tags: config.apmPriority?.tags || [],
},
};
} else {
// 旧格式:只配置 sparkPriority(兼容旧配置)
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.pkgnames || [],
categories: config.categories || [],
tags: config.tags || [],
},
apmPriority: {
pkgnames: [],
categories: [],
tags: [],
},
};
}
isPriorityConfigLoaded = true;
console.log(
"[PriorityConfig] 已从服务器加载优先级配置:",
dynamicPriorityConfig,
);
const configPath = `/${arch}-store/priority-config.json`;
console.log(
`[PriorityConfig] 开始加载优先级配置: ${APM_STORE_BASE_URL}${configPath}`,
);
const response = await priorityConfigAxios.get(configPath);
const config = response.data;
hasPriorityConfigFile = true;
// 支持新旧两种配置格式
if (config.sparkPriority || config.apmPriority) {
// 新格式:双向配置
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.sparkPriority?.pkgnames || [],
categories: config.sparkPriority?.categories || [],
tags: config.sparkPriority?.tags || [],
},
apmPriority: {
pkgnames: config.apmPriority?.pkgnames || [],
categories: config.apmPriority?.categories || [],
tags: config.apmPriority?.tags || [],
},
};
} else {
// 配置文件不存在,默认优先 Spark
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
hasPriorityConfigFile = false;
resetPriorityConfig();
// 旧格式:只配置 sparkPriority(兼容旧配置)
dynamicPriorityConfig = {
sparkPriority: {
pkgnames: config.pkgnames || [],
categories: config.categories || [],
tags: config.tags || [],
},
apmPriority: {
pkgnames: [],
categories: [],
tags: [],
},
};
}
isPriorityConfigLoaded = true;
console.log(
"[PriorityConfig] 已从服务器加载优先级配置:",
JSON.stringify(dynamicPriorityConfig),
);
} catch (error) {
// 获取失败,默认优先 Spark
console.warn("[PriorityConfig] 加载配置失败,使用默认 Spark 优先:", error);
// 获取失败(含 404:服务器无配置文件),默认优先 APM
console.warn(
`[PriorityConfig] 加载配置失败(${APM_STORE_BASE_URL}/${arch}-store/priority-config.json),使用默认 APM 优先:`,
error,
);
hasPriorityConfigFile = false;
resetPriorityConfig();
}
@@ -155,69 +158,58 @@ function resetPriorityConfig(): void {
}
/**
* 获取混合模式下应用的默认优先来源
* 判断优先级(从高到低):
* 1. apmPriority.pkgnames - 强制优先 APM
* 2. sparkPriority.pkgnames - 强制优先 Spark
* 3. apmPriority.categories - 该分类优先 APM
* 4. sparkPriority.categories - 该分类优先 Spark
* 5. apmPriority.tags - 包含标签优先 APM
* 6. sparkPriority.tags - 包含标签优先 Spark
* 7. 默认行为:
* - 没有配置文件 → 优先 Spark
* - 有配置文件但规则为空 → 优先 APM
* 按优先级配置规则判断单个应用标识是否命中「优先来源」。
* 命中则返回 "apm" / "spark",未命中返回 null。
* 规则优先级(从高到低):apmPriority.pkgnames > sparkPriority.pkgnames >
* apmPriority.categories > sparkPriority.categories > apmPriority.tags > sparkPriority.tags
*/
function matchPriority(app: App): "apm" | "spark" | null {
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
if (apmPriority.pkgnames.includes(app.pkgname)) return "apm";
if (sparkPriority.pkgnames.includes(app.pkgname)) return "spark";
if (apmPriority.categories.includes(app.category)) return "apm";
if (sparkPriority.categories.includes(app.category)) return "spark";
if (app.tags && apmPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of apmPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) return "apm";
}
}
if (app.tags && sparkPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of sparkPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) return "spark";
}
}
return null;
}
/**
* 获取混合模式下应用的默认优先来源(即「应用配置优先级」)。
* 对合并应用,依次用顶层标识、sparkApp、apmApp 去匹配优先级配置,
* 任一来源命中即生效(避免顶层 pkgname 取自某一子版而漏掉另一子版的规则)。
* 均不命中时回退到 HYBRID_DEFAULT_PRIORITY(默认优先 APM)。
* @param app 应用信息
* @returns "apm" 或 "spark"
*/
export function getHybridDefaultOrigin(app: App): "apm" | "spark" {
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
const result = matchPriority(app);
if (result) return result;
// 1. 检查 APM 优先的包名(最高优先级)
if (apmPriority.pkgnames.includes(app.pkgname)) {
return "apm";
}
// 2. 检查 Spark 优先的包名
if (sparkPriority.pkgnames.includes(app.pkgname)) {
return "spark";
}
// 3. 检查 APM 优先的分类
if (apmPriority.categories.includes(app.category)) {
return "apm";
}
// 4. 检查 Spark 优先的分类
if (sparkPriority.categories.includes(app.category)) {
return "spark";
}
// 5. 检查 APM 优先的标签
if (app.tags && apmPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of apmPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) {
return "apm";
}
// 合并应用:两个子版分别匹配,任一命中即采用
if (app.isMerged) {
if (app.sparkApp) {
const r = matchPriority(app.sparkApp);
if (r) return r;
}
if (app.apmApp) {
const r = matchPriority(app.apmApp);
if (r) return r;
}
}
// 6. 检查 Spark 优先的标签
if (app.tags && sparkPriority.tags.length > 0) {
const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase());
for (const ruleTag of sparkPriority.tags) {
if (appTags.includes(ruleTag.toLowerCase())) {
return "spark";
}
}
}
// 7. 默认行为
if (!hasPriorityConfigFile) {
// 没有配置文件 → 默认优先 Spark
return "spark";
} else {
// 有配置文件但规则为空 → 默认优先 APM
return "apm";
}
// 默认行为:与 HYBRID_DEFAULT_PRIORITY 保持一致(默认优先 APM)
return HYBRID_DEFAULT_PRIORITY;
}
+3
View File
@@ -21,6 +21,9 @@ const VALID_STRATEGIES: TagPriorityStrategy[] = ["auto", "spark", "apm"];
// 共享响应式引用:设置页写入、详情页读取,保证跨组件同步
const strategy = ref<TagPriorityStrategy>("auto");
// 导出共享 ref,供详情页以响应式方式 watch(设置变化实时生效)
export const tagPriorityStrategyRef = strategy;
let loaded = false;
const readFromStorage = (): TagPriorityStrategy => {
+1
View File
@@ -115,6 +115,7 @@ 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
forceViewingOrigin?: boolean; // true 表示 viewingOrigin 为父组件显式指定(如从已安装页按特定来源打开),优先级高于用户标签策略;未设置或 false 时由标签策略决定默认展示
origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项)
downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析)
}