!408 refactor(已安装应用): 合并 APM/Spark 分页为列表标签并新增统计

Merge pull request !408 from xiyidaiwa/Erotica
This commit is contained in:
xiyidaiwa
2026-07-29 06:47:41 +00:00
committed by shenmo7192
parent c1b89cab1a
commit 77b4244f1d
8 changed files with 816 additions and 321 deletions
+244 -108
View File
@@ -73,7 +73,9 @@
:category-counts="categoryCounts"
@select-category="selectSubCategory"
/>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-6 lg:px-10">
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-6 lg:px-10"
>
<FavoriteFolderManager
v-if="currentView === 'favorites'"
:folders="favoriteFolders"
@@ -172,10 +174,7 @@
:apps="installedApps"
:loading="installedLoading"
:error="installedError"
:active-origin="activeInstalledOrigin"
:store-filter="storeFilter"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:warning="installedWarning"
:logged-in="isLoggedIn"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
@@ -184,7 +183,6 @@
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
@uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号。')"
@@ -287,7 +285,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import axios from "axios";
import axios, { AxiosError } from "axios";
import pino from "pino";
import AppSidebar from "./components/AppSidebar.vue";
import AppHeader from "./components/AppHeader.vue";
@@ -362,10 +360,9 @@ import {
setAuthSession,
} from "./global/authState";
import {
getAllowedInstalledOrigin,
getEffectiveStoreFilter,
getDefaultInstalledOrigin,
isOriginEnabled,
isOriginUsable,
} from "./modules/storeFilter";
import { createUpdateCenterStore } from "./modules/updateCenter";
import {
@@ -424,7 +421,16 @@ const fetchWithRetry = async <T,>(
const response = await axiosInstance.get<T>(url);
return response.data;
} catch (error) {
if (retries > 0) {
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
// 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试;
// 4xx(如 404/400)属于明确的客户端错误,直接抛出以避免无谓重试。
const isNetworkError = status === undefined;
const isServerError = typeof status === "number" && status >= 500;
const isTimeout =
axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT";
if (retries > 0 && (isNetworkError || isServerError || isTimeout)) {
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, retries - 1, delay * 2);
}
@@ -432,6 +438,44 @@ const fetchWithRetry = async <T,>(
}
};
// 渲染进程从 IPC 拿到的 result.apps 实际类型为 anyipcRenderer.invoke 返回 Promise<any>),
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<InstalledAppInfo>;
return (
typeof v.pkgname === "string" &&
typeof v.name === "string" &&
typeof v.version === "string" &&
typeof v.arch === "string" &&
(v.origin === "spark" || v.origin === "apm") &&
typeof v.flags === "string" &&
typeof v.isDependency === "boolean" &&
// icon 在类型中为可选字段(string | undefined),需显式校验其类型,
// 避免非字符串值进入下游 app.icon || "" 触发隐式转换异常
(typeof v.icon === "string" || v.icon === undefined)
);
};
// 单来源已安装查询超时时间:某个来源(如 APM)响应极慢或挂起时,
// 不应阻塞其它来源整体返回,超时后该来源标记为失败并走 warning/error 流程。
const LIST_INSTALLED_TIMEOUT_MS = 15000;
// 为 Promise 增加超时控制:超时即 reject,配合 Promise.allSettled 让单来源失败不影响其它来源。
const withTimeout = <T,>(
promise: Promise<T>,
ms: number,
label: string,
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`${label} 超时(${ms}ms`)), ms),
),
]);
};
// 响应式状态
const themeMode = ref<"light" | "dark" | "auto">("auto");
const systemIsDark = ref(
@@ -468,10 +512,13 @@ const loading = ref(true);
const showDownloadDetailModal = ref(false);
const currentDownload: Ref<DownloadItem | null> = ref(null);
const showInstalledModal = ref(false);
const activeInstalledOrigin = ref<"apm" | "spark">("apm");
const installedApps = ref<App[]>([]);
const installedLoading = ref(false);
const installedError = ref("");
// 部分来源失败(如 Spark/APM 之一不可用)时使用更轻量的 warning 提示,避免与已有列表同时呈现红色致命错误条造成 UX 混淆
const installedWarning = ref("");
// refreshInstalledApps 的代次计数器,用于异步竞态防护:新进入一次刷新自增,await 后若代次已变则放弃本轮写入
const installedRefreshGeneration = ref(0);
const updateCenterStore = createUpdateCenterStore();
const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null);
@@ -1379,131 +1426,203 @@ const confirmMigrationStart = async () => {
};
const openInstalledModal = () => {
const defaultOrigin = getDefaultInstalledOrigin(
storeFilter.value,
availableSources.value,
);
if (!defaultOrigin) {
if (
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
) {
return;
}
showInstalledModal.value = true;
activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
) ?? defaultOrigin;
refreshInstalledApps();
};
const closeInstalledModal = () => {
showInstalledModal.value = false;
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
installedError.value = "";
installedWarning.value = "";
};
const handleSwitchOrigin = (origin: "apm" | "spark") => {
activeInstalledOrigin.value =
getAllowedInstalledOrigin(
storeFilter.value,
origin,
availableSources.value,
) ?? activeInstalledOrigin.value;
refreshInstalledApps();
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
origins.push("spark");
}
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
origins.push("apm");
}
return origins;
};
const refreshInstalledApps = async () => {
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
if (!showInstalledModal.value) return;
installedLoading.value = true;
installedError.value = "";
installedWarning.value = "";
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
const generation = ++installedRefreshGeneration.value;
try {
const origin = getAllowedInstalledOrigin(
storeFilter.value,
activeInstalledOrigin.value,
availableSources.value,
const origins = resolveInstalledOrigins();
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
const sparkPkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
const effectiveOrigins = origins.filter(
(o) => o !== "spark" || sparkPkgnameList.length > 0,
);
if (!origin) {
if (effectiveOrigins.length === 0) {
installedApps.value = [];
installedError.value = "当前系统不可用应用管理功能";
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
installedError.value =
apps.value.length === 0
? "正在加载应用目录,请稍候…"
: "当前系统不可用应用管理功能";
return;
}
activeInstalledOrigin.value = origin;
// 并行查询每个 origin 的已安装应用:
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
const results = await Promise.allSettled(
effectiveOrigins.map((origin) =>
withTimeout(
window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
}),
LIST_INSTALLED_TIMEOUT_MS,
`${origin} list-installed`,
),
),
);
if (!isOriginEnabled(storeFilter.value, origin)) {
installedApps.value = [];
installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`;
return;
}
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
// Spark 优化:只检查远端商店目录中的应用,避免全量扫描
let pkgnameList: string[] | undefined;
if (origin === "spark") {
pkgnameList = apps.value
.filter((a) => a.origin === "spark")
.map((a) => a.pkgname);
}
const combinedApps: App[] = [];
const failedOrigins: string[] = [];
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) {
installedApps.value = [];
installedError.value = result?.message || "读取已安装应用失败";
return;
}
installedApps.value = [];
for (const app of result.apps) {
// Find matching remote app to enrich data. We look exactly for that origin.
let appInfo = apps.value.find(
(a) => a.pkgname === app.pkgname && a.origin === origin,
);
if (origin === "spark" && !appInfo) {
// Only show Spark packages that exist in the App Store catalogue
for (let i = 0; i < effectiveOrigins.length; i++) {
const origin = effectiveOrigins[i];
const settled = results[i];
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
if (settled.status !== "fulfilled" || !settled.value?.success) {
failedOrigins.push(origin);
continue;
}
const result = settled.value;
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
} else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
if (!isInstalledAppInfo(rawApp)) continue;
const app = rawApp;
// Find matching remote app to enrich data. We look exactly for that origin.
let appInfo = apps.value.find(
(a) => a.pkgname === app.pkgname && a.origin === origin,
);
if (origin === "spark" && !appInfo) {
// Only show Spark packages that exist in the App Store catalogue
continue;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
} else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = {
name: app.name || app.pkgname,
pkgname: app.pkgname,
version: app.version,
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
isDependency: app.isDependency,
};
}
combinedApps.push(appInfo);
}
}
installedApps.value = combinedApps;
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
if (failedOrigins.length > 0) {
const labels = failedOrigins
.map((o) => (o === "spark" ? "Spark" : "APM"))
.join("、");
if (combinedApps.length > 0) {
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
} else {
installedError.value = `读取${labels}已安装应用失败`;
}
installedApps.value.push(appInfo);
}
} catch (error: unknown) {
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
installedApps.value = [];
installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally {
installedLoading.value = false;
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
if (generation === installedRefreshGeneration.value) {
installedLoading.value = false;
}
}
};
// 应用目录(apps.value)可能在打开"已安装应用"模态框之后才加载完成。
// 当目录长度变化、且模态框处于打开状态时,自动重查已安装应用,避免列表一直为空。
// 注意:上一版仅在 prevLen===0→len>0 触发,会遗漏目录后续更新(例如分类切换触发目录重建)。
// 现改为 len 任意 >0 的正向变化都允许触发;密集分批推送由下方 300ms 防抖合并最后一次写入。
// 每次变化都先自增 installedRefreshGeneration:即使上一轮刷新仍在加载中,也会立即失效,
// 避免用陈旧目录数据覆盖已安装列表(清理不单纯依赖定时器,代次校验兜底)。
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => apps.value.length,
(len) => {
if (!showInstalledModal.value || len <= 0) {
return;
}
installedRefreshGeneration.value++;
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
}
refreshDebounceTimer = setTimeout(() => {
refreshDebounceTimer = null;
void refreshInstalledApps();
}, 300);
},
);
onUnmounted(() => {
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
}
});
const mapInstalledAppToCatalogApp = (
app: InstalledAppInfo,
origin: "spark" | "apm",
@@ -1573,8 +1692,11 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
});
if (!result?.success) return;
for (const app of result.apps as InstalledAppInfo[]) {
const appInfo = mapInstalledAppToCatalogApp(app, origin);
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfo(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
@@ -1916,8 +2038,11 @@ const refreshInstalledSyncCandidates = async (
});
if (!result?.success) return;
for (const app of result.apps as InstalledAppInfo[]) {
const appInfo = mapInstalledAppToCatalogApp(app, origin);
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
if (!isInstalledAppInfo(rawApp)) continue;
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
if (appInfo) refreshedApps.push(appInfo);
}
}),
@@ -2450,7 +2575,9 @@ const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
// fallbackText: `打开应用: ${download.pkgname}`
// });
window.ipcRenderer.invoke("launch-app", { pkgname, origin });
window.ipcRenderer
.invoke("launch-app", { pkgname, origin })
.catch((err) => logger.error("启动应用失败 (launch-app):", err));
};
const loadCategories = async () => {
@@ -2794,6 +2921,15 @@ const handleSearchFocus = () => {
if (activeTab.value === "home") activeTab.value = "all";
};
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
let saveBoundsTimer: number | undefined;
const handleWindowResize = () => {
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
saveBoundsTimer = window.setTimeout(() => {
void window.ipcRenderer.invoke("save-window-bounds");
}, 400);
};
// 生命周期钩子
onMounted(async () => {
initTheme();
@@ -2806,6 +2942,9 @@ onMounted(async () => {
handleHashChange();
window.addEventListener("hashchange", handleHashChange);
// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸
window.addEventListener("resize", handleWindowResize);
try {
systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
} catch (error: unknown) {
@@ -2827,10 +2966,6 @@ onMounted(async () => {
apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available");
}
activeInstalledOrigin.value =
getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ??
"spark";
await loadCategories();
await loadSidebarConfig();
@@ -2988,6 +3123,7 @@ onUnmounted(() => {
"install-complete",
handleInstallCompleteForDownloadRecord,
);
window.removeEventListener("resize", handleWindowResize);
});
// 观察器
+134 -104
View File
@@ -26,21 +26,20 @@ const createApp = (overrides: Partial<App> = {}): App => ({
});
describe("InstalledAppsModal", () => {
const baseProps = {
show: true,
apps: [] as App[],
loading: false,
error: "",
warning: "",
loggedIn: false,
syncing: false,
syncMessage: "",
};
it("keeps scroll chaining inside the modal list", () => {
const { container } = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
props: baseProps,
});
expect(screen.getByText("已安装应用")).toBeTruthy();
@@ -52,17 +51,8 @@ describe("InstalledAppsModal", () => {
it("renders open and detail actions for a store-backed installed app", () => {
render(InstalledAppsModal, {
props: {
show: true,
...baseProps,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -70,20 +60,42 @@ describe("InstalledAppsModal", () => {
expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy();
});
it("renders the spark origin tag for spark apps", () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp({ origin: "spark", name: "Spark Notes" })],
},
});
// 精确匹配应用行内的来源标签(页头统计区也有 "Spark" 文案,getAllByText 过于宽泛)
expect(screen.getByTestId("origin-tag-spark")).toBeTruthy();
});
it("renders the APM origin tag for APM apps", () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({
origin: "apm",
name: "APM Container",
pkgname: "amber-pm-container",
version: "1.0.0",
}),
],
},
});
// 精确匹配应用行内的来源标签(页头统计区也有 "APM" 文案,getAllByText 过于宽泛)
expect(screen.getByTestId("origin-tag-apm")).toBeTruthy();
});
it("emits open-app when clicking 打开", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
...baseProps,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -98,17 +110,8 @@ describe("InstalledAppsModal", () => {
it("emits open-detail when clicking 查看详情", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
...baseProps,
apps: [createApp()],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -123,17 +126,8 @@ describe("InstalledAppsModal", () => {
it("shows 查看详情 for metadata-rich unknown-category apps", () => {
render(InstalledAppsModal, {
props: {
show: true,
...baseProps,
apps: [createApp({ category: "unknown", more: "Has store metadata" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -143,17 +137,8 @@ describe("InstalledAppsModal", () => {
it("hides 查看详情 for unknown-category apps", () => {
render(InstalledAppsModal, {
props: {
show: true,
...baseProps,
apps: [createApp({ category: "unknown" })],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -162,19 +147,7 @@ describe("InstalledAppsModal", () => {
it("requests login for cloud actions when logged out", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
props: baseProps,
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
@@ -186,17 +159,8 @@ describe("InstalledAppsModal", () => {
it("emits cloud sync and restore events when logged in", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
...baseProps,
loggedIn: true,
syncing: false,
syncMessage: "",
},
});
@@ -210,17 +174,9 @@ describe("InstalledAppsModal", () => {
it("disables sync button while syncing", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
...baseProps,
loggedIn: true,
syncing: true,
syncMessage: "",
},
});
@@ -230,20 +186,94 @@ describe("InstalledAppsModal", () => {
it("shows account sync feedback in the installed apps modal", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
...baseProps,
loggedIn: true,
syncing: false,
syncMessage: "同步完成",
},
});
expect(screen.getByText("同步完成")).toBeTruthy();
});
it("filters installed apps by search query (name match)", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({ name: "Spark Notes", pkgname: "spark-notes" }),
createApp({
name: "Visual Studio Code",
pkgname: "code",
category: "dev",
more: "https://code.visualstudio.com/",
}),
],
},
});
// 初始两条都在
expect(screen.getByText("Spark Notes")).toBeTruthy();
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "code");
// "code" 只匹配到 pkgname 为 "code" 的项(名称不区分大小写)
expect(screen.queryByText("Spark Notes")).toBeNull();
expect(screen.getByText("Visual Studio Code")).toBeTruthy();
});
it("filters installed apps by search query (case-insensitive)", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [
createApp({ name: "钉钉", pkgname: "com.alibaba.dingtalk" }),
],
},
});
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "DINGTALK");
// 包名大写不区分大小写匹配
expect(screen.getByText("钉钉")).toBeTruthy();
});
it("shows a no-match hint when search query has no results", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp({ name: "Spark Notes" })],
},
});
const input = screen.getByPlaceholderText("搜索已安装应用…");
await fireEvent.update(input, "不存在的关键字xyz");
expect(screen.queryByText("Spark Notes")).toBeNull();
expect(screen.getByText(/未找到匹配/)).toBeTruthy();
});
it("clears the search when clicking the clear button", async () => {
render(InstalledAppsModal, {
props: {
...baseProps,
apps: [createApp()],
},
});
const input = screen.getByPlaceholderText(
"搜索已安装应用…",
) as HTMLInputElement;
await fireEvent.update(input, "code");
expect(input.value).toBe("code");
// 清除按钮存在(仅在有内容时显示)
const clearBtn = screen.getByRole("button", { name: "清除搜索" });
await fireEvent.click(clearBtn);
expect(input.value).toBe("");
expect(screen.getByText("Spark Notes")).toBeTruthy();
});
});
+7 -2
View File
@@ -21,8 +21,8 @@
<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">
<AppCard
v-for="(app, index) in apps"
:key="index"
v-for="app in apps"
:key="app.pkgname"
:app="app"
:show-origin="effectiveShowOrigin"
@open-detail="$emit('open-detail', app)"
@@ -182,12 +182,17 @@ const gridRows = computed(() => {
.non-virtual-scroller {
height: 100%;
overflow-y: auto;
margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */
}
@media (min-width: 1024px) {
.scroller {
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
}
.non-virtual-scroller {
margin: -24px -40px; /* 抵消父容器的 lg:px-10 */
}
}
.grid-row {
+228 -74
View File
@@ -14,71 +14,142 @@
@wheel="onOverlayWheel"
>
<div
class="flex w-full max-w-4xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
class="flex w-full max-w-4xl max-h-[85vh] flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div>
<div class="flex flex-col gap-2">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
已安装应用
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
管理本机安装的应用程序
</p>
<div
v-if="!loading && !error"
class="mt-2 flex flex-wrap items-center gap-3"
>
<div
class="inline-flex flex-wrap items-stretch overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-50/60 text-sm shadow-sm dark:border-slate-700/60 dark:bg-slate-800/40"
>
<!-- APM -->
<div
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-amber-50/80 dark:hover:bg-amber-500/10"
:class="
filterOrigin === 'apm'
? 'bg-amber-100 ring-2 ring-amber-400/50 dark:bg-amber-500/20'
: ''
"
title="仅显示 APM 应用"
role="button"
@click="filterOrigin = 'apm'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-500/30"
>
<i class="fas fa-box-open text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-amber-700 dark:text-amber-300"
>{{ apmCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-amber-600/80 dark:text-amber-400/80"
>APM</span
>
</div>
</div>
<!-- 分隔线 -->
<span
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
></span>
<!-- Spark -->
<div
class="group flex cursor-pointer items-center gap-2.5 px-3.5 py-2 transition hover:bg-sky-50/80 dark:hover:bg-sky-500/10"
:class="
filterOrigin === 'spark'
? 'bg-sky-100 ring-2 ring-sky-400/50 dark:bg-sky-500/20'
: ''
"
title="仅显示 Spark 应用"
role="button"
@click="filterOrigin = 'spark'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-500 text-white shadow-sm shadow-sky-500/30"
>
<i class="fas fa-bolt text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-sky-700 dark:text-sky-300"
>{{ sparkCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-sky-600/80 dark:text-sky-400/80"
>Spark</span
>
</div>
</div>
<!-- 分隔线 -->
<span
class="self-stretch w-px bg-slate-200/80 dark:bg-slate-700/80"
></span>
<!-- 总数 -->
<div
class="flex cursor-pointer items-center gap-2.5 px-3.5 py-2"
:class="
filterOrigin === 'all'
? 'bg-slate-100 ring-2 ring-slate-400/40 dark:bg-slate-700/40'
: ''
"
title="显示全部已安装应用"
role="button"
@click="filterOrigin = 'all'"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-slate-500 to-slate-700 text-white shadow-sm dark:from-slate-400 dark:to-slate-600"
>
<i class="fas fa-cubes text-[12px]"></i>
</span>
<div class="flex flex-col leading-tight">
<span
class="text-[15px] font-bold tabular-nums text-slate-900 dark:text-white"
>{{ totalCount }}</span
>
<span
class="text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
>总数</span
>
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="relative flex-1 min-w-[200px]">
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
></i>
<input
v-model="searchQuery"
type="text"
placeholder="搜索已安装应用…"
class="w-full rounded-2xl border border-slate-200/70 bg-slate-50/60 py-2 pl-9 pr-9 text-sm text-slate-700 placeholder-slate-400 transition focus:border-brand/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800/40 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:bg-slate-800"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-200/60 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-white"
aria-label="清除搜索"
@click="searchQuery = ''"
>
<i class="fas fa-xmark text-xs"></i>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 云端同步功能暂时关闭
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
:disabled="syncing"
@click="handleSyncClick"
>
<i class="fas fa-cloud-arrow-up"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="handleRestoreClick"
>
<i class="fas fa-cloud-arrow-down"></i>
从账号恢复
</button>
-->
<div
v-if="showOriginSwitcher"
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
>
<button
v-if="apmEnabled"
type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class="
activeOrigin === 'apm'
? 'bg-brand/10 text-brand dark:bg-brand/15'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
"
:disabled="!apmAvailable"
@click="$emit('switch-origin', 'apm')"
>
APM 软件
</button>
<button
v-if="sparkEnabled"
type="button"
class="rounded-xl px-4 py-1.5 text-sm font-semibold transition"
:class="
activeOrigin === 'spark'
? 'bg-brand/10 text-brand dark:bg-brand/15'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
"
@click="$emit('switch-origin', 'spark')"
>
Spark 软件
</button>
</div>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -100,7 +171,7 @@
</div>
<div
class="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4 mr-4 mb-4"
class="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4 mb-6"
>
<div
v-if="syncMessage"
@@ -120,15 +191,37 @@
>
{{ error }}
</div>
<div
v-else-if="warning"
class="rounded-2xl border border-amber-200/70 bg-amber-50/60 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<i class="fas fa-triangle-exclamation mr-1.5"></i>
{{ warning }}
</div>
<div
v-else-if="apps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
暂无已安装应用
</div>
<div
v-else-if="filteredApps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
<i class="fas fa-search mr-1.5 text-slate-400"></i>
<template v-if="searchQuery">
未找到匹配<span class="font-semibold text-slate-700 dark:text-slate-300">{{ searchQuery }}</span>的已安装应用
</template>
<template v-else-if="filterOrigin === 'apm'">
暂无已安装的 APM 应用
</template>
<template v-else-if="filterOrigin === 'spark'">
暂无已安装的 Spark 应用
</template>
</div>
<div v-else class="space-y-3">
<div
v-for="app in apps"
v-for="app in filteredApps"
:key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
>
@@ -155,6 +248,22 @@
>
{{ 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 软件"
>
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
</span>
<span
v-if="app.isDependency"
class="rounded-md bg-rose-100 px-2 py-0.5 text-[11px] font-semibold text-rose-600 dark:bg-rose-500/20 dark:text-rose-400"
@@ -212,14 +321,32 @@
</template>
<script setup lang="ts">
import { computed, reactive } from "vue";
import { computed, reactive, ref } from "vue";
import { App } from "../global/typedefinition";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
const iconErrors = reactive<Record<string, boolean>>({});
// 仅允许从这些常见图标目录读取本地图标,避免通过 app.icons 读取任意本地文件
const ALLOWED_LOCAL_ICON_PREFIXES = [
"/usr/share/",
"/usr/lib/",
"/usr/local/share/",
"/opt/",
"/var/lib/apm/",
"/var/lib/",
];
const getIconUrl = (app: App) => {
if (app.icons && app.icons.startsWith("/")) return `file://${app.icons}`;
// 本地图标:仅允许以白名单目录开头、且不含路径遍历("..")的绝对路径
if (
app.icons &&
app.icons.startsWith("/") &&
!app.icons.includes("..") &&
ALLOWED_LOCAL_ICON_PREFIXES.some((prefix) => app.icons!.startsWith(prefix))
) {
return `file://${app.icons}`;
}
if (!app.category || app.category === "unknown") return "";
const arch = window.apm_store.arch || "amd64";
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
@@ -241,21 +368,60 @@ const props = defineProps<{
apps: App[];
loading: boolean;
error: string;
activeOrigin: "apm" | "spark";
storeFilter: "spark" | "apm" | "both";
sparkAvailable: boolean;
apmAvailable: boolean;
warning: string;
loggedIn: boolean;
syncing: boolean;
syncMessage: string;
}>();
const apmCount = computed(
() => props.apps.filter((a) => a.origin === "apm").length,
);
const sparkCount = computed(
() => props.apps.filter((a) => a.origin === "spark").length,
);
const totalCount = computed(() => props.apps.length);
// 来源筛选:默认全部;点击统计徽章可在 all/apm/spark 间切换
const filterOrigin = ref<"all" | "apm" | "spark">("all");
// 搜索关键词(按名称/包名不区分大小写过滤已安装应用)
const searchQuery = ref("");
const filteredApps = computed(() => {
// 1. 先按搜索关键词过滤
const q = searchQuery.value.trim().toLowerCase();
let list = props.apps;
if (q) {
list = list.filter(
(a) =>
a.name.toLowerCase().includes(q) ||
a.pkgname.toLowerCase().includes(q),
);
}
// 2. 再按来源筛选(默认 all = 不过滤)
if (filterOrigin.value === "apm") {
list = list.filter((a) => a.origin === "apm");
} else if (filterOrigin.value === "spark") {
list = list.filter((a) => a.origin === "spark");
}
// 3. 排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
// 返回新数组,避免修改原始 props.apps
return [...list].sort((a, b) => {
const aApm = a.origin === "apm" ? 0 : 1;
const bApm = b.origin === "apm" ? 0 : 1;
if (aApm !== bApm) return aApm - bApm;
// 同类内保持原有的字母序,体验更一致
return a.pkgname.localeCompare(b.pkgname);
});
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
(e: "sync-to-account"): void;
@@ -287,16 +453,4 @@ const onOverlayWheel = (e: WheelEvent) => {
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
const sparkEnabled = computed(() => {
return props.storeFilter !== "apm" && props.sparkAvailable;
});
const apmEnabled = computed(() => {
return props.storeFilter !== "spark" && props.apmAvailable;
});
const showOriginSwitcher = computed(() => {
return sparkEnabled.value && apmEnabled.value;
});
</script>
+4 -4
View File
@@ -1,6 +1,6 @@
<template>
<div
class="flex h-screen flex-col overflow-hidden rounded-3xl shadow-2xl bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100"
class="flex h-screen flex-col overflow-hidden rounded-3xl bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 ring-1 ring-black/5 dark:ring-white/5 shadow-[inset_0_1px_3px_rgba(0,0,0,0.25)]"
>
<div
class="submitter-titlebar shrink-0 z-30 border-b border-slate-200/70 bg-white px-4 py-3 dark:border-slate-800/70 dark:bg-slate-900"
@@ -34,7 +34,7 @@
</div>
</div>
<div class="flex-1 overflow-y-auto mr-4 mb-4">
<div class="flex-1 overflow-y-auto mb-6">
<div class="p-6 max-w-2xl mx-auto">
<div class="space-y-6">
<div>
@@ -608,7 +608,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/50"
class="absolute inset-0 bg-black/50 rounded-3xl"
@click="showArchPackDialog = false"
></div>
<div
@@ -684,7 +684,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/50"
class="absolute inset-0 bg-black/50 rounded-3xl"
@click="showArchDialog = false"
></div>
<div