refactor: improve code formatting and consistency across components

- Updated button and span elements in ThemeToggle.vue and TopActions.vue for better readability.
- Enhanced UninstallConfirmModal.vue and UpdateAppsModal.vue with consistent indentation and spacing.
- Refactored downloadStatus.ts and storeConfig.ts for improved code clarity.
- Standardized string quotes and spacing in typedefinition.ts and processInstall.ts.
- Ensured consistent use of arrow functions and improved variable declarations throughout the codebase.
This commit is contained in:
Elysia
2026-02-12 18:32:41 +08:00
parent e11740ad4c
commit 6622e70033
29 changed files with 1681 additions and 1042 deletions

View File

@@ -2,7 +2,7 @@
declare namespace NodeJS { declare namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
VSCODE_DEBUG?: 'true' VSCODE_DEBUG?: "true";
/** /**
* The built directory structure * The built directory structure
* *
@@ -16,8 +16,8 @@ declare namespace NodeJS {
* │ └── index.html > Electron-Renderer * │ └── index.html > Electron-Renderer
* ``` * ```
*/ */
APP_ROOT: string APP_ROOT: string;
/** /dist/ or /public/ */ /** /dist/ or /public/ */
VITE_PUBLIC: string VITE_PUBLIC: string;
} }
} }

View File

@@ -1,2 +1,2 @@
import { ref } from 'vue'; import { ref } from "vue";
export const isLoaded = ref(false); export const isLoaded = ref(false);

View File

@@ -1,11 +1,11 @@
import { ipcMain, WebContents } from 'electron'; import { ipcMain, WebContents } from "electron";
import { spawn, ChildProcess, exec } from 'node:child_process'; import { spawn, ChildProcess, exec } from "node:child_process";
import { promisify } from 'node:util'; import { promisify } from "node:util";
import pino from 'pino'; import pino from "pino";
import { ChannelPayload, InstalledAppInfo } from '../../typedefinition'; import { ChannelPayload, InstalledAppInfo } from "../../typedefinition";
const logger = pino({ 'name': 'install-manager' }); const logger = pino({ name: "install-manager" });
type InstallTask = { type InstallTask = {
id: number; id: number;
@@ -16,67 +16,69 @@ type InstallTask = {
webContents: WebContents | null; webContents: WebContents | null;
}; };
const SHELL_CALLER_PATH = '/opt/apm-store/extras/shell-caller.sh'; const SHELL_CALLER_PATH = "/opt/apm-store/extras/shell-caller.sh";
export const tasks = new Map<number, InstallTask>(); export const tasks = new Map<number, InstallTask>();
let idle = true; // Indicates if the installation manager is idle let idle = true; // Indicates if the installation manager is idle
const checkSuperUserCommand = async (): Promise<string> => { const checkSuperUserCommand = async (): Promise<string> => {
let superUserCmd = ''; let superUserCmd = "";
const execAsync = promisify(exec); const execAsync = promisify(exec);
if (process.getuid && process.getuid() !== 0) { if (process.getuid && process.getuid() !== 0) {
const { stdout, stderr } = await execAsync('which /usr/bin/pkexec'); const { stdout, stderr } = await execAsync("which /usr/bin/pkexec");
if (stderr) { if (stderr) {
logger.error('没有找到 pkexec 命令'); logger.error("没有找到 pkexec 命令");
return; return;
} }
logger.info(`找到提升权限命令: ${stdout.trim()}`); logger.info(`找到提升权限命令: ${stdout.trim()}`);
superUserCmd = stdout.trim(); superUserCmd = stdout.trim();
if (superUserCmd.length === 0) { if (superUserCmd.length === 0) {
logger.error('没有找到提升权限的命令 pkexec!'); logger.error("没有找到提升权限的命令 pkexec!");
} }
} }
return superUserCmd; return superUserCmd;
} };
const runCommandCapture = async (execCommand: string, execParams: string[]) => { const runCommandCapture = async (execCommand: string, execParams: string[]) => {
return await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { return await new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve) => {
const child = spawn(execCommand, execParams, { const child = spawn(execCommand, execParams, {
shell: true, shell: true,
env: process.env env: process.env,
}); });
let stdout = ''; let stdout = "";
let stderr = ''; let stderr = "";
child.stdout?.on('data', (data) => { child.stdout?.on("data", (data) => {
stdout += data.toString(); stdout += data.toString();
}); });
child.stderr?.on('data', (data) => { child.stderr?.on("data", (data) => {
stderr += data.toString(); stderr += data.toString();
}); });
child.on('error', (err) => { child.on("error", (err) => {
resolve({ code: -1, stdout, stderr: err.message }); resolve({ code: -1, stdout, stderr: err.message });
}); });
child.on('close', (code) => { child.on("close", (code) => {
resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr }); resolve({ code: typeof code === "number" ? code : -1, stdout, stderr });
});
}); });
},
);
}; };
const parseInstalledList = (output: string) => { const parseInstalledList = (output: string) => {
const apps: Array<InstalledAppInfo> = []; const apps: Array<InstalledAppInfo> = [];
const lines = output.split('\n'); const lines = output.split("\n");
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
if (trimmed.startsWith('Listing')) continue; if (trimmed.startsWith("Listing")) continue;
if (trimmed.startsWith('[INFO]')) continue; if (trimmed.startsWith("[INFO]")) continue;
const match = trimmed.match(/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/); const match = trimmed.match(/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/);
if (!match) continue; if (!match) continue;
@@ -85,32 +87,40 @@ const parseInstalledList = (output: string) => {
version: match[2], version: match[2],
arch: match[3], arch: match[3],
flags: match[4], flags: match[4],
raw: trimmed raw: trimmed,
}); });
} }
return apps; return apps;
}; };
const parseUpgradableList = (output: string) => { const parseUpgradableList = (output: string) => {
const apps: Array<{ pkgname: string; newVersion: string; currentVersion: string; raw: string }> = []; const apps: Array<{
const lines = output.split('\n'); pkgname: string;
newVersion: string;
currentVersion: string;
raw: string;
}> = [];
const lines = output.split("\n");
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
if (trimmed.startsWith('Listing')) continue; if (trimmed.startsWith("Listing")) continue;
if (trimmed.startsWith('[INFO]')) continue; if (trimmed.startsWith("[INFO]")) continue;
if (trimmed.includes('=') && !trimmed.includes('/')) continue; if (trimmed.includes("=") && !trimmed.includes("/")) continue;
if (!trimmed.includes('/')) continue; if (!trimmed.includes("/")) continue;
const tokens = trimmed.split(/\s+/); const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) continue; if (tokens.length < 2) continue;
const pkgToken = tokens[0]; const pkgToken = tokens[0];
const pkgname = pkgToken.split('/')[0]; const pkgname = pkgToken.split("/")[0];
const newVersion = tokens[1] || ''; const newVersion = tokens[1] || "";
const currentMatch = trimmed.match(/\[(?:upgradable from|from):\s*([^\]\s]+)\]/i); const currentMatch = trimmed.match(
const currentToken = tokens[5] || ''; /\[(?:upgradable from|from):\s*([^\]\s]+)\]/i,
const currentVersion = currentMatch?.[1] || currentToken.replace('[', '').replace(']', ''); );
const currentToken = tokens[5] || "";
const currentVersion =
currentMatch?.[1] || currentToken.replace("[", "").replace("]", "");
if (!pkgname) continue; if (!pkgname) continue;
apps.push({ pkgname, newVersion, currentVersion, raw: trimmed }); apps.push({ pkgname, newVersion, currentVersion, raw: trimmed });
@@ -119,12 +129,12 @@ const parseUpgradableList = (output: string) => {
}; };
// Listen for download requests from renderer process // Listen for download requests from renderer process
ipcMain.on('queue-install', async (event, download_json) => { ipcMain.on("queue-install", async (event, download_json) => {
const download = JSON.parse(download_json); const download = JSON.parse(download_json);
const { id, pkgname } = download || {}; const { id, pkgname } = download || {};
if (!id || !pkgname) { if (!id || !pkgname) {
logger.warn('passed arguments missing id or pkgname'); logger.warn("passed arguments missing id or pkgname");
return; return;
} }
@@ -132,17 +142,17 @@ ipcMain.on('queue-install', async (event, download_json) => {
// 避免重复添加同一任务,但允许重试下载 // 避免重复添加同一任务,但允许重试下载
if (tasks.has(id) && !download.retry) { if (tasks.has(id) && !download.retry) {
tasks.get(id)?.webContents.send('install-log', { tasks.get(id)?.webContents.send("install-log", {
id, id,
time: Date.now(), time: Date.now(),
message: `任务id ${id} 已在列表中,忽略重复添加` message: `任务id ${id} 已在列表中,忽略重复添加`,
}); });
tasks.get(id)?.webContents.send('install-complete', { tasks.get(id)?.webContents.send("install-complete", {
id: id, id: id,
success: false, success: false,
time: Date.now(), time: Date.now(),
exitCode: -1, exitCode: -1,
message: `{"message":"任务id ${id} 已在列表中,忽略重复添加","stdout":"","stderr":""}` message: `{"message":"任务id ${id} 已在列表中,忽略重复添加","stdout":"","stderr":""}`,
}); });
return; return;
} }
@@ -151,7 +161,7 @@ ipcMain.on('queue-install', async (event, download_json) => {
// 开始组装安装命令 // 开始组装安装命令
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
let execCommand = ''; let execCommand = "";
const execParams = []; const execParams = [];
if (superUserCmd.length > 0) { if (superUserCmd.length > 0) {
execCommand = superUserCmd; execCommand = superUserCmd;
@@ -159,7 +169,7 @@ ipcMain.on('queue-install', async (event, download_json) => {
} else { } else {
execCommand = SHELL_CALLER_PATH; execCommand = SHELL_CALLER_PATH;
} }
execParams.push('apm', 'install', '-y', pkgname); execParams.push("apm", "install", "-y", pkgname);
const task: InstallTask = { const task: InstallTask = {
id, id,
@@ -167,7 +177,7 @@ ipcMain.on('queue-install', async (event, download_json) => {
execCommand, execCommand,
execParams, execParams,
process: null, process: null,
webContents webContents,
}; };
tasks.set(id, task); tasks.set(id, task);
if (idle) processNextInQueue(0); if (idle) processNextInQueue(0);
@@ -179,53 +189,53 @@ function processNextInQueue(index: number) {
idle = false; idle = false;
const task = Array.from(tasks.values())[index]; const task = Array.from(tasks.values())[index];
const webContents = task.webContents; const webContents = task.webContents;
let stdoutData = ''; let stdoutData = "";
let stderrData = ''; let stderrData = "";
webContents.send('install-status', { webContents.send("install-status", {
id: task.id, id: task.id,
time: Date.now(), time: Date.now(),
message: 'installing' message: "installing",
})
webContents.send('install-log', {
id: task.id,
time: Date.now(),
message: `开始执行: ${task.execCommand} ${task.execParams.join(' ')}`
}); });
logger.info(`启动安装命令: ${task.execCommand} ${task.execParams.join(' ')}`); webContents.send("install-log", {
id: task.id,
time: Date.now(),
message: `开始执行: ${task.execCommand} ${task.execParams.join(" ")}`,
});
logger.info(`启动安装命令: ${task.execCommand} ${task.execParams.join(" ")}`);
const child = spawn(task.execCommand, task.execParams, { const child = spawn(task.execCommand, task.execParams, {
shell: true, shell: true,
env: process.env env: process.env,
}); });
task.process = child; task.process = child;
// 监听 stdout // 监听 stdout
child.stdout.on('data', (data) => { child.stdout.on("data", (data) => {
stdoutData += data.toString(); stdoutData += data.toString();
webContents.send('install-log', { webContents.send("install-log", {
id: task.id, id: task.id,
time: Date.now(), time: Date.now(),
message: data.toString() message: data.toString(),
}); });
}); });
// 监听 stderr // 监听 stderr
child.stderr.on('data', (data) => { child.stderr.on("data", (data) => {
stderrData += data.toString(); stderrData += data.toString();
webContents.send('install-log', { webContents.send("install-log", {
id: task.id, id: task.id,
time: Date.now(), time: Date.now(),
message: data.toString() message: data.toString(),
}); });
}); });
child.on('close', (code) => { child.on("close", (code) => {
const success = code === 0; const success = code === 0;
// 拼接json消息 // 拼接json消息
const messageJSONObj = { const messageJSONObj = {
message: success ? '安装完成' : `安装失败,退出码 ${code}`, message: success ? "安装完成" : `安装失败,退出码 ${code}`,
stdout: stdoutData, stdout: stdoutData,
stderr: stderrData stderr: stderrData,
}; };
if (success) { if (success) {
@@ -234,42 +244,45 @@ function processNextInQueue(index: number) {
logger.error(messageJSONObj); logger.error(messageJSONObj);
} }
webContents.send('install-complete', { webContents.send("install-complete", {
id: task.id, id: task.id,
success: success, success: success,
time: Date.now(), time: Date.now(),
exitCode: code, exitCode: code,
message: JSON.stringify(messageJSONObj) message: JSON.stringify(messageJSONObj),
}); });
tasks.delete(task.id); tasks.delete(task.id);
idle = true; idle = true;
if (tasks.size > 0) if (tasks.size > 0) processNextInQueue(0);
processNextInQueue(0);
}); });
} }
ipcMain.handle('check-installed', async (_event, pkgname: string) => { ipcMain.handle("check-installed", async (_event, pkgname: string) => {
if (!pkgname) { if (!pkgname) {
logger.warn('check-installed missing pkgname'); logger.warn("check-installed missing pkgname");
return false; return false;
} }
let isInstalled = false; let isInstalled = false;
logger.info(`检查应用是否已安装: ${pkgname}`); logger.info(`检查应用是否已安装: ${pkgname}`);
const child = spawn(SHELL_CALLER_PATH, ['apm', 'list', '--installed', pkgname], { const child = spawn(
SHELL_CALLER_PATH,
["apm", "list", "--installed", pkgname],
{
shell: true, shell: true,
env: process.env env: process.env,
}); },
);
let output = ''; let output = "";
child.stdout.on('data', (data) => { child.stdout.on("data", (data) => {
output += data.toString(); output += data.toString();
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
child.on('close', (code) => { child.on("close", (code) => {
if (code === 0 && output.includes(pkgname)) { if (code === 0 && output.includes(pkgname)) {
isInstalled = true; isInstalled = true;
logger.info(`应用已安装: ${pkgname}`); logger.info(`应用已安装: ${pkgname}`);
@@ -282,16 +295,16 @@ ipcMain.handle('check-installed', async (_event, pkgname: string) => {
return isInstalled; return isInstalled;
}); });
ipcMain.on('remove-installed', async (_event, pkgname: string) => { ipcMain.on("remove-installed", async (_event, pkgname: string) => {
const webContents = _event.sender; const webContents = _event.sender;
if (!pkgname) { if (!pkgname) {
logger.warn('remove-installed missing pkgname'); logger.warn("remove-installed missing pkgname");
return; return;
} }
logger.info(`卸载已安装应用: ${pkgname}`); logger.info(`卸载已安装应用: ${pkgname}`);
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
let execCommand = ''; let execCommand = "";
const execParams = []; const execParams = [];
if (superUserCmd.length > 0) { if (superUserCmd.length > 0) {
execCommand = superUserCmd; execCommand = superUserCmd;
@@ -299,25 +312,29 @@ ipcMain.on('remove-installed', async (_event, pkgname: string) => {
} else { } else {
execCommand = SHELL_CALLER_PATH; execCommand = SHELL_CALLER_PATH;
} }
const child = spawn(execCommand, [...execParams, 'apm', 'remove', '-y', pkgname], { const child = spawn(
execCommand,
[...execParams, "apm", "remove", "-y", pkgname],
{
shell: true, shell: true,
env: process.env env: process.env,
}); },
let output = ''; );
let output = "";
child.stdout.on('data', (data) => { child.stdout.on("data", (data) => {
const chunk = data.toString(); const chunk = data.toString();
output += chunk; output += chunk;
webContents.send('remove-progress', chunk); webContents.send("remove-progress", chunk);
}); });
child.on('close', (code) => { child.on("close", (code) => {
const success = code === 0; const success = code === 0;
// 拼接json消息 // 拼接json消息
const messageJSONObj = { const messageJSONObj = {
message: success ? '卸载完成' : `卸载失败,退出码 ${code}`, message: success ? "卸载完成" : `卸载失败,退出码 ${code}`,
stdout: output, stdout: output,
stderr: '' stderr: "",
}; };
if (success) { if (success) {
@@ -326,26 +343,28 @@ ipcMain.on('remove-installed', async (_event, pkgname: string) => {
logger.error(messageJSONObj); logger.error(messageJSONObj);
} }
webContents.send('remove-complete', { webContents.send("remove-complete", {
id: 0, id: 0,
success: success, success: success,
time: Date.now(), time: Date.now(),
exitCode: code, exitCode: code,
message: JSON.stringify(messageJSONObj) message: JSON.stringify(messageJSONObj),
} satisfies ChannelPayload); } satisfies ChannelPayload);
}); });
}); });
ipcMain.handle('list-upgradable', async () => { ipcMain.handle("list-upgradable", async () => {
const { code, stdout, stderr } = await runCommandCapture( const { code, stdout, stderr } = await runCommandCapture(SHELL_CALLER_PATH, [
SHELL_CALLER_PATH, "apm",
['apm', 'list', '--upgradable']); "list",
"--upgradable",
]);
if (code !== 0) { if (code !== 0) {
logger.error(`list-upgradable failed: ${stderr || stdout}`); logger.error(`list-upgradable failed: ${stderr || stdout}`);
return { return {
success: false, success: false,
message: stderr || stdout || `list-upgradable failed with code ${code}`, message: stderr || stdout || `list-upgradable failed with code ${code}`,
apps: [] apps: [],
}; };
} }
@@ -353,20 +372,25 @@ ipcMain.handle('list-upgradable', async () => {
return { success: true, apps }; return { success: true, apps };
}); });
ipcMain.handle('list-installed', async () => { ipcMain.handle("list-installed", async () => {
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
const execCommand = superUserCmd.length > 0 ? superUserCmd : SHELL_CALLER_PATH; const execCommand =
const execParams = superUserCmd.length > 0 superUserCmd.length > 0 ? superUserCmd : SHELL_CALLER_PATH;
? [SHELL_CALLER_PATH, 'apm', 'list', '--installed'] const execParams =
: ['apm', 'list', '--installed']; superUserCmd.length > 0
? [SHELL_CALLER_PATH, "apm", "list", "--installed"]
: ["apm", "list", "--installed"];
const { code, stdout, stderr } = await runCommandCapture(execCommand, execParams); const { code, stdout, stderr } = await runCommandCapture(
execCommand,
execParams,
);
if (code !== 0) { if (code !== 0) {
logger.error(`list-installed failed: ${stderr || stdout}`); logger.error(`list-installed failed: ${stderr || stdout}`);
return { return {
success: false, success: false,
message: stderr || stdout || `list-installed failed with code ${code}`, message: stderr || stdout || `list-installed failed with code ${code}`,
apps: [] apps: [],
}; };
} }
@@ -374,19 +398,24 @@ ipcMain.handle('list-installed', async () => {
return { success: true, apps }; return { success: true, apps };
}); });
ipcMain.handle('uninstall-installed', async (_event, pkgname: string) => { ipcMain.handle("uninstall-installed", async (_event, pkgname: string) => {
if (!pkgname) { if (!pkgname) {
logger.warn('uninstall-installed missing pkgname'); logger.warn("uninstall-installed missing pkgname");
return { success: false, message: 'missing pkgname' }; return { success: false, message: "missing pkgname" };
} }
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
const execCommand = superUserCmd.length > 0 ? superUserCmd : SHELL_CALLER_PATH; const execCommand =
const execParams = superUserCmd.length > 0 superUserCmd.length > 0 ? superUserCmd : SHELL_CALLER_PATH;
? [SHELL_CALLER_PATH, 'apm', 'remove', '-y', pkgname] const execParams =
: ['apm', 'remove', '-y', pkgname]; superUserCmd.length > 0
? [SHELL_CALLER_PATH, "apm", "remove", "-y", pkgname]
: ["apm", "remove", "-y", pkgname];
const { code, stdout, stderr } = await runCommandCapture(execCommand, execParams); const { code, stdout, stderr } = await runCommandCapture(
execCommand,
execParams,
);
const success = code === 0; const success = code === 0;
if (success) { if (success) {
@@ -397,24 +426,28 @@ ipcMain.handle('uninstall-installed', async (_event, pkgname: string) => {
return { return {
success, success,
message: success ? '卸载完成' : (stderr || stdout || `卸载失败,退出码 ${code}`) message: success
? "卸载完成"
: stderr || stdout || `卸载失败,退出码 ${code}`,
}; };
}); });
ipcMain.handle('launch-app', async (_event, pkgname: string) => { ipcMain.handle("launch-app", async (_event, pkgname: string) => {
if (!pkgname) { if (!pkgname) {
logger.warn('No pkgname provided for launch-app'); logger.warn("No pkgname provided for launch-app");
} }
const execCommand = "/opt/apm-store/extras/host-spawn"; const execCommand = "/opt/apm-store/extras/host-spawn";
const execParams = ['/opt/apm-store/extras/apm-launcher', 'launch', pkgname ]; const execParams = ["/opt/apm-store/extras/apm-launcher", "launch", pkgname];
logger.info(`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(' ')}`); logger.info(
`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`,
);
spawn(execCommand, execParams, { spawn(execCommand, execParams, {
shell: false, shell: false,
env: process.env, env: process.env,
detached: true, detached: true,
stdio: 'ignore' stdio: "ignore",
}).unref(); }).unref();
}); });

View File

@@ -5,7 +5,7 @@
import { app } from "electron"; import { app } from "electron";
import pino from "pino"; import pino from "pino";
const logger = pino({ 'name': 'deeplink.ts' }); const logger = pino({ name: "deeplink.ts" });
type Query = Record<string, string>; type Query = Record<string, string>;
export type Listener = (query: Query) => void; export type Listener = (query: Query) => void;
@@ -52,13 +52,13 @@ export const deepLink = {
on: (event: string, listener: Listener) => { on: (event: string, listener: Listener) => {
const count = listeners.add(event, listener); const count = listeners.add(event, listener);
logger.info( logger.info(
`Deep link: listener added for event ${event}. Total event listeners: ${count}` `Deep link: listener added for event ${event}. Total event listeners: ${count}`,
); );
}, },
off: (event: string, listener: Listener) => { off: (event: string, listener: Listener) => {
const count = listeners.remove(event, listener); const count = listeners.remove(event, listener);
logger.info( logger.info(
`Deep link: listener removed for event ${event}. Total event listeners: ${count}` `Deep link: listener removed for event ${event}. Total event listeners: ${count}`,
); );
}, },
once: (event: string, listener: Listener) => { once: (event: string, listener: Listener) => {
@@ -72,7 +72,7 @@ export const deepLink = {
export function handleCommandLine(commandLine: string[]) { export function handleCommandLine(commandLine: string[]) {
const target = commandLine.find((arg) => const target = commandLine.find((arg) =>
protocols.some((protocol) => arg.startsWith(protocol + "://")) protocols.some((protocol) => arg.startsWith(protocol + "://")),
); );
if (!target) return; if (!target) return;

View File

@@ -1,9 +1,9 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow } from "electron";
import { deepLink } from './deeplink'; import { deepLink } from "./deeplink";
import { isLoaded } from '../global'; import { isLoaded } from "../global";
import pino from 'pino'; import pino from "pino";
const logger = pino({ 'name': 'handle-url-scheme.ts' }); const logger = pino({ name: "handle-url-scheme.ts" });
const pendingActions: Array<() => void> = []; const pendingActions: Array<() => void> = [];
@@ -24,22 +24,26 @@ new Promise<void>((resolve) => {
}); });
deepLink.on("event", (query) => { deepLink.on("event", (query) => {
logger.info(`Deep link: event "event" fired with query: ${JSON.stringify(query)}`); logger.info(
`Deep link: event "event" fired with query: ${JSON.stringify(query)}`,
);
}); });
deepLink.on("action", (query) => { deepLink.on("action", (query) => {
logger.info(`Deep link: event "action" fired with query: ${JSON.stringify(query)}`); logger.info(
`Deep link: event "action" fired with query: ${JSON.stringify(query)}`,
);
const action = () => { const action = () => {
const win = BrowserWindow.getAllWindows()[0]; const win = BrowserWindow.getAllWindows()[0];
if (!win) return; if (!win) return;
if (query.cmd === 'update') { if (query.cmd === "update") {
win.webContents.send('deep-link-update'); win.webContents.send("deep-link-update");
if (win.isMinimized()) win.restore(); if (win.isMinimized()) win.restore();
win.focus(); win.focus();
} else if (query.cmd === 'list') { } else if (query.cmd === "list") {
win.webContents.send('deep-link-installed'); win.webContents.send("deep-link-installed");
if (win.isMinimized()) win.restore(); if (win.isMinimized()) win.restore();
win.focus(); win.focus();
} }
@@ -55,14 +59,16 @@ deepLink.on("action", (query) => {
}); });
deepLink.on("install", (query) => { deepLink.on("install", (query) => {
logger.info(`Deep link: event "install" fired with query: ${JSON.stringify(query)}`); logger.info(
`Deep link: event "install" fired with query: ${JSON.stringify(query)}`,
);
const action = () => { const action = () => {
const win = BrowserWindow.getAllWindows()[0]; const win = BrowserWindow.getAllWindows()[0];
if (!win) return; if (!win) return;
if (query.pkg) { if (query.pkg) {
win.webContents.send('deep-link-install', query.pkg); win.webContents.send("deep-link-install", query.pkg);
if (win.isMinimized()) win.restore(); if (win.isMinimized()) win.restore();
win.focus(); win.focus();
} }

View File

@@ -1,24 +1,23 @@
import { app, BrowserWindow, ipcMain, Menu, shell, Tray } from 'electron' import { app, BrowserWindow, ipcMain, Menu, shell, Tray } from "electron";
import { fileURLToPath } from 'node:url' import { fileURLToPath } from "node:url";
import path from 'node:path' import path from "node:path";
import os from 'node:os' import os from "node:os";
import fs from 'node:fs' import fs from "node:fs";
import pino from 'pino' import pino from "pino";
import { handleCommandLine } from './deeplink.js' import { handleCommandLine } from "./deeplink.js";
import { isLoaded } from '../global.js' import { isLoaded } from "../global.js";
import { tasks } from './backend/install-manager.js' import { tasks } from "./backend/install-manager.js";
// Assure single instance application // Assure single instance application
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
app.exit(0); app.exit(0);
} }
import './backend/install-manager.js' import "./backend/install-manager.js";
import './handle-url-scheme.js' import "./handle-url-scheme.js";
const logger = pino({ 'name': 'index.ts' }); const logger = pino({ name: "index.ts" });
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The built directory structure // The built directory structure
// //
@@ -30,38 +29,38 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
// ├─┬ dist // ├─┬ dist
// │ └── index.html > Electron-Renderer // │ └── index.html > Electron-Renderer
// //
process.env.APP_ROOT = path.join(__dirname, '../..') process.env.APP_ROOT = path.join(__dirname, "../..");
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public') ? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST : RENDERER_DIST;
// Disable GPU Acceleration for Windows 7 // Disable GPU Acceleration for Windows 7
if (os.release().startsWith('6.1')) app.disableHardwareAcceleration() if (os.release().startsWith("6.1")) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications // Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName()) if (process.platform === "win32") app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
app.quit() app.quit();
process.exit(0) process.exit(0);
} }
let win: BrowserWindow | null = null let win: BrowserWindow | null = null;
const preload = path.join(__dirname, '../preload/index.mjs') const preload = path.join(__dirname, "../preload/index.mjs");
const indexHtml = path.join(RENDERER_DIST, 'index.html') const indexHtml = path.join(RENDERER_DIST, "index.html");
async function createWindow() { async function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
title: 'APM AppStore', title: "APM AppStore",
width: 1366, width: 1366,
height: 768, height: 768,
autoHideMenuBar: true, autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC, 'favicon.ico'), icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: { webPreferences: {
preload, preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
@@ -71,30 +70,31 @@ async function createWindow() {
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
// contextIsolation: false, // contextIsolation: false,
}, },
}) });
if (VITE_DEV_SERVER_URL) { // #298 if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL) // #298
win.loadURL(VITE_DEV_SERVER_URL);
// Open devTool if the app is not packaged // Open devTool if the app is not packaged
win.webContents.openDevTools({mode:'detach'}) win.webContents.openDevTools({ mode: "detach" });
} else { } else {
win.loadFile(indexHtml) win.loadFile(indexHtml);
} }
// Test actively push message to the Electron-Renderer // Test actively push message to the Electron-Renderer
win.webContents.on('did-finish-load', () => { win.webContents.on("did-finish-load", () => {
win?.webContents.send('main-process-message', new Date().toLocaleString()) win?.webContents.send("main-process-message", new Date().toLocaleString());
logger.info('Renderer process is ready.'); logger.info("Renderer process is ready.");
}) });
// Make all links open with the browser, not with the application // Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) shell.openExternal(url) if (url.startsWith("https:")) shell.openExternal(url);
return { action: 'deny' } return { action: "deny" };
}) });
// win.webContents.on('will-navigate', (event, url) => { }) #344 // win.webContents.on('will-navigate', (event, url) => { }) #344
win.on('close', (event) => { win.on("close", (event) => {
// 截获 close 默认行为 // 截获 close 默认行为
event.preventDefault(); event.preventDefault();
// 点击关闭时触发close事件我们按照之前的思路在关闭时隐藏窗口隐藏任务栏窗口 // 点击关闭时触发close事件我们按照之前的思路在关闭时隐藏窗口隐藏任务栏窗口
@@ -103,50 +103,52 @@ async function createWindow() {
win.setSkipTaskbar(true); win.setSkipTaskbar(true);
} else { } else {
// 如果没有下载任务,才允许关闭窗口 // 如果没有下载任务,才允许关闭窗口
win.destroy() win.destroy();
}
});
} }
}) ipcMain.on("renderer-ready", (event, args) => {
} logger.info(
"Received renderer-ready event with args: " + JSON.stringify(args),
ipcMain.on('renderer-ready', (event, args) => { );
logger.info('Received renderer-ready event with args: ' + JSON.stringify(args));
isLoaded.value = args.status; isLoaded.value = args.status;
logger.info(`isLoaded set to: ${isLoaded.value}`); logger.info(`isLoaded set to: ${isLoaded.value}`);
}) });
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow() createWindow();
handleCommandLine(process.argv) handleCommandLine(process.argv);
}) });
app.on('window-all-closed', () => { app.on("window-all-closed", () => {
win = null win = null;
if (process.platform !== 'darwin') app.quit() if (process.platform !== "darwin") app.quit();
}) });
app.on('second-instance', () => { app.on("second-instance", () => {
if (win) { if (win) {
// Focus on the main window if the user tried to open another // Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore() if (win.isMinimized()) win.restore();
win.focus() win.focus();
} }
}) });
app.on('activate', () => { app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows() const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) { if (allWindows.length) {
allWindows[0].focus() allWindows[0].focus();
} else { } else {
createWindow() createWindow();
} }
}) });
// 设置托盘 // 设置托盘
// 获取图标路径 // 获取图标路径
function getIconPath() { function getIconPath() {
let iconPath = ""; let iconPath = "";
const iconFile = process.platform === "win32" ? "amber-pm-logo.ico" : "amber-pm-logo.png"; // 图标文件名linux下需要png格式不然会不显示 const iconFile =
process.platform === "win32" ? "amber-pm-logo.ico" : "amber-pm-logo.png"; // 图标文件名linux下需要png格式不然会不显示
// 判断是否在打包模式 // 判断是否在打包模式
if (app.isPackaged) { if (app.isPackaged) {
// 打包模式 // 打包模式
@@ -171,19 +173,24 @@ function getIconPath() {
let tray = null; let tray = null;
app.whenReady().then(() => { app.whenReady().then(() => {
tray = new Tray(getIconPath()); tray = new Tray(getIconPath());
const contextMenu = Menu.buildFromTemplate([{ const contextMenu = Menu.buildFromTemplate([
label: '显示主界面', {
click: () => { win.show() } label: "显示主界面",
click: () => {
win.show();
},
}, },
{ {
label: '退出程序', label: "退出程序",
click: () => { win.destroy() } click: () => {
} win.destroy();
},
},
]); ]);
tray.setToolTip('APM 应用商店') tray.setToolTip("APM 应用商店");
tray.setContextMenu(contextMenu) tray.setContextMenu(contextMenu);
// 双击触发 // 双击触发
tray.on('click', () => { tray.on("click", () => {
// 双击通知区图标实现应用的显示或隐藏 // 双击通知区图标实现应用的显示或隐藏
if (win.isVisible()) { if (win.isVisible()) {
win.hide(); win.hide();
@@ -193,8 +200,7 @@ app.whenReady().then(() => {
win.setSkipTaskbar(false); win.setSkipTaskbar(false);
} }
}); });
}) });
// New window example arg: new windows url // New window example arg: new windows url
// ipcMain.handle('open-win', (_, arg) => { // ipcMain.handle('open-win', (_, arg) => {

View File

@@ -1,66 +1,70 @@
import { ipcRenderer, contextBridge } from 'electron' import { ipcRenderer, contextBridge } from "electron";
// --------- Expose some API to the Renderer process --------- // --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', { contextBridge.exposeInMainWorld("ipcRenderer", {
on(...args: Parameters<typeof ipcRenderer.on>) { on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args const [channel, listener] = args;
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)) return ipcRenderer.on(channel, (event, ...args) =>
listener(event, ...args),
);
}, },
off(...args: Parameters<typeof ipcRenderer.off>) { off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit) return ipcRenderer.off(channel, ...omit);
}, },
send(...args: Parameters<typeof ipcRenderer.send>) { send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit) return ipcRenderer.send(channel, ...omit);
}, },
invoke(...args: Parameters<typeof ipcRenderer.invoke>) { invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit) return ipcRenderer.invoke(channel, ...omit);
}, },
// You can expose other APTs you need here. // You can expose other APTs you need here.
// ... // ...
}) });
contextBridge.exposeInMainWorld('apm_store', { contextBridge.exposeInMainWorld("apm_store", {
arch: (() => { arch: (() => {
const arch = process.arch; const arch = process.arch;
if (arch === 'x64') { if (arch === "x64") {
return 'amd64' + '-apm'; return "amd64" + "-apm";
} else { } else {
return arch + '-apm'; return arch + "-apm";
} }
})() })(),
}); });
// --------- Preload scripts loading --------- // --------- Preload scripts loading ---------
function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) { function domReady(
condition: DocumentReadyState[] = ["complete", "interactive"],
) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (condition.includes(document.readyState)) { if (condition.includes(document.readyState)) {
resolve(true) resolve(true);
} else { } else {
document.addEventListener('readystatechange', () => { document.addEventListener("readystatechange", () => {
if (condition.includes(document.readyState)) { if (condition.includes(document.readyState)) {
resolve(true) resolve(true);
} }
}) });
} }
}) });
} }
const safeDOM = { const safeDOM = {
append(parent: HTMLElement, child: HTMLElement) { append(parent: HTMLElement, child: HTMLElement) {
if (!Array.from(parent.children).find(e => e === child)) { if (!Array.from(parent.children).find((e) => e === child)) {
return parent.appendChild(child) return parent.appendChild(child);
} }
}, },
remove(parent: HTMLElement, child: HTMLElement) { remove(parent: HTMLElement, child: HTMLElement) {
if (Array.from(parent.children).find(e => e === child)) { if (Array.from(parent.children).find((e) => e === child)) {
return parent.removeChild(child) return parent.removeChild(child);
} }
}, },
} };
/** /**
* https://tobiasahlin.com/spinkit * https://tobiasahlin.com/spinkit
@@ -69,7 +73,7 @@ const safeDOM = {
* https://matejkustec.github.io/SpinThatShit * https://matejkustec.github.io/SpinThatShit
*/ */
function useLoading() { function useLoading() {
const className = `loaders-css__square-spin` const className = `loaders-css__square-spin`;
const styleContent = ` const styleContent = `
@keyframes square-spin { @keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
@@ -96,35 +100,34 @@ function useLoading() {
background: #282c34; background: #282c34;
z-index: 9; z-index: 9;
} }
` `;
const oStyle = document.createElement('style') const oStyle = document.createElement("style");
const oDiv = document.createElement('div') const oDiv = document.createElement("div");
oStyle.id = 'app-loading-style' oStyle.id = "app-loading-style";
oStyle.innerHTML = styleContent oStyle.innerHTML = styleContent;
oDiv.className = 'app-loading-wrap' oDiv.className = "app-loading-wrap";
oDiv.innerHTML = `<div class="${className}"><div></div></div>` oDiv.innerHTML = `<div class="${className}"><div></div></div>`;
return { return {
appendLoading() { appendLoading() {
safeDOM.append(document.head, oStyle) safeDOM.append(document.head, oStyle);
safeDOM.append(document.body, oDiv) safeDOM.append(document.body, oDiv);
}, },
removeLoading() { removeLoading() {
safeDOM.remove(document.head, oStyle) safeDOM.remove(document.head, oStyle);
safeDOM.remove(document.body, oDiv) safeDOM.remove(document.body, oDiv);
}, },
} };
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const { appendLoading, removeLoading } = useLoading() const { appendLoading, removeLoading } = useLoading();
domReady().then(appendLoading) domReady().then(appendLoading);
window.onmessage = (ev) => { window.onmessage = (ev) => {
if (ev.data.payload === 'removeLoading') if (ev.data.payload === "removeLoading") removeLoading();
removeLoading() };
}
setTimeout(removeLoading, 4999) setTimeout(removeLoading, 4999);

View File

@@ -6,7 +6,6 @@ export interface InstalledAppInfo {
raw: string; raw: string;
} }
export type ChannelPayload = { export type ChannelPayload = {
success: boolean; success: boolean;
message: string; message: string;

View File

@@ -27,7 +27,9 @@
"build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm", "build:rpm": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux rpm",
"build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb", "build:deb": "vue-tsc --noEmit && vite build --mode production && electron-builder --config electron-builder.yml --linux deb",
"preview": "vite preview --mode debug", "preview": "vite preview --mode debug",
"lint": "eslint --ext .ts,.vue src electron" "lint": "eslint --ext .ts,.vue src electron",
"lint:fix": "eslint --ext .ts,.vue src electron --fix",
"format": "prettier --write \"src/**/*.{ts,vue}\" \"electron/**/*.{ts,vue}\""
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "^1.51.4", "@dotenvx/dotenvx": "^1.51.4",

View File

@@ -1,67 +1,148 @@
<template> <template>
<div <div
class="flex min-h-screen flex-col bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100 lg:flex-row"> class="flex min-h-screen flex-col bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100 lg:flex-row"
>
<aside <aside
class="w-full border-b border-slate-200/70 bg-white/80 px-5 py-6 backdrop-blur dark:border-slate-800/70 dark:bg-slate-900/70 lg:sticky lg:top-0 lg:flex lg:h-screen lg:w-72 lg:flex-col lg:border-b-0 lg:border-r"> class="w-full border-b border-slate-200/70 bg-white/80 px-5 py-6 backdrop-blur dark:border-slate-800/70 dark:bg-slate-900/70 lg:sticky lg:top-0 lg:flex lg:h-screen lg:w-72 lg:flex-col lg:border-b-0 lg:border-r"
<AppSidebar :categories="categories" :active-category="activeCategory" :category-counts="categoryCounts" >
:is-dark-theme="isDarkTheme" @toggle-theme="toggleTheme" @select-category="selectCategory" /> <AppSidebar
:categories="categories"
:active-category="activeCategory"
:category-counts="categoryCounts"
:is-dark-theme="isDarkTheme"
@toggle-theme="toggleTheme"
@select-category="selectCategory"
/>
</aside> </aside>
<main class="flex-1 px-4 py-6 lg:px-10"> <main class="flex-1 px-4 py-6 lg:px-10">
<AppHeader :search-query="searchQuery" :apps-count="filteredApps.length" @update-search="handleSearchInput" <AppHeader
@update="handleUpdate" @list="handleList" /> :search-query="searchQuery"
<AppGrid :apps="filteredApps" :loading="loading" @open-detail="openDetail" /> :apps-count="filteredApps.length"
@update-search="handleSearchInput"
@update="handleUpdate"
@list="handleList"
/>
<AppGrid
:apps="filteredApps"
:loading="loading"
@open-detail="openDetail"
/>
</main> </main>
<AppDetailModal data-app-modal="detail" :show="showModal" :app="currentApp" :screenshots="screenshots" <AppDetailModal
:isinstalled="currentAppIsInstalled" @close="closeDetail" @install="handleInstall" @remove="requestUninstallFromDetail" data-app-modal="detail"
@open-preview="openScreenPreview" @open-app="openDownloadedApp" /> :show="showModal"
:app="currentApp"
:screenshots="screenshots"
:isinstalled="currentAppIsInstalled"
@close="closeDetail"
@install="handleInstall"
@remove="requestUninstallFromDetail"
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
/>
<ScreenPreview :show="showPreview" :screenshots="screenshots" :current-screen-index="currentScreenIndex" <ScreenPreview
@close="closeScreenPreview" @prev="prevScreen" @next="nextScreen" /> :show="showPreview"
:screenshots="screenshots"
:current-screen-index="currentScreenIndex"
@close="closeScreenPreview"
@prev="prevScreen"
@next="nextScreen"
/>
<DownloadQueue :downloads="downloads" @pause="pauseDownload" @resume="resumeDownload" <DownloadQueue
@cancel="cancelDownload" @retry="retryDownload" @clear-completed="clearCompletedDownloads" :downloads="downloads"
@show-detail="showDownloadDetailModalFunc" /> @pause="pauseDownload"
@resume="resumeDownload"
@cancel="cancelDownload"
@retry="retryDownload"
@clear-completed="clearCompletedDownloads"
@show-detail="showDownloadDetailModalFunc"
/>
<DownloadDetail :show="showDownloadDetailModal" :download="currentDownload" @close="closeDownloadDetail" <DownloadDetail
@pause="pauseDownload" @resume="resumeDownload" @cancel="cancelDownload" @retry="retryDownload" :show="showDownloadDetailModal"
@open-app="openDownloadedApp" /> :download="currentDownload"
@close="closeDownloadDetail"
@pause="pauseDownload"
@resume="resumeDownload"
@cancel="cancelDownload"
@retry="retryDownload"
@open-app="openDownloadedApp"
/>
<InstalledAppsModal :show="showInstalledModal" :apps="installedApps" :loading="installedLoading" <InstalledAppsModal
:error="installedError" @close="closeInstalledModal" @refresh="refreshInstalledApps" :show="showInstalledModal"
@uninstall="uninstallInstalledApp" /> :apps="installedApps"
:loading="installedLoading"
:error="installedError"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@uninstall="uninstallInstalledApp"
/>
<UpdateAppsModal :show="showUpdateModal" :apps="upgradableApps" :loading="updateLoading" <UpdateAppsModal
:error="updateError" :has-selected="hasSelectedUpgrades" @close="closeUpdateModal" :show="showUpdateModal"
@refresh="refreshUpgradableApps" @toggle-all="toggleAllUpgrades" :apps="upgradableApps"
@upgrade-selected="upgradeSelectedApps" @upgrade-one="upgradeSingleApp" /> :loading="updateLoading"
:error="updateError"
:has-selected="hasSelectedUpgrades"
@close="closeUpdateModal"
@refresh="refreshUpgradableApps"
@toggle-all="toggleAllUpgrades"
@upgrade-selected="upgradeSelectedApps"
@upgrade-one="upgradeSingleApp"
/>
<UninstallConfirmModal :show="showUninstallModal" :app="uninstallTargetApp" @close="closeUninstallModal" <UninstallConfirmModal
@success="onUninstallSuccess" /> :show="showUninstallModal"
:app="uninstallTargetApp"
@close="closeUninstallModal"
@success="onUninstallSuccess"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'; import { ref, computed, onMounted, watch, nextTick } from "vue";
import axios from 'axios'; import axios from "axios";
import pino from 'pino'; import pino from "pino";
import AppSidebar from './components/AppSidebar.vue'; import AppSidebar from "./components/AppSidebar.vue";
import AppHeader from './components/AppHeader.vue'; import AppHeader from "./components/AppHeader.vue";
import AppGrid from './components/AppGrid.vue'; import AppGrid from "./components/AppGrid.vue";
import AppDetailModal from './components/AppDetailModal.vue'; import AppDetailModal from "./components/AppDetailModal.vue";
import ScreenPreview from './components/ScreenPreview.vue'; import ScreenPreview from "./components/ScreenPreview.vue";
import DownloadQueue from './components/DownloadQueue.vue'; import DownloadQueue from "./components/DownloadQueue.vue";
import DownloadDetail from './components/DownloadDetail.vue'; import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from './components/InstalledAppsModal.vue'; import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import UpdateAppsModal from './components/UpdateAppsModal.vue'; import UpdateAppsModal from "./components/UpdateAppsModal.vue";
import UninstallConfirmModal from './components/UninstallConfirmModal.vue'; import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import { APM_STORE_BASE_URL, currentApp, currentAppIsInstalled } from './global/storeConfig'; import {
import { downloads, removeDownloadItem, watchDownloadsChange } from './global/downloadStatus'; APM_STORE_BASE_URL,
import { handleInstall, handleRetry, handleUpgrade } from './modeuls/processInstall'; currentApp,
import type { App, AppJson, DownloadItem, UpdateAppItem, ChannelPayload } from './global/typedefinition'; currentAppIsInstalled,
import type { Ref } from 'vue'; } from "./global/storeConfig";
import type { IpcRendererEvent } from 'electron'; import {
downloads,
removeDownloadItem,
watchDownloadsChange,
} from "./global/downloadStatus";
import {
handleInstall,
handleRetry,
handleUpgrade,
} from "./modeuls/processInstall";
import type {
App,
AppJson,
DownloadItem,
UpdateAppItem,
ChannelPayload,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
const logger = pino(); const logger = pino();
// Axios 全局配置 // Axios 全局配置
@@ -74,8 +155,8 @@ const axiosInstance = axios.create({
const isDarkTheme = ref(false); const isDarkTheme = ref(false);
const categories: Ref<Record<string, string>> = ref({}); const categories: Ref<Record<string, string>> = ref({});
const apps: Ref<App[]> = ref([]); const apps: Ref<App[]> = ref([]);
const activeCategory = ref('all'); const activeCategory = ref("all");
const searchQuery = ref(''); const searchQuery = ref("");
const showModal = ref(false); const showModal = ref(false);
const showPreview = ref(false); const showPreview = ref(false);
const currentScreenIndex = ref(0); const currentScreenIndex = ref(0);
@@ -86,11 +167,13 @@ const currentDownload: Ref<DownloadItem | null> = ref(null);
const showInstalledModal = ref(false); const showInstalledModal = ref(false);
const installedApps = ref<App[]>([]); const installedApps = ref<App[]>([]);
const installedLoading = ref(false); const installedLoading = ref(false);
const installedError = ref(''); const installedError = ref("");
const showUpdateModal = ref(false); const showUpdateModal = ref(false);
const upgradableApps = ref<(App & { selected: boolean; upgrading: boolean })[]>([]); const upgradableApps = ref<(App & { selected: boolean; upgrading: boolean })[]>(
[],
);
const updateLoading = ref(false); const updateLoading = ref(false);
const updateError = ref(''); 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);
@@ -99,19 +182,21 @@ const filteredApps = computed(() => {
let result = [...apps.value]; let result = [...apps.value];
// 按分类筛选 // 按分类筛选
if (activeCategory.value !== 'all') { if (activeCategory.value !== "all") {
result = result.filter(app => app.category === activeCategory.value); result = result.filter((app) => app.category === activeCategory.value);
} }
// 按搜索关键词筛选 // 按搜索关键词筛选
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim(); const q = searchQuery.value.toLowerCase().trim();
result = result.filter(app => { result = result.filter((app) => {
// 兼容可能为 undefined 的情况,虽然类型定义是 string // 兼容可能为 undefined 的情况,虽然类型定义是 string
return ((app.name || '').toLowerCase().includes(q) || return (
(app.pkgname || '').toLowerCase().includes(q) || (app.name || "").toLowerCase().includes(q) ||
(app.tags || '').toLowerCase().includes(q) || (app.pkgname || "").toLowerCase().includes(q) ||
(app.more || '').toLowerCase().includes(q)); (app.tags || "").toLowerCase().includes(q) ||
(app.more || "").toLowerCase().includes(q)
);
}); });
} }
@@ -120,7 +205,7 @@ const filteredApps = computed(() => {
const categoryCounts = computed(() => { const categoryCounts = computed(() => {
const counts: Record<string, number> = { all: apps.value.length }; const counts: Record<string, number> = { all: apps.value.length };
apps.value.forEach(app => { apps.value.forEach((app) => {
if (!counts[app.category]) counts[app.category] = 0; if (!counts[app.category]) counts[app.category] = 0;
counts[app.category]++; counts[app.category]++;
}); });
@@ -128,17 +213,17 @@ const categoryCounts = computed(() => {
}); });
const hasSelectedUpgrades = computed(() => { const hasSelectedUpgrades = computed(() => {
return upgradableApps.value.some(app => app.selected); return upgradableApps.value.some((app) => app.selected);
}); });
// 方法 // 方法
const syncThemePreference = (enabled: boolean) => { const syncThemePreference = (enabled: boolean) => {
document.documentElement.classList.toggle('dark', enabled); document.documentElement.classList.toggle("dark", enabled);
}; };
const initTheme = () => { const initTheme = () => {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem("theme");
isDarkTheme.value = savedTheme === 'dark'; isDarkTheme.value = savedTheme === "dark";
syncThemePreference(isDarkTheme.value); syncThemePreference(isDarkTheme.value);
}; };
@@ -162,13 +247,17 @@ const openDetail = (app: App) => {
// 确保模态框显示后滚动到顶部 // 确保模态框显示后滚动到顶部
nextTick(() => { nextTick(() => {
const modal = document.querySelector('[data-app-modal="detail"] .modal-panel'); const modal = document.querySelector(
'[data-app-modal="detail"] .modal-panel',
);
if (modal) modal.scrollTop = 0; if (modal) modal.scrollTop = 0;
}); });
}; };
const checkAppInstalled = (app: App) => { const checkAppInstalled = (app: App) => {
window.ipcRenderer.invoke('check-installed', app.pkgname).then((isInstalled: boolean) => { window.ipcRenderer
.invoke("check-installed", app.pkgname)
.then((isInstalled: boolean) => {
currentAppIsInstalled.value = isInstalled; currentAppIsInstalled.value = isInstalled;
}); });
}; };
@@ -230,12 +319,12 @@ const closeUpdateModal = () => {
const refreshUpgradableApps = async () => { const refreshUpgradableApps = async () => {
updateLoading.value = true; updateLoading.value = true;
updateError.value = ''; updateError.value = "";
try { try {
const result = await window.ipcRenderer.invoke('list-upgradable'); const result = await window.ipcRenderer.invoke("list-upgradable");
if (!result?.success) { if (!result?.success) {
upgradableApps.value = []; upgradableApps.value = [];
updateError.value = result?.message || '检查更新失败'; updateError.value = result?.message || "检查更新失败";
return; return;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -244,32 +333,34 @@ const refreshUpgradableApps = async () => {
// Map properties if needed or assume main matches App interface except field names might differ // Map properties if needed or assume main matches App interface except field names might differ
// For now assuming result.apps returns objects compatible with App for core fields, // For now assuming result.apps returns objects compatible with App for core fields,
// but let's normalize just in case if main returns different structure. // but let's normalize just in case if main returns different structure.
name: app.name || app.Name || '', name: app.name || app.Name || "",
pkgname: app.pkgname || app.Pkgname || '', pkgname: app.pkgname || app.Pkgname || "",
version: app.newVersion || app.version || '', version: app.newVersion || app.version || "",
category: app.category || 'unknown', category: app.category || "unknown",
selected: false, selected: false,
upgrading: false upgrading: false,
})); }));
} catch (error: unknown) { } catch (error: unknown) {
upgradableApps.value = []; upgradableApps.value = [];
updateError.value = (error as Error)?.message || '检查更新失败'; updateError.value = (error as Error)?.message || "检查更新失败";
} finally { } finally {
updateLoading.value = false; updateLoading.value = false;
} }
}; };
const toggleAllUpgrades = () => { const toggleAllUpgrades = () => {
const shouldSelectAll = !hasSelectedUpgrades.value || upgradableApps.value.some(app => !app.selected); const shouldSelectAll =
upgradableApps.value = upgradableApps.value.map(app => ({ !hasSelectedUpgrades.value ||
upgradableApps.value.some((app) => !app.selected);
upgradableApps.value = upgradableApps.value.map((app) => ({
...app, ...app,
selected: shouldSelectAll ? true : false selected: shouldSelectAll ? true : false,
})); }));
}; };
const upgradeSingleApp = (app: UpdateAppItem) => { const upgradeSingleApp = (app: UpdateAppItem) => {
if (!app?.pkgname) return; if (!app?.pkgname) return;
const target = apps.value.find(a => a.pkgname === app.pkgname); const target = apps.value.find((a) => a.pkgname === app.pkgname);
if (target) { if (target) {
handleUpgrade(target); handleUpgrade(target);
} else { } else {
@@ -278,28 +369,28 @@ const upgradeSingleApp = (app: UpdateAppItem) => {
let minimalApp: App = { let minimalApp: App = {
name: app.pkgname, name: app.pkgname,
pkgname: app.pkgname, pkgname: app.pkgname,
version: app.newVersion || '', version: app.newVersion || "",
category: 'unknown', category: "unknown",
tags: '', tags: "",
more: '', more: "",
filename: '', filename: "",
torrent_address: '', torrent_address: "",
author: '', author: "",
contributor: '', contributor: "",
website: '', website: "",
update: '', update: "",
size: '', size: "",
img_urls: [], img_urls: [],
icons: '', icons: "",
currentStatus: 'installed' currentStatus: "installed",
} };
handleUpgrade(minimalApp); handleUpgrade(minimalApp);
} }
}; };
const upgradeSelectedApps = () => { const upgradeSelectedApps = () => {
const selectedApps = upgradableApps.value.filter(app => app.selected); const selectedApps = upgradableApps.value.filter((app) => app.selected);
selectedApps.forEach(app => { selectedApps.forEach((app) => {
upgradeSingleApp(app); upgradeSingleApp(app);
}); });
}; };
@@ -315,50 +406,50 @@ const closeInstalledModal = () => {
const refreshInstalledApps = async () => { const refreshInstalledApps = async () => {
installedLoading.value = true; installedLoading.value = true;
installedError.value = ''; installedError.value = "";
try { try {
const result = await window.ipcRenderer.invoke('list-installed'); const result = await window.ipcRenderer.invoke("list-installed");
if (!result?.success) { if (!result?.success) {
installedApps.value = []; installedApps.value = [];
installedError.value = result?.message || '读取已安装应用失败'; installedError.value = result?.message || "读取已安装应用失败";
return; return;
} }
installedApps.value = [] installedApps.value = [];
for (const app of result.apps) { for (const app of result.apps) {
let appInfo = apps.value.find(a => a.pkgname === app.pkgname); let appInfo = apps.value.find((a) => a.pkgname === app.pkgname);
if (appInfo) { if (appInfo) {
appInfo.flags = app.flags; appInfo.flags = app.flags;
appInfo.arch = app.arch; appInfo.arch = app.arch;
appInfo.currentStatus = 'installed'; appInfo.currentStatus = "installed";
} else { } else {
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
appInfo = { appInfo = {
name: app.name || app.pkgname, name: app.name || app.pkgname,
pkgname: app.pkgname, pkgname: app.pkgname,
version: app.version, version: app.version,
category: 'unknown', category: "unknown",
tags: '', tags: "",
more: '', more: "",
filename: '', filename: "",
torrent_address: '', torrent_address: "",
author: '', author: "",
contributor: '', contributor: "",
website: '', website: "",
update: '', update: "",
size: '', size: "",
img_urls: [], img_urls: [],
icons: '', icons: "",
currentStatus: 'installed', currentStatus: "installed",
arch: app.arch, arch: app.arch,
flags: app.flags flags: app.flags,
}; };
} }
installedApps.value.push(appInfo); installedApps.value.push(appInfo);
} }
} catch (error: unknown) { } catch (error: unknown) {
installedApps.value = []; installedApps.value = [];
installedError.value = (error as Error)?.message || '读取已安装应用失败'; installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally { } finally {
installedLoading.value = false; installedLoading.value = false;
} }
@@ -366,7 +457,7 @@ const refreshInstalledApps = async () => {
const requestUninstall = (app: App) => { const requestUninstall = (app: App) => {
let target = null; let target = null;
target = apps.value.find(a => a.pkgname === app.pkgname) || app; target = apps.value.find((a) => a.pkgname === app.pkgname) || app;
if (target) { if (target) {
uninstallTargetApp.value = target as App; uninstallTargetApp.value = target as App;
@@ -402,7 +493,7 @@ const installCompleteCallback = () => {
if (currentApp.value) { if (currentApp.value) {
checkAppInstalled(currentApp.value); checkAppInstalled(currentApp.value);
} }
} };
watchDownloadsChange(installCompleteCallback); watchDownloadsChange(installCompleteCallback);
@@ -412,24 +503,25 @@ const uninstallInstalledApp = (app: App) => {
// 目前 APM 商店不能暂停下载(因为 APM 本身不支持),但保留这些方法以备将来使用 // 目前 APM 商店不能暂停下载(因为 APM 本身不支持),但保留这些方法以备将来使用
const pauseDownload = (id: DownloadItem) => { const pauseDownload = (id: DownloadItem) => {
const download = downloads.value.find(d => d.id === id.id); const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === 'installing') { // 'installing' matches type definition, previously 'downloading' if (download && download.status === "installing") {
download.status = 'paused'; // 'installing' matches type definition, previously 'downloading'
download.status = "paused";
download.logs.push({ download.logs.push({
time: Date.now(), time: Date.now(),
message: '下载已暂停' message: "下载已暂停",
}); });
} }
}; };
// 同理 // 同理
const resumeDownload = (id: DownloadItem) => { const resumeDownload = (id: DownloadItem) => {
const download = downloads.value.find(d => d.id === id.id); const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === 'paused') { if (download && download.status === "paused") {
download.status = 'installing'; // previously 'downloading' download.status = "installing"; // previously 'downloading'
download.logs.push({ download.logs.push({
time: Date.now(), time: Date.now(),
message: '继续下载...' message: "继续下载...",
}); });
// simulateDownload(download); // removed or undefined? // simulateDownload(download); // removed or undefined?
} }
@@ -437,14 +529,14 @@ const resumeDownload = (id: DownloadItem) => {
// 同理 // 同理
const cancelDownload = (id: DownloadItem) => { const cancelDownload = (id: DownloadItem) => {
const index = downloads.value.findIndex(d => d.id === id.id); const index = downloads.value.findIndex((d) => d.id === id.id);
if (index !== -1) { if (index !== -1) {
const download = downloads.value[index]; const download = downloads.value[index];
// download.status = 'cancelled'; // 'cancelled' not in DownloadItem type union? Check type // download.status = 'cancelled'; // 'cancelled' not in DownloadItem type union? Check type
download.status = 'failed'; // Use 'failed' or add 'cancelled' to type if needed. User asked to keep type simple. download.status = "failed"; // Use 'failed' or add 'cancelled' to type if needed. User asked to keep type simple.
download.logs.push({ download.logs.push({
time: Date.now(), time: Date.now(),
message: '下载已取消' message: "下载已取消",
}); });
// 延迟删除,让用户看到取消状态 // 延迟删除,让用户看到取消状态
setTimeout(() => { setTimeout(() => {
@@ -455,21 +547,21 @@ const cancelDownload = (id: DownloadItem) => {
}; };
const retryDownload = (id: DownloadItem) => { const retryDownload = (id: DownloadItem) => {
const download = downloads.value.find(d => d.id === id.id); const download = downloads.value.find((d) => d.id === id.id);
if (download && download.status === 'failed') { if (download && download.status === "failed") {
download.status = 'queued'; download.status = "queued";
download.progress = 0; download.progress = 0;
download.downloadedSize = 0; download.downloadedSize = 0;
download.logs.push({ download.logs.push({
time: Date.now(), time: Date.now(),
message: '重新开始下载...' message: "重新开始下载...",
}); });
handleRetry(download); handleRetry(download);
} }
}; };
const clearCompletedDownloads = () => { const clearCompletedDownloads = () => {
downloads.value = downloads.value.filter(d => d.status !== 'completed'); downloads.value = downloads.value.filter((d) => d.status !== "completed");
}; };
const showDownloadDetailModalFunc = (download: DownloadItem) => { const showDownloadDetailModalFunc = (download: DownloadItem) => {
@@ -487,12 +579,14 @@ const openDownloadedApp = (pkgname: string) => {
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, { // openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
// fallbackText: `打开应用: ${download.pkgname}` // fallbackText: `打开应用: ${download.pkgname}`
// }); // });
window.ipcRenderer.invoke('launch-app', pkgname); window.ipcRenderer.invoke("launch-app", pkgname);
}; };
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const response = await axiosInstance.get(`/${window.apm_store.arch}/categories.json`); const response = await axiosInstance.get(
`/${window.apm_store.arch}/categories.json`,
);
categories.value = response.data; categories.value = response.data;
} catch (error) { } catch (error) {
logger.error(`读取 categories.json 失败: ${error}`); logger.error(`读取 categories.json 失败: ${error}`);
@@ -502,10 +596,12 @@ const loadCategories = async () => {
const loadApps = async () => { const loadApps = async () => {
loading.value = true; loading.value = true;
try { try {
logger.info('开始加载应用数据...'); logger.info("开始加载应用数据...");
const promises = Object.keys(categories.value).map(async category => { const promises = Object.keys(categories.value).map(async (category) => {
try { try {
const response = await axiosInstance.get<AppJson[]>(`/${window.apm_store.arch}/${category}/applist.json`); const response = await axiosInstance.get<AppJson[]>(
`/${window.apm_store.arch}/${category}/applist.json`,
);
return response.status === 200 ? response.data : []; return response.status === 200 ? response.data : [];
} catch { } catch {
return []; return [];
@@ -532,10 +628,13 @@ const loadApps = async () => {
size: appJson.Size, size: appJson.Size,
more: appJson.More, more: appJson.More,
tags: appJson.Tags, tags: appJson.Tags,
img_urls: typeof appJson.img_urls === 'string' ? JSON.parse(appJson.img_urls) : appJson.img_urls, img_urls:
typeof appJson.img_urls === "string"
? JSON.parse(appJson.img_urls)
: appJson.img_urls,
icons: appJson.icons, icons: appJson.icons,
category: category, category: category,
currentStatus: 'not-installed', currentStatus: "not-installed",
}; };
apps.value.push(normalizedApp); apps.value.push(normalizedApp);
}); });
@@ -559,19 +658,19 @@ onMounted(async () => {
await loadApps(); await loadApps();
// 设置键盘导航 // 设置键盘导航
document.addEventListener('keydown', (e) => { document.addEventListener("keydown", (e) => {
if (showPreview.value) { if (showPreview.value) {
if (e.key === 'Escape') closeScreenPreview(); if (e.key === "Escape") closeScreenPreview();
if (e.key === 'ArrowLeft') prevScreen(); if (e.key === "ArrowLeft") prevScreen();
if (e.key === 'ArrowRight') nextScreen(); if (e.key === "ArrowRight") nextScreen();
} }
if (showModal.value && e.key === 'Escape') { if (showModal.value && e.key === "Escape") {
closeDetail(); closeDetail();
} }
}); });
// Deep link Handlers // Deep link Handlers
window.ipcRenderer.on('deep-link-update', () => { window.ipcRenderer.on("deep-link-update", () => {
if (loading.value) { if (loading.value) {
const stop = watch(loading, (val) => { const stop = watch(loading, (val) => {
if (!val) { if (!val) {
@@ -584,7 +683,7 @@ onMounted(async () => {
} }
}); });
window.ipcRenderer.on('deep-link-installed', () => { window.ipcRenderer.on("deep-link-installed", () => {
if (loading.value) { if (loading.value) {
const stop = watch(loading, (val) => { const stop = watch(loading, (val) => {
if (!val) { if (!val) {
@@ -597,9 +696,11 @@ onMounted(async () => {
} }
}); });
window.ipcRenderer.on('deep-link-install', (_event: IpcRendererEvent, pkgname: string) => { window.ipcRenderer.on(
"deep-link-install",
(_event: IpcRendererEvent, pkgname: string) => {
const tryOpen = () => { const tryOpen = () => {
const target = apps.value.find(a => a.pkgname === pkgname); const target = apps.value.find((a) => a.pkgname === pkgname);
if (target) { if (target) {
openDetail(target); openDetail(target);
} else { } else {
@@ -617,23 +718,26 @@ onMounted(async () => {
} else { } else {
tryOpen(); tryOpen();
} }
}); },
);
window.ipcRenderer.on('remove-complete', (_event: IpcRendererEvent, payload: ChannelPayload) => { window.ipcRenderer.on(
const pkgname = currentApp.value?.pkgname "remove-complete",
(_event: IpcRendererEvent, payload: ChannelPayload) => {
const pkgname = currentApp.value?.pkgname;
if (payload.success && pkgname) { if (payload.success && pkgname) {
removeDownloadItem(pkgname); removeDownloadItem(pkgname);
} }
}); },
);
window.ipcRenderer.send("renderer-ready", { status: true });
window.ipcRenderer.send('renderer-ready', { status: true }); logger.info("Renderer process is ready!");
logger.info('Renderer process is ready!');
}); });
// 观察器 // 观察器
watch(isDarkTheme, (newVal) => { watch(isDarkTheme, (newVal) => {
localStorage.setItem('theme', newVal ? 'dark' : 'light'); localStorage.setItem("theme", newVal ? "dark" : "light");
syncThemePreference(newVal); syncThemePreference(newVal);
}); });
</script> </script>

View File

@@ -1,47 +1,67 @@
<template> <template>
<div @click="openDetail"
class="group flex h-full cursor-pointer gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-1 hover:border-brand/50 hover:shadow-lg dark:border-slate-800/60 dark:bg-slate-900/60">
<div <div
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"> @click="openDetail"
<img ref="iconImg" :src="loadedIcon" alt="icon" class="group flex h-full cursor-pointer gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-1 hover:border-brand/50 hover:shadow-lg dark:border-slate-800/60 dark:bg-slate-900/60"
:class="['h-full w-full object-cover transition-opacity duration-300', isLoaded ? 'opacity-100' : 'opacity-0']" /> >
<div
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
>
<img
ref="iconImg"
:src="loadedIcon"
alt="icon"
:class="[
'h-full w-full object-cover transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
]"
/>
</div> </div>
<div class="flex flex-1 flex-col gap-1 overflow-hidden"> <div class="flex flex-1 flex-col gap-1 overflow-hidden">
<div class="truncate text-base font-semibold text-slate-900 dark:text-white">{{ app.name || '' }}</div> <div
<div class="text-sm text-slate-500 dark:text-slate-400">{{ app.pkgname || '' }} · {{ app.version || '' }}</div> class="truncate text-base font-semibold text-slate-900 dark:text-white"
<div class="text-sm text-slate-500 dark:text-slate-400">{{ description }}</div> >
{{ app.name || "" }}
</div>
<div class="text-sm text-slate-500 dark:text-slate-400">
{{ app.pkgname || "" }} · {{ app.version || "" }}
</div>
<div class="text-sm text-slate-500 dark:text-slate-400">
{{ description }}
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
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";
const props = defineProps<{ const props = defineProps<{
app: App app: App;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'open-detail', app: App): void (e: "open-detail", app: App): void;
}>(); }>();
const iconImg = ref<HTMLImageElement | null>(null); const iconImg = ref<HTMLImageElement | null>(null);
const isLoaded = ref(false); const isLoaded = ref(false);
const loadedIcon = ref('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E'); const loadedIcon = ref(
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E',
);
const iconPath = computed(() => { const iconPath = computed(() => {
return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`;
}); });
const description = computed(() => { const description = computed(() => {
const more = props.app.more || ''; const more = props.app.more || "";
return more.substring(0, 80) + (more.length > 80 ? '...' : ''); return more.substring(0, 80) + (more.length > 80 ? "..." : "");
}); });
const openDetail = () => { const openDetail = () => {
emit('open-detail', props.app); emit("open-detail", props.app);
}; };
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
@@ -57,24 +77,23 @@ onMounted(() => {
img.onload = () => { img.onload = () => {
loadedIcon.value = iconPath.value; loadedIcon.value = iconPath.value;
isLoaded.value = true; isLoaded.value = true;
if (observer) if (observer) observer.unobserve(entry.target);
observer.unobserve(entry.target);
}; };
img.onerror = () => { img.onerror = () => {
// 加载失败时使用默认图标 // 加载失败时使用默认图标
loadedIcon.value = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23e0e0e0" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3ENo Icon%3C/text%3E%3C/svg%3E'; loadedIcon.value =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23e0e0e0" width="100" height="100"/%3E%3Ctext x="50" y="50" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3ENo Icon%3C/text%3E%3C/svg%3E';
isLoaded.value = true; isLoaded.value = true;
if (observer) if (observer) observer.unobserve(entry.target);
observer.unobserve(entry.target);
}; };
img.src = iconPath.value; img.src = iconPath.value;
} }
}); });
}, },
{ {
rootMargin: '50px', // 提前50px开始加载 rootMargin: "50px", // 提前50px开始加载
threshold: 0.01 threshold: 0.01,
} },
); );
// 观察图标元素 // 观察图标元素
@@ -85,7 +104,8 @@ onMounted(() => {
// 当 app 变更时重置懒加载状态并重新观察 // 当 app 变更时重置懒加载状态并重新观察
watch(iconPath, () => { watch(iconPath, () => {
loadedIcon.value = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E'; loadedIcon.value =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E';
isLoaded.value = false; isLoaded.value = false;
if (observer && iconImg.value) { if (observer && iconImg.value) {
observer.unobserve(iconImg.value); observer.unobserve(iconImg.value);

View File

@@ -1,107 +1,197 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 scale-95" <Transition
enter-to-class="opacity-100 scale-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> enter-from-class="opacity-0 scale-95"
<div v-if="show" v-bind="attrs" enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4" class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal"> @click.self="closeModal"
<div class="modal-panel relative w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"> >
<div
class="modal-panel relative w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<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 flex-1 items-center gap-4"> <div class="flex flex-1 items-center gap-4">
<div <div
class="flex h-20 w-20 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"> class="flex h-20 w-20 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
<img v-if="app" :src="iconPath" alt="icon" class="h-full w-full object-cover" /> >
<img
v-if="app"
:src="iconPath"
alt="icon"
class="h-full w-full object-cover"
/>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<p class="text-2xl font-bold text-slate-900 dark:text-white">{{ app?.name || '' }}</p> <p class="text-2xl font-bold text-slate-900 dark:text-white">
{{ app?.name || "" }}
</p>
<!-- Close button for mobile layout could be considered here if needed, but for now sticking to desktop layout logic mainly --> <!-- Close button for mobile layout could be considered here if needed, but for now sticking to desktop layout logic mainly -->
</div> </div>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ app?.pkgname || '' }} · {{ app?.version || '' }}</p> <p class="text-sm text-slate-500 dark:text-slate-400">
{{ app?.pkgname || "" }} · {{ app?.version || "" }}
</p>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2 lg:ml-auto"> <div class="flex flex-wrap gap-2 lg:ml-auto">
<button v-if="!isinstalled" type="button" <button
v-if="!isinstalled"
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40 transition hover:-translate-y-0.5" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40 transition hover:-translate-y-0.5"
:class="installFeedback ? 'from-emerald-500 to-emerald-600' : 'from-brand to-brand-dark'" :class="
installFeedback
? 'from-emerald-500 to-emerald-600'
: 'from-brand to-brand-dark'
"
@click="handleInstall" @click="handleInstall"
:disabled="installFeedback || isCompleted"> :disabled="installFeedback || isCompleted"
<i class="fas" :class="installFeedback ? 'fa-check' : 'fa-download'"></i> >
<i
class="fas"
:class="installFeedback ? 'fa-check' : 'fa-download'"
></i>
<span>{{ installBtnText }}</span> <span>{{ installBtnText }}</span>
</button> </button>
<template v-else> <template v-else>
<button type="button" <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" 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"
@click="emit('open-app', app?.pkgname || '')"> @click="emit('open-app', app?.pkgname || '')"
>
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
<span>打开</span> <span>打开</span>
</button> </button>
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-rose-500 to-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 disabled:opacity-40 transition hover:-translate-y-0.5" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-rose-500 to-rose-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 disabled:opacity-40 transition hover:-translate-y-0.5"
@click="handleRemove"> @click="handleRemove"
>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
<span>卸载</span> <span>卸载</span>
</button> </button>
</template> </template>
<button type="button" <button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="closeModal" aria-label="关闭"> @click="closeModal"
aria-label="关闭"
>
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
</div> </div>
<div <div
class="mt-4 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3 text-sm text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"> class="mt-4 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3 text-sm text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"
>
首次安装 APM 后需要重启系统以在启动器中看到应用入口可前往 首次安装 APM 后需要重启系统以在启动器中看到应用入口可前往
<a href="https://gitee.com/amber-ce/amber-pm/releases" target="_blank" <a
class="font-semibold text-brand hover:underline">APM Releases</a> href="https://gitee.com/amber-ce/amber-pm/releases"
target="_blank"
class="font-semibold text-brand hover:underline"
>APM Releases</a
>
获取 APM 获取 APM
</div> </div>
<div v-if="screenshots.length" class="mt-6 grid gap-3 sm:grid-cols-2"> <div v-if="screenshots.length" class="mt-6 grid gap-3 sm:grid-cols-2">
<img v-for="(screen, index) in screenshots" :key="index" :src="screen" alt="screenshot" <img
v-for="(screen, index) in screenshots"
:key="index"
:src="screen"
alt="screenshot"
class="h-40 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60" class="h-40 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
@click="openPreview(index)" @error="hideImage" /> @click="openPreview(index)"
@error="hideImage"
/>
</div> </div>
<div class="mt-6 grid gap-4 sm:grid-cols-2"> <div class="mt-6 grid gap-4 sm:grid-cols-2">
<div v-if="app?.author" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.author"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">作者</p> <p class="text-xs uppercase tracking-wide text-slate-400">作者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.author }}</p> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.author }}
</p>
</div> </div>
<div v-if="app?.contributor" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.contributor"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p> <p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.contributor }}</p> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.contributor }}
</p>
</div> </div>
<div v-if="app?.size" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.size"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">大小</p> <p class="text-xs uppercase tracking-wide text-slate-400">大小</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.size }}</p> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.size }}
</p>
</div> </div>
<div v-if="app?.update" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
<p class="text-xs uppercase tracking-wide text-slate-400">更新时间</p> v-if="app?.update"
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.update }}</p> class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">
更新时间
</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.update }}
</p>
</div> </div>
<div v-if="app?.website" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.website"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">网站</p> <p class="text-xs uppercase tracking-wide text-slate-400">网站</p>
<a :href="app.website" target="_blank" <a
class="text-sm font-medium text-brand hover:underline">{{ app.website }}</a> :href="app.website"
target="_blank"
class="text-sm font-medium text-brand hover:underline"
>{{ app.website }}</a
>
</div> </div>
<div v-if="app?.version" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.version"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">版本</p> <p class="text-xs uppercase tracking-wide text-slate-400">版本</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.version }}</p> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.version }}
</p>
</div> </div>
<div v-if="app?.tags" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="app?.tags"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<p class="text-xs uppercase tracking-wide text-slate-400">标签</p> <p class="text-xs uppercase tracking-wide text-slate-400">标签</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">{{ app.tags }}</p> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.tags }}
</p>
</div> </div>
</div> </div>
<div v-if="app?.more && app.more.trim() !== ''" class="mt-6 space-y-3"> <div v-if="app?.more && app.more.trim() !== ''" class="mt-6 space-y-3">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">应用详情</h3> <h3 class="text-lg font-semibold text-slate-900 dark:text-white">
应用详情
</h3>
<div <div
class="max-h-60 space-y-2 overflow-y-auto rounded-2xl border border-slate-200/60 bg-slate-50/80 p-4 text-sm leading-relaxed text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300" class="max-h-60 space-y-2 overflow-y-auto rounded-2xl border border-slate-200/60 bg-slate-50/80 p-4 text-sm leading-relaxed text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"
v-html="app.more.replace(/\n/g, '<br>')"></div> v-html="app.more.replace(/\n/g, '<br>')"
></div>
</div> </div>
</div> </div>
</div> </div>
@@ -109,10 +199,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed,useAttrs } from 'vue'; import { computed, useAttrs } from "vue";
import { useDownloadItemStatus,useInstallFeedback } from '../global/downloadStatus'; import {
import { APM_STORE_BASE_URL } from '../global/storeConfig'; useDownloadItemStatus,
import type { App } from '../global/typedefinition'; useInstallFeedback,
} from "../global/downloadStatus";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const attrs = useAttrs(); const attrs = useAttrs();
@@ -124,14 +217,13 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'install'): void; (e: "install"): void;
(e: 'remove'): void; (e: "remove"): void;
(e: 'open-preview', index: number): void; (e: "open-preview", index: number): void;
(e: 'open-app', pkgname: string ): void; (e: "open-app", pkgname: string): void;
}>(); }>();
const appPkgname = computed(() => props.app?.pkgname); const appPkgname = computed(() => props.app?.pkgname);
const { installFeedback } = useInstallFeedback(appPkgname); const { installFeedback } = useInstallFeedback(appPkgname);
const { isCompleted } = useDownloadItemStatus(appPkgname); const { isCompleted } = useDownloadItemStatus(appPkgname);
@@ -143,27 +235,27 @@ const installBtnText = computed(() => {
return installFeedback.value ? "已加入队列" : "安装"; return installFeedback.value ? "已加入队列" : "安装";
}); });
const iconPath = computed(() => { const iconPath = computed(() => {
if (!props.app) return ''; if (!props.app) return "";
return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`;
}); });
const closeModal = () => { const closeModal = () => {
emit('close'); emit("close");
}; };
const handleInstall = () => { const handleInstall = () => {
emit('install'); emit("install");
}; };
const handleRemove = () => { const handleRemove = () => {
emit('remove'); emit("remove");
} };
const openPreview = (index: number) => { const openPreview = (index: number) => {
emit('open-preview', index); emit("open-preview", index);
}; };
const hideImage = (e: Event) => { const hideImage = (e: Event) => {
(e.target as HTMLElement).style.display = 'none'; (e.target as HTMLElement).style.display = "none";
}; };
</script> </script>

View File

@@ -1,22 +1,42 @@
<template> <template>
<div v-if="!loading" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div
<AppCard v-for="(app, index) in apps" :key="index" :app="app" @open-detail="$emit('open-detail', app)" /> v-if="!loading"
class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<AppCard
v-for="(app, index) in apps"
:key="index"
:app="app"
@open-detail="$emit('open-detail', app)"
/>
</div> </div>
<div v-else class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div
<div v-for="n in 8" :key="n" v-else
class="flex gap-4 rounded-2xl border border-slate-200/60 bg-white/80 p-4 shadow-sm dark:border-slate-800/60 dark:bg-slate-900/50"> class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
<div class="h-16 w-16 animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800"></div> >
<div
v-for="n in 8"
:key="n"
class="flex gap-4 rounded-2xl border border-slate-200/60 bg-white/80 p-4 shadow-sm dark:border-slate-800/60 dark:bg-slate-900/50"
>
<div
class="h-16 w-16 animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800"
></div>
<div class="flex flex-1 flex-col justify-center gap-2"> <div class="flex flex-1 flex-col justify-center gap-2">
<div class="h-4 w-2/3 animate-pulse rounded-full bg-slate-200 dark:bg-slate-800"></div> <div
<div class="h-3 w-1/2 animate-pulse rounded-full bg-slate-200/80 dark:bg-slate-800/80"></div> class="h-4 w-2/3 animate-pulse rounded-full bg-slate-200 dark:bg-slate-800"
></div>
<div
class="h-3 w-1/2 animate-pulse rounded-full bg-slate-200/80 dark:bg-slate-800/80"
></div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppCard from './AppCard.vue'; import AppCard from "./AppCard.vue";
import type { App } from '../global/typedefinition'; import type { App } from "../global/typedefinition";
defineProps<{ defineProps<{
apps: App[]; apps: App[];
@@ -24,6 +44,6 @@ defineProps<{
}>(); }>();
defineEmits<{ defineEmits<{
(e: 'open-detail', app: App): void; (e: "open-detail", app: App): void;
}>(); }>();
</script> </script>

View File

@@ -5,10 +5,16 @@
<div class="w-full flex-1"> <div class="w-full flex-1">
<label for="searchBox" class="sr-only">搜索应用</label> <label for="searchBox" class="sr-only">搜索应用</label>
<div class="relative"> <div class="relative">
<i class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"></i> <i
<input id="searchBox" v-model="localSearchQuery" class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
id="searchBox"
v-model="localSearchQuery"
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-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-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"
placeholder="搜索应用名 / 包名 / 标签" @input="debounceSearch" /> placeholder="搜索应用名 / 包名 / 标签"
@input="debounceSearch"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -19,8 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
import TopActions from './TopActions.vue'; import TopActions from "./TopActions.vue";
const props = defineProps<{ const props = defineProps<{
searchQuery: string; searchQuery: string;
@@ -28,22 +34,25 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update-search', query: string): void; (e: "update-search", query: string): void;
(e: 'update'): void; (e: "update"): void;
(e: 'list'): void; (e: "list"): void;
}>(); }>();
const localSearchQuery = ref(props.searchQuery || ''); const localSearchQuery = ref(props.searchQuery || "");
const timeoutId = ref<ReturnType<typeof setTimeout> | null>(null); const timeoutId = ref<ReturnType<typeof setTimeout> | null>(null);
const debounceSearch = () => { const debounceSearch = () => {
if (timeoutId.value) clearTimeout(timeoutId.value); if (timeoutId.value) clearTimeout(timeoutId.value);
timeoutId.value = setTimeout(() => { timeoutId.value = setTimeout(() => {
emit('update-search', localSearchQuery.value); emit("update-search", localSearchQuery.value);
}, 220); }, 220);
}; };
watch(() => props.searchQuery, (newVal) => { watch(
localSearchQuery.value = newVal || ''; () => props.searchQuery,
}); (newVal) => {
localSearchQuery.value = newVal || "";
},
);
</script> </script>

View File

@@ -1,41 +1,71 @@
<template> <template>
<div class="flex h-full flex-col gap-6"> <div class="flex h-full flex-col gap-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img :src="amberLogo" alt="Amber PM" class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800" /> <img
:src="amberLogo"
alt="Amber PM"
class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
/>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">APM Store</span> <span
<span class="text-lg font-semibold text-slate-900 dark:text-white">APM 客户端商店</span> class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
>APM Store</span
>
<span class="text-lg font-semibold text-slate-900 dark:text-white"
>APM 客户端商店</span
>
</div> </div>
</div> </div>
<ThemeToggle :is-dark="isDarkTheme" @toggle="toggleTheme" /> <ThemeToggle :is-dark="isDarkTheme" @toggle="toggleTheme" />
<div class="flex-1 space-y-2 overflow-y-auto scrollbar-muted pr-2"> <div class="flex-1 space-y-2 overflow-y-auto scrollbar-muted pr-2">
<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" <button
:class="activeCategory === 'all' ? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15' : ''" type="button"
@click="selectCategory('all')"> 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="
activeCategory === 'all'
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
: ''
"
@click="selectCategory('all')"
>
<span>全部应用</span> <span>全部应用</span>
<span class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300">{{ categoryCounts.all || 0 }}</span> <span
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>{{ categoryCounts.all || 0 }}</span
>
</button> </button>
<button v-for="(category, key) in categories" :key="key" type="button" <button
v-for="(category, key) in categories"
:key="key"
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"
:class="activeCategory === key ? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15' : ''" :class="
@click="selectCategory(key)"> activeCategory === key
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
: ''
"
@click="selectCategory(key)"
>
<span class="flex flex-col"> <span class="flex flex-col">
<span> <span>
<div class="text-left">{{ category.zh }}</div> <div class="text-left">{{ category.zh }}</div>
</span> </span>
</span> </span>
<span class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300">{{ categoryCounts[key] || 0 }}</span> <span
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>{{ categoryCounts[key] || 0 }}</span
>
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ThemeToggle from './ThemeToggle.vue'; import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from '../assets/imgs/amber-pm-logo.png'; import amberLogo from "../assets/imgs/amber-pm-logo.png";
defineProps<{ defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -46,15 +76,15 @@ defineProps<{
}>(); }>();
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;
}>(); }>();
const toggleTheme = () => { const toggleTheme = () => {
emit('toggle-theme'); emit("toggle-theme");
}; };
const selectCategory = (category: string) => { const selectCategory = (category: string) => {
emit('select-category', category); emit("select-category", category);
}; };
</script> </script>

View File

@@ -1,72 +1,123 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 scale-95" <Transition
enter-to-class="opacity-100 scale-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> enter-from-class="opacity-0 scale-95"
<div v-if="show" enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10" class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click="handleOverlayClick"> @click="handleOverlayClick"
<div class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" >
@click.stop> <div
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop
>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">下载详情</p> <p class="text-2xl font-semibold text-slate-900 dark:text-white">
<p class="text-sm text-slate-500 dark:text-slate-400">实时了解安装进度</p> 下载详情
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
实时了解安装进度
</p>
</div> </div>
<button type="button" <button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="close"> @click="close"
>
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
<div v-if="download" class="mt-6 space-y-6"> <div v-if="download" class="mt-6 space-y-6">
<div class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
<div class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"> class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
<img :src="download.icon" :alt="download.name" class="h-full w-full object-cover" /> >
<div
class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
>
<img
:src="download.icon"
:alt="download.name"
class="h-full w-full object-cover"
/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-lg font-semibold text-slate-900 dark:text-white">{{ download.name }}</p> <p class="text-lg font-semibold text-slate-900 dark:text-white">
<p class="text-sm text-slate-500 dark:text-slate-400">{{ download.pkgname }} · {{ download.version }}</p> {{ download.name }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ download.pkgname }} · {{ download.version }}
</p>
</div> </div>
</div> </div>
<div class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-500">状态</span> <span class="text-sm font-medium text-slate-500">状态</span>
<span <span
class="rounded-full px-3 py-1 text-xs font-semibold" class="rounded-full px-3 py-1 text-xs font-semibold"
:class="{ :class="{
'bg-blue-100 text-blue-700': download.status === 'downloading', 'bg-blue-100 text-blue-700':
'bg-amber-100 text-amber-600': download.status === 'installing', download.status === 'downloading',
'bg-emerald-100 text-emerald-700': download.status === 'completed', 'bg-amber-100 text-amber-600':
download.status === 'installing',
'bg-emerald-100 text-emerald-700':
download.status === 'completed',
'bg-rose-100 text-rose-600': download.status === 'failed', 'bg-rose-100 text-rose-600': download.status === 'failed',
'bg-slate-200 text-slate-600': download.status === 'paused' 'bg-slate-200 text-slate-600': download.status === 'paused',
}"> }"
>
{{ getStatusText(download.status) }} {{ getStatusText(download.status) }}
</span> </span>
</div> </div>
<div v-if="download.status === 'downloading'" class="space-y-3"> <div v-if="download.status === 'downloading'" class="space-y-3">
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800"> <div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
<div class="h-full rounded-full bg-brand" :style="{ width: download.progress + '%' }"></div> <div
class="h-full rounded-full bg-brand"
:style="{ width: download.progress + '%' }"
></div>
</div> </div>
<div class="flex flex-wrap items-center justify-between text-sm text-slate-500 dark:text-slate-400"> <div
class="flex flex-wrap items-center justify-between text-sm text-slate-500 dark:text-slate-400"
>
<span>{{ download.progress }}%</span> <span>{{ download.progress }}%</span>
<span v-if="download.downloadedSize && download.totalSize"> <span v-if="download.downloadedSize && download.totalSize">
{{ formatSize(download.downloadedSize) }} / {{ formatSize(download.totalSize) }} {{ formatSize(download.downloadedSize) }} /
{{ formatSize(download.totalSize) }}
</span> </span>
</div> </div>
<div v-if="download.speed" class="flex flex-wrap gap-3 text-sm text-slate-500 dark:text-slate-300"> <div
<span class="flex items-center gap-2"><i class="fas fa-tachometer-alt"></i>{{ formatSpeed(download.speed) }}</span> v-if="download.speed"
<span v-if="download.timeRemaining">剩余 {{ formatTime(download.timeRemaining) }}</span> class="flex flex-wrap gap-3 text-sm text-slate-500 dark:text-slate-300"
>
<span class="flex items-center gap-2"
><i class="fas fa-tachometer-alt"></i
>{{ formatSpeed(download.speed) }}</span
>
<span v-if="download.timeRemaining"
>剩余 {{ formatTime(download.timeRemaining) }}</span
>
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"> <div
class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
>
<div class="flex justify-between py-1"> <div class="flex justify-between py-1">
<span class="text-slate-400">下载源</span> <span class="text-slate-400">下载源</span>
<span class="font-medium text-slate-900 dark:text-white">{{ download.source || 'APM Store' }}</span> <span class="font-medium text-slate-900 dark:text-white">{{
download.source || "APM Store"
}}</span>
</div> </div>
<div v-if="download.startTime" class="flex justify-between py-1"> <div v-if="download.startTime" class="flex justify-between py-1">
<span class="text-slate-400">开始时间</span> <span class="text-slate-400">开始时间</span>
@@ -76,40 +127,64 @@
<span class="text-slate-400">完成时间</span> <span class="text-slate-400">完成时间</span>
<span>{{ formatDate(download.endTime) }}</span> <span>{{ formatDate(download.endTime) }}</span>
</div> </div>
<div v-if="download.error" class="flex justify-between py-1 text-rose-500"> <div
v-if="download.error"
class="flex justify-between py-1 text-rose-500"
>
<span>错误信息</span> <span>错误信息</span>
<span class="text-right">{{ download.error }}</span> <span class="text-right">{{ download.error }}</span>
</div> </div>
</div> </div>
<div v-if="download.logs && download.logs.length" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"> <div
v-if="download.logs && download.logs.length"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="font-semibold text-slate-800 dark:text-slate-100">下载日志</span> <span class="font-semibold text-slate-800 dark:text-slate-100"
<button type="button" >下载日志</span
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300" class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300"
@click="copyLogs"> @click="copyLogs"
>
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
复制日志 复制日志
</button> </button>
</div> </div>
<div class="max-h-48 space-y-2 overflow-y-auto rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"> <div
<div v-for="(log, index) in download.logs" :key="index" class="flex gap-3"> class="max-h-48 space-y-2 overflow-y-auto rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
<span class="text-slate-400">{{ formatLogTime(log.time) }}</span> >
<div
v-for="(log, index) in download.logs"
:key="index"
class="flex gap-3"
>
<span class="text-slate-400">{{
formatLogTime(log.time)
}}</span>
<span>{{ log.message }}</span> <span>{{ log.message }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-end gap-3"> <div class="flex flex-wrap justify-end gap-3">
<button v-if="download.status === 'failed'" type="button" <button
v-if="download.status === 'failed'"
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-500 transition hover:bg-rose-50" class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-500 transition hover:bg-rose-50"
@click="retry"> @click="retry"
>
<i class="fas fa-redo"></i> <i class="fas fa-redo"></i>
重试下载 重试下载
</button> </button>
<button v-if="download.status === 'completed'" type="button" <button
v-if="download.status === 'completed'"
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" 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"
@click="openApp"> @click="openApp"
>
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
打开应用 打开应用
</button> </button>
@@ -121,7 +196,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DownloadItem } from '../global/typedefinition'; import type { DownloadItem } from "../global/typedefinition";
const props = defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
@@ -129,17 +204,16 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'pause', download: DownloadItem): void; (e: "pause", download: DownloadItem): void;
(e: 'resume', download: DownloadItem): void; (e: "resume", download: DownloadItem): void;
(e: 'cancel', download: DownloadItem): void; (e: "cancel", download: DownloadItem): void;
(e: 'retry', download: DownloadItem): void; (e: "retry", download: DownloadItem): void;
(e: 'open-app', download: string): void; (e: "open-app", download: string): void;
}>(); }>();
const close = () => { const close = () => {
emit('close'); emit("close");
}; };
const handleOverlayClick = () => { const handleOverlayClick = () => {
@@ -148,38 +222,38 @@ const handleOverlayClick = () => {
const retry = () => { const retry = () => {
if (props.download) { if (props.download) {
emit('retry', props.download); emit("retry", props.download);
} }
}; };
const openApp = () => { const openApp = () => {
if (props.download) { if (props.download) {
emit('open-app', props.download.pkgname); emit("open-app", props.download.pkgname);
} }
}; };
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
'pending': '等待中', pending: "等待中",
'downloading': '下载中', downloading: "下载中",
'installing': '安装中', installing: "安装中",
'completed': '已完成', completed: "已完成",
'failed': '失败', failed: "失败",
'paused': '已暂停', paused: "已暂停",
'cancelled': '已取消' cancelled: "已取消",
}; };
return statusMap[status] || status; return statusMap[status] || status;
}; };
const formatSize = (bytes: number) => { const formatSize = (bytes: number) => {
if (!bytes) return '0 B'; if (!bytes) return "0 B";
const units = ['B', 'KB', 'MB', 'GB']; const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; return (bytes / Math.pow(1024, i)).toFixed(2) + " " + units[i];
}; };
const formatSpeed = (bytesPerSecond: number) => { const formatSpeed = (bytesPerSecond: number) => {
return formatSize(bytesPerSecond) + '/s'; return formatSize(bytesPerSecond) + "/s";
}; };
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -190,23 +264,26 @@ const formatTime = (seconds: number) => {
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleString('zh-CN'); return date.toLocaleString("zh-CN");
}; };
const formatLogTime = (timestamp: number) => { const formatLogTime = (timestamp: number) => {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleTimeString('zh-CN'); return date.toLocaleTimeString("zh-CN");
}; };
const copyLogs = () => { const copyLogs = () => {
if (!props.download?.logs) return; if (!props.download?.logs) return;
const logsText = props.download.logs const logsText = props.download.logs
.map(log => `[${formatLogTime(log.time)}] ${log.message}`) .map((log) => `[${formatLogTime(log.time)}] ${log.message}`)
.join('\n'); .join("\n");
navigator.clipboard?.writeText(logsText).then(() => { navigator.clipboard
alert('日志已复制到剪贴板'); ?.writeText(logsText)
}).catch(() => { .then(() => {
prompt('请手动复制日志:', logsText); alert("日志已复制到剪贴板");
})
.catch(() => {
prompt("请手动复制日志:", logsText);
}); });
}; };
</script> </script>

View File

@@ -1,65 +1,121 @@
<template> <template>
<div <div
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white/95 shadow-2xl backdrop-blur dark:border-slate-800/70 dark:bg-slate-900/90 sm:left-auto sm:right-6 sm:w-96"> class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white/95 shadow-2xl backdrop-blur dark:border-slate-800/70 dark:bg-slate-900/90 sm:left-auto sm:right-6 sm:w-96"
<div class="flex items-center justify-between px-5 py-4" @click="toggleExpand"> >
<div class="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200"> <div
class="flex items-center justify-between px-5 py-4"
@click="toggleExpand"
>
<div
class="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200"
>
<i class="fas fa-download text-brand"></i> <i class="fas fa-download text-brand"></i>
<span>下载队列</span> <span>下载队列</span>
<span v-if="downloads.length" <span
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"> v-if="downloads.length"
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>
({{ activeDownloads }}/{{ downloads.length }}) ({{ activeDownloads }}/{{ downloads.length }})
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button v-if="downloads.length" type="button" <button
v-if="downloads.length"
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400"
title="清除已完成" @click.stop="clearCompleted"> title="清除已完成"
@click.stop="clearCompleted"
>
<i class="fas fa-broom"></i> <i class="fas fa-broom"></i>
</button> </button>
<button type="button" <button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400"
@click.stop="toggleExpand"> @click.stop="toggleExpand"
<i class="fas" :class="isExpanded ? 'fa-chevron-down' : 'fa-chevron-up'"></i> >
<i
class="fas"
:class="isExpanded ? 'fa-chevron-down' : 'fa-chevron-up'"
></i>
</button> </button>
</div> </div>
</div> </div>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 -translate-y-2" <Transition
enter-to-class="opacity-100 translate-y-0" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 -translate-y-2"> enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-show="isExpanded" class="max-h-96 overflow-y-auto px-3 pb-4"> <div v-show="isExpanded" class="max-h-96 overflow-y-auto px-3 pb-4">
<div v-if="downloads.length === 0" <div
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"> v-if="downloads.length === 0"
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
<i class="fas fa-inbox text-3xl"></i> <i class="fas fa-inbox text-3xl"></i>
<p class="mt-3 text-sm">暂无下载任务</p> <p class="mt-3 text-sm">暂无下载任务</p>
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div v-for="download in downloads" :key="download.id" <div
v-for="download in downloads"
:key="download.id"
class="flex cursor-pointer items-center gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-3 shadow-sm transition hover:border-brand/40 hover:shadow-lg dark:border-slate-800/70 dark:bg-slate-900" class="flex cursor-pointer items-center gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-3 shadow-sm transition hover:border-brand/40 hover:shadow-lg dark:border-slate-800/70 dark:bg-slate-900"
:class="download.status === 'failed' ? 'border-rose-300/70 dark:border-rose-500/40' : ''" :class="
@click="showDownloadDetail(download)"> download.status === 'failed'
<div class="h-12 w-12 overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"> ? 'border-rose-300/70 dark:border-rose-500/40'
<img :src="download.icon" :alt="download.name" class="h-full w-full object-cover" /> : ''
"
@click="showDownloadDetail(download)"
>
<div
class="h-12 w-12 overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
>
<img
:src="download.icon"
:alt="download.name"
class="h-full w-full object-cover"
/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="truncate text-sm font-semibold text-slate-800 dark:text-slate-100">{{ download.name }}</p> <p
class="truncate text-sm font-semibold text-slate-800 dark:text-slate-100"
>
{{ download.name }}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400"> <p class="text-xs text-slate-500 dark:text-slate-400">
<span v-if="download.status === 'downloading'">下载中 {{ download.progress }}%</span> <span v-if="download.status === 'downloading'"
<span v-else-if="download.status === 'installing'">安装中...</span> >下载中 {{ download.progress }}%</span
>
<span v-else-if="download.status === 'installing'"
>安装中...</span
>
<span v-else-if="download.status === 'completed'">已完成</span> <span v-else-if="download.status === 'completed'">已完成</span>
<span v-else-if="download.status === 'failed'">失败: {{ download.error }}</span> <span v-else-if="download.status === 'failed'"
>失败: {{ download.error }}</span
>
<span v-else-if="download.status === 'paused'">已暂停</span> <span v-else-if="download.status === 'paused'">已暂停</span>
<span v-else>等待中...</span> <span v-else>等待中...</span>
</p> </p>
<div v-if="download.status === 'downloading'" <div
class="mt-2 h-1.5 rounded-full bg-slate-100 dark:bg-slate-800"> v-if="download.status === 'downloading'"
<div class="h-full rounded-full bg-brand" :style="{ width: `${download.progress}%` }"></div> class="mt-2 h-1.5 rounded-full bg-slate-100 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-brand"
:style="{ width: `${download.progress}%` }"
></div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button v-if="download.status === 'failed'" type="button" <button
v-if="download.status === 'failed'"
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/60 text-rose-500 transition hover:bg-rose-50" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/60 text-rose-500 transition hover:bg-rose-50"
title="重试" @click.stop="retryDownload(download)"> title="重试"
@click.stop="retryDownload(download)"
>
<i class="fas fa-redo"></i> <i class="fas fa-redo"></i>
</button> </button>
</div> </div>
@@ -71,28 +127,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from "vue";
import type { DownloadItem } from '../global/typedefinition'; import type { DownloadItem } from "../global/typedefinition";
const props = defineProps<{ const props = defineProps<{
downloads: DownloadItem[]; downloads: DownloadItem[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'pause', download: DownloadItem): void; (e: "pause", download: DownloadItem): void;
(e: 'resume', download: DownloadItem): void; (e: "resume", download: DownloadItem): void;
(e: 'cancel', download: DownloadItem): void; (e: "cancel", download: DownloadItem): void;
(e: 'retry', download: DownloadItem): void; (e: "retry", download: DownloadItem): void;
(e: 'clear-completed'): void; (e: "clear-completed"): void;
(e: 'show-detail', download: DownloadItem): void; (e: "show-detail", download: DownloadItem): void;
}>(); }>();
const isExpanded = ref(false); const isExpanded = ref(false);
const activeDownloads = computed(() => { const activeDownloads = computed(() => {
return props.downloads.filter(d => return props.downloads.filter(
d.status === 'downloading' || d.status === 'installing' (d) => d.status === "downloading" || d.status === "installing",
).length; ).length;
}); });
@@ -101,14 +156,14 @@ const toggleExpand = () => {
}; };
const retryDownload = (download: DownloadItem) => { const retryDownload = (download: DownloadItem) => {
emit('retry', download); emit("retry", download);
}; };
const clearCompleted = () => { const clearCompleted = () => {
emit('clear-completed'); emit("clear-completed");
}; };
const showDownloadDetail = (download: DownloadItem) => { const showDownloadDetail = (download: DownloadItem) => {
emit('show-detail', download); emit("show-detail", download);
}; };
</script> </script>

View File

@@ -1,67 +1,108 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 scale-95" <Transition
enter-to-class="opacity-100 scale-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> enter-from-class="opacity-0 scale-95"
<div v-if="show" enter-to-class="opacity-100 scale-100"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"> leave-active-class="duration-150 ease-in"
<div class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"> leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
>
<div
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">已安装应用</p> <p class="text-2xl font-semibold text-slate-900 dark:text-white">
<p class="text-sm text-slate-500 dark:text-slate-400">来自本机 APM 安装列表</p> 已安装应用
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
来自本机 APM 安装列表
</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="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 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200" 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"
:disabled="loading" @click="$emit('refresh')"> :disabled="loading"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
刷新 刷新
</button> </button>
<button type="button" <button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="$emit('close')" aria-label="关闭"> @click="$emit('close')"
aria-label="关闭"
>
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
<div v-if="loading" <div
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"> v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在读取已安装应用 正在读取已安装应用
</div> </div>
<div v-else-if="error" <div
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"> v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }} {{ error }}
</div> </div>
<div v-else-if="apps.length === 0" <div
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"> 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>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div v-for="app in apps" :key="app.pkgname" <div
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"> v-for="app in apps"
: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"
>
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="text-base font-semibold text-slate-900 dark:text-white">{{ app.pkgname }}</p> <p
<span v-if="app.flags && app.flags.includes('automatic')" class="text-base font-semibold text-slate-900 dark:text-white"
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"> >
{{ 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> </span>
</div> </div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400"> <div
class="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400"
>
<span>{{ app.version }}</span> <span>{{ app.version }}</span>
<span>·</span> <span>·</span>
<span>{{ app.arch }}</span> <span>{{ app.arch }}</span>
<template v-if="app.flags && !app.flags.includes('automatic')"> <template
v-if="app.flags && !app.flags.includes('automatic')"
>
<span>·</span> <span>·</span>
<span>{{ app.flags }}</span> <span>{{ app.flags }}</span>
</template> </template>
</div> </div>
</div> </div>
<button type="button" <button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50" class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50"
:disabled="app.currentStatus === 'not-installed'" @click="$emit('uninstall', app)"> :disabled="app.currentStatus === 'not-installed'"
@click="$emit('uninstall', app)"
>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
卸载 卸载
</button> </button>
@@ -74,7 +115,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { App } from '../global/typedefinition'; import { App } from "../global/typedefinition";
defineProps<{ defineProps<{
show: boolean; show: boolean;
@@ -84,9 +125,8 @@ defineProps<{
}>(); }>();
defineEmits<{ defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'refresh'): void; (e: "refresh"): void;
(e: 'uninstall', app: App): void; (e: "uninstall", app: App): void;
}>(); }>();
</script> </script>

View File

@@ -1,34 +1,62 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0" <Transition
enter-to-class="opacity-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100" leave-to-class="opacity-0"> enter-from-class="opacity-0"
<div v-if="show" enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/80 px-4 py-10" class="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/80 px-4 py-10"
@click.self="closePreview"> @click.self="closePreview"
>
<div class="relative w-full max-w-5xl"> <div class="relative w-full max-w-5xl">
<img :src="currentScreenshot" alt="应用截图预览" <img
class="max-h-[80vh] w-full rounded-3xl border border-slate-200/40 bg-black/40 object-contain shadow-2xl dark:border-slate-700" /> :src="currentScreenshot"
<div class="absolute inset-x-0 top-4 flex items-center justify-between px-6"> alt="应用截图预览"
class="max-h-[80vh] w-full rounded-3xl border border-slate-200/40 bg-black/40 object-contain shadow-2xl dark:border-slate-700"
/>
<div
class="absolute inset-x-0 top-4 flex items-center justify-between px-6"
>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="button" <button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
@click="prevScreen" :disabled="currentScreenIndex === 0" aria-label="上一张"> @click="prevScreen"
:disabled="currentScreenIndex === 0"
aria-label="上一张"
>
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
<button type="button" <button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
@click="nextScreen" :disabled="currentScreenIndex === screenshots.length - 1" aria-label="下一张"> @click="nextScreen"
:disabled="currentScreenIndex === screenshots.length - 1"
aria-label="下一张"
>
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</button> </button>
</div> </div>
<button type="button" <button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white" class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/80 text-slate-700 shadow-lg transition hover:bg-white"
@click="closePreview" aria-label="关闭"> @click="closePreview"
aria-label="关闭"
>
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<div class="absolute inset-x-0 bottom-6 flex items-center justify-center"> <div
<span class="rounded-full bg-black/60 px-4 py-1 text-sm font-medium text-white">{{ previewCounterText }}</span> class="absolute inset-x-0 bottom-6 flex items-center justify-center"
>
<span
class="rounded-full bg-black/60 px-4 py-1 text-sm font-medium text-white"
>{{ previewCounterText }}</span
>
</div> </div>
</div> </div>
</div> </div>
@@ -36,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
@@ -45,13 +73,13 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'prev'): void; (e: "prev"): void;
(e: 'next'): void; (e: "next"): void;
}>(); }>();
const currentScreenshot = computed(() => { const currentScreenshot = computed(() => {
return props.screenshots[props.currentScreenIndex] || ''; return props.screenshots[props.currentScreenIndex] || "";
}); });
const previewCounterText = computed(() => { const previewCounterText = computed(() => {
@@ -59,14 +87,14 @@ const previewCounterText = computed(() => {
}); });
const closePreview = () => { const closePreview = () => {
emit('close'); emit("close");
}; };
const prevScreen = () => { const prevScreen = () => {
emit('prev'); emit("prev");
}; };
const nextScreen = () => { const nextScreen = () => {
emit('next'); emit("next");
}; };
</script> </script>

View File

@@ -1,13 +1,26 @@
<template> <template>
<button type="button" <button
type="button"
class="flex items-center justify-between rounded-2xl border border-slate-200/80 bg-white/70 px-4 py-3 text-sm font-medium text-slate-600 shadow-sm transition hover:border-brand/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-300" class="flex items-center justify-between rounded-2xl border border-slate-200/80 bg-white/70 px-4 py-3 text-sm font-medium text-slate-600 shadow-sm transition hover:border-brand/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-300"
:aria-pressed="isDark" @click="toggle"> :aria-pressed="isDark"
@click="toggle"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<i class="fas" :class="isDark ? 'fa-moon text-amber-200' : 'fa-sun text-amber-400'"></i> <i
<span>{{ isDark ? '深色主题' : '浅色主题' }}</span> class="fas"
:class="isDark ? 'fa-moon text-amber-200' : 'fa-sun text-amber-400'"
></i>
<span>{{ isDark ? "深色主题" : "浅色主题" }}</span>
</span> </span>
<span class="relative inline-flex h-6 w-12 items-center rounded-full bg-slate-300/80 transition dark:bg-slate-700"> <span
<span :class="['inline-block h-4 w-4 rounded-full bg-white shadow transition', isDark ? 'translate-x-6' : 'translate-x-1']"></span> class="relative inline-flex h-6 w-12 items-center rounded-full bg-slate-300/80 transition dark:bg-slate-700"
>
<span
:class="[
'inline-block h-4 w-4 rounded-full bg-white shadow transition',
isDark ? 'translate-x-6' : 'translate-x-1',
]"
></span>
</span> </span>
</button> </button>
</template> </template>
@@ -18,10 +31,10 @@ defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle'): void; (e: "toggle"): void;
}>(); }>();
const toggle = () => { const toggle = () => {
emit('toggle'); emit("toggle");
}; };
</script> </script>

View File

@@ -1,14 +1,20 @@
<template> <template>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<button type="button" <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"
@click="handleUpdate" title="启动 apm-update-tool"> @click="handleUpdate"
title="启动 apm-update-tool"
>
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
<span>软件更新</span> <span>软件更新</span>
</button> </button>
<button type="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" 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"> @click="handleList"
title="启动 apm-installer --list"
>
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
<span>应用管理</span> <span>应用管理</span>
</button> </button>
@@ -17,15 +23,15 @@
<script setup lang="ts"> <script setup lang="ts">
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update'): void; (e: "update"): void;
(e: 'list'): void; (e: "list"): void;
}>(); }>();
const handleUpdate = () => { const handleUpdate = () => {
emit('update'); emit("update");
}; };
const handleList = () => { const handleList = () => {
emit('list'); emit("list");
}; };
</script> </script>

View File

@@ -1,64 +1,100 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 scale-95" <Transition
enter-to-class="opacity-100 scale-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> enter-from-class="opacity-0 scale-95"
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 p-4" enter-to-class="opacity-100 scale-100"
@click.self="handleClose"> leave-active-class="duration-150 ease-in"
<div class="relative w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"> leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 p-4"
@click.self="handleClose"
>
<div
class="relative w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="mb-6 flex items-center gap-4"> <div class="mb-6 flex items-center gap-4">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-rose-100 to-rose-50 shadow-inner dark:from-rose-900/30 dark:to-rose-800/20"> <div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-rose-100 to-rose-50 shadow-inner dark:from-rose-900/30 dark:to-rose-800/20"
>
<i class="fas fa-trash-alt text-2xl text-rose-500"></i> <i class="fas fa-trash-alt text-2xl text-rose-500"></i>
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white">卸载应用</h3> <h3 class="text-xl font-bold text-slate-900 dark:text-white">
卸载应用
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
您确定要卸载 <span class="font-semibold text-slate-700 dark:text-slate-200">{{ appName }}</span> 您确定要卸载
<span class="font-semibold text-slate-700 dark:text-slate-200">{{
appName
}}</span>
</p> </p>
<p class="text-xs text-slate-400 mt-1">{{ appPkg }}</p> <p class="text-xs text-slate-400 mt-1">{{ appPkg }}</p>
</div> </div>
</div> </div>
<!-- Terminal Output --> <!-- Terminal Output -->
<div v-if="uninstalling || completed" <div
class="mb-6 max-h-48 overflow-y-auto rounded-xl border border-slate-200/50 bg-slate-900 p-3 font-mono text-xs text-slate-300 shadow-inner scrollbar-muted dark:border-slate-700"> v-if="uninstalling || completed"
<div v-for="(line, index) in logs" :key="index" class="whitespace-pre-wrap break-all">{{ line }}</div> class="mb-6 max-h-48 overflow-y-auto rounded-xl border border-slate-200/50 bg-slate-900 p-3 font-mono text-xs text-slate-300 shadow-inner scrollbar-muted dark:border-slate-700"
>
<div
v-for="(line, index) in logs"
:key="index"
class="whitespace-pre-wrap break-all"
>
{{ line }}
</div>
<div ref="logEnd"></div> <div ref="logEnd"></div>
</div> </div>
<div v-if="error" class="mb-4 rounded-xl border border-rose-200/50 bg-rose-50 p-3 text-sm text-rose-600 dark:border-rose-900/30 dark:bg-rose-900/20 dark:text-rose-400"> <div
v-if="error"
class="mb-4 rounded-xl border border-rose-200/50 bg-rose-50 p-3 text-sm text-rose-600 dark:border-rose-900/30 dark:bg-rose-900/20 dark:text-rose-400"
>
{{ error }} {{ error }}
</div> </div>
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3">
<button v-if="!uninstalling" type="button" <button
v-if="!uninstalling"
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="handleClose"> @click="handleClose"
>
取消 取消
</button> </button>
<button v-if="!uninstalling && !completed" type="button" <button
v-if="!uninstalling && !completed"
type="button"
class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5" class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5"
@click="confirmUninstall"> @click="confirmUninstall"
>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
确认卸载 确认卸载
</button> </button>
<button v-if="completed" type="button" <button
v-if="completed"
type="button"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="handleFinish"> @click="handleFinish"
>
完成 完成
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, nextTick, onUnmounted } from 'vue'; import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { App } from '../global/typedefinition'; import type { App } from "../global/typedefinition";
const props = defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
@@ -66,48 +102,48 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'success'): void; (e: "success"): void;
}>(); }>();
const uninstalling = ref(false); const uninstalling = ref(false);
const completed = ref(false); const completed = ref(false);
const logs = ref<string[]>([]); const logs = ref<string[]>([]);
const error = ref(''); const error = ref("");
const logEnd = ref<HTMLElement | null>(null); const logEnd = ref<HTMLElement | null>(null);
const appName = computed(() => props.app?.name || '未知应用'); const appName = computed(() => props.app?.name || "未知应用");
const appPkg = computed(() => props.app?.pkgname || ''); const appPkg = computed(() => props.app?.pkgname || "");
const handleClose = () => { const handleClose = () => {
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
reset(); reset();
emit('close'); emit("close");
}; };
const handleFinish = () => { const handleFinish = () => {
reset(); reset();
emit('success'); // Parent should refresh list emit("success"); // Parent should refresh list
emit('close'); emit("close");
}; };
const reset = () => { const reset = () => {
uninstalling.value = false; uninstalling.value = false;
completed.value = false; completed.value = false;
logs.value = []; logs.value = [];
error.value = ''; error.value = "";
}; };
const confirmUninstall = () => { const confirmUninstall = () => {
if (!appPkg.value) { if (!appPkg.value) {
error.value = '无效的包名'; error.value = "无效的包名";
return; return;
} }
uninstalling.value = true; uninstalling.value = true;
logs.value = ['正在请求卸载: ' + appPkg.value + '...']; logs.value = ["正在请求卸载: " + appPkg.value + "..."];
window.ipcRenderer.send('remove-installed', appPkg.value); window.ipcRenderer.send("remove-installed", appPkg.value);
}; };
// Listeners // Listeners
@@ -124,17 +160,23 @@ const onProgress = (_event: any, chunk: string) => {
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const onComplete = (_event: any, result: { success: boolean; message: any }) => { const onComplete = (
_event: any,
result: { success: boolean; message: any },
) => {
if (!uninstalling.value) return; // Ignore if not current session if (!uninstalling.value) return; // Ignore if not current session
const msgObj = typeof result.message === 'string' ? JSON.parse(result.message) : result.message; const msgObj =
typeof result.message === "string"
? JSON.parse(result.message)
: result.message;
if (result.success) { if (result.success) {
logs.value.push('\n[完成] ' + (msgObj.message || '卸载成功')); logs.value.push("\n[完成] " + (msgObj.message || "卸载成功"));
completed.value = true; completed.value = true;
} else { } else {
logs.value.push('\n[错误] ' + (msgObj.message || '卸载失败')); logs.value.push("\n[错误] " + (msgObj.message || "卸载失败"));
error.value = msgObj.message || '卸载失败'; error.value = msgObj.message || "卸载失败";
// Allow trying again or closing? // Allow trying again or closing?
// We stay in "uninstalling" state visually or switch to completed=true but with error? // We stay in "uninstalling" state visually or switch to completed=true but with error?
// Let's set completed=true so user can click "Finish" (Close). // Let's set completed=true so user can click "Finish" (Close).
@@ -146,25 +188,27 @@ const onComplete = (_event: any, result: { success: boolean; message: any }) =>
const scrollToBottom = () => { const scrollToBottom = () => {
nextTick(() => { nextTick(() => {
if (logEnd.value) { if (logEnd.value) {
logEnd.value.scrollIntoView({ behavior: 'smooth' }); logEnd.value.scrollIntoView({ behavior: "smooth" });
} }
}); });
}; };
watch(() => props.show, (val) => { watch(
() => props.show,
(val) => {
if (val) { if (val) {
// specific setup if needed // specific setup if needed
window.ipcRenderer.on('remove-progress', onProgress); window.ipcRenderer.on("remove-progress", onProgress);
window.ipcRenderer.on('remove-complete', onComplete); window.ipcRenderer.on("remove-complete", onComplete);
} else { } else {
window.ipcRenderer.off('remove-progress', onProgress); window.ipcRenderer.off("remove-progress", onProgress);
window.ipcRenderer.off('remove-complete', onComplete); window.ipcRenderer.off("remove-complete", onComplete);
} }
}); },
);
onUnmounted(() => { onUnmounted(() => {
window.ipcRenderer.off('remove-progress', onProgress); window.ipcRenderer.off("remove-progress", onProgress);
window.ipcRenderer.off('remove-complete', onComplete); window.ipcRenderer.off("remove-complete", onComplete);
}); });
</script> </script>

View File

@@ -1,74 +1,118 @@
<template> <template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0 scale-95" <Transition
enter-to-class="opacity-100 scale-100" leave-active-class="duration-150 ease-in" enter-active-class="duration-200 ease-out"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> enter-from-class="opacity-0 scale-95"
<div v-if="show" enter-to-class="opacity-100 scale-100"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"> leave-active-class="duration-150 ease-in"
<div class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"> leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
>
<div
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="flex-1"> <div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">软件更新</p> <p class="text-2xl font-semibold text-slate-900 dark:text-white">
<p class="text-sm text-slate-500 dark:text-slate-400">可更新的 APM 应用</p> 软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
可更新的 APM 应用
</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button type="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 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200" 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"
:disabled="loading" @click="$emit('refresh')"> :disabled="loading"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
刷新 刷新
</button> </button>
<button type="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 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200" 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"
:disabled="loading || apps.length === 0" @click="$emit('toggle-all')"> :disabled="loading || apps.length === 0"
@click="$emit('toggle-all')"
>
<i class="fas fa-check-square"></i> <i class="fas fa-check-square"></i>
全选/全不选 全选/全不选
</button> </button>
<button type="button" <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 disabled:opacity-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 disabled:opacity-40"
:disabled="loading || !hasSelected" @click="$emit('upgrade-selected')"> :disabled="loading || !hasSelected"
@click="$emit('upgrade-selected')"
>
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
更新选中 更新选中
</button> </button>
<button type="button" <button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="$emit('close')" aria-label="关闭"> @click="$emit('close')"
aria-label="关闭"
>
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
<div v-if="loading" <div
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"> v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在检查可更新应用 正在检查可更新应用
</div> </div>
<div v-else-if="error" <div
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"> v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }} {{ error }}
</div> </div>
<div v-else-if="apps.length === 0" <div
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"> 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>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<label v-for="app in apps" :key="app.pkgname" <label
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:gap-4"> v-for="app in apps"
: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:gap-4"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<input type="checkbox" class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand" <input
v-model="app.selected" :disabled="app.upgrading" /> type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
v-model="app.selected"
:disabled="app.upgrading"
/>
<div> <div>
<p class="font-semibold text-slate-900 dark:text-white">{{ app.pkgname }}</p> <p class="font-semibold text-slate-900 dark:text-white">
{{ app.pkgname }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
当前 {{ app.currentVersion || '-' }} · 更新至 {{ app.newVersion || '-' }} 当前 {{ app.currentVersion || "-" }} · 更新至
{{ app.newVersion || "-" }}
</p> </p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 sm:ml-auto"> <div class="flex items-center gap-2 sm:ml-auto">
<button type="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 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200" 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"
:disabled="app.upgrading" @click.prevent="$emit('upgrade-one', app)"> :disabled="app.upgrading"
@click.prevent="$emit('upgrade-one', app)"
>
<i class="fas fa-arrow-up"></i> <i class="fas fa-arrow-up"></i>
{{ app.upgrading ? '更新中…' : '更新' }} {{ app.upgrading ? "更新中…" : "更新" }}
</button> </button>
</div> </div>
</label> </label>
@@ -80,7 +124,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UpdateAppItem } from '../global/typedefinition'; import type { UpdateAppItem } from "../global/typedefinition";
defineProps<{ defineProps<{
show: boolean; show: boolean;
@@ -92,11 +136,10 @@ defineProps<{
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void; (e: "close"): void;
(e: 'refresh'): void; (e: "refresh"): void;
(e: 'toggle-all'): void; (e: "toggle-all"): void;
(e: 'upgrade-selected'): void; (e: "upgrade-selected"): void;
(e: 'upgrade-one', app: UpdateAppItem): void; (e: "upgrade-one", app: UpdateAppItem): void;
}>(); }>();
</script> </script>

View File

@@ -1,7 +1,8 @@
import { ref } from "vue"; import { ref } from "vue";
import type { App } from "./typedefinition"; import type { App } from "./typedefinition";
export const APM_STORE_BASE_URL: string = import.meta.env.VITE_APM_STORE_BASE_URL || ''; export const APM_STORE_BASE_URL: string =
import.meta.env.VITE_APM_STORE_BASE_URL || "";
// 下面的变量用于存储当前应用的信息,其实用在多个组件中 // 下面的变量用于存储当前应用的信息,其实用在多个组件中
export const currentApp = ref<App | null>(null); export const currentApp = ref<App | null>(null);

View File

@@ -11,7 +11,13 @@ export interface DownloadResult extends InstallLog {
exitCode: number | null; exitCode: number | null;
} }
export type DownloadItemStatus = 'downloading' | 'installing' | 'paused' | 'completed' | 'failed' | 'queued'; // 可根据实际状态扩展 export type DownloadItemStatus =
| "downloading"
| "installing"
| "paused"
| "completed"
| "failed"
| "queued"; // 可根据实际状态扩展
export interface DownloadItem { export interface DownloadItem {
id: number; id: number;
@@ -90,7 +96,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"
currentStatus: 'not-installed' | 'installed'; // Current installation status currentStatus: "not-installed" | "installed"; // Current installation status
} }
export interface UpdateAppItem { export interface UpdateAppItem {
@@ -101,7 +107,6 @@ export interface UpdateAppItem {
upgrading?: boolean; upgrading?: boolean;
} }
/**************Below are type from main process ********************/ /**************Below are type from main process ********************/
export interface InstalledAppInfo { export interface InstalledAppInfo {
pkgname: string; pkgname: string;

View File

@@ -1,15 +1,15 @@
import { createApp } from 'vue' import { createApp } from "vue";
import App from './App.vue' import App from "./App.vue";
import './3rdparty/fontawesome-free-6.7.2/css/all.min.css' import "./3rdparty/fontawesome-free-6.7.2/css/all.min.css";
import './assets/css/appstyle.css' import "./assets/css/appstyle.css";
// import './demos/ipc' // import './demos/ipc'
// If you want use Node.js, the`nodeIntegration` needs to be enabled in the Main process. // If you want use Node.js, the`nodeIntegration` needs to be enabled in the Main process.
// import './demos/node' // import './demos/node'
createApp(App) createApp(App)
.mount('#app') .mount("#app")
.$nextTick(() => { .$nextTick(() => {
postMessage({ payload: 'removeLoading' }, '*') postMessage({ payload: "removeLoading" }, "*");
}) });

View File

@@ -1,21 +1,27 @@
// window.ipcRenderer.on('main-process-message', (_event, ...args) => { // window.ipcRenderer.on('main-process-message', (_event, ...args) => {
// console.log('[Receive Main-process message]:', ...args) // console.log('[Receive Main-process message]:', ...args)
// }) // })
import pino from 'pino'; import pino from "pino";
import { currentApp, currentAppIsInstalled } from "../global/storeConfig"; import { currentApp, currentAppIsInstalled } from "../global/storeConfig";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { downloads } from "../global/downloadStatus"; import { downloads } from "../global/downloadStatus";
import { InstallLog, DownloadItem, DownloadResult, App, DownloadItemStatus } from '../global/typedefinition'; import {
InstallLog,
DownloadItem,
DownloadResult,
App,
DownloadItemStatus,
} from "../global/typedefinition";
let downloadIdCounter = 0; let downloadIdCounter = 0;
const logger = pino({ name: 'processInstall.ts' }); const logger = pino({ name: "processInstall.ts" });
export const handleInstall = () => { export const handleInstall = () => {
if (!currentApp.value?.pkgname) return; if (!currentApp.value?.pkgname) return;
if (downloads.value.find(d => d.pkgname === currentApp.value?.pkgname)) { if (downloads.value.find((d) => d.pkgname === currentApp.value?.pkgname)) {
logger.info(`任务已存在,忽略重复添加: ${currentApp.value.pkgname}`); logger.info(`任务已存在,忽略重复添加: ${currentApp.value.pkgname}`);
return; return;
} }
@@ -28,24 +34,22 @@ export const handleInstall = () => {
pkgname: currentApp.value.pkgname, pkgname: currentApp.value.pkgname,
version: currentApp.value.version, version: currentApp.value.version,
icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${currentApp.value.category}/${currentApp.value.pkgname}/icon.png`, icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${currentApp.value.category}/${currentApp.value.pkgname}/icon.png`,
status: 'queued', status: "queued",
progress: 0, progress: 0,
downloadedSize: 0, downloadedSize: 0,
totalSize: 0, totalSize: 0,
speed: 0, speed: 0,
timeRemaining: 0, timeRemaining: 0,
startTime: Date.now(), startTime: Date.now(),
logs: [ logs: [{ time: Date.now(), message: "开始下载..." }],
{ time: Date.now(), message: '开始下载...' } source: "APM Store",
], retry: false,
source: 'APM Store',
retry: false
}; };
downloads.value.push(download); downloads.value.push(download);
// Send to main process to start download // Send to main process to start download
window.ipcRenderer.send('queue-install', JSON.stringify(download)); window.ipcRenderer.send("queue-install", JSON.stringify(download));
// const encodedPkg = encodeURIComponent(currentApp.value.Pkgname); // const encodedPkg = encodeURIComponent(currentApp.value.Pkgname);
// openApmStoreUrl(`apmstore://install?pkg=${encodedPkg}`, { // openApmStoreUrl(`apmstore://install?pkg=${encodedPkg}`, {
@@ -57,13 +61,13 @@ export const handleRetry = (download_: DownloadItem) => {
if (!download_?.pkgname) return; if (!download_?.pkgname) return;
download_.retry = true; download_.retry = true;
// Send to main process to start download // Send to main process to start download
window.ipcRenderer.send('queue-install', JSON.stringify(download_)); window.ipcRenderer.send("queue-install", JSON.stringify(download_));
}; };
export const handleUpgrade = (app: App) => { export const handleUpgrade = (app: App) => {
if (!app.pkgname) return; if (!app.pkgname) return;
if (downloads.value.find(d => d.pkgname === app.pkgname)) { if (downloads.value.find((d) => d.pkgname === app.pkgname)) {
logger.info(`任务已存在,忽略重复添加: ${app.pkgname}`); logger.info(`任务已存在,忽略重复添加: ${app.pkgname}`);
return; return;
} }
@@ -75,58 +79,57 @@ export const handleUpgrade = (app: App) => {
pkgname: app.pkgname, pkgname: app.pkgname,
version: app.version, version: app.version,
icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${app.category}/${app.pkgname}/icon.png`, icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${app.category}/${app.pkgname}/icon.png`,
status: 'queued', status: "queued",
progress: 0, progress: 0,
downloadedSize: 0, downloadedSize: 0,
totalSize: 0, totalSize: 0,
speed: 0, speed: 0,
timeRemaining: 0, timeRemaining: 0,
startTime: Date.now(), startTime: Date.now(),
logs: [ logs: [{ time: Date.now(), message: "开始更新..." }],
{ time: Date.now(), message: '开始更新...' } source: "APM Update",
],
source: 'APM Update',
retry: false, retry: false,
upgradeOnly: true upgradeOnly: true,
}; };
downloads.value.push(download); downloads.value.push(download);
window.ipcRenderer.send('queue-install', JSON.stringify(download)); window.ipcRenderer.send("queue-install", JSON.stringify(download));
}; };
export const handleRemove = () => { export const handleRemove = () => {
if (!currentApp.value?.pkgname) return; if (!currentApp.value?.pkgname) return;
window.ipcRenderer.send('remove-installed', currentApp.value.pkgname); window.ipcRenderer.send("remove-installed", currentApp.value.pkgname);
} };
window.ipcRenderer.on('remove-complete', (_event, log: DownloadResult) => { window.ipcRenderer.on("remove-complete", (_event, log: DownloadResult) => {
if (log.success) { if (log.success) {
currentAppIsInstalled.value = false; currentAppIsInstalled.value = false;
} else { } else {
currentAppIsInstalled.value = true; currentAppIsInstalled.value = true;
console.error('卸载失败:', log.message); console.error("卸载失败:", log.message);
} }
}); });
window.ipcRenderer.on('install-status', (_event, log: InstallLog) => { window.ipcRenderer.on("install-status", (_event, log: InstallLog) => {
const downloadObj = downloads.value.find(d => d.id === log.id); const downloadObj = downloads.value.find((d) => d.id === log.id);
if (downloadObj) downloadObj.status = log.message as DownloadItemStatus; if (downloadObj) downloadObj.status = log.message as DownloadItemStatus;
}); });
window.ipcRenderer.on('install-log', (_event, log: InstallLog) => { window.ipcRenderer.on("install-log", (_event, log: InstallLog) => {
const downloadObj = downloads.value.find(d => d.id === log.id); const downloadObj = downloads.value.find((d) => d.id === log.id);
if(downloadObj) downloadObj.logs.push({ if (downloadObj)
downloadObj.logs.push({
time: log.time, time: log.time,
message: log.message message: log.message,
}); });
}); });
window.ipcRenderer.on('install-complete', (_event, log: DownloadResult) => { window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
const downloadObj = downloads.value.find(d => d.id === log.id); const downloadObj = downloads.value.find((d) => d.id === log.id);
if (downloadObj) { if (downloadObj) {
if (log.success) { if (log.success) {
downloadObj.status = 'completed'; downloadObj.status = "completed";
} else { } else {
downloadObj.status = 'failed'; downloadObj.status = "failed";
} }
} }
}); });

12
src/vite-env.d.ts vendored
View File

@@ -1,14 +1,14 @@
/* eslint-disable */ /* eslint-disable */
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' { declare module "*.vue" {
import type { DefineComponent } from 'vue' import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any> const component: DefineComponent<{}, {}, any>;
export default component export default component;
} }
interface Window { interface Window {
// expose in the `electron/preload/index.ts` // expose in the `electron/preload/index.ts`
ipcRenderer: import('electron').IpcRenderer ipcRenderer: import("electron").IpcRenderer;
apm_store: any apm_store: any;
} }