mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 06:03:49 +08:00
feat(sync): add installed app cloud sync
This commit is contained in:
+130
-2
@@ -170,12 +170,27 @@
|
||||
:store-filter="storeFilter"
|
||||
:spark-available="sparkAvailable"
|
||||
:apm-available="apmAvailable"
|
||||
:logged-in="isLoggedIn"
|
||||
:syncing="syncLoading"
|
||||
@close="closeInstalledModal"
|
||||
@refresh="refreshInstalledApps"
|
||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||
@open-detail="openDetail"
|
||||
@uninstall="uninstallInstalledApp"
|
||||
@switch-origin="handleSwitchOrigin"
|
||||
@sync-to-account="syncInstalledAppsToAccount"
|
||||
@restore-from-account="openRestoreFromAccount"
|
||||
@request-login="requireLogin('云端同步需要登录星火账号。')"
|
||||
/>
|
||||
|
||||
<AppListRestoreModal
|
||||
:show="showRestoreModal"
|
||||
:loading="restoreLoading"
|
||||
:error="restoreError"
|
||||
:items="restoreItems"
|
||||
:installed-keys="installedCloudKeys"
|
||||
@close="showRestoreModal = false"
|
||||
@install-selected="installCloudItems"
|
||||
/>
|
||||
|
||||
<UpdateCenterModal
|
||||
@@ -249,6 +264,7 @@ import ScreenPreview from "./components/ScreenPreview.vue";
|
||||
import DownloadQueue from "./components/DownloadQueue.vue";
|
||||
import DownloadDetail from "./components/DownloadDetail.vue";
|
||||
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
|
||||
import AppListRestoreModal from "./components/AppListRestoreModal.vue";
|
||||
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
|
||||
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
|
||||
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
|
||||
@@ -291,10 +307,12 @@ import {
|
||||
bulkDeleteFavoriteItems,
|
||||
createFavoriteFolder,
|
||||
exchangeFlarumToken,
|
||||
fetchSyncedAppList,
|
||||
listDownloadedApps,
|
||||
listFavoriteFolders,
|
||||
listFavoriteItems,
|
||||
recordDownloadedApp,
|
||||
uploadSyncedAppList,
|
||||
} from "./modules/backendApi";
|
||||
import { requestFlarumToken } from "./modules/flarumAuth";
|
||||
import {
|
||||
@@ -318,6 +336,7 @@ import {
|
||||
parsePackageArch,
|
||||
} from "./modules/appIdentity";
|
||||
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||
import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
|
||||
import type {
|
||||
App,
|
||||
AppJson,
|
||||
@@ -337,6 +356,7 @@ import type {
|
||||
ResolvedFavoriteItem,
|
||||
SystemInfo,
|
||||
DownloadedAppRecord,
|
||||
SyncedAppListItem,
|
||||
} from "./global/typedefinition";
|
||||
import type { Ref } from "vue";
|
||||
import type { IpcRendererEvent } from "electron";
|
||||
@@ -420,6 +440,13 @@ const downloadedApps = ref<DownloadedAppRecord[]>([]);
|
||||
const downloadedLoading = ref(false);
|
||||
const downloadedError = ref("");
|
||||
const downloadedRequestGeneration = ref(0);
|
||||
const syncLoading = ref(false);
|
||||
const restoreLoading = ref(false);
|
||||
const restoreError = ref("");
|
||||
const showRestoreModal = ref(false);
|
||||
const restoreItems = ref<SyncedAppListItem[]>([]);
|
||||
const restoreRequestGeneration = ref(0);
|
||||
const installedSyncPromptShown = ref(false);
|
||||
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||
type PendingDownloadRecord = Omit<
|
||||
DownloadedAppRecord,
|
||||
@@ -540,6 +567,10 @@ const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const installedCloudKeys = computed(
|
||||
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||
);
|
||||
|
||||
// 方法
|
||||
const syncThemePreference = () => {
|
||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||
@@ -1418,6 +1449,14 @@ const clearDownloadedState = () => {
|
||||
downloadedError.value = "";
|
||||
};
|
||||
|
||||
const clearRestoreState = () => {
|
||||
restoreRequestGeneration.value += 1;
|
||||
restoreItems.value = [];
|
||||
restoreLoading.value = false;
|
||||
restoreError.value = "";
|
||||
showRestoreModal.value = false;
|
||||
};
|
||||
|
||||
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
||||
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
||||
|
||||
@@ -1428,11 +1467,16 @@ const isCurrentDownloadedRequest = (
|
||||
downloadedRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId;
|
||||
|
||||
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
|
||||
restoreRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
pendingDownloadRecords.clear();
|
||||
clearFavoriteState();
|
||||
clearDownloadedState();
|
||||
clearRestoreState();
|
||||
showLoginModal.value = false;
|
||||
showLoginPrompt.value = false;
|
||||
isSidebarOpen.value = false;
|
||||
@@ -1485,8 +1529,91 @@ const loadDownloadedHistory = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const syncInstalledAppsNow = () => {
|
||||
logger.warn("已安装应用同步将在后续任务中启用");
|
||||
const refreshInstalledSyncCandidates = async (): Promise<void> => {
|
||||
await refreshFavoriteInstalledApps();
|
||||
};
|
||||
|
||||
const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||
syncLoading.value = true;
|
||||
try {
|
||||
await refreshInstalledSyncCandidates();
|
||||
const items = buildSyncItems(installedApps.value);
|
||||
await uploadSyncedAppList({
|
||||
clientArch: window.apm_store.arch || "amd64",
|
||||
distro: systemInfo.value.distro,
|
||||
items,
|
||||
});
|
||||
downloadedError.value = "";
|
||||
} catch (error: unknown) {
|
||||
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||
} finally {
|
||||
syncLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const syncInstalledAppsNow = (): void => {
|
||||
void syncInstalledAppsToAccount();
|
||||
};
|
||||
|
||||
const openRestoreFromAccount = async (): Promise<void> => {
|
||||
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||
const userId = currentUser.value?.id;
|
||||
if (userId === undefined) return;
|
||||
const generation = restoreRequestGeneration.value + 1;
|
||||
restoreRequestGeneration.value = generation;
|
||||
showRestoreModal.value = true;
|
||||
restoreLoading.value = true;
|
||||
restoreError.value = "";
|
||||
restoreItems.value = [];
|
||||
try {
|
||||
await refreshInstalledSyncCandidates();
|
||||
const result = await fetchSyncedAppList();
|
||||
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||
restoreItems.value = result?.items || [];
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
|
||||
} finally {
|
||||
if (isCurrentRestoreRequest(generation, userId)) {
|
||||
restoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const installCloudItems = (items: SyncedAppListItem[]): void => {
|
||||
for (const item of items) {
|
||||
const app = apps.value.find(
|
||||
(candidate) =>
|
||||
candidate.pkgname === item.pkgname &&
|
||||
candidate.origin === item.origin &&
|
||||
candidate.category === item.category,
|
||||
);
|
||||
if (!app) continue;
|
||||
void onDetailInstall(app);
|
||||
}
|
||||
showRestoreModal.value = false;
|
||||
};
|
||||
|
||||
const maybePromptInstalledSync = async (): Promise<void> => {
|
||||
if (
|
||||
import.meta.env.MODE === "test" ||
|
||||
!isLoggedIn.value ||
|
||||
installedSyncPromptShown.value ||
|
||||
installedSyncEnabled.value !== null
|
||||
) {
|
||||
if (isLoggedIn.value && installedSyncEnabled.value === true) {
|
||||
await syncInstalledAppsToAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
installedSyncPromptShown.value = true;
|
||||
const enabled = window.confirm(
|
||||
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
|
||||
);
|
||||
setInstalledSyncEnabled(enabled);
|
||||
if (enabled) await syncInstalledAppsToAccount();
|
||||
};
|
||||
|
||||
const openUserManagement = async () => {
|
||||
@@ -1954,6 +2081,7 @@ onMounted(async () => {
|
||||
]).then(() => {
|
||||
// 所有数据加载完成后的回调(可选)
|
||||
logger.info("所有应用数据加载完成");
|
||||
void maybePromptInstalledSync();
|
||||
});
|
||||
|
||||
// 设置键盘导航
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
|
||||
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||
|
||||
const createItem = (
|
||||
overrides: Partial<SyncedAppListItem> = {},
|
||||
): SyncedAppListItem => ({
|
||||
pkgname: "spark-notes",
|
||||
origin: "spark",
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "Spark Notes",
|
||||
iconUrl: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("AppListRestoreModal", () => {
|
||||
it("emits selected installable cloud items", async () => {
|
||||
const rendered = render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [
|
||||
createItem(),
|
||||
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
|
||||
],
|
||||
installedKeys: new Set<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||
|
||||
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
|
||||
expect.objectContaining({ pkgname: "spark-notes" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables already installed cloud items", () => {
|
||||
render(AppListRestoreModal, {
|
||||
props: {
|
||||
show: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
items: [createItem()],
|
||||
installedKeys: new Set(["spark:spark-notes"]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||
expect(screen.getByText("已安装")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,8 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
|
||||
});
|
||||
|
||||
it("requests login for cloud actions when logged out", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: false,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
|
||||
|
||||
expect(rendered.emitted("request-login")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("emits cloud sync and restore events when logged in", async () => {
|
||||
const rendered = render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: false,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
|
||||
|
||||
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
|
||||
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disables sync button while syncing", () => {
|
||||
render(InstalledAppsModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apps: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
activeOrigin: "spark",
|
||||
storeFilter: "both",
|
||||
sparkAvailable: true,
|
||||
apmAvailable: true,
|
||||
loggedIn: true,
|
||||
syncing: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSyncItems, cloudItemKey } from "@/modules/appListSync";
|
||||
import type { App } from "@/global/typedefinition";
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
name: "Spark Notes",
|
||||
pkgname: "spark-notes",
|
||||
version: "1.0.0",
|
||||
filename: "spark-notes_1.0.0_amd64.deb",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "1 MB",
|
||||
more: "",
|
||||
tags: "",
|
||||
img_urls: [],
|
||||
icons: "https://example.test/icon.png",
|
||||
category: "office",
|
||||
origin: "spark",
|
||||
currentStatus: "installed",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("appListSync", () => {
|
||||
it("builds cloud sync items for installed store-recognized user apps", () => {
|
||||
expect(buildSyncItems([createApp()])).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
origin: "spark",
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "Spark Notes",
|
||||
iconUrl: "https://example.test/icon.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out non-installed unknown dependency and unusable package entries", () => {
|
||||
const items = buildSyncItems([
|
||||
createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
|
||||
createApp({ pkgname: "unknown-app", category: "unknown" }),
|
||||
createApp({ pkgname: "dependency", isDependency: true }),
|
||||
createApp({ pkgname: "" }),
|
||||
createApp({ pkgname: "blank-origin", origin: "spark" }),
|
||||
createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
|
||||
]);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ pkgname: "blank-origin" }),
|
||||
expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
|
||||
const app = createApp({ icons: "", pkgname: "fallback-name" });
|
||||
app.name = "";
|
||||
const [syncItem] = buildSyncItems([app]);
|
||||
|
||||
expect(syncItem).toMatchObject({
|
||||
appName: "fallback-name",
|
||||
iconUrl: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds stable installed keys from origin and package", () => {
|
||||
expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
|
||||
"apm:amber-ce",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
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"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="flex w-full max-w-3xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||
>
|
||||
<div>
|
||||
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
从账号恢复
|
||||
</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
选择云端已同步的应用加入安装队列
|
||||
</p>
|
||||
</div>
|
||||
<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 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
aria-label="关闭"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div
|
||||
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
|
||||
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 }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||
>
|
||||
云端暂无已同步应用
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<label
|
||||
v-for="item in items"
|
||||
:key="cloudItemKey(item)"
|
||||
class="flex items-center 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"
|
||||
:class="isInstalled(item) ? 'opacity-60' : 'cursor-pointer'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
|
||||
:aria-label="item.appName || item.pkgname"
|
||||
:checked="selectedKeys.has(cloudItemKey(item))"
|
||||
:disabled="isInstalled(item)"
|
||||
@change="toggleSelection(item)"
|
||||
/>
|
||||
<img
|
||||
v-if="item.iconUrl"
|
||||
:src="item.iconUrl"
|
||||
class="h-10 w-10 rounded-xl object-contain"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800"
|
||||
>
|
||||
<i class="fas fa-cube"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-slate-900 dark:text-white">
|
||||
{{ item.appName || item.pkgname }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ item.origin }} · {{ item.category }} · {{ item.pkgname }} ·
|
||||
{{ item.version }} · {{ item.packageArch }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="isInstalled(item)"
|
||||
class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||||
>
|
||||
已安装
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 border-t border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@click="emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:opacity-40"
|
||||
:disabled="selectedItems.length === 0"
|
||||
@click="emit('install-selected', selectedItems)"
|
||||
>
|
||||
加入安装队列
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||
import { cloudItemKey } from "@/modules/appListSync";
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
items: SyncedAppListItem[];
|
||||
installedKeys: Set<string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "install-selected", items: SyncedAppListItem[]): void;
|
||||
}>();
|
||||
|
||||
const selectedKeys = ref<Set<string>>(new Set());
|
||||
|
||||
const isInstalled = (item: SyncedAppListItem): boolean =>
|
||||
props.installedKeys.has(cloudItemKey(item));
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
props.items.filter((item) => selectedKeys.value.has(cloudItemKey(item))),
|
||||
);
|
||||
|
||||
const toggleSelection = (item: SyncedAppListItem): void => {
|
||||
if (isInstalled(item)) return;
|
||||
const key = cloudItemKey(item);
|
||||
const next = new Set(selectedKeys.value);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
selectedKeys.value = next;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.show, props.items] as const,
|
||||
() => {
|
||||
selectedKeys.value = new Set();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
@@ -28,6 +28,23 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncClick"
|
||||
>
|
||||
<i class="fas fa-cloud-arrow-up"></i>
|
||||
{{ syncing ? "同步中" : "同步到账号" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@click="handleRestoreClick"
|
||||
>
|
||||
<i class="fas fa-cloud-arrow-down"></i>
|
||||
从账号恢复
|
||||
</button>
|
||||
<div
|
||||
v-if="showOriginSwitcher"
|
||||
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
||||
@@ -220,17 +237,30 @@ const props = defineProps<{
|
||||
storeFilter: "spark" | "apm" | "both";
|
||||
sparkAvailable: boolean;
|
||||
apmAvailable: boolean;
|
||||
loggedIn: boolean;
|
||||
syncing: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "refresh"): void;
|
||||
(e: "uninstall", app: App): void;
|
||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
||||
(e: "open-app", app: App): void;
|
||||
(e: "open-detail", app: App): void;
|
||||
(e: "sync-to-account"): void;
|
||||
(e: "restore-from-account"): void;
|
||||
(e: "request-login"): void;
|
||||
}>();
|
||||
|
||||
const handleSyncClick = () => {
|
||||
emit(props.loggedIn ? "sync-to-account" : "request-login");
|
||||
};
|
||||
|
||||
const handleRestoreClick = () => {
|
||||
emit(props.loggedIn ? "restore-from-account" : "request-login");
|
||||
};
|
||||
|
||||
const onOverlayWheel = (e: WheelEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { App, SyncedAppListItem } from "@/global/typedefinition";
|
||||
import { parsePackageArch } from "@/modules/appIdentity";
|
||||
|
||||
const hasUsablePackageIdentity = (app: App): boolean => {
|
||||
return app.pkgname.trim().length > 0 && Boolean(app.origin);
|
||||
};
|
||||
|
||||
export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
|
||||
return apps
|
||||
.filter(
|
||||
(app) =>
|
||||
app.currentStatus === "installed" &&
|
||||
app.category !== "unknown" &&
|
||||
!app.isDependency &&
|
||||
hasUsablePackageIdentity(app),
|
||||
)
|
||||
.map((app) => ({
|
||||
pkgname: app.pkgname,
|
||||
origin: app.origin,
|
||||
category: app.category,
|
||||
version: app.version,
|
||||
packageArch: app.arch || parsePackageArch(app.filename),
|
||||
appName: app.name || app.pkgname,
|
||||
iconUrl: app.icons || "",
|
||||
}));
|
||||
};
|
||||
|
||||
export const cloudItemKey = (
|
||||
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
||||
): string => `${item.origin}:${item.pkgname}`;
|
||||
Reference in New Issue
Block a user