mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 06:03: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"
|
||||
:loading="downloadedLoading"
|
||||
:error="downloadedError"
|
||||
:syncing="syncLoading"
|
||||
:sync-message="syncStatusMessage"
|
||||
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
|
||||
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
|
||||
@toggle-sync="setInstalledSyncEnabled"
|
||||
@@ -86,6 +88,7 @@
|
||||
@create-folder="createFavoriteFolderFromPrompt"
|
||||
@remove-selected="removeSelectedFavorites"
|
||||
@install-selected="installResolvedFavorites"
|
||||
@open-detail="openDetail"
|
||||
/>
|
||||
<template v-else-if="activeTab === 'home'">
|
||||
<HomeView
|
||||
@@ -187,6 +190,7 @@
|
||||
:error="restoreError"
|
||||
:items="restoreItems"
|
||||
:installed-keys="installedCloudKeys"
|
||||
:installed-package-keys="installedCloudPackageKeys"
|
||||
@close="showRestoreModal = false"
|
||||
@install-selected="installCloudItems"
|
||||
/>
|
||||
@@ -244,6 +248,7 @@
|
||||
:folders="favoriteFolders"
|
||||
@close="showFavoriteSelector = false"
|
||||
@select-folder="addCurrentFavoriteToFolder"
|
||||
@create-folder="createFavoriteFolderFromSelector"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -338,6 +343,7 @@ import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||
import {
|
||||
buildSyncItems,
|
||||
cloudItemKey,
|
||||
cloudPackageKey,
|
||||
mergeInstalledApps,
|
||||
} from "./modules/appListSync";
|
||||
import type {
|
||||
@@ -444,6 +450,7 @@ const downloadedLoading = ref(false);
|
||||
const downloadedError = ref("");
|
||||
const downloadedRequestGeneration = ref(0);
|
||||
const syncLoading = ref(false);
|
||||
const syncStatusMessage = ref("");
|
||||
const syncRequestGeneration = ref(0);
|
||||
const syncCandidateApps = ref<App[]>([]);
|
||||
const restoreLoading = ref(false);
|
||||
@@ -576,6 +583,10 @@ const installedCloudKeys = computed(
|
||||
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||
);
|
||||
|
||||
const installedCloudPackageKeys = computed(
|
||||
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
|
||||
);
|
||||
|
||||
// 方法
|
||||
const syncThemePreference = () => {
|
||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||
@@ -1459,6 +1470,13 @@ const clearRestoreState = () => {
|
||||
showRestoreModal.value = false;
|
||||
};
|
||||
|
||||
const clearInstalledSyncState = () => {
|
||||
syncRequestGeneration.value += 1;
|
||||
syncLoading.value = false;
|
||||
syncStatusMessage.value = "";
|
||||
syncCandidateApps.value = [];
|
||||
};
|
||||
|
||||
const nextFavoriteRequestGeneration = (): number => {
|
||||
favoriteRequestGeneration.value += 1;
|
||||
return favoriteRequestGeneration.value;
|
||||
@@ -1489,9 +1507,7 @@ const handleLogout = () => {
|
||||
clearFavoriteState();
|
||||
clearDownloadedState();
|
||||
clearRestoreState();
|
||||
syncRequestGeneration.value += 1;
|
||||
syncLoading.value = false;
|
||||
syncCandidateApps.value = [];
|
||||
clearInstalledSyncState();
|
||||
loadInstalledSyncPreference(null);
|
||||
showLoginModal.value = false;
|
||||
showLoginPrompt.value = false;
|
||||
@@ -1514,6 +1530,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
||||
flarumToken: flarumToken.token,
|
||||
});
|
||||
setAuthSession(session);
|
||||
clearInstalledSyncState();
|
||||
loadInstalledSyncPreference(session.user.id);
|
||||
showLoginModal.value = false;
|
||||
} catch (error: unknown) {
|
||||
@@ -1599,6 +1616,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||
const generation = syncRequestGeneration.value + 1;
|
||||
syncRequestGeneration.value = generation;
|
||||
syncLoading.value = true;
|
||||
syncStatusMessage.value = "";
|
||||
try {
|
||||
const refreshed = await refreshInstalledSyncCandidates(
|
||||
() =>
|
||||
@@ -1619,6 +1637,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
downloadedError.value = "";
|
||||
syncStatusMessage.value = "同步完成";
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
syncRequestGeneration.value !== generation ||
|
||||
@@ -1627,6 +1646,7 @@ const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||
syncStatusMessage.value = downloadedError.value;
|
||||
} finally {
|
||||
if (
|
||||
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 () => {
|
||||
if (!requireLogin("请登录后查看我的收藏。")) return;
|
||||
currentView.value = "favorites";
|
||||
|
||||
@@ -678,12 +678,64 @@ describe("App account placeholders", () => {
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "立即同步" }),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "立即同步" }));
|
||||
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -55,6 +55,22 @@ describe("AppListRestoreModal", () => {
|
||||
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 () => {
|
||||
const rendered = render(AppListRestoreModal, {
|
||||
props: {
|
||||
@@ -72,4 +88,26 @@ describe("AppListRestoreModal", () => {
|
||||
expect(screen.getByLabelText("Spark Notes")).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();
|
||||
});
|
||||
|
||||
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 type {
|
||||
App,
|
||||
FavoriteFolder,
|
||||
ResolvedFavoriteItem,
|
||||
} from "@/global/typedefinition";
|
||||
@@ -30,6 +31,26 @@ const item: ResolvedFavoriteItem = {
|
||||
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", () => {
|
||||
it("shows downlisted favorites and emits bulk delete", async () => {
|
||||
const rendered = render(FavoriteFolderManager, {
|
||||
@@ -48,4 +69,23 @@ describe("FavoriteFolderManager", () => {
|
||||
|
||||
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 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", () => {
|
||||
it("renders above the app detail modal and its child popups", () => {
|
||||
@@ -16,4 +25,30 @@ describe("FavoriteFolderSelector", () => {
|
||||
|
||||
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";
|
||||
|
||||
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 () => {
|
||||
const rendered = render(LoginModal, {
|
||||
props: { show: true, loading: false, error: "" },
|
||||
|
||||
@@ -35,6 +35,8 @@ describe("UserManagementView", () => {
|
||||
syncEnabled: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
syncing: false,
|
||||
syncMessage: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,4 +47,24 @@ describe("UserManagementView", () => {
|
||||
expect(screen.getByText("WPS")).toBeTruthy();
|
||||
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 {
|
||||
buildSyncItems,
|
||||
cloudItemKey,
|
||||
cloudPackageKey,
|
||||
mergeInstalledApps,
|
||||
} from "@/modules/appListSync";
|
||||
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", () => {
|
||||
const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
|
||||
const refreshed = [
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||
import { cloudItemKey } from "@/modules/appListSync";
|
||||
import { cloudItemKey, cloudPackageKey } from "@/modules/appListSync";
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
@@ -137,6 +137,7 @@ const props = defineProps<{
|
||||
error: string;
|
||||
items: SyncedAppListItem[];
|
||||
installedKeys: Set<string>;
|
||||
installedPackageKeys?: Set<string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -147,7 +148,8 @@ const emit = defineEmits<{
|
||||
const selectedKeys = ref<Set<string>>(new Set());
|
||||
|
||||
const isInstalled = (item: SyncedAppListItem): boolean =>
|
||||
props.installedKeys.has(cloudItemKey(item));
|
||||
props.installedKeys.has(cloudItemKey(item)) ||
|
||||
Boolean(props.installedPackageKeys?.has(cloudPackageKey(item)));
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
props.items.filter(
|
||||
@@ -157,7 +159,12 @@ const selectedItems = computed(() =>
|
||||
|
||||
const pruneSelectedKeys = (): void => {
|
||||
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(
|
||||
() => props.installedKeys,
|
||||
() => [props.installedKeys, props.installedPackageKeys] as const,
|
||||
() => {
|
||||
pruneSelectedKeys();
|
||||
},
|
||||
|
||||
@@ -20,24 +20,24 @@
|
||||
:alt="accountLabel"
|
||||
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
|
||||
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
|
||||
>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<AccountQuickMenu
|
||||
v-if="currentUser && showAccountMenu"
|
||||
@open-user-management="emit('open-user-management')"
|
||||
@open-favorites="emit('open-favorites')"
|
||||
@open-forum="emit('open-forum')"
|
||||
@edit-profile="emit('edit-profile')"
|
||||
@logout="emit('logout')"
|
||||
@open-user-management="emitAccountAction('open-user-management')"
|
||||
@open-favorites="emitAccountAction('open-favorites')"
|
||||
@open-forum="emitAccountAction('open-forum')"
|
||||
@edit-profile="emitAccountAction('edit-profile')"
|
||||
@logout="emitAccountAction('logout')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -177,6 +177,22 @@ const handleAccountClick = () => {
|
||||
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 = () => {
|
||||
emit("toggle-theme");
|
||||
};
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
@click="selectCategory('all')"
|
||||
>
|
||||
<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
|
||||
v-for="(category, key) in categories"
|
||||
@@ -19,7 +21,9 @@
|
||||
@click="selectCategory(key)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,22 +105,22 @@ const selectCategory = (category: string) => {
|
||||
}
|
||||
|
||||
.category-pill-active {
|
||||
background: #0071e3;
|
||||
background: #2b7fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-pill-active:hover {
|
||||
background: #0066cc;
|
||||
background: #2b7fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark .category-pill-active {
|
||||
background: #409cff;
|
||||
background: #2b7fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark .category-pill-active:hover {
|
||||
background: #0071e3;
|
||||
background: #2b7fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
>
|
||||
当前收藏夹暂无应用。
|
||||
</div>
|
||||
<label
|
||||
<div
|
||||
v-for="resolved in items"
|
||||
: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"
|
||||
@@ -60,33 +60,45 @@
|
||||
:value="resolved.item.id"
|
||||
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
|
||||
/>
|
||||
<img
|
||||
v-if="resolved.item.iconUrl"
|
||||
:src="resolved.item.iconUrl"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-xl object-cover"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
<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)"
|
||||
>
|
||||
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-slate-900 dark:text-white">
|
||||
{{ resolved.item.name || resolved.item.pkgname }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
v-if="resolved.item.iconUrl"
|
||||
:src="resolved.item.iconUrl"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-xl object-cover"
|
||||
/>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span
|
||||
class="block truncate font-medium text-slate-900 dark:text-white"
|
||||
>
|
||||
{{ resolved.item.name || resolved.item.pkgname }}
|
||||
</span>
|
||||
<span
|
||||
class="block truncate text-xs text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||
:class="statusClass(resolved.status)"
|
||||
>
|
||||
{{ resolved.reason }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
@@ -121,6 +133,7 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type {
|
||||
FavoriteAvailabilityStatus,
|
||||
App,
|
||||
FavoriteFolder,
|
||||
ResolvedFavoriteItem,
|
||||
} from "@/global/typedefinition";
|
||||
@@ -138,6 +151,7 @@ const emit = defineEmits<{
|
||||
"create-folder": [];
|
||||
"remove-selected": [itemIds: number[]];
|
||||
"install-selected": [items: ResolvedFavoriteItem[]];
|
||||
"open-detail": [app: App];
|
||||
}>();
|
||||
|
||||
const selectedIds = ref<number[]>([]);
|
||||
@@ -163,6 +177,11 @@ const selectInstallable = () => {
|
||||
.map((item) => item.item.id);
|
||||
};
|
||||
|
||||
const openFavoriteDetail = (resolved: ResolvedFavoriteItem) => {
|
||||
if (!resolved.selectedApp) return;
|
||||
emit("open-detail", resolved.selectedApp);
|
||||
};
|
||||
|
||||
const statusClass = (status: FavoriteAvailabilityStatus): string => {
|
||||
if (status === "installable") {
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
</p>
|
||||
<div class="mt-5 space-y-2">
|
||||
<button
|
||||
v-if="!hasDefaultFolder"
|
||||
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"
|
||||
@click="emit('select-folder', 'default')"
|
||||
@@ -34,6 +35,13 @@
|
||||
{{ folder.name }}
|
||||
</button>
|
||||
</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
|
||||
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"
|
||||
@@ -46,15 +54,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
folders: FavoriteFolder[];
|
||||
}>();
|
||||
|
||||
const hasDefaultFolder = computed(() =>
|
||||
props.folders.some((folder) => folder.name === "默认收藏夹"),
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
"select-folder": [folderId: number | "default"];
|
||||
"create-folder": [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
登录星火账号
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
使用论坛账号登录。密码仅直接提交到星火论坛用于换取论坛令牌,不会发送给商店后端。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="mb-4 block">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div
|
||||
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
|
||||
v-if="user.avatarUrl"
|
||||
:src="user.avatarUrl"
|
||||
@@ -18,16 +18,16 @@
|
||||
>
|
||||
{{ userInitial }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
用户管理
|
||||
</h1>
|
||||
<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 }}
|
||||
</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 }}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
@@ -84,13 +84,18 @@
|
||||
</label>
|
||||
<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')"
|
||||
>
|
||||
立即同步
|
||||
{{ syncing ? "同步中..." : "立即同步" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="syncMessage" class="text-sm text-sky-600 dark:text-sky-300">
|
||||
{{ syncMessage }}
|
||||
</p>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -158,6 +163,8 @@ const props = defineProps<{
|
||||
syncEnabled: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
syncing?: boolean;
|
||||
syncMessage?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -29,6 +29,10 @@ export const cloudItemKey = (
|
||||
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
||||
): string => `${item.origin}:${item.pkgname}`;
|
||||
|
||||
export const cloudPackageKey = (
|
||||
item: Pick<SyncedAppListItem, "pkgname">,
|
||||
): string => item.pkgname;
|
||||
|
||||
export const mergeInstalledApps = (
|
||||
currentApps: App[],
|
||||
refreshedApps: App[],
|
||||
|
||||
Reference in New Issue
Block a user