mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
feat(sync): add installed app cloud sync
This commit is contained in:
+130
-2
@@ -170,12 +170,27 @@
|
|||||||
:store-filter="storeFilter"
|
:store-filter="storeFilter"
|
||||||
:spark-available="sparkAvailable"
|
:spark-available="sparkAvailable"
|
||||||
:apm-available="apmAvailable"
|
:apm-available="apmAvailable"
|
||||||
|
:logged-in="isLoggedIn"
|
||||||
|
:syncing="syncLoading"
|
||||||
@close="closeInstalledModal"
|
@close="closeInstalledModal"
|
||||||
@refresh="refreshInstalledApps"
|
@refresh="refreshInstalledApps"
|
||||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||||
@open-detail="openDetail"
|
@open-detail="openDetail"
|
||||||
@uninstall="uninstallInstalledApp"
|
@uninstall="uninstallInstalledApp"
|
||||||
@switch-origin="handleSwitchOrigin"
|
@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
|
<UpdateCenterModal
|
||||||
@@ -249,6 +264,7 @@ 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 AppListRestoreModal from "./components/AppListRestoreModal.vue";
|
||||||
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
|
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
|
||||||
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
|
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
|
||||||
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
|
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
|
||||||
@@ -291,10 +307,12 @@ import {
|
|||||||
bulkDeleteFavoriteItems,
|
bulkDeleteFavoriteItems,
|
||||||
createFavoriteFolder,
|
createFavoriteFolder,
|
||||||
exchangeFlarumToken,
|
exchangeFlarumToken,
|
||||||
|
fetchSyncedAppList,
|
||||||
listDownloadedApps,
|
listDownloadedApps,
|
||||||
listFavoriteFolders,
|
listFavoriteFolders,
|
||||||
listFavoriteItems,
|
listFavoriteItems,
|
||||||
recordDownloadedApp,
|
recordDownloadedApp,
|
||||||
|
uploadSyncedAppList,
|
||||||
} from "./modules/backendApi";
|
} from "./modules/backendApi";
|
||||||
import { requestFlarumToken } from "./modules/flarumAuth";
|
import { requestFlarumToken } from "./modules/flarumAuth";
|
||||||
import {
|
import {
|
||||||
@@ -318,6 +336,7 @@ import {
|
|||||||
parsePackageArch,
|
parsePackageArch,
|
||||||
} from "./modules/appIdentity";
|
} from "./modules/appIdentity";
|
||||||
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||||
|
import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
@@ -337,6 +356,7 @@ import type {
|
|||||||
ResolvedFavoriteItem,
|
ResolvedFavoriteItem,
|
||||||
SystemInfo,
|
SystemInfo,
|
||||||
DownloadedAppRecord,
|
DownloadedAppRecord,
|
||||||
|
SyncedAppListItem,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -420,6 +440,13 @@ const downloadedApps = ref<DownloadedAppRecord[]>([]);
|
|||||||
const downloadedLoading = ref(false);
|
const downloadedLoading = ref(false);
|
||||||
const downloadedError = ref("");
|
const downloadedError = ref("");
|
||||||
const downloadedRequestGeneration = ref(0);
|
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" });
|
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||||
type PendingDownloadRecord = Omit<
|
type PendingDownloadRecord = Omit<
|
||||||
DownloadedAppRecord,
|
DownloadedAppRecord,
|
||||||
@@ -540,6 +567,10 @@ const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const installedCloudKeys = computed(
|
||||||
|
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||||
|
);
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -1418,6 +1449,14 @@ const clearDownloadedState = () => {
|
|||||||
downloadedError.value = "";
|
downloadedError.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearRestoreState = () => {
|
||||||
|
restoreRequestGeneration.value += 1;
|
||||||
|
restoreItems.value = [];
|
||||||
|
restoreLoading.value = false;
|
||||||
|
restoreError.value = "";
|
||||||
|
showRestoreModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
||||||
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
||||||
|
|
||||||
@@ -1428,11 +1467,16 @@ const isCurrentDownloadedRequest = (
|
|||||||
downloadedRequestGeneration.value === generation &&
|
downloadedRequestGeneration.value === generation &&
|
||||||
currentUser.value?.id === userId;
|
currentUser.value?.id === userId;
|
||||||
|
|
||||||
|
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
|
||||||
|
restoreRequestGeneration.value === generation &&
|
||||||
|
currentUser.value?.id === userId;
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
pendingDownloadRecords.clear();
|
pendingDownloadRecords.clear();
|
||||||
clearFavoriteState();
|
clearFavoriteState();
|
||||||
clearDownloadedState();
|
clearDownloadedState();
|
||||||
|
clearRestoreState();
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
@@ -1485,8 +1529,91 @@ const loadDownloadedHistory = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncInstalledAppsNow = () => {
|
const refreshInstalledSyncCandidates = async (): Promise<void> => {
|
||||||
logger.warn("已安装应用同步将在后续任务中启用");
|
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 () => {
|
const openUserManagement = async () => {
|
||||||
@@ -1954,6 +2081,7 @@ onMounted(async () => {
|
|||||||
]).then(() => {
|
]).then(() => {
|
||||||
// 所有数据加载完成后的回调(可选)
|
// 所有数据加载完成后的回调(可选)
|
||||||
logger.info("所有应用数据加载完成");
|
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",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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
|
<div
|
||||||
v-if="showOriginSwitcher"
|
v-if="showOriginSwitcher"
|
||||||
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
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";
|
storeFilter: "spark" | "apm" | "both";
|
||||||
sparkAvailable: boolean;
|
sparkAvailable: boolean;
|
||||||
apmAvailable: boolean;
|
apmAvailable: boolean;
|
||||||
|
loggedIn: boolean;
|
||||||
|
syncing: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "refresh"): void;
|
(e: "refresh"): void;
|
||||||
(e: "uninstall", app: App): void;
|
(e: "uninstall", app: App): void;
|
||||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
(e: "switch-origin", origin: "apm" | "spark"): void;
|
||||||
(e: "open-app", app: App): void;
|
(e: "open-app", app: App): void;
|
||||||
(e: "open-detail", 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 onOverlayWheel = (e: WheelEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
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