fix(account): polish sidebar favorites and sync feedback

This commit is contained in:
2026-05-19 23:48:41 +08:00
parent 932e69fca7
commit 8d80a02316
18 changed files with 403 additions and 56 deletions
+30 -3
View File
@@ -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();
});
}); });
+19
View File
@@ -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);
});
}); });
+8
View File
@@ -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();
});
}); });
+5
View File
@@ -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 = [
+11 -4
View File
@@ -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();
}, },
+24 -8
View File
@@ -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");
}; };
+10 -6
View File
@@ -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;
} }
+29 -10
View File
@@ -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";
+15 -1
View File
@@ -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>
-5
View File
@@ -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">
+13 -6
View File
@@ -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<{
+4
View File
@@ -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[],