mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
fix(account): polish sidebar favorites and sync feedback
This commit is contained in:
+30
-3
@@ -69,6 +69,8 @@
|
|||||||
:sync-enabled="installedSyncEnabled ?? false"
|
:sync-enabled="installedSyncEnabled ?? false"
|
||||||
:loading="downloadedLoading"
|
:loading="downloadedLoading"
|
||||||
:error="downloadedError"
|
:error="downloadedError"
|
||||||
|
:syncing="syncLoading"
|
||||||
|
:sync-message="syncStatusMessage"
|
||||||
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
|
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
|
||||||
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
|
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
|
||||||
@toggle-sync="setInstalledSyncEnabled"
|
@toggle-sync="setInstalledSyncEnabled"
|
||||||
@@ -86,6 +88,7 @@
|
|||||||
@create-folder="createFavoriteFolderFromPrompt"
|
@create-folder="createFavoriteFolderFromPrompt"
|
||||||
@remove-selected="removeSelectedFavorites"
|
@remove-selected="removeSelectedFavorites"
|
||||||
@install-selected="installResolvedFavorites"
|
@install-selected="installResolvedFavorites"
|
||||||
|
@open-detail="openDetail"
|
||||||
/>
|
/>
|
||||||
<template v-else-if="activeTab === 'home'">
|
<template v-else-if="activeTab === 'home'">
|
||||||
<HomeView
|
<HomeView
|
||||||
@@ -187,6 +190,7 @@
|
|||||||
:error="restoreError"
|
:error="restoreError"
|
||||||
:items="restoreItems"
|
:items="restoreItems"
|
||||||
:installed-keys="installedCloudKeys"
|
:installed-keys="installedCloudKeys"
|
||||||
|
:installed-package-keys="installedCloudPackageKeys"
|
||||||
@close="showRestoreModal = false"
|
@close="showRestoreModal = false"
|
||||||
@install-selected="installCloudItems"
|
@install-selected="installCloudItems"
|
||||||
/>
|
/>
|
||||||
@@ -244,6 +248,7 @@
|
|||||||
:folders="favoriteFolders"
|
:folders="favoriteFolders"
|
||||||
@close="showFavoriteSelector = false"
|
@close="showFavoriteSelector = false"
|
||||||
@select-folder="addCurrentFavoriteToFolder"
|
@select-folder="addCurrentFavoriteToFolder"
|
||||||
|
@create-folder="createFavoriteFolderFromSelector"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -338,6 +343,7 @@ import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
|||||||
import {
|
import {
|
||||||
buildSyncItems,
|
buildSyncItems,
|
||||||
cloudItemKey,
|
cloudItemKey,
|
||||||
|
cloudPackageKey,
|
||||||
mergeInstalledApps,
|
mergeInstalledApps,
|
||||||
} from "./modules/appListSync";
|
} from "./modules/appListSync";
|
||||||
import type {
|
import type {
|
||||||
@@ -444,6 +450,7 @@ const downloadedLoading = ref(false);
|
|||||||
const downloadedError = ref("");
|
const downloadedError = ref("");
|
||||||
const downloadedRequestGeneration = ref(0);
|
const downloadedRequestGeneration = ref(0);
|
||||||
const syncLoading = ref(false);
|
const syncLoading = ref(false);
|
||||||
|
const syncStatusMessage = ref("");
|
||||||
const syncRequestGeneration = ref(0);
|
const syncRequestGeneration = ref(0);
|
||||||
const syncCandidateApps = ref<App[]>([]);
|
const syncCandidateApps = ref<App[]>([]);
|
||||||
const restoreLoading = ref(false);
|
const restoreLoading = ref(false);
|
||||||
@@ -576,6 +583,10 @@ const installedCloudKeys = computed(
|
|||||||
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const installedCloudPackageKeys = computed(
|
||||||
|
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
|
||||||
|
);
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -1459,6 +1470,13 @@ const clearRestoreState = () => {
|
|||||||
showRestoreModal.value = false;
|
showRestoreModal.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearInstalledSyncState = () => {
|
||||||
|
syncRequestGeneration.value += 1;
|
||||||
|
syncLoading.value = false;
|
||||||
|
syncStatusMessage.value = "";
|
||||||
|
syncCandidateApps.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
const nextFavoriteRequestGeneration = (): number => {
|
const nextFavoriteRequestGeneration = (): number => {
|
||||||
favoriteRequestGeneration.value += 1;
|
favoriteRequestGeneration.value += 1;
|
||||||
return favoriteRequestGeneration.value;
|
return favoriteRequestGeneration.value;
|
||||||
@@ -1489,9 +1507,7 @@ const handleLogout = () => {
|
|||||||
clearFavoriteState();
|
clearFavoriteState();
|
||||||
clearDownloadedState();
|
clearDownloadedState();
|
||||||
clearRestoreState();
|
clearRestoreState();
|
||||||
syncRequestGeneration.value += 1;
|
clearInstalledSyncState();
|
||||||
syncLoading.value = false;
|
|
||||||
syncCandidateApps.value = [];
|
|
||||||
loadInstalledSyncPreference(null);
|
loadInstalledSyncPreference(null);
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
@@ -1514,6 +1530,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
|||||||
flarumToken: flarumToken.token,
|
flarumToken: flarumToken.token,
|
||||||
});
|
});
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
|
clearInstalledSyncState();
|
||||||
loadInstalledSyncPreference(session.user.id);
|
loadInstalledSyncPreference(session.user.id);
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -1599,6 +1616,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
|||||||
const generation = syncRequestGeneration.value + 1;
|
const generation = syncRequestGeneration.value + 1;
|
||||||
syncRequestGeneration.value = generation;
|
syncRequestGeneration.value = generation;
|
||||||
syncLoading.value = true;
|
syncLoading.value = true;
|
||||||
|
syncStatusMessage.value = "";
|
||||||
try {
|
try {
|
||||||
const refreshed = await refreshInstalledSyncCandidates(
|
const refreshed = await refreshInstalledSyncCandidates(
|
||||||
() =>
|
() =>
|
||||||
@@ -1619,6 +1637,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
downloadedError.value = "";
|
downloadedError.value = "";
|
||||||
|
syncStatusMessage.value = "同步完成";
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
syncRequestGeneration.value !== generation ||
|
syncRequestGeneration.value !== generation ||
|
||||||
@@ -1627,6 +1646,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||||
|
syncStatusMessage.value = downloadedError.value;
|
||||||
} finally {
|
} finally {
|
||||||
if (
|
if (
|
||||||
syncRequestGeneration.value === generation &&
|
syncRequestGeneration.value === generation &&
|
||||||
@@ -1801,6 +1821,13 @@ const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFavoriteFolderFromSelector = async () => {
|
||||||
|
await createFavoriteFolderFromPrompt();
|
||||||
|
const app = favoriteTargetApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
showFavoriteSelector.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const openFavoriteManagement = async () => {
|
const openFavoriteManagement = async () => {
|
||||||
if (!requireLogin("请登录后查看我的收藏。")) return;
|
if (!requireLogin("请登录后查看我的收藏。")) return;
|
||||||
currentView.value = "favorites";
|
currentView.value = "favorites";
|
||||||
|
|||||||
@@ -678,12 +678,64 @@ describe("App account placeholders", () => {
|
|||||||
await fireEvent.click(
|
await fireEvent.click(
|
||||||
await screen.findByRole("button", { name: "立即同步" }),
|
await screen.findByRole("button", { name: "立即同步" }),
|
||||||
);
|
);
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "立即同步" }));
|
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
syncUpload.resolve(syncedList([]));
|
syncUpload.resolve(syncedList([]));
|
||||||
|
expect(await screen.findByText("同步完成")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears manual sync feedback before another user opens account management", async () => {
|
||||||
|
const syncUpload = createDeferred<SyncedAppList>();
|
||||||
|
vi.mocked(uploadSyncedAppList).mockReturnValue(syncUpload.promise);
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") return { success: true, apps: [] };
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "立即同步" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
syncUpload.resolve(syncedList([]));
|
||||||
|
expect(await screen.findByText("同步完成")).toBeTruthy();
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByText("退出登录")) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByText("退出登录"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "登录 / 注册" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
setSecondUserSession();
|
||||||
|
const secondUserButton = await screen.findByRole("button", {
|
||||||
|
name: /^Second User$/,
|
||||||
|
});
|
||||||
|
if (!screen.queryByText("用户管理")) {
|
||||||
|
await fireEvent.click(secondUserButton);
|
||||||
|
}
|
||||||
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
|
|
||||||
|
expect(screen.queryByText("同步完成")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not upload stale sync candidates after logout", async () => {
|
it("does not upload stale sync candidates after logout", async () => {
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ describe("AppListRestoreModal", () => {
|
|||||||
expect(screen.getByText("已安装")).toBeTruthy();
|
expect(screen.getByText("已安装")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats the same package installed from another source as installed", () => {
|
||||||
|
render(AppListRestoreModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
items: [createItem({ origin: "spark" })],
|
||||||
|
installedKeys: new Set(["apm:spark-notes"]),
|
||||||
|
installedPackageKeys: new Set(["spark-notes"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||||
|
expect(screen.getByText("已安装")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("removes selected items when they become installed", async () => {
|
it("removes selected items when they become installed", async () => {
|
||||||
const rendered = render(AppListRestoreModal, {
|
const rendered = render(AppListRestoreModal, {
|
||||||
props: {
|
props: {
|
||||||
@@ -72,4 +88,26 @@ describe("AppListRestoreModal", () => {
|
|||||||
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes selected items when the same package becomes installed from another source", async () => {
|
||||||
|
const rendered = render(AppListRestoreModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
items: [createItem({ origin: "spark" })],
|
||||||
|
installedKeys: new Set<string>(),
|
||||||
|
installedPackageKeys: new Set<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||||
|
await rendered.rerender({
|
||||||
|
installedPackageKeys: new Set(["spark-notes"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText("Spark Notes")).not.toBeChecked();
|
||||||
|
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,4 +45,39 @@ describe("AppSidebar account entry", () => {
|
|||||||
expect(screen.getByText("我的收藏")).toBeTruthy();
|
expect(screen.getByText("我的收藏")).toBeTruthy();
|
||||||
expect(screen.getByText("退出登录")).toBeTruthy();
|
expect(screen.getByText("退出登录")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps long account names inside the sidebar account entry", () => {
|
||||||
|
const longUser: SparkUser = {
|
||||||
|
...user,
|
||||||
|
displayName: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(AppSidebar, {
|
||||||
|
props: { ...baseProps, currentUser: longUser },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountButton = screen.getByRole("button", {
|
||||||
|
name: /SuperEndermanSM/,
|
||||||
|
});
|
||||||
|
const textWrapper = accountButton.querySelector(
|
||||||
|
"[data-testid='account-text']",
|
||||||
|
);
|
||||||
|
const accountName = screen.getByText(longUser.displayName);
|
||||||
|
|
||||||
|
expect(textWrapper?.className).toContain("min-w-0");
|
||||||
|
expect(accountName.className).toContain("truncate");
|
||||||
|
expect(container.textContent).toContain(longUser.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the quick menu after selecting an account action", async () => {
|
||||||
|
const rendered = render(AppSidebar, {
|
||||||
|
props: { ...baseProps, currentUser: user },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "用户管理" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("open-user-management")).toHaveLength(1);
|
||||||
|
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const categoryBarSource = readFileSync(
|
||||||
|
resolve(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../components/CategoryBar.vue",
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("CategoryBar", () => {
|
||||||
|
it("uses the requested blue for the selected category pill", () => {
|
||||||
|
expect(categoryBarSource.toLowerCase()).toContain("background: #2b7fff;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
|
import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
|
||||||
import type {
|
import type {
|
||||||
|
App,
|
||||||
FavoriteFolder,
|
FavoriteFolder,
|
||||||
ResolvedFavoriteItem,
|
ResolvedFavoriteItem,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
@@ -30,6 +31,26 @@ const item: ResolvedFavoriteItem = {
|
|||||||
selectedApp: null,
|
selectedApp: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedApp: App = {
|
||||||
|
name: "WPS",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "110M",
|
||||||
|
more: "Office suite",
|
||||||
|
tags: "office",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category: "office",
|
||||||
|
origin: "apm",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
};
|
||||||
|
|
||||||
describe("FavoriteFolderManager", () => {
|
describe("FavoriteFolderManager", () => {
|
||||||
it("shows downlisted favorites and emits bulk delete", async () => {
|
it("shows downlisted favorites and emits bulk delete", async () => {
|
||||||
const rendered = render(FavoriteFolderManager, {
|
const rendered = render(FavoriteFolderManager, {
|
||||||
@@ -48,4 +69,23 @@ describe("FavoriteFolderManager", () => {
|
|||||||
|
|
||||||
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a favorite item's app detail from the row content", async () => {
|
||||||
|
const rendered = render(FavoriteFolderManager, {
|
||||||
|
props: {
|
||||||
|
folders: [folder],
|
||||||
|
activeFolderId: 1,
|
||||||
|
items: [{ ...item, status: "installable", selectedApp }],
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "打开 WPS 详情" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
|
||||||
|
expect(rendered.emitted("remove-selected")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { render } from "@testing-library/vue";
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import FavoriteFolderSelector from "@/components/FavoriteFolderSelector.vue";
|
import FavoriteFolderSelector from "@/components/FavoriteFolderSelector.vue";
|
||||||
|
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const defaultFolder: FavoriteFolder = {
|
||||||
|
id: 1,
|
||||||
|
name: "默认收藏夹",
|
||||||
|
itemCount: 1,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
describe("FavoriteFolderSelector", () => {
|
describe("FavoriteFolderSelector", () => {
|
||||||
it("renders above the app detail modal and its child popups", () => {
|
it("renders above the app detail modal and its child popups", () => {
|
||||||
@@ -16,4 +25,30 @@ describe("FavoriteFolderSelector", () => {
|
|||||||
|
|
||||||
expect(overlay?.className).toContain("z-[90]");
|
expect(overlay?.className).toContain("z-[90]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not duplicate the default folder returned by the backend", () => {
|
||||||
|
render(FavoriteFolderSelector, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
folders: [defaultFolder],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("button", { name: "默认收藏夹" })).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers creating a folder while selecting favorites", async () => {
|
||||||
|
const rendered = render(FavoriteFolderSelector, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
folders: [defaultFolder],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("create-folder")).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
|
|||||||
import LoginModal from "@/components/LoginModal.vue";
|
import LoginModal from "@/components/LoginModal.vue";
|
||||||
|
|
||||||
describe("LoginModal", () => {
|
describe("LoginModal", () => {
|
||||||
|
it("does not show the old password forwarding note", () => {
|
||||||
|
render(LoginModal, {
|
||||||
|
props: { show: true, loading: false, error: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText(/密码仅直接/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits login credentials and register request", async () => {
|
it("emits login credentials and register request", async () => {
|
||||||
const rendered = render(LoginModal, {
|
const rendered = render(LoginModal, {
|
||||||
props: { show: true, loading: false, error: "" },
|
props: { show: true, loading: false, error: "" },
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ describe("UserManagementView", () => {
|
|||||||
syncEnabled: true,
|
syncEnabled: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,4 +47,24 @@ describe("UserManagementView", () => {
|
|||||||
expect(screen.getByText("WPS")).toBeTruthy();
|
expect(screen.getByText("WPS")).toBeTruthy();
|
||||||
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
|
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows manual sync progress and result feedback", async () => {
|
||||||
|
const { rerender } = render(UserManagementView, {
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
downloadedApps: [],
|
||||||
|
syncEnabled: false,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
syncing: true,
|
||||||
|
syncMessage: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
||||||
|
|
||||||
|
await rerender({ syncing: false, syncMessage: "同步完成" });
|
||||||
|
|
||||||
|
expect(screen.getByText("同步完成")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildSyncItems,
|
buildSyncItems,
|
||||||
cloudItemKey,
|
cloudItemKey,
|
||||||
|
cloudPackageKey,
|
||||||
mergeInstalledApps,
|
mergeInstalledApps,
|
||||||
} from "@/modules/appListSync";
|
} from "@/modules/appListSync";
|
||||||
import type { App } from "@/global/typedefinition";
|
import type { App } from "@/global/typedefinition";
|
||||||
@@ -76,6 +77,10 @@ describe("appListSync", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds origin-agnostic package keys for cross-source restore detection", () => {
|
||||||
|
expect(cloudPackageKey({ pkgname: "amber-ce" })).toBe("amber-ce");
|
||||||
|
});
|
||||||
|
|
||||||
it("merges refreshed apps without mutating active modal origin lists", () => {
|
it("merges refreshed apps without mutating active modal origin lists", () => {
|
||||||
const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
|
const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
|
||||||
const refreshed = [
|
const refreshed = [
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
import type { SyncedAppListItem } from "@/global/typedefinition";
|
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||||
import { cloudItemKey } from "@/modules/appListSync";
|
import { cloudItemKey, cloudPackageKey } from "@/modules/appListSync";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -137,6 +137,7 @@ const props = defineProps<{
|
|||||||
error: string;
|
error: string;
|
||||||
items: SyncedAppListItem[];
|
items: SyncedAppListItem[];
|
||||||
installedKeys: Set<string>;
|
installedKeys: Set<string>;
|
||||||
|
installedPackageKeys?: Set<string>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -147,7 +148,8 @@ const emit = defineEmits<{
|
|||||||
const selectedKeys = ref<Set<string>>(new Set());
|
const selectedKeys = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
const isInstalled = (item: SyncedAppListItem): boolean =>
|
const isInstalled = (item: SyncedAppListItem): boolean =>
|
||||||
props.installedKeys.has(cloudItemKey(item));
|
props.installedKeys.has(cloudItemKey(item)) ||
|
||||||
|
Boolean(props.installedPackageKeys?.has(cloudPackageKey(item)));
|
||||||
|
|
||||||
const selectedItems = computed(() =>
|
const selectedItems = computed(() =>
|
||||||
props.items.filter(
|
props.items.filter(
|
||||||
@@ -157,7 +159,12 @@ const selectedItems = computed(() =>
|
|||||||
|
|
||||||
const pruneSelectedKeys = (): void => {
|
const pruneSelectedKeys = (): void => {
|
||||||
selectedKeys.value = new Set(
|
selectedKeys.value = new Set(
|
||||||
[...selectedKeys.value].filter((key) => !props.installedKeys.has(key)),
|
[...selectedKeys.value].filter((key) => {
|
||||||
|
const item = props.items.find(
|
||||||
|
(candidate) => cloudItemKey(candidate) === key,
|
||||||
|
);
|
||||||
|
return item ? !isInstalled(item) : !props.installedKeys.has(key);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,7 +186,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.installedKeys,
|
() => [props.installedKeys, props.installedPackageKeys] as const,
|
||||||
() => {
|
() => {
|
||||||
pruneSelectedKeys();
|
pruneSelectedKeys();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,24 +20,24 @@
|
|||||||
:alt="accountLabel"
|
:alt="accountLabel"
|
||||||
class="h-11 w-11 rounded-2xl object-cover shadow-sm ring-1 ring-slate-900/5"
|
class="h-11 w-11 rounded-2xl object-cover shadow-sm ring-1 ring-slate-900/5"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col">
|
<div data-testid="account-text" class="flex min-w-0 flex-col">
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
|
class="truncate text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
|
||||||
>{{ currentUser ? currentUser.forumLevel : "Spark Store" }}</span
|
>{{ currentUser ? currentUser.forumLevel : "Spark Store" }}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-lg font-semibold text-slate-900 dark:text-white"
|
class="truncate text-lg font-semibold text-slate-900 dark:text-white"
|
||||||
>{{ accountLabel }}</span
|
>{{ accountLabel }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<AccountQuickMenu
|
<AccountQuickMenu
|
||||||
v-if="currentUser && showAccountMenu"
|
v-if="currentUser && showAccountMenu"
|
||||||
@open-user-management="emit('open-user-management')"
|
@open-user-management="emitAccountAction('open-user-management')"
|
||||||
@open-favorites="emit('open-favorites')"
|
@open-favorites="emitAccountAction('open-favorites')"
|
||||||
@open-forum="emit('open-forum')"
|
@open-forum="emitAccountAction('open-forum')"
|
||||||
@edit-profile="emit('edit-profile')"
|
@edit-profile="emitAccountAction('edit-profile')"
|
||||||
@logout="emit('logout')"
|
@logout="emitAccountAction('logout')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -177,6 +177,22 @@ const handleAccountClick = () => {
|
|||||||
showAccountMenu.value = !showAccountMenu.value;
|
showAccountMenu.value = !showAccountMenu.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emitAccountAction = (
|
||||||
|
action:
|
||||||
|
| "open-user-management"
|
||||||
|
| "open-favorites"
|
||||||
|
| "open-forum"
|
||||||
|
| "edit-profile"
|
||||||
|
| "logout",
|
||||||
|
) => {
|
||||||
|
showAccountMenu.value = false;
|
||||||
|
if (action === "open-user-management") emit("open-user-management");
|
||||||
|
else if (action === "open-favorites") emit("open-favorites");
|
||||||
|
else if (action === "open-forum") emit("open-forum");
|
||||||
|
else if (action === "edit-profile") emit("edit-profile");
|
||||||
|
else emit("logout");
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
emit("toggle-theme");
|
emit("toggle-theme");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
@click="selectCategory('all')"
|
@click="selectCategory('all')"
|
||||||
>
|
>
|
||||||
<span>全部</span>
|
<span>全部</span>
|
||||||
<span v-if="totalCount > 0" class="category-pill-count">{{ totalCount }}</span>
|
<span v-if="totalCount > 0" class="category-pill-count">{{
|
||||||
|
totalCount
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="(category, key) in categories"
|
v-for="(category, key) in categories"
|
||||||
@@ -19,7 +21,9 @@
|
|||||||
@click="selectCategory(key)"
|
@click="selectCategory(key)"
|
||||||
>
|
>
|
||||||
<span>{{ category.zh }}</span>
|
<span>{{ category.zh }}</span>
|
||||||
<span v-if="categoryCounts[key]" class="category-pill-count">{{ categoryCounts[key] }}</span>
|
<span v-if="categoryCounts[key]" class="category-pill-count">{{
|
||||||
|
categoryCounts[key]
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,22 +105,22 @@ const selectCategory = (category: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-pill-active {
|
.category-pill-active {
|
||||||
background: #0071e3;
|
background: #2b7fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-pill-active:hover {
|
.category-pill-active:hover {
|
||||||
background: #0066cc;
|
background: #2b7fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .category-pill-active {
|
.dark .category-pill-active {
|
||||||
background: #409cff;
|
background: #2b7fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .category-pill-active:hover {
|
.dark .category-pill-active:hover {
|
||||||
background: #0071e3;
|
background: #2b7fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
>
|
>
|
||||||
当前收藏夹暂无应用。
|
当前收藏夹暂无应用。
|
||||||
</div>
|
</div>
|
||||||
<label
|
<div
|
||||||
v-for="resolved in items"
|
v-for="resolved in items"
|
||||||
:key="resolved.item.id"
|
:key="resolved.item.id"
|
||||||
class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800/60"
|
class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800/60"
|
||||||
@@ -60,33 +60,45 @@
|
|||||||
:value="resolved.item.id"
|
:value="resolved.item.id"
|
||||||
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
|
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
:aria-label="`打开 ${resolved.item.name || resolved.item.pkgname} 详情`"
|
||||||
|
:disabled="!resolved.selectedApp"
|
||||||
|
@click="openFavoriteDetail(resolved)"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="resolved.item.iconUrl"
|
v-if="resolved.item.iconUrl"
|
||||||
:src="resolved.item.iconUrl"
|
:src="resolved.item.iconUrl"
|
||||||
alt=""
|
alt=""
|
||||||
class="h-10 w-10 rounded-xl object-cover"
|
class="h-10 w-10 rounded-xl object-cover"
|
||||||
/>
|
/>
|
||||||
<div
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-500 dark:bg-slate-800"
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-500 dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
|
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
|
||||||
</div>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<p class="truncate font-medium text-slate-900 dark:text-white">
|
<span
|
||||||
|
class="block truncate font-medium text-slate-900 dark:text-white"
|
||||||
|
>
|
||||||
{{ resolved.item.name || resolved.item.pkgname }}
|
{{ resolved.item.name || resolved.item.pkgname }}
|
||||||
</p>
|
</span>
|
||||||
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
<span
|
||||||
|
class="block truncate text-xs text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
|
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
:class="statusClass(resolved.status)"
|
:class="statusClass(resolved.status)"
|
||||||
>
|
>
|
||||||
{{ resolved.reason }}
|
{{ resolved.reason }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-3">
|
<div class="mt-6 flex flex-wrap gap-3">
|
||||||
@@ -121,6 +133,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import type {
|
import type {
|
||||||
FavoriteAvailabilityStatus,
|
FavoriteAvailabilityStatus,
|
||||||
|
App,
|
||||||
FavoriteFolder,
|
FavoriteFolder,
|
||||||
ResolvedFavoriteItem,
|
ResolvedFavoriteItem,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
@@ -138,6 +151,7 @@ const emit = defineEmits<{
|
|||||||
"create-folder": [];
|
"create-folder": [];
|
||||||
"remove-selected": [itemIds: number[]];
|
"remove-selected": [itemIds: number[]];
|
||||||
"install-selected": [items: ResolvedFavoriteItem[]];
|
"install-selected": [items: ResolvedFavoriteItem[]];
|
||||||
|
"open-detail": [app: App];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedIds = ref<number[]>([]);
|
const selectedIds = ref<number[]>([]);
|
||||||
@@ -163,6 +177,11 @@ const selectInstallable = () => {
|
|||||||
.map((item) => item.item.id);
|
.map((item) => item.item.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFavoriteDetail = (resolved: ResolvedFavoriteItem) => {
|
||||||
|
if (!resolved.selectedApp) return;
|
||||||
|
emit("open-detail", resolved.selectedApp);
|
||||||
|
};
|
||||||
|
|
||||||
const statusClass = (status: FavoriteAvailabilityStatus): string => {
|
const statusClass = (status: FavoriteAvailabilityStatus): string => {
|
||||||
if (status === "installable") {
|
if (status === "installable") {
|
||||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-5 space-y-2">
|
<div class="mt-5 space-y-2">
|
||||||
<button
|
<button
|
||||||
|
v-if="!hasDefaultFolder"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
@click="emit('select-folder', 'default')"
|
@click="emit('select-folder', 'default')"
|
||||||
@@ -34,6 +35,13 @@
|
|||||||
{{ folder.name }}
|
{{ folder.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 w-full rounded-xl border border-dashed border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-slate-700 dark:text-slate-300 dark:hover:border-blue-500 dark:hover:text-blue-300"
|
||||||
|
@click="emit('create-folder')"
|
||||||
|
>
|
||||||
|
新建收藏夹
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
|
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
|
||||||
@@ -46,15 +54,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
import type { FavoriteFolder } from "@/global/typedefinition";
|
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
folders: FavoriteFolder[];
|
folders: FavoriteFolder[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const hasDefaultFolder = computed(() =>
|
||||||
|
props.folders.some((folder) => folder.name === "默认收藏夹"),
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
"select-folder": [folderId: number | "default"];
|
"select-folder": [folderId: number | "default"];
|
||||||
|
"create-folder": [];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
登录星火账号
|
登录星火账号
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
|
||||||
class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
使用论坛账号登录。密码仅直接提交到星火论坛用于换取论坛令牌,不会发送给商店后端。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="mb-4 block">
|
<label class="mb-4 block">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
|
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
<img
|
<img
|
||||||
v-if="user.avatarUrl"
|
v-if="user.avatarUrl"
|
||||||
:src="user.avatarUrl"
|
:src="user.avatarUrl"
|
||||||
@@ -18,16 +18,16 @@
|
|||||||
>
|
>
|
||||||
{{ userInitial }}
|
{{ userInitial }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
用户管理
|
用户管理
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="mt-1 text-lg font-medium text-slate-800 dark:text-slate-100"
|
class="mt-1 truncate text-lg font-medium text-slate-800 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
{{ user.displayName }}
|
{{ user.displayName }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="truncate text-sm text-slate-500 dark:text-slate-400">
|
||||||
@{{ user.username }}
|
@{{ user.username }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
@@ -84,13 +84,18 @@
|
|||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
:disabled="syncing"
|
||||||
@click="emit('sync-now')"
|
@click="emit('sync-now')"
|
||||||
>
|
>
|
||||||
立即同步
|
{{ syncing ? "同步中..." : "立即同步" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="syncMessage" class="text-sm text-sky-600 dark:text-sky-300">
|
||||||
|
{{ syncMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -158,6 +163,8 @@ const props = defineProps<{
|
|||||||
syncEnabled: boolean;
|
syncEnabled: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
syncing?: boolean;
|
||||||
|
syncMessage?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const cloudItemKey = (
|
|||||||
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
||||||
): string => `${item.origin}:${item.pkgname}`;
|
): string => `${item.origin}:${item.pkgname}`;
|
||||||
|
|
||||||
|
export const cloudPackageKey = (
|
||||||
|
item: Pick<SyncedAppListItem, "pkgname">,
|
||||||
|
): string => item.pkgname;
|
||||||
|
|
||||||
export const mergeInstalledApps = (
|
export const mergeInstalledApps = (
|
||||||
currentApps: App[],
|
currentApps: App[],
|
||||||
refreshedApps: App[],
|
refreshedApps: App[],
|
||||||
|
|||||||
Reference in New Issue
Block a user