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 @@
+
+
+
+
+
+
+
+ 从账号恢复
+
+
+ 选择云端已同步的应用加入安装队列
+
+
+
+
+
+
+
+ 正在读取云端应用列表…
+
+
+ {{ error }}
+
+
+ 云端暂无已同步应用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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}`;