feat: 添加 APM 应用管理功能并优化界面

- 新增 APM 应用管理功能,支持显示已安装应用及其依赖项
- 优化已安装应用列表界面,增加应用图标和名称显示
- 调整顶部操作栏布局,将设置和关于按钮移至搜索框旁
- 修复类型定义,增加 isDependency 字段和更多应用信息
- 改进暗色模式下的界面显示效果
This commit is contained in:
2026-03-24 20:47:55 +08:00
parent 480a7f3b77
commit 7ff079276e
13 changed files with 326 additions and 158 deletions

View File

@@ -5,7 +5,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import { ChannelPayload, InstalledAppInfo } from "../../typedefinition"; import { ChannelPayload } from "../../typedefinition";
import axios from "axios"; import axios from "axios";
const logger = pino({ name: "install-manager" }); const logger = pino({ name: "install-manager" });
@@ -79,28 +79,6 @@ const runCommandCapture = async (execCommand: string, execParams: string[]) => {
); );
}; };
const parseInstalledList = (output: string) => {
const apps: Array<InstalledAppInfo> = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("Listing")) continue;
if (trimmed.startsWith("[INFO]")) continue;
const match = trimmed.match(/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/);
if (!match) continue;
apps.push({
pkgname: match[1],
version: match[2],
arch: match[3],
flags: match[4],
raw: trimmed,
});
}
return apps;
};
/** 检测本机是否已安装 apm 命令 */ /** 检测本机是否已安装 apm 命令 */
const checkApmAvailable = async (): Promise<boolean> => { const checkApmAvailable = async (): Promise<boolean> => {
const { code, stdout } = await runCommandCapture("which", ["apm"]); const { code, stdout } = await runCommandCapture("which", ["apm"]);
@@ -251,7 +229,8 @@ ipcMain.on("queue-install", async (event, download_json) => {
type: "info", type: "info",
title: "APM 安装成功", title: "APM 安装成功",
message: "恭喜您APM 已成功安装", message: "恭喜您APM 已成功安装",
detail: "APM 应用需重启后方可展示和使用,若完成安装后无法在应用列表中展示,请重启电脑后继续。", detail:
"APM 应用需重启后方可展示和使用,若完成安装后无法在应用列表中展示,请重启电脑后继续。",
buttons: ["确定"], buttons: ["确定"],
defaultId: 0, defaultId: 0,
}); });
@@ -422,10 +401,15 @@ async function processNextInQueue() {
const timeoutChecker = setInterval(() => { const timeoutChecker = setInterval(() => {
const now = Date.now(); const now = Date.now();
// 只在进度为0时检查超时 // 只在进度为0时检查超时
if (lastProgress === 0 && now - lastProgressTime > zeroProgressTimeout) { if (
lastProgress === 0 &&
now - lastProgressTime > zeroProgressTimeout
) {
clearInterval(timeoutChecker); clearInterval(timeoutChecker);
child.kill(); child.kill();
reject(new Error(`下载卡在0%超过 ${zeroProgressTimeout / 1000}`)); reject(
new Error(`下载卡在0%超过 ${zeroProgressTimeout / 1000}`),
);
} }
}, progressCheckInterval); }, progressCheckInterval);
@@ -466,7 +450,7 @@ async function processNextInQueue() {
} }
sendLog(`下载失败,准备重试 (${retryCount}/${maxRetries})`); sendLog(`下载失败,准备重试 (${retryCount}/${maxRetries})`);
// 等待2秒后重试 // 等待2秒后重试
await new Promise(r => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
} }
} }
} }
@@ -573,8 +557,11 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
"--installed", "--installed",
]); ]);
if (code === 0) { if (code === 0) {
// eslint-disable-next-line no-control-regex const cleanStdout = stdout.replace(
const cleanStdout = stdout.replace(/\x1b\[[0-9;]*m/g, ""); // eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
);
const lines = cleanStdout.split("\n"); const lines = cleanStdout.split("\n");
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -625,7 +612,6 @@ ipcMain.handle("check-installed", async (_event, payload: any) => {
if (isInstalled) return true; if (isInstalled) return true;
} }
return isInstalled; return isInstalled;
}); });
@@ -691,9 +677,133 @@ ipcMain.on("remove-installed", async (_event, payload) => {
}); });
}); });
ipcMain.handle("list-installed", async () => {
const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
try {
if (!fs.existsSync(apmBasePath)) {
logger.warn(`APM base path not found: ${apmBasePath}`);
return {
success: false,
message: "APM base path not found",
apps: [],
};
}
const packages = fs.readdirSync(apmBasePath, { withFileTypes: true });
const installedApps: Array<{
pkgname: string;
name: string;
version: string;
arch: string;
flags: string;
origin: "spark" | "apm";
icon?: string;
isDependency: boolean;
}> = [];
for (const pkg of packages) {
if (!pkg.isDirectory()) continue;
const pkgname = pkg.name;
const pkgPath = path.join(apmBasePath, pkgname);
const { code, stdout } = await runCommandCapture("apm", [
"list",
pkgname,
]);
if (code !== 0) {
logger.warn(`Failed to list package ${pkgname}: ${stdout}`);
continue;
}
const cleanStdout = stdout.replace(
// eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
);
const lines = cleanStdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (
!trimmed ||
trimmed.startsWith("Listing") ||
trimmed.startsWith("[INFO]") ||
trimmed.startsWith("警告")
)
continue;
const match = trimmed.match(
/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/,
);
if (!match) continue;
const [, listedPkgname, version, arch, flags] = match;
if (listedPkgname !== pkgname) continue;
let appName = pkgname;
let icon = "";
const entriesPath = path.join(pkgPath, "entries", "applications");
const hasEntries = fs.existsSync(entriesPath);
if (hasEntries) {
const desktopFiles = fs.readdirSync(entriesPath);
for (const file of desktopFiles) {
if (file.endsWith(".desktop")) {
const desktopPath = path.join(entriesPath, file);
const content = fs.readFileSync(desktopPath, "utf-8");
const nameMatch = content.match(/^Name=(.+)$/m);
const iconMatch = content.match(/^Icon=(.+)$/m);
if (nameMatch) appName = nameMatch[1].trim();
if (iconMatch) icon = iconMatch[1].trim();
break;
}
}
}
installedApps.push({
pkgname,
name: appName,
version,
arch,
flags,
origin: "apm",
icon: icon || undefined,
isDependency: !hasEntries,
});
}
}
installedApps.sort((a, b) => {
const getOrder = (app: { pkgname: string; isDependency: boolean }) => {
if (app.isDependency) return 2;
if (app.pkgname.startsWith("amber-pm")) return 1;
return 0;
};
const aOrder = getOrder(a);
const bOrder = getOrder(b);
if (aOrder !== bOrder) return aOrder - bOrder;
return a.pkgname.localeCompare(b.pkgname);
});
return { success: true, apps: installedApps };
} catch (error) {
logger.error(
`list-installed failed: ${error instanceof Error ? error.message : String(error)}`,
);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
apps: [],
};
}
});
ipcMain.handle("list-upgradable", async () => { ipcMain.handle("list-upgradable", async () => {
const { code, stdout, stderr } = await runCommandCapture(SHELL_CALLER_PATH, [ const { code, stdout, stderr } = await runCommandCapture("apm", [
"aptss",
"list", "list",
"--upgradable", "--upgradable",
]); ]);
@@ -710,6 +820,9 @@ ipcMain.handle("list-upgradable", async () => {
return { success: true, apps }; return { success: true, apps };
}); });
ipcMain.handle("check-apm-available", async () => {
return await checkApmAvailable();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle("uninstall-installed", async (_event, payload: any) => { ipcMain.handle("uninstall-installed", async (_event, payload: any) => {

View File

@@ -136,4 +136,4 @@ export function sendTelemetryOnce(storeVersion: string): void {
.catch((err) => { .catch((err) => {
logger.warn({ err }, "Telemetry request failed"); logger.warn({ err }, "Telemetry request failed");
}); });
} }

View File

@@ -101,10 +101,8 @@ function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
return "both"; return "both";
} }
ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" => ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
getStoreFilterFromArgv(), getStoreFilterFromArgv(),
); );
async function createWindow() { async function createWindow() {
@@ -220,7 +218,7 @@ app.whenReady().then(() => {
}); });
createWindow(); createWindow();
handleCommandLine(process.argv); handleCommandLine(process.argv);
// 启动后执行一次遥测(仅 Linux不阻塞 // 启动后执行一次遥测(仅 Linux不阻塞
sendTelemetryOnce(getAppVersion()); sendTelemetryOnce(getAppVersion());
}); });
@@ -281,7 +279,6 @@ function getTrayIconPath(): string | null {
const FALLBACK_TRAY_PNG = const FALLBACK_TRAY_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHklEQVQ4T2NkYGD4z0ABYBwNwMAwGoChNQAAAABJRU5ErkJggg=="; "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHklEQVQ4T2NkYGD4z0ABYBwNwMAwGoChNQAAAABJRU5ErkJggg==";
function getTrayImage(): function getTrayImage():
| string | string
| ReturnType<typeof nativeImage.createFromDataURL> { | ReturnType<typeof nativeImage.createFromDataURL> {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "spark-store", "name": "spark-store",
"version": "4.9.9", "version": "4.9.9alpha4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "spark-store", "name": "spark-store",
"version": "4.9.9", "version": "4.9.9alpha4",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

View File

@@ -20,27 +20,32 @@
:active-category="activeCategory" :active-category="activeCategory"
:category-counts="categoryCounts" :category-counts="categoryCounts"
:theme-mode="themeMode" :theme-mode="themeMode"
:apm-available="apmAvailable"
@toggle-theme="toggleTheme" @toggle-theme="toggleTheme"
@select-category="selectCategory" @select-category="selectCategory"
@close="isSidebarOpen = false" @close="isSidebarOpen = false"
@open-about="openAboutModal" @list="handleList"
@update="handleUpdate"
/> />
</aside> </aside>
<main class="flex-1 px-4 py-6 lg:px-10"> <main class="flex-1">
<AppHeader <div
:search-query="searchQuery" class="sticky top-0 z-30 border-b border-slate-200/70 bg-slate-50/95 px-4 py-4 backdrop-blur lg:px-10 dark:border-slate-800/70 dark:bg-slate-950/95"
:active-category="activeCategory" >
:apps-count="filteredApps.length" <AppHeader
@update-search="handleSearchInput" :search-query="searchQuery"
@search-focus="handleSearchFocus" :active-category="activeCategory"
@update="handleUpdate" :apps-count="filteredApps.length"
@list="handleList" @update-search="handleSearchInput"
@open-install-settings="handleOpenInstallSettings" @search-focus="handleSearchFocus"
@toggle-sidebar="isSidebarOpen = !isSidebarOpen" @open-install-settings="handleOpenInstallSettings"
/> @open-about="openAboutModal"
<template v-if="activeCategory === 'home'"> @toggle-sidebar="isSidebarOpen = !isSidebarOpen"
<div class="pt-6"> />
</div>
<div class="px-4 py-6 lg:px-10">
<template v-if="activeCategory === 'home'">
<HomeView <HomeView
:links="homeLinks" :links="homeLinks"
:lists="homeLists" :lists="homeLists"
@@ -48,15 +53,15 @@
:error="homeError" :error="homeError"
@open-detail="openDetail" @open-detail="openDetail"
/> />
</div> </template>
</template> <template v-else>
<template v-else> <AppGrid
<AppGrid :apps="filteredApps"
:apps="filteredApps" :loading="loading"
:loading="loading" @open-detail="openDetail"
@open-detail="openDetail" />
/> </template>
</template> </div>
</main> </main>
<AppDetailModal <AppDetailModal
@@ -134,10 +139,7 @@
@success="onUninstallSuccess" @success="onUninstallSuccess"
/> />
<AboutModal <AboutModal :show="showAboutModal" @close="closeAboutModal" />
:show="showAboutModal"
@close="closeAboutModal"
/>
</div> </div>
</template> </template>
@@ -248,6 +250,7 @@ const updateError = ref("");
const showUninstallModal = ref(false); const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null); const uninstallTargetApp: Ref<App | null> = ref(null);
const showAboutModal = ref(false); const showAboutModal = ref(false);
const apmAvailable = ref(false);
/** 启动参数 --no-apm => 仅 Spark--no-spark => 仅 APM由主进程 IPC 提供 */ /** 启动参数 --no-apm => 仅 Spark--no-spark => 仅 APM由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both"); const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -403,16 +406,16 @@ const openDetail = async (app: App | Record<string, unknown>) => {
if (fullApp.isMerged && (fullApp.sparkApp || fullApp.apmApp)) { if (fullApp.isMerged && (fullApp.sparkApp || fullApp.apmApp)) {
const [sparkInstalled, apmInstalled] = await Promise.all([ const [sparkInstalled, apmInstalled] = await Promise.all([
fullApp.sparkApp fullApp.sparkApp
? window.ipcRenderer.invoke("check-installed", { ? (window.ipcRenderer.invoke("check-installed", {
pkgname: fullApp.sparkApp.pkgname, pkgname: fullApp.sparkApp.pkgname,
origin: "spark", origin: "spark",
}) as Promise<boolean> }) as Promise<boolean>)
: Promise.resolve(false), : Promise.resolve(false),
fullApp.apmApp fullApp.apmApp
? window.ipcRenderer.invoke("check-installed", { ? (window.ipcRenderer.invoke("check-installed", {
pkgname: fullApp.apmApp.pkgname, pkgname: fullApp.apmApp.pkgname,
origin: "apm", origin: "apm",
}) as Promise<boolean> }) as Promise<boolean>)
: Promise.resolve(false), : Promise.resolve(false),
]); ]);
if (sparkInstalled && !apmInstalled) { if (sparkInstalled && !apmInstalled) {
@@ -425,9 +428,9 @@ const openDetail = async (app: App | Record<string, unknown>) => {
const displayAppForScreenshots = const displayAppForScreenshots =
fullApp.viewingOrigin !== undefined && fullApp.isMerged fullApp.viewingOrigin !== undefined && fullApp.isMerged
? (fullApp.viewingOrigin === "spark" ? ((fullApp.viewingOrigin === "spark"
? fullApp.sparkApp ? fullApp.sparkApp
: fullApp.apmApp) ?? fullApp : fullApp.apmApp) ?? fullApp)
: fullApp; : fullApp;
currentApp.value = fullApp; currentApp.value = fullApp;
@@ -762,6 +765,7 @@ const refreshInstalledApps = async () => {
appInfo.flags = app.flags; appInfo.flags = app.flags;
appInfo.arch = app.arch; appInfo.arch = app.arch;
appInfo.currentStatus = "installed"; appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
} else { } else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = { appInfo = {
@@ -779,11 +783,12 @@ const refreshInstalledApps = async () => {
update: "", update: "",
size: "", size: "",
img_urls: [], img_urls: [],
icons: "", icons: app.icon || "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"), origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed", currentStatus: "installed",
arch: app.arch, arch: app.arch,
flags: app.flags, flags: app.flags,
isDependency: app.isDependency,
}; };
} }
installedApps.value.push(appInfo); installedApps.value.push(appInfo);
@@ -1061,6 +1066,12 @@ onMounted(async () => {
// 从主进程获取启动参数(--no-apm / --no-spark再加载数据 // 从主进程获取启动参数(--no-apm / --no-spark再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter"); storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
// 检查 apm 是否可用
if (storeFilter.value !== "spark") {
apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available");
}
await loadCategories(); await loadCategories();
// 分类目录加载后,并行加载主页数据和所有应用列表 // 分类目录加载后,并行加载主页数据和所有应用列表

View File

@@ -33,7 +33,9 @@
class="h-24 w-24 rounded-3xl bg-white p-4 shadow-lg ring-1 ring-slate-900/5 dark:bg-slate-800" class="h-24 w-24 rounded-3xl bg-white p-4 shadow-lg ring-1 ring-slate-900/5 dark:bg-slate-800"
/> />
</div> </div>
<h2 class="mb-2 text-2xl font-bold text-slate-900 dark:text-white"> <h2
class="mb-2 text-2xl font-bold text-slate-900 dark:text-white"
>
星火应用商店 星火应用商店
</h2> </h2>
<p class="mb-4 text-sm text-slate-500 dark:text-slate-400"> <p class="mb-4 text-sm text-slate-500 dark:text-slate-400">
@@ -42,16 +44,23 @@
<div <div
class="mb-6 inline-flex items-center gap-2 rounded-full bg-slate-100 px-4 py-2 dark:bg-slate-800" class="mb-6 inline-flex items-center gap-2 rounded-full bg-slate-100 px-4 py-2 dark:bg-slate-800"
> >
<span class="text-sm text-slate-600 dark:text-slate-300">版本号</span> <span class="text-sm text-slate-600 dark:text-slate-300"
>版本号</span
>
<span <span
class="font-mono text-sm font-semibold text-brand dark:text-brand" class="font-mono text-sm font-semibold text-brand dark:text-brand"
>{{ version }}</span >{{ version }}</span
> >
</div> </div>
<p class="mb-6 text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p
星火应用商店是专为 Linux 设计的应用商店提供丰富的应用资源和便捷的安装体验 class="mb-6 text-sm leading-relaxed text-slate-600 dark:text-slate-400"
>
星火应用商店是专为 Linux
设计的应用商店提供丰富的应用资源和便捷的安装体验
</p> </p>
<div class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400"> <div
class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400"
>
<a <a
href="https://gitee.com/spark-store-project/spark-store" href="https://gitee.com/spark-store-project/spark-store"
target="_blank" target="_blank"

View File

@@ -97,9 +97,7 @@
: 'from-brand to-brand-dark' : 'from-brand to-brand-dark'
" "
@click="handleInstall" @click="handleInstall"
:disabled=" :disabled="installFeedback || isOtherVersionInstalled"
installFeedback || isOtherVersionInstalled
"
> >
<i <i
class="fas" class="fas"
@@ -259,10 +257,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useAttrs, ref, watch } from "vue"; import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios"; import axios from "axios";
import { import { useInstallFeedback, downloads } from "../global/downloadStatus";
useInstallFeedback,
downloads,
} from "../global/downloadStatus";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition"; import type { App } from "../global/typedefinition";
@@ -289,7 +284,7 @@ const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false); const isIconLoaded = ref(false);
const viewingOrigin = ref<"spark" | "apm">("apm"); const viewingOrigin = ref<"spark" | "apm">("spark");
watch( watch(
() => props.app, () => props.app,
@@ -297,9 +292,9 @@ watch(
isIconLoaded.value = false; isIconLoaded.value = false;
if (newApp) { if (newApp) {
if (newApp.isMerged) { if (newApp.isMerged) {
// 若父组件已根据安装状态设置了优先展示的版本,则使用;否则默认 APM // 若父组件已根据安装状态设置了优先展示的版本,则使用;否则默认 Spark
viewingOrigin.value = viewingOrigin.value =
newApp.viewingOrigin ?? (newApp.apmApp ? "apm" : "spark"); newApp.viewingOrigin ?? (newApp.sparkApp ? "spark" : "apm");
} else { } else {
viewingOrigin.value = newApp.origin; viewingOrigin.value = newApp.origin;
} }
@@ -348,7 +343,9 @@ const installBtnText = computed(() => {
return "已安装"; return "已安装";
} }
if (isOtherVersionInstalled.value) { if (isOtherVersionInstalled.value) {
return viewingOrigin.value === "spark" ? "已安装 APM 版" : "已安装 Spark 版"; return viewingOrigin.value === "spark"
? "已安装 APM 版"
: "已安装 Spark 版";
} }
if (installFeedback.value) { if (installFeedback.value) {
const status = activeDownload.value?.status; const status = activeDownload.value?.status;

View File

@@ -1,36 +1,45 @@
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center">
<div class="flex items-center gap-3"> <button
<button type="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/80 text-slate-500 shadow-sm backdrop-blur transition hover:bg-slate-50 lg:hidden dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-400 dark:hover:bg-slate-800"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white/80 text-slate-500 shadow-sm backdrop-blur transition hover:bg-slate-50 lg:hidden dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-400 dark:hover:bg-slate-800" @click="$emit('toggle-sidebar')"
@click="$emit('toggle-sidebar')" title="切换侧边栏"
title="切换侧边栏" >
> <i class="fas fa-bars"></i>
<i class="fas fa-bars"></i> </button>
</button> <div class="flex w-full flex-1 items-center gap-3">
<TopActions <div class="relative flex-1">
@update="$emit('update')" <label for="searchBox" class="sr-only">搜索应用</label>
@list="$emit('list')"
@open-install-settings="$emit('open-install-settings')"
/>
</div>
<div class="w-full flex-1">
<label for="searchBox" class="sr-only">搜索应用</label>
<div class="relative">
<i <i
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
></i> ></i>
<input <input
id="searchBox" id="searchBox"
v-model="localSearchQuery" v-model="localSearchQuery"
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-4 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" class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-24 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="搜索应用名 / 包名 / 标签,按回车键搜索" placeholder="搜索应用名 / 包名 / 标签,按回车键搜索"
@keydown.enter="handleSearch" @keydown.enter="handleSearch"
@focus="handleSearchFocus" @focus="handleSearchFocus"
/> />
</div> </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/80 text-slate-500 shadow-sm backdrop-blur transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900/60 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/80 text-slate-500 shadow-sm backdrop-blur transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900/60 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> </div>
<div <div
@@ -45,20 +54,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import TopActions from "./TopActions.vue";
const props = defineProps<{ const props = defineProps<{
searchQuery: string; searchQuery: string;
activeCategory: string; activeCategory: string;
appsCount: number; appsCount: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update-search", query: string): void; (e: "update-search", query: string): void;
(e: "update"): void;
(e: "list"): void;
(e: "search-focus"): void; (e: "search-focus"): void;
(e: "open-install-settings"): void; (e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-sidebar"): void; (e: "toggle-sidebar"): void;
}>(); }>();

View File

@@ -89,12 +89,21 @@
<div class="border-t border-slate-200 pt-4 dark:border-slate-800"> <div class="border-t border-slate-200 pt-4 dark:border-slate-800">
<button <button
v-if="apmAvailable"
type="button" type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800" class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="openAbout" @click="$emit('list')"
> >
<i class="fas fa-info-circle"></i> <i class="fas fa-download"></i>
<span>关于</span> <span>APM 应用管理</span>
</button>
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('update')"
>
<i class="fas fa-sync-alt"></i>
<span>软件更新</span>
</button> </button>
</div> </div>
</div> </div>
@@ -110,13 +119,15 @@ defineProps<{
activeCategory: string; activeCategory: string;
categoryCounts: Record<string, number>; categoryCounts: Record<string, number>;
themeMode: "light" | "dark" | "auto"; themeMode: "light" | "dark" | "auto";
apmAvailable: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "toggle-theme"): void; (e: "toggle-theme"): void;
(e: "select-category", category: string): void; (e: "select-category", category: string): void;
(e: "close"): void; (e: "close"): void;
(e: "open-about"): void; (e: "list"): void;
(e: "update"): void;
}>(); }>();
const toggleTheme = () => { const toggleTheme = () => {
@@ -126,8 +137,4 @@ const toggleTheme = () => {
const selectCategory = (category: string) => { const selectCategory = (category: string) => {
emit("select-category", category); emit("select-category", category);
}; };
const openAbout = () => {
emit("open-about");
};
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="space-y-8"> <div class="space-y-8">
<div <div
v-if="loading" v-if="loading"
class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400" class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400"
@@ -21,7 +21,7 @@
:key="link.url + link.name" :key="link.url + link.name"
:href="link.type === '_blank' ? undefined : link.url" :href="link.type === '_blank' ? undefined : link.url"
@click.prevent="onLinkClick(link)" @click.prevent="onLinkClick(link)"
class="flex flex-col items-start gap-2 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm hover:shadow-lg transition" class="flex flex-col items-start gap-2 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:shadow-lg dark:border-slate-800/70 dark:bg-slate-900/90"
:title="link.more as string" :title="link.more as string"
> >
<img <img
@@ -29,10 +29,12 @@
class="h-20 w-full object-contain" class="h-20 w-full object-contain"
loading="lazy" loading="lazy"
/> />
<div class="text-base font-semibold text-slate-900"> <div class="text-base font-semibold text-slate-900 dark:text-white">
{{ link.name }} {{ link.name }}
</div> </div>
<div class="text-sm text-slate-500">{{ link.more }}</div> <div class="text-sm text-slate-500 dark:text-slate-400">
{{ link.more }}
</div>
</a> </a>
</div> </div>

View File

@@ -69,32 +69,42 @@
:key="app.pkgname" :key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between" class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:justify-between"
> >
<div> <div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<p
class="text-base font-semibold text-slate-900 dark:text-white"
>
{{ app.pkgname }}
</p>
<span
v-if="app.flags && app.flags.includes('automatic')"
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"
>
依赖项
</span>
</div>
<div <div
class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400" v-if="app.icons"
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800"
> >
<span>{{ app.version }}</span> <img
<span>·</span> v-if="app.icons.startsWith('/')"
<span>{{ app.arch }}</span> :src="`file://${app.icons}`"
<template class="h-8 w-8 object-contain"
v-if="app.flags && !app.flags.includes('automatic')" alt=""
/>
<i v-else class="fas fa-cube text-xl text-slate-400"></i>
</div>
<div>
<div class="flex items-center gap-2">
<p
class="text-base font-semibold text-slate-900 dark:text-white"
>
{{ app.name }}
</p>
<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"
>
依赖项
</span>
</div>
<div
class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400"
> >
<span class="font-mono">{{ app.pkgname }}</span>
<span>·</span> <span>·</span>
<span>{{ app.flags }}</span> <span>{{ app.version }}</span>
</template> <span>·</span>
<span>{{ app.arch }}</span>
</div>
</div> </div>
</div> </div>
<button <button

View File

@@ -1,5 +1,12 @@
<template> <template>
<div class="flex flex-wrap gap-3"> <div :class="['flex flex-wrap gap-3', $attrs.class]">
<button
v-if="apmAvailable"
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40"
@click="handleList"
title="已安装应用"
></button>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40"
@@ -18,19 +25,19 @@
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
<span>安装设置</span> <span>安装设置</span>
</button> </button>
<!-- <button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-slate-900/90 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-slate-900/40 transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40 dark:bg-white/90 dark:text-slate-900"
@click="handleList"
title="启动 apm-installer --list"
>
<i class="fas fa-download"></i>
<span>应用管理</span>
</button> -->
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<{
apmAvailable: boolean;
}>();
defineOptions({
inheritAttrs: false,
});
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update"): void; (e: "update"): void;
(e: "list"): void; (e: "list"): void;
@@ -44,4 +51,8 @@ const handleUpdate = () => {
const handleSettings = () => { const handleSettings = () => {
emit("open-install-settings"); emit("open-install-settings");
}; };
const handleList = () => {
emit("list");
};
</script> </script>

View File

@@ -107,6 +107,7 @@ export interface App {
installed?: boolean; // Frontend state installed?: boolean; // Frontend state
flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies
arch?: string; // Architecture, e.g. "amd64", "arm64" arch?: string; // Architecture, e.g. "amd64", "arm64"
isDependency?: boolean; // Whether this is a dependency package
currentStatus: "not-installed" | "installed"; // Current installation status currentStatus: "not-installed" | "installed"; // Current installation status
isMerged?: boolean; // FLAG for overlapping apps isMerged?: boolean; // FLAG for overlapping apps
sparkApp?: App; // Optional reference to the spark version sparkApp?: App; // Optional reference to the spark version
@@ -125,10 +126,14 @@ export interface UpdateAppItem {
/**************Below are type from main process ********************/ /**************Below are type from main process ********************/
export interface InstalledAppInfo { export interface InstalledAppInfo {
pkgname: string; pkgname: string;
name: string;
version: string; version: string;
arch: string; arch: string;
flags: string; flags: string;
raw: string; origin: "spark" | "apm";
icon?: string;
isDependency: boolean;
raw?: string;
} }
/** /**