diff --git a/src/App.vue b/src/App.vue index 1bd1927a..170cd6ec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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('云端同步需要登录星火账号。')" + /> + + ([]); 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([]); +const restoreRequestGeneration = ref(0); +const installedSyncPromptShown = ref(false); const systemInfo = ref({ distro: "unknown" }); type PendingDownloadRecord = Omit< DownloadedAppRecord, @@ -540,6 +567,10 @@ const resolvedFavoriteItems = computed(() => ), ); +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 => { } }; -const syncInstalledAppsNow = () => { - logger.warn("已安装应用同步将在后续任务中启用"); +const refreshInstalledSyncCandidates = async (): Promise => { + await refreshFavoriteInstalledApps(); +}; + +const syncInstalledAppsToAccount = async (): Promise => { + 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 => { + 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 => { + 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(); }); // 设置键盘导航 diff --git a/src/__tests__/unit/AppListRestoreModal.test.ts b/src/__tests__/unit/AppListRestoreModal.test.ts new file mode 100644 index 00000000..dbe26fc5 --- /dev/null +++ b/src/__tests__/unit/AppListRestoreModal.test.ts @@ -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 => ({ + 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(), + }, + }); + + 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(); + }); +}); diff --git a/src/__tests__/unit/InstalledAppsModal.test.ts b/src/__tests__/unit/InstalledAppsModal.test.ts index 5236fd44..bbd5f136 100644 --- a/src/__tests__/unit/InstalledAppsModal.test.ts +++ b/src/__tests__/unit/InstalledAppsModal.test.ts @@ -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(); + }); }); diff --git a/src/__tests__/unit/appListSync.test.ts b/src/__tests__/unit/appListSync.test.ts new file mode 100644 index 00000000..988e438b --- /dev/null +++ b/src/__tests__/unit/appListSync.test.ts @@ -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 => ({ + 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", + ); + }); +}); diff --git a/src/components/AppListRestoreModal.vue b/src/components/AppListRestoreModal.vue new file mode 100644 index 00000000..3b577c90 --- /dev/null +++ b/src/components/AppListRestoreModal.vue @@ -0,0 +1,172 @@ + + + diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue index f4b8f763..280968e7 100644 --- a/src/components/InstalledAppsModal.vue +++ b/src/components/InstalledAppsModal.vue @@ -28,6 +28,23 @@

+ +
(); -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; diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts new file mode 100644 index 00000000..6e26f78a --- /dev/null +++ b/src/modules/appListSync.ts @@ -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, +): string => `${item.origin}:${item.pkgname}`;