feat(account): polish reviews favorites and account UI

This commit is contained in:
2026-05-29 21:34:42 +08:00
parent abeb511c06
commit 439af8c26f
40 changed files with 3158 additions and 250 deletions
+22
View File
@@ -249,6 +249,7 @@ async function createWindow() {
title: "星火应用商店",
width: 1366,
height: 768,
frame: false,
autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: {
@@ -307,6 +308,27 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
nativeTheme.themeSource = theme;
});
ipcMain.on("window-control-minimize", () => {
win?.minimize();
});
ipcMain.on("window-control-toggle-maximize", () => {
if (!win) {
return;
}
if (win.isMaximized()) {
win.unmaximize();
return;
}
win.maximize();
});
ipcMain.on("window-control-close", () => {
win?.close();
});
// 配置文件路径
const SPARK_CONFIG_DIR = path.join(
os.homedir(),
+12
View File
@@ -42,6 +42,12 @@ type IpcRendererFacade = {
invoke: typeof ipcRenderer.invoke;
};
type WindowControlBridge = {
minimize: () => void;
toggleMaximize: () => void;
close: () => void;
};
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
type UpdateCenterStartTask = {
taskKey: string;
@@ -91,6 +97,12 @@ contextBridge.exposeInMainWorld("apm_store", {
})(),
});
contextBridge.exposeInMainWorld("windowControls", {
minimize: () => ipcRenderer.send("window-control-minimize"),
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
close: () => ipcRenderer.send("window-control-close"),
} satisfies WindowControlBridge);
contextBridge.exposeInMainWorld("updateCenter", {
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open", storeFilter),
+393 -129
View File
@@ -1,116 +1,109 @@
<template>
<div
class="flex min-h-screen flex-col bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100 lg:flex-row"
class="flex min-h-screen flex-col bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100"
>
<!-- 移动端侧边栏遮罩 -->
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm lg:hidden"
@click="isSidebarOpen = false"
></div>
<WindowTitleBar />
<aside
class="fixed inset-y-0 left-0 z-50 w-64 shrink-0 transform border-r border-slate-200/70 bg-white/95 px-4 py-6 backdrop-blur transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-0 lg:flex lg:h-screen lg:translate-x-0 lg:flex-col lg:border-b-0"
:class="
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
"
>
<AppSidebar
:active-tab="activeTab"
:category-counts="categoryCounts"
:theme-mode="themeMode"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:store-filter="storeFilter"
:sidebar-entries="sidebarEntries"
:entry-counts="entryCounts"
:current-user="currentUser"
@toggle-theme="toggleTheme"
@select-tab="selectTab"
@close="isSidebarOpen = false"
@list="handleList"
@update="handleUpdate"
@request-login="showLoginModal = true"
@open-user-management="openUserManagement"
@open-favorites="openFavoriteManagement"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@logout="handleLogout"
/>
</aside>
<main class="flex-1">
<div class="flex flex-1 flex-col lg:flex-row">
<!-- 移动端侧边栏遮罩 -->
<div
class="sticky top-0 z-30 border-b border-slate-200/70 bg-slate-50/95 px-4 py-4 backdrop-blur lg:px-10 dark:border-slate-800/70 dark:bg-slate-950/95"
v-if="isSidebarOpen"
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm lg:hidden"
@click="isSidebarOpen = false"
></div>
<aside
class="fixed top-10 bottom-0 left-0 z-50 w-64 shrink-0 transform border-r border-slate-200/70 bg-white/95 px-4 py-6 backdrop-blur transition-transform duration-300 ease-in-out dark:border-slate-800/70 dark:bg-slate-900 lg:sticky lg:top-10 lg:flex lg:h-[calc(100vh-2.5rem)] lg:translate-x-0 lg:flex-col lg:border-b-0"
:class="
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
"
>
<AppHeader
:search-query="searchQuery"
<AppSidebar
:active-tab="activeTab"
:apps-count="filteredApps.length"
@update-search="handleSearchInput"
@search-focus="handleSearchFocus"
@open-install-settings="handleOpenInstallSettings"
@open-about="openAboutModal"
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
/>
</div>
<CategoryBar
v-if="activeTab !== 'home' && Object.keys(categories).length > 0"
:categories="categories"
:selected-category="selectedCategory"
:category-counts="categoryCounts"
@select-category="selectSubCategory"
/>
<div class="px-4 py-6 lg:px-10">
<UserManagementView
v-if="currentView === 'account' && currentUser"
:user="currentUser"
:downloaded-apps="downloadedApps"
:sync-enabled="installedSyncEnabled ?? false"
:loading="downloadedLoading"
:error="downloadedError"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
:category-counts="categoryCounts"
:theme-mode="themeMode"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:store-filter="storeFilter"
:sidebar-entries="sidebarEntries"
:entry-counts="entryCounts"
:current-user="currentUser"
@toggle-theme="toggleTheme"
@select-tab="selectTab"
@close="isSidebarOpen = false"
@list="handleList"
@update="handleUpdate"
@request-login="showLoginModal = true"
@open-user-management="openUserManagement"
@open-favorites="openFavoriteManagement"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@toggle-sync="setInstalledSyncEnabled"
@sync-now="syncInstalledAppsNow"
@refresh-downloads="loadDownloadedHistory"
@logout="handleLogout"
/>
<FavoriteFolderManager
v-else-if="currentView === 'favorites'"
:folders="favoriteFolders"
:active-folder-id="activeFavoriteFolderId"
:items="resolvedFavoriteItems"
:loading="favoriteLoading"
:error="favoriteError"
@select-folder="selectFavoriteFolder"
@create-folder="createFavoriteFolderFromPrompt"
@remove-selected="removeSelectedFavorites"
@install-selected="installResolvedFavorites"
@open-detail="openDetail"
</aside>
<main class="flex-1">
<div
class="sticky top-10 z-30 border-b border-slate-200/70 bg-slate-50/95 px-4 py-4 backdrop-blur lg:px-10 dark:border-slate-800/70 dark:bg-slate-950/95"
>
<AppHeader
:search-query="searchQuery"
:active-tab="activeTab"
:apps-count="filteredApps.length"
@update-search="handleSearchInput"
@search-focus="handleSearchFocus"
@open-install-settings="handleOpenInstallSettings"
@open-about="openAboutModal"
@toggle-sidebar="isSidebarOpen = !isSidebarOpen"
/>
</div>
<CategoryBar
v-if="
currentView === 'default' &&
activeTab !== 'home' &&
Object.keys(categories).length > 0
"
:categories="categories"
:selected-category="selectedCategory"
:category-counts="categoryCounts"
@select-category="selectSubCategory"
/>
<template v-else-if="activeTab === 'home'">
<HomeView
:links="homeLinks"
:lists="homeLists"
:loading="homeLoading"
:error="homeError"
:store-filter="storeFilter"
<div class="px-4 py-6 lg:px-10">
<FavoriteFolderManager
v-if="currentView === 'favorites'"
:folders="favoriteFolders"
:active-folder-id="activeFavoriteFolderId"
:items="resolvedFavoriteItems"
:loading="favoriteLoading"
:error="favoriteError"
@select-folder="selectFavoriteFolder"
@create-folder="createFavoriteFolderFromPrompt"
@remove-selected="removeSelectedFavorites"
@install-selected="installResolvedFavorites"
@open-detail="openDetail"
/>
</template>
<template v-else>
<AppGrid
:apps="filteredApps"
:loading="loading"
:scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
</div>
</main>
<template v-else-if="activeTab === 'home'">
<HomeView
:links="homeLinks"
:lists="homeLists"
:loading="homeLoading"
:error="homeError"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
<template v-else>
<AppGrid
:apps="filteredApps"
:loading="loading"
:scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
</div>
</main>
</div>
<AppDetailModal
data-app-modal="detail"
@@ -122,6 +115,9 @@
:logged-in="isLoggedIn"
:review-app-key="currentReviewAppKey"
:review-tags="currentReviewTags"
:favorited="currentFavoriteMetadata.favorited"
:favorite-folder-name="currentFavoriteMetadata.folderName"
@select-origin="selectDetailOrigin"
@close="closeDetail"
@install="onDetailInstall"
@remove="onDetailRemove"
@@ -130,6 +126,7 @@
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
@check-install="checkAppInstalled"
@show-user="openReviewUserProfile"
/>
<ScreenPreview
@@ -173,6 +170,7 @@
:apm-available="apmAvailable"
:logged-in="isLoggedIn"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@@ -243,11 +241,37 @@
@register="openExternalUrl(FLARUM_REGISTER_URL)"
/>
<UserManagementModal
v-if="currentUser"
:show="showUserManagementModal"
:user="currentUser"
:downloaded-apps="downloadedApps"
:sync-enabled="installedSyncEnabled ?? false"
:loading="downloadedLoading"
:error="downloadedError"
:syncing="syncLoading"
:sync-message="syncStatusMessage"
@close="showUserManagementModal = false"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@toggle-sync="setInstalledSyncEnabled"
@sync-now="syncInstalledAppsNow"
@refresh-downloads="loadDownloadedHistory"
/>
<ReviewUserProfileModal
:show="showReviewUserProfileModal"
:profile="selectedReviewUserProfile"
@close="showReviewUserProfileModal = false"
@open-forum-profile="openExternalUrl"
/>
<FavoriteFolderSelector
:show="showFavoriteSelector"
:folders="favoriteFolders"
:selected-folder-ids="currentFavoriteFolderIds"
@close="showFavoriteSelector = false"
@select-folder="addCurrentFavoriteToFolder"
@save-selection="saveCurrentFavoriteFolders"
@create-folder="createFavoriteFolderFromSelector"
/>
</div>
@@ -277,7 +301,9 @@ import LoginModal from "./components/LoginModal.vue";
import LoginPromptModal from "./components/LoginPromptModal.vue";
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
import UserManagementView from "./components/UserManagementView.vue";
import UserManagementModal from "./components/UserManagementModal.vue";
import ReviewUserProfileModal from "./components/ReviewUserProfileModal.vue";
import WindowTitleBar from "./components/WindowTitleBar.vue";
import {
APM_STORE_BASE_URL,
FLARUM_BASE_URL,
@@ -310,6 +336,7 @@ import {
addFavoriteItem,
bulkDeleteFavoriteItems,
createFavoriteFolder,
deleteFavoriteItem,
exchangeFlarumToken,
fetchSyncedAppList,
listDownloadedApps,
@@ -345,6 +372,7 @@ import {
cloudItemKey,
cloudPackageKey,
mergeInstalledApps,
resolveCloudInstallCandidate,
} from "./modules/appListSync";
import type {
App,
@@ -366,6 +394,8 @@ import type {
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
AppReview,
ReviewUserProfile,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -407,7 +437,7 @@ const isDarkTheme = computed(() => {
const categories: Ref<Record<string, CategoryInfo>> = ref({});
const apps: Ref<App[]> = ref([]);
const activeTab = ref("home");
type MainView = "default" | "account" | "favorites";
type MainView = "default" | "favorites";
const currentView = ref<MainView>("default");
const selectedCategory = ref("all");
const searchQuery = ref("");
@@ -434,14 +464,21 @@ const loginLoading = ref(false);
const loginError = ref("");
const showLoginPrompt = ref(false);
const loginPromptMessage = ref("请登录星火账号后继续操作。");
const showUserManagementModal = ref(false);
const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
const showReviewUserProfileModal = ref(false);
const sparkAvailable = ref(false);
const apmAvailable = ref(false);
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
const favoriteFolders = ref<FavoriteFolder[]>([]);
const activeFavoriteFolderId = ref<number | null>(null);
const favoriteItems = ref<FavoriteItem[]>([]);
const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
const showFavoriteSelector = ref(false);
const favoriteTargetApp = ref<App | null>(null);
const favoriteSelectorDraftFolderIds = ref<Array<number | "default"> | null>(
null,
);
const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
@@ -568,6 +605,47 @@ const currentReviewTags = computed<ReviewTags | null>(() => {
});
});
const currentFavoriteMetadata = computed(
(): {
favorited: boolean;
folderName: string;
} => {
const app = currentDisplayApp.value;
if (!app) return { favorited: false, folderName: "" };
const folder = favoriteFolders.value.find((favoriteFolder) => {
const items = favoriteItemsByFolder.value[favoriteFolder.id] ?? [];
return items.some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
);
});
if (!folder) return { favorited: false, folderName: "" };
return { favorited: true, folderName: folder.name.trim() };
},
);
const currentFavoriteFolderIds = computed((): Array<number | "default"> => {
if (favoriteSelectorDraftFolderIds.value) {
return favoriteSelectorDraftFolderIds.value;
}
const app = favoriteTargetApp.value ?? currentDisplayApp.value;
if (!app) return [];
return favoriteFolders.value
.filter((folder) =>
(favoriteItemsByFolder.value[folder.id] ?? []).some(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
)
.map((folder) => folder.id);
});
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
resolveFavoriteItems(
favoriteItems.value,
@@ -851,6 +929,13 @@ const openDetail = async (app: App | Record<string, unknown>) => {
currentAppSparkInstalled.value = false;
currentAppApmInstalled.value = false;
checkAppInstalled(finalApp);
if (
isLoggedIn.value &&
favoriteFolders.value.length === 0 &&
!favoriteLoading.value
) {
void loadFavoriteMetadataForDetail();
}
nextTick(() => {
const modal = document.querySelector(
@@ -1368,6 +1453,12 @@ const onDetailFavorite = async (app: App) => {
await openFavoriteSelector(app);
};
const selectDetailOrigin = (origin: "spark" | "apm") => {
if (currentApp.value?.isMerged) {
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
}
};
const handleDetailRequestLogin = (message: string) => {
requireLogin(message);
};
@@ -1432,6 +1523,25 @@ const openExternalUrl = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
};
const openReviewUserProfile = (review: AppReview): void => {
const current = currentUser.value;
const isCurrentUser =
review.isAuthor === true ||
(review.userId !== undefined &&
current?.id !== undefined &&
review.userId === current.id);
selectedReviewUserProfile.value = {
displayName: review.userDisplayName || "星火用户",
username: isCurrentUser ? current?.username : undefined,
avatarUrl:
review.userAvatarUrl || (isCurrentUser ? current?.avatarUrl : undefined),
coverUrl: isCurrentUser ? current?.coverUrl : undefined,
forumGroups: isCurrentUser ? current?.forumGroups : undefined,
};
showReviewUserProfileModal.value = true;
};
const requireLogin = (message: string): boolean => {
if (isLoggedIn.value) return true;
loginPromptMessage.value = message;
@@ -1449,8 +1559,10 @@ const clearFavoriteState = () => {
favoriteFolders.value = [];
activeFavoriteFolderId.value = null;
favoriteItems.value = [];
favoriteItemsByFolder.value = {};
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
favoriteSelectorDraftFolderIds.value = null;
favoriteLoading.value = false;
favoriteError.value = "";
};
@@ -1512,7 +1624,8 @@ const handleLogout = () => {
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
if (currentView.value === "favorites" || currentView.value === "account") {
showUserManagementModal.value = false;
if (currentView.value === "favorites") {
currentView.value = "default";
activeTab.value = "home";
selectedCategory.value = "all";
@@ -1691,13 +1804,7 @@ const openRestoreFromAccount = async (): Promise<void> => {
const installCloudItems = (items: SyncedAppListItem[]): void => {
for (const item of items) {
const candidates = apps.value.filter(
(candidate) =>
candidate.pkgname === item.pkgname && candidate.origin === item.origin,
);
const app =
candidates.find((candidate) => candidate.category === item.category) ??
candidates[0];
const app = resolveCloudInstallCandidate(item, apps.value);
if (!app) continue;
void onDetailInstall(app);
}
@@ -1727,8 +1834,7 @@ const maybePromptInstalledSync = async (): Promise<void> => {
const openUserManagement = async () => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
currentView.value = "account";
activeTab.value = "account";
showUserManagementModal.value = true;
isSidebarOpen.value = false;
showLoginPrompt.value = false;
await loadDownloadedHistory();
@@ -1762,9 +1868,57 @@ const loadActiveFavoriteItems = async (
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = items;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: items,
};
return true;
};
const loadAllFavoriteItems = async (
generation = favoriteRequestGeneration.value,
): Promise<boolean> => {
const folderIds = favoriteFolders.value.map((folder) => folder.id);
const entries = await Promise.all(
folderIds.map(async (folderId) => ({
folderId,
items: await listFavoriteItems(folderId),
})),
);
if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
favoriteItems.value = activeFavoriteFolderId.value
? (favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? [])
: [];
return true;
};
const loadFavoriteMetadataForDetail = async (): Promise<void> => {
const generation = favoriteRequestGeneration.value;
try {
const folders = await listFavoriteFolders();
if (!isCurrentFavoriteRequest(generation)) return;
const entries = await Promise.all(
folders.map(async (folder) => ({
folderId: folder.id,
items: await listFavoriteItems(folder.id),
})),
);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = folders;
favoriteItemsByFolder.value = Object.fromEntries(
entries.map(({ folderId, items }) => [folderId, items]),
);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const refreshFavorites = async (): Promise<void> => {
const generation = nextFavoriteRequestGeneration();
favoriteLoading.value = true;
@@ -1775,7 +1929,7 @@ const refreshFavorites = async (): Promise<void> => {
loadFavoriteFolders(generation),
]);
if (!isCurrentFavoriteRequest(generation)) return;
await loadActiveFavoriteItems(generation);
await loadAllFavoriteItems(generation);
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
@@ -1786,12 +1940,15 @@ const refreshFavorites = async (): Promise<void> => {
const openFavoriteSelector = async (app: App) => {
if (!requireLogin("收藏应用需要登录星火账号。")) return;
const generation = favoriteRequestGeneration.value;
const generation = nextFavoriteRequestGeneration();
favoriteTargetApp.value = app;
favoriteSelectorDraftFolderIds.value = null;
favoriteError.value = "";
try {
const foldersLoaded = await loadFavoriteFolders(generation);
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
const itemsLoaded = await loadAllFavoriteItems(generation);
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
@@ -1799,33 +1956,126 @@ const openFavoriteSelector = async (app: App) => {
}
};
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
const toFavoritePayload = (
app: App,
): Omit<FavoriteItem, "id" | "createdAt"> => ({
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons,
});
const saveCurrentFavoriteFolders = async (
folderIds: Array<number | "default">,
) => {
const generation = favoriteRequestGeneration.value;
const app = favoriteTargetApp.value;
if (!app) return;
try {
await addFavoriteItem(folderId, {
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons,
});
const numericFolderIds = folderIds.filter(
(folderId): folderId is number => typeof folderId === "number",
);
const includesFallbackDefault = folderIds.includes("default");
const nextFolderIds = new Set(numericFolderIds);
const existingByFolder = favoriteFolders.value
.map((folder) => ({
folderId: folder.id,
item: (favoriteItemsByFolder.value[folder.id] ?? []).find(
(favorite) =>
favorite.pkgname === app.pkgname &&
favorite.category === app.category,
),
}))
.filter(
(entry): entry is { folderId: number; item: FavoriteItem } =>
entry.item !== undefined,
);
const existingFolderIds = new Set(
existingByFolder.map((entry) => entry.folderId),
);
const payload = toFavoritePayload(app);
const addedItemPromises = numericFolderIds
.filter((folderId) => !existingFolderIds.has(folderId))
.map(async (folderId) => ({
folderId,
item: await addFavoriteItem(folderId, payload),
}));
const deletedEntries = existingByFolder.filter(
({ folderId }) => !nextFolderIds.has(folderId),
);
const [addedEntries] = await Promise.all([
Promise.all(addedItemPromises),
...(includesFallbackDefault ? [addFavoriteItem("default", payload)] : []),
...deletedEntries.map(({ folderId, item }) =>
deleteFavoriteItem(folderId, item.id),
),
]);
if (!isCurrentFavoriteRequest(generation)) return;
const nextItemsByFolder = { ...favoriteItemsByFolder.value };
deletedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = (nextItemsByFolder[folderId] ?? []).filter(
(favorite) => favorite.id !== item.id,
);
});
addedEntries.forEach(({ folderId, item }) => {
nextItemsByFolder[folderId] = [
...(nextItemsByFolder[folderId] ?? []).filter(
(favorite) =>
favorite.pkgname !== app.pkgname ||
favorite.category !== app.category,
),
item,
];
});
favoriteItemsByFolder.value = nextItemsByFolder;
if (activeFavoriteFolderId.value) {
favoriteItems.value =
nextItemsByFolder[activeFavoriteFolderId.value] ?? [];
}
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
await refreshFavorites();
favoriteSelectorDraftFolderIds.value = null;
if (includesFallbackDefault) await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "添加收藏失败";
favoriteError.value = (error as Error)?.message || "更新收藏失败";
}
};
const createFavoriteFolderFromSelector = async () => {
await createFavoriteFolderFromPrompt();
const createFavoriteFolderFromSelector = async (
draftFolderIds: Array<number | "default"> = currentFavoriteFolderIds.value,
): Promise<void> => {
const generation = favoriteRequestGeneration.value;
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
const app = favoriteTargetApp.value;
if (!app) return;
showFavoriteSelector.value = true;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteSelectorDraftFolderIds.value = [
...new Set([...draftFolderIds, folder.id]),
];
showFavoriteSelector.value = true;
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteManagement = async () => {
@@ -1862,8 +2112,16 @@ const createFavoriteFolderFromPrompt = async () => {
try {
const folder = await createFavoriteFolder(folderName);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteFolders.value = [
...favoriteFolders.value.filter((item) => item.id !== folder.id),
folder,
];
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
};
favoriteItems.value = [];
activeFavoriteFolderId.value = folder.id;
await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
@@ -1880,6 +2138,12 @@ const removeSelectedFavorites = async (ids: number[]) => {
try {
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
if (!isCurrentFavoriteRequest(generation)) return;
favoriteItemsByFolder.value = {
...favoriteItemsByFolder.value,
[activeFavoriteFolderId.value]: (
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
).filter((favorite) => !ids.includes(favorite.id)),
};
await refreshFavorites();
} catch (error: unknown) {
if (!isCurrentFavoriteRequest(generation)) return;
@@ -9,9 +9,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
import {
addFavoriteItem,
deleteFavoriteItem,
fetchRatingSummary,
fetchReviews,
fetchSyncedAppList,
createFavoriteFolder,
listDownloadedApps,
listFavoriteFolders,
listFavoriteItems,
uploadSyncedAppList,
} from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
@@ -158,8 +164,39 @@ vi.mock("@/modules/backendApi", () => ({
addFavoriteItem: vi.fn(),
bulkDeleteFavoriteItems: vi.fn(),
createFavoriteFolder: vi.fn(),
deleteFavoriteItem: vi.fn(),
exchangeFlarumToken: vi.fn(),
fetchRatingSummary: vi.fn(async () => ({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
})),
fetchReviews: vi.fn(async () => [
{
id: 31,
rating: 5,
content: "profile entry",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
origin: "apm",
category: "office",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Detail User",
userAvatarUrl: "https://bbs.spark-app.store/avatar-detail.png",
canDelete: false,
replies: [],
},
]),
fetchSyncedAppList: vi.fn(async () => null),
submitReview: vi.fn(),
likeReview: vi.fn(),
createReviewReply: vi.fn(),
deleteReview: vi.fn(),
likeReviewReply: vi.fn(),
deleteReviewReply: vi.fn(),
listDownloadedApps: vi.fn(async () => downloadedList([])),
listFavoriteFolders: vi.fn(async () => favoriteFolders),
listFavoriteItems: vi.fn(async () => favoriteItems),
@@ -197,10 +234,36 @@ describe("App account placeholders", () => {
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl:
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
},
});
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
});
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 31,
rating: 5,
content: "profile entry",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
origin: "apm",
category: "office",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Detail User",
userAvatarUrl: "https://bbs.spark-app.store/avatar-detail.png",
canDelete: false,
replies: [],
},
]);
vi.stubGlobal(
"matchMedia",
@@ -219,18 +282,91 @@ describe("App account placeholders", () => {
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
it("shows the user management placeholder from the logged-in quick menu", async () => {
it("shows user management as a global modal from the logged-in quick menu", async () => {
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
expect(await screen.findByText("wps · 1.0.0")).toBeTruthy();
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("用户管理"));
expect(
await screen.findByRole("heading", { name: "用户管理" }),
await screen.findByRole("dialog", { name: "用户管理" }),
).toBeTruthy();
const frame = await screen.findByTitle("星火账号用户管理");
expect(frame).toBeTruthy();
expect((frame as HTMLIFrameElement).src).toContain(
"account.spark-app.store",
);
expect(screen.getByText("wps · 1.0.0")).toBeTruthy();
expect(screen.queryByText("请登录后查看和管理账号信息。")).toBeNull();
});
it("opens an in-app profile modal from review author clicks", async () => {
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(
await screen.findByRole("button", { name: "Detail User" }),
);
const dialog = await screen.findByRole("dialog", { name: "用户资料" });
expect(dialog).toBeTruthy();
expect(within(dialog).getByText("Detail User")).toBeTruthy();
});
it("enriches the current user's own review profile without a review user id", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
vi.mocked(fetchReviews).mockResolvedValueOnce([
{
id: 32,
rating: 5,
content: "own profile entry",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
origin: "apm",
category: "office",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Momen",
userAvatarUrl: "",
isAuthor: true,
canDelete: false,
replies: [],
},
]);
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(
await screen.findByRole("button", { name: "查看Momen的资料" }),
);
const dialog = await screen.findByRole("dialog", { name: "用户资料" });
expect(within(dialog).getByText("@momen")).toBeTruthy();
expect(within(dialog).getByText("管理员")).toBeTruthy();
expect(
within(dialog).getByRole("button", { name: "查看论坛资料" }),
).toBeTruthy();
expect(openSpy).not.toHaveBeenCalled();
await fireEvent.click(
within(dialog).getByRole("button", { name: "查看论坛资料" }),
);
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining("/u/momen"),
"_blank",
"noopener,noreferrer",
);
});
it("shows the favorites placeholder from the logged-in quick menu", async () => {
render(App);
@@ -245,6 +381,301 @@ describe("App account placeholders", () => {
expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
});
it("shows favorite management as a standalone page without category pills", async () => {
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
);
await fireEvent.click(screen.getByText("我的收藏"));
expect(
await screen.findByRole("heading", { name: "我的收藏" }),
).toBeTruthy();
expect(await screen.findByText("默认收藏夹 (1)")).toBeTruthy();
expect(screen.queryByRole("button", { name: /办公/ })).toBeNull();
});
it("loads favorite state when opening app detail directly", async () => {
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(
await screen.findByRole("button", { name: "已收藏 · 默认收藏夹" }),
).toBeTruthy();
expect(listFavoriteFolders).toHaveBeenCalled();
expect(listFavoriteItems).toHaveBeenCalledWith(7);
});
it("shows a newly created favorite folder immediately on the management page", async () => {
const createdFolder: FavoriteFolder = {
id: 8,
name: "办公收藏",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
vi.spyOn(window, "prompt").mockReturnValue("办公收藏");
vi.mocked(createFavoriteFolder).mockResolvedValueOnce(createdFolder);
vi.mocked(listFavoriteFolders).mockResolvedValue(favoriteFolders);
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
);
await fireEvent.click(screen.getByText("我的收藏"));
expect(
await screen.findByRole("heading", { name: "我的收藏" }),
).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
expect(createFavoriteFolder).toHaveBeenCalledWith("办公收藏");
expect(await screen.findByText("办公收藏 (0)")).toBeTruthy();
expect(await screen.findByText("当前收藏夹暂无应用。")).toBeTruthy();
});
it("shows detail favorite state for an app added to a non-active folder", async () => {
const folders = [
favoriteFolders[0],
{
id: 8,
name: "办公收藏",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
},
];
vi.mocked(listFavoriteFolders)
.mockResolvedValueOnce(folders)
.mockResolvedValueOnce(folders);
vi.mocked(listFavoriteItems).mockResolvedValue([]);
vi.mocked(addFavoriteItem).mockResolvedValueOnce({
...favoriteItems[0],
id: 12,
});
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
await fireEvent.click(await screen.findByLabelText("收藏到 办公收藏"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(
await screen.findByRole("button", { name: "已收藏 · 办公收藏" }),
).toBeTruthy();
});
it("adds a favorite through the fallback default folder", async () => {
vi.mocked(listFavoriteFolders)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce(favoriteFolders);
vi.mocked(listFavoriteItems).mockResolvedValueOnce([]);
vi.mocked(addFavoriteItem).mockResolvedValueOnce(favoriteItems[0]);
render(App);
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
await fireEvent.click(await screen.findByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(addFavoriteItem).toHaveBeenCalledWith(
"default",
expect.objectContaining({ pkgname: "wps" }),
);
});
it("ignores stale favorite refreshes while opening the detail selector", async () => {
const refreshFolders = createDeferred<FavoriteFolder[]>();
const selectorFolders = createDeferred<FavoriteFolder[]>();
const staleFolder = {
id: 9,
name: "旧收藏夹",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
const currentFolder = {
id: 8,
name: "办公收藏",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
vi.mocked(listFavoriteFolders)
.mockReturnValueOnce(refreshFolders.promise)
.mockReturnValueOnce(selectorFolders.promise);
vi.mocked(listFavoriteItems).mockResolvedValue([]);
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
);
await fireEvent.click(screen.getByText("我的收藏"));
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
selectorFolders.resolve([currentFolder]);
expect(await screen.findByLabelText("收藏到 办公收藏")).toBeTruthy();
refreshFolders.resolve([staleFolder]);
await refreshFolders.promise;
await Promise.resolve();
await Promise.resolve();
expect(screen.getByLabelText("收藏到 办公收藏")).toBeTruthy();
expect(screen.queryByLabelText("收藏到 旧收藏夹")).toBeNull();
});
it("shows detail favorite state from a non-active backend folder", async () => {
const folders = [
favoriteFolders[0],
{
id: 8,
name: "办公收藏",
itemCount: 1,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
},
];
vi.mocked(listFavoriteFolders).mockResolvedValueOnce(folders);
vi.mocked(listFavoriteItems)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(favoriteItems);
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
);
await fireEvent.click(screen.getByText("我的收藏"));
expect(
await screen.findByRole("heading", { name: "我的收藏" }),
).toBeTruthy();
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(
await screen.findByRole("button", { name: "已收藏 · 办公收藏" }),
).toBeTruthy();
});
it("saves favorite folder checkbox changes from the detail selector", async () => {
const folders = [
favoriteFolders[0],
{
id: 8,
name: "办公收藏",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
},
];
vi.mocked(listFavoriteFolders).mockResolvedValue(folders);
vi.mocked(listFavoriteItems).mockImplementation(async (folderId: number) =>
folderId === 7 ? favoriteItems : [],
);
vi.mocked(addFavoriteItem).mockResolvedValueOnce({
...favoriteItems[0],
id: 12,
});
vi.mocked(deleteFavoriteItem).mockResolvedValueOnce(undefined);
render(App);
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("我的收藏"));
expect(
await screen.findByRole("heading", { name: "我的收藏" }),
).toBeTruthy();
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
expect(
await screen.findByRole("button", { name: "已收藏 · 默认收藏夹" }),
).toBeTruthy();
await fireEvent.click(
screen.getByRole("button", { name: "已收藏 · 默认收藏夹" }),
);
await fireEvent.click(await screen.findByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(await screen.findByLabelText("收藏到 办公收藏"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(deleteFavoriteItem).toHaveBeenCalledWith(7, 11);
expect(addFavoriteItem).toHaveBeenCalledWith(
8,
expect.objectContaining({
pkgname: "wps",
}),
);
});
it("creates a folder from the favorite selector without closing it", async () => {
const createdFolder = {
id: 8,
name: "办公收藏",
itemCount: 0,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
vi.spyOn(window, "prompt").mockReturnValue("办公收藏");
vi.mocked(createFavoriteFolder).mockResolvedValueOnce(createdFolder);
vi.mocked(listFavoriteFolders)
.mockResolvedValueOnce(favoriteFolders)
.mockResolvedValueOnce([favoriteFolders[0], createdFolder]);
vi.mocked(listFavoriteItems)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
vi.mocked(addFavoriteItem).mockResolvedValueOnce(favoriteItems[0]);
render(App);
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("我的收藏"));
expect(
await screen.findByRole("heading", { name: "我的收藏" }),
).toBeTruthy();
await fireEvent.click(await screen.findByText("全部应用"));
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
await fireEvent.click(
await screen.findByRole("button", { name: "新建收藏夹" }),
);
expect(createFavoriteFolder).toHaveBeenCalledWith("办公收藏");
expect(
await screen.findByRole("dialog", { name: "选择收藏夹" }),
).toBeTruthy();
expect(await screen.findByLabelText("收藏到 办公收藏")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(addFavoriteItem).toHaveBeenCalledWith(
8,
expect.objectContaining({ pkgname: "wps" }),
);
});
it("refreshes installed apps before resolving favorite management state", async () => {
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "both";
@@ -416,7 +847,7 @@ describe("App account placeholders", () => {
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("用户管理"));
expect(await screen.findByText("正在加载下载历史...")).toBeTruthy();
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
@@ -437,7 +868,8 @@ describe("App account placeholders", () => {
}
await fireEvent.click(await screen.findByText("用户管理"));
secondHistory.resolve(downloadedList([]));
expect(await screen.findByText("暂无下载记录。")).toBeTruthy();
const secondUserFrame = await screen.findByTitle("星火账号用户管理");
expect((secondUserFrame as HTMLIFrameElement).src).toContain("user=second");
firstHistory.resolve(
downloadedList([
@@ -459,7 +891,7 @@ describe("App account placeholders", () => {
await Promise.resolve();
expect(screen.queryByText("旧账号应用")).toBeNull();
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
expect(screen.getByTitle("星火账号用户管理")).toBeTruthy();
});
it("ignores older downloaded history refreshes for the same user", async () => {
@@ -489,7 +921,7 @@ describe("App account placeholders", () => {
},
]),
);
expect(await screen.findByText("新下载应用")).toBeTruthy();
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
firstHistory.resolve(
downloadedList([
@@ -511,7 +943,7 @@ describe("App account placeholders", () => {
await Promise.resolve();
expect(screen.queryByText("旧下载应用")).toBeNull();
expect(screen.getByText("新下载应用")).toBeTruthy();
expect(screen.queryByText("新下载应用")).toBeNull();
});
it("ignores older favorite folder refreshes for the same user", async () => {
@@ -675,16 +1107,9 @@ describe("App account placeholders", () => {
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("用户管理"));
await fireEvent.click(
await screen.findByRole("button", { name: "立即同步" }),
);
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
await waitFor(() => {
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
});
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
syncUpload.resolve(syncedList([]));
expect(await screen.findByText("同步完成")).toBeTruthy();
expect(uploadSyncedAppList).not.toHaveBeenCalled();
});
it("clears manual sync feedback before another user opens account management", async () => {
@@ -703,15 +1128,9 @@ describe("App account placeholders", () => {
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);
});
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
syncUpload.resolve(syncedList([]));
expect(await screen.findByText("同步完成")).toBeTruthy();
expect(screen.queryByText("同步完成")).toBeNull();
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
@@ -735,6 +1154,7 @@ describe("App account placeholders", () => {
}
await fireEvent.click(await screen.findByText("用户管理"));
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
expect(screen.queryByText("同步完成")).toBeNull();
});
@@ -769,9 +1189,7 @@ describe("App account placeholders", () => {
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("用户管理"));
await fireEvent.click(
await screen.findByRole("button", { name: "立即同步" }),
);
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
await fireEvent.click(
await screen.findByRole("button", { name: /^Momen$/ }),
);
@@ -806,15 +1224,7 @@ describe("App account placeholders", () => {
await fireEvent.click(secondUserButton);
}
await fireEvent.click(await screen.findByText("用户管理"));
await fireEvent.click(
await screen.findByRole("button", { name: "立即同步" }),
);
await waitFor(() => {
expect(uploadSyncedAppList).toHaveBeenCalledWith(
expect.objectContaining({ items: [] }),
);
});
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
const uploadedItemNames = vi
.mocked(uploadSyncedAppList)
.mock.calls.flatMap(([payload]) =>
+48 -2
View File
@@ -14,8 +14,9 @@ vi.mock("@/components/ReviewsPanel.vue", () => ({
default: {
name: "ReviewsPanel",
props: ["appKey", "tags", "loggedIn", "canSubmit"],
emits: ["request-login", "show-user"],
template:
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
},
}));
@@ -110,7 +111,7 @@ describe("AppDetailModal", () => {
});
it("updates review identity when switching a merged app origin", async () => {
render(AppDetailModal, {
const rendered = render(AppDetailModal, {
props: {
show: true,
app: mergedApp,
@@ -142,6 +143,7 @@ describe("AppDetailModal", () => {
"data-version",
"1.0.0",
);
expect(rendered.emitted("select-origin")?.[0]?.[0]).toBe("apm");
});
it("marks reviews read-only when the selected origin is not installed", () => {
@@ -163,4 +165,48 @@ describe("AppDetailModal", () => {
"false",
);
});
it("forwards review user profile events", async () => {
const rendered = render(AppDetailModal, {
props: {
show: true,
app,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
await fireEvent.click(screen.getByTestId("reviews-panel"));
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
expect.objectContaining({ userDisplayName: "Detail User" }),
);
});
it("renders favorited state with folder name and still emits favorite", async () => {
const rendered = render(AppDetailModal, {
props: {
show: true,
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
favorited: true,
favoriteFolderName: "办公收藏",
},
});
await fireEvent.click(
screen.getByRole("button", { name: "已收藏 · 办公收藏" }),
);
expect(rendered.emitted("favorite")?.[0]?.[0]).toEqual(app);
});
});
+22 -1
View File
@@ -8,8 +8,9 @@ vi.mock("@/components/ReviewsPanel.vue", () => ({
default: {
name: "ReviewsPanel",
props: ["appKey", "tags", "loggedIn", "canSubmit"],
emits: ["request-login", "show-user"],
template:
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)"></div>',
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
},
}));
@@ -169,4 +170,24 @@ describe("AppDetailPage", () => {
"false",
);
});
it("forwards review user profile events", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
await fireEvent.click(screen.getByTestId("reviews-panel"));
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
expect.objectContaining({ userDisplayName: "Detail User" }),
);
});
});
+39 -6
View File
@@ -46,10 +46,22 @@ describe("AppSidebar account entry", () => {
expect(screen.getByText("退出登录")).toBeTruthy();
});
it("closes the quick menu after clicking outside the account area", async () => {
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
expect(screen.getByText("用户管理")).toBeTruthy();
await fireEvent.mouseDown(document.body);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
});
it("keeps long account names inside the sidebar account entry", () => {
const longUser: SparkUser = {
...user,
displayName: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
username: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
displayName: "",
};
const { container } = render(AppSidebar, {
@@ -62,22 +74,43 @@ describe("AppSidebar account entry", () => {
const textWrapper = accountButton.querySelector(
"[data-testid='account-text']",
);
const accountName = screen.getByText(longUser.displayName);
const accountName = screen.getByText(longUser.username);
expect(textWrapper?.className).toContain("min-w-0");
expect(accountName.className).toContain("truncate");
expect(container.textContent).toContain(longUser.displayName);
expect(container.textContent).toContain(longUser.username);
});
it("closes the quick menu after selecting an account action", async () => {
it.each([
["用户管理", "open-user-management"],
["我的收藏", "open-favorites"],
["论坛首页", "open-forum"],
["修改论坛资料", "edit-profile"],
["退出登录", "logout"],
] as const)(
"closes the quick menu after selecting %s",
async (label, eventName) => {
const rendered = render(AppSidebar, {
props: { ...baseProps, currentUser: user },
});
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByRole("button", { name: label }));
expect(rendered.emitted(eventName)).toHaveLength(1);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
},
);
it("closes the quick menu after selecting a sidebar 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: "用管理" }));
await fireEvent.click(screen.getByRole("button", { name: "用管理" }));
expect(rendered.emitted("open-user-management")).toHaveLength(1);
expect(rendered.emitted("list")).toHaveLength(1);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
});
});
@@ -88,4 +88,69 @@ describe("FavoriteFolderManager", () => {
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
expect(rendered.emitted("remove-selected")).toBeUndefined();
});
it("keeps checkbox selection isolated from opening app detail", async () => {
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [{ ...item, status: "installable", selectedApp }],
loading: false,
error: "",
},
});
await fireEvent.click(screen.getByLabelText("选择 WPS"));
expect(rendered.emitted("open-detail")).toBeUndefined();
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
});
it("selects installable favorites and emits them for installation", async () => {
const installableItem: ResolvedFavoriteItem = {
...item,
status: "installable",
reason: "可安装",
selectedApp,
};
const installedItem: ResolvedFavoriteItem = {
...item,
item: {
...item.item,
id: 3,
pkgname: "installed-app",
name: "已安装应用",
},
status: "installed",
reason: "已安装",
selectedApp: {
...selectedApp,
pkgname: "installed-app",
name: "已安装应用",
},
};
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [installableItem, installedItem],
loading: false,
error: "",
},
});
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
await fireEvent.click(screen.getByRole("button", { name: "选择可安装" }));
expect(screen.getByLabelText("选择 WPS")).toBeChecked();
expect(screen.getByLabelText("选择 已安装应用")).not.toBeChecked();
expect(screen.getByText("已选择 1 个可安装应用")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
installableItem,
]);
});
});
@@ -34,9 +34,22 @@ describe("FavoriteFolderSelector", () => {
},
});
expect(screen.getAllByRole("button", { name: "默认收藏夹" })).toHaveLength(
1,
);
expect(
screen.getAllByRole("checkbox", { name: "收藏到 默认收藏夹" }),
).toHaveLength(1);
});
it("normalizes backend default folder names before adding fallback default", () => {
render(FavoriteFolderSelector, {
props: {
show: true,
folders: [{ ...defaultFolder, name: " 默认收藏夹 " }],
},
});
expect(
screen.getAllByRole("checkbox", { name: /收藏到\s*默认收藏夹/ }),
).toHaveLength(1);
});
it("offers creating a folder while selecting favorites", async () => {
@@ -51,4 +64,75 @@ describe("FavoriteFolderSelector", () => {
expect(rendered.emitted("create-folder")).toHaveLength(1);
});
it("emits the current draft selection when creating a folder", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
expect(rendered.emitted("create-folder")?.[0]?.[0]).toEqual([1]);
});
it("emits checked folder ids only after confirmation", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [
defaultFolder,
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
],
selectedFolderIds: [defaultFolder.id],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
expect(rendered.emitted("save-selection")).toBeUndefined();
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([2]);
});
it("emits the fallback default folder selection after confirmation", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual(["default"]);
});
it("preserves unsaved folder checks when the folder list changes", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
selectedFolderIds: [],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await rendered.rerender({
folders: [
defaultFolder,
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
],
});
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([1, 2]);
});
});
@@ -39,6 +39,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -61,6 +62,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -81,6 +83,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -105,6 +108,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -129,6 +133,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -148,6 +153,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -167,6 +173,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -189,6 +196,7 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: true,
syncing: false,
syncMessage: "",
},
});
@@ -212,9 +220,30 @@ describe("InstalledAppsModal", () => {
apmAvailable: true,
loggedIn: true,
syncing: true,
syncMessage: "",
},
});
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
});
it("shows account sync feedback in the installed apps modal", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: false,
syncMessage: "同步完成",
},
});
expect(screen.getByText("同步完成")).toBeTruthy();
});
});
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import ReviewUserProfileModal from "@/components/ReviewUserProfileModal.vue";
import type { ReviewUserProfile } from "@/global/typedefinition";
const profile = (
overrides: Partial<ReviewUserProfile> = {},
): ReviewUserProfile => ({
displayName: "Momen",
...overrides,
});
describe("ReviewUserProfileModal", () => {
it("does not insert unsafe cover URLs into background image styles", () => {
const { container } = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile({ coverUrl: "javascript:alert(1)" }),
},
});
expect(container.innerHTML).not.toContain("javascript:alert(1)");
const cover = container.querySelector("[data-testid='review-user-cover']");
expect((cover as HTMLElement | null)?.style.backgroundImage).toBe("");
});
it("emits close when Escape is pressed inside the dialog", async () => {
const rendered = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile(),
},
});
await fireEvent.keyDown(screen.getByRole("dialog", { name: "用户资料" }), {
key: "Escape",
});
expect(rendered.emitted("close")).toHaveLength(1);
});
it("emits the forum profile URL when opening a username profile", async () => {
const rendered = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile({ username: "momen" }),
},
});
await fireEvent.click(screen.getByRole("button", { name: "查看论坛资料" }));
expect(rendered.emitted("open-forum-profile")?.[0]?.[0]).toMatch(
/\/u\/momen$/,
);
});
});
+464
View File
@@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
@@ -20,8 +26,13 @@ const emptySummary: RatingSummary = {
};
vi.mock("@/modules/backendApi", () => ({
createReviewReply: vi.fn(),
deleteReview: vi.fn(),
deleteReviewReply: vi.fn(),
fetchRatingSummary: vi.fn(async () => emptySummary),
fetchReviews: vi.fn(async () => []),
likeReview: vi.fn(),
likeReviewReply: vi.fn(),
submitReview: vi.fn(),
}));
@@ -35,13 +46,70 @@ const tags: ReviewTags = {
distro: "deepin 25",
};
const makeReview = (overrides: Partial<AppReview>): AppReview => ({
id: 1,
rating: 5,
content: "默认评价",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "星火用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
const makeReply = (overrides: Partial<AppReviewReply>): AppReviewReply => ({
id: 101,
reviewId: 1,
parentId: null,
content: "默认回复",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "回复用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
describe("ReviewsPanel", () => {
beforeEach(() => {
vi.mocked(fetchRatingSummary).mockReset();
vi.mocked(fetchReviews).mockReset();
vi.mocked(submitReview).mockReset();
vi.mocked(likeReview).mockReset();
vi.mocked(createReviewReply).mockReset();
vi.mocked(deleteReview).mockReset();
vi.mocked(likeReviewReply).mockReset();
vi.mocked(deleteReviewReply).mockReset();
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
vi.mocked(fetchReviews).mockResolvedValue([]);
vi.mocked(likeReview).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(createReviewReply).mockResolvedValue(makeReply({}));
vi.mocked(deleteReview).mockResolvedValue(undefined);
vi.mocked(likeReviewReply).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(deleteReviewReply).mockResolvedValue(undefined);
});
it("shows anonymous login prompt and read-only review tags", () => {
@@ -171,6 +239,402 @@ describe("ReviewsPanel", () => {
).toBeTruthy();
});
it("shows a re-login prompt when loading reviews with a stale token", async () => {
vi.mocked(fetchRatingSummary).mockRejectedValueOnce(
new Error("Request failed with status code 401"),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("submits the rating selected by sliding over stars", async () => {
vi.mocked(submitReview).mockResolvedValueOnce(
makeReview({ rating: 3, content: "一般" }),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerDown = new MouseEvent("pointerdown", {
bubbles: true,
clientX: 250,
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
const pointerUp = new MouseEvent("pointerup", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerDown, "pointerId", { value: 1 });
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
Object.defineProperty(pointerUp, "pointerId", { value: 1 });
await fireEvent(slider, pointerDown);
await fireEvent(slider, pointerMove);
await fireEvent(slider, pointerUp);
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"一般",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(submitReview).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
expect.objectContaining({ rating: 3 }),
);
});
it("updates rating preview from hovering over the star hitbox", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
await fireEvent(slider, pointerMove);
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("does not include a trailing rating label in the star slider hitbox", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
expect(slider).not.toHaveTextContent("星");
expect(slider).not.toHaveClass("border");
expect(slider).not.toHaveClass("bg-amber-50");
});
it("supports keyboard changes for the sliding star rating", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("filters loaded reviews by package architecture and distro", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 11,
rating: 5,
content: "amd64 deepin",
version: tags.version,
packageArch: "amd64",
clientArch: tags.clientArch,
distro: "deepin 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Deepin User",
userAvatarUrl: "",
},
{
id: 12,
rating: 4,
content: "arm64 gxde",
version: tags.version,
packageArch: "arm64",
clientArch: tags.clientArch,
distro: "GXDE OS 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "GXDE User",
userAvatarUrl: "",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("amd64 deepin")).toBeTruthy();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "" },
});
await fireEvent.change(screen.getByLabelText("按发行版筛选"), {
target: { value: "GXDE OS 25" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
});
it("resets stale review filters when the app key changes", async () => {
vi.mocked(fetchReviews)
.mockResolvedValueOnce([
makeReview({ id: 21, content: "first amd64", packageArch: "amd64" }),
makeReview({
id: 22,
content: "first arm64",
packageArch: "arm64",
distro: "GXDE OS 25",
}),
])
.mockResolvedValueOnce([
makeReview({
id: 23,
content: "second only amd64",
packageArch: "amd64",
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
expect(await screen.findByText("first amd64")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("first amd64")).toBeNull();
expect(screen.getByText("first arm64")).toBeTruthy();
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
expect(await screen.findByText("second only amd64")).toBeTruthy();
expect(screen.queryByText("没有符合筛选条件的评价")).toBeNull();
});
it("exposes reviewer detail affordances from avatar and name buttons", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 31,
content: "用户资料入口",
userDisplayName: "Detail User",
}),
]);
const { emitted } = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
await screen.findByRole("button", { name: "查看Detail User的资料" }),
);
expect(screen.getByText("正在查看 Detail User 的资料")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Detail User" }));
expect(emitted()["show-user"]).toHaveLength(2);
});
it("calls backend review actions and only shows delete from backend metadata", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 41, content: "普通评价", userDisplayName: "Reader" }),
makeReview({
id: 42,
content: "作者评价",
userDisplayName: "Author",
isAuthor: true,
canDelete: true,
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("普通评价")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "点赞" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "回复" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[0]);
expect(likeReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 41);
expect(fetchReviews).toHaveBeenCalledTimes(2);
expect(await screen.findByText("作者评价")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(deleteReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 42);
await rendered.rerender({
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
currentUserIsAdmin: true,
});
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
});
it("creates review replies and nested replies through backend APIs", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 51,
content: "可回复评价",
replies: [
makeReply({
id: 151,
reviewId: 51,
content: "已有回复",
replies: [
makeReply({
id: 152,
reviewId: 51,
parentId: 151,
content: "已有二级回复",
}),
],
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
(await screen.findAllByRole("button", { name: "回复" }))[0],
);
expect(screen.getByText("已有二级回复")).toBeTruthy();
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"一级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "一级回复" },
);
expect(await screen.findByText("已有回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "回复" })[1]);
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"二级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "二级回复", parentId: 151 },
);
});
it("calls backend reply actions and shows permission errors", async () => {
vi.mocked(deleteReview).mockRejectedValueOnce(
new Error("请登录星火账号后重试。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 61,
content: "带回复评价",
canDelete: true,
replies: [
makeReply({
id: 161,
reviewId: 61,
content: "可操作回复",
canDelete: true,
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[1]);
expect(likeReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(await screen.findByText("请登录星火账号后重试。"));
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
expect(deleteReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
});
it("preserves stale-token prompts from review action failures", async () => {
vi.mocked(likeReview).mockRejectedValueOnce(
new Error("登录状态已失效,请重新登录星火账号。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 71, content: "旧登录态评价" }),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("旧登录态评价")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "点赞" }));
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import UserManagementModal from "@/components/UserManagementModal.vue";
import type { SparkUser } from "@/global/typedefinition";
const user: SparkUser = {
id: 1,
flarumUserId: "42",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
describe("UserManagementModal", () => {
it("renders account management in an independent iframe without token query params", () => {
render(UserManagementModal, {
props: {
show: true,
user,
downloadedApps: [],
syncEnabled: true,
loading: false,
error: "",
},
});
const frame = screen.getByTitle("星火账号用户管理") as HTMLIFrameElement;
expect(frame).toBeTruthy();
expect(frame.src).toContain("account.spark-app.store");
expect(frame.src).toContain("/account");
expect(frame.src).not.toMatch(/token|jwt|password|access/i);
});
it("shows retry controls when iframe reports a load failure", async () => {
render(UserManagementModal, {
props: {
show: true,
user,
downloadedApps: [],
syncEnabled: true,
loading: false,
error: "",
},
});
await fireEvent.error(screen.getByTitle("星火账号用户管理"));
expect(screen.getByText("账号页面加载失败")).toBeTruthy();
expect(screen.getByRole("button", { name: "重试" })).toBeTruthy();
});
});
@@ -67,4 +67,27 @@ describe("UserManagementView", () => {
expect(screen.getByText("同步完成")).toBeTruthy();
});
it("renders the forum profile cover when available", () => {
render(UserManagementView, {
props: {
user: {
...user,
coverUrl:
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
},
downloadedApps: [],
syncEnabled: false,
loading: false,
error: "",
syncing: false,
syncMessage: "",
},
});
expect(screen.getByTestId("profile-cover")).toHaveStyle({
backgroundImage:
'url("https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg")',
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WindowTitleBar from "@/components/WindowTitleBar.vue";
const windowControls = {
minimize: vi.fn(),
toggleMaximize: vi.fn(),
close: vi.fn(),
};
describe("WindowTitleBar", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "windowControls", {
value: windowControls,
configurable: true,
});
});
it("sends window control requests", async () => {
render(WindowTitleBar);
await fireEvent.click(screen.getByRole("button", { name: "最小化" }));
await fireEvent.click(screen.getByRole("button", { name: "最大化或还原" }));
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
expect(windowControls.minimize).toHaveBeenCalledTimes(1);
expect(windowControls.toggleMaximize).toHaveBeenCalledTimes(1);
expect(windowControls.close).toHaveBeenCalledTimes(1);
});
it("stays below modal overlays in the stacking order", () => {
const { container } = render(WindowTitleBar);
expect(container.firstElementChild?.className).toContain("z-20");
expect(container.firstElementChild?.className).not.toContain("z-[60]");
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
describe("accountCenterUrl", () => {
it("falls back to the production account URL when configured URL is malformed", () => {
const url = buildAccountFrameUrl("not a url", "momen");
expect(url).toContain("https://account.spark-app.store/account");
expect(url).toContain("view=management");
expect(url).toContain("user=momen");
expect(url).not.toMatch(/token|jwt|password|access/i);
});
it("falls back when configured URL uses an unsafe protocol", () => {
const url = buildAccountFrameUrl("javascript:alert(1)", "momen");
expect(url).toContain("https://account.spark-app.store/account");
expect(url).toContain("view=management");
expect(url).toContain("user=momen");
expect(url).not.toMatch(/^javascript:/i);
});
});
+2
View File
@@ -37,6 +37,7 @@ describe("account shared types", () => {
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
@@ -91,6 +92,7 @@ describe("account shared types", () => {
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
expect(FLARUM_REGISTER_URL).toContain("register");
expect(user.forumGroups).toEqual(["管理员"]);
expect(user.coverUrl).toContain("/assets/covers/");
expect(folder.itemCount).toBe(1);
expect(favorite.appKey).toBe("app:office:wps");
expect(download.selectedOrigin).toBe("apm");
+63
View File
@@ -5,6 +5,7 @@ import {
cloudItemKey,
cloudPackageKey,
mergeInstalledApps,
resolveCloudInstallCandidate,
} from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
@@ -95,4 +96,66 @@ describe("appListSync", () => {
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
]);
});
it("resolves cloud restore items by exact source before package fallback", () => {
const sparkCloudItem = {
pkgname: "shared-app",
origin: "spark" as const,
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Shared App",
iconUrl: "",
};
const apmCandidate = createApp({
origin: "apm",
pkgname: "shared-app",
category: "office",
});
const sparkCandidate = createApp({
origin: "spark",
pkgname: "shared-app",
category: "office",
});
expect(
resolveCloudInstallCandidate(sparkCloudItem, [
apmCandidate,
sparkCandidate,
]),
).toBe(sparkCandidate);
expect(resolveCloudInstallCandidate(sparkCloudItem, [apmCandidate])).toBe(
apmCandidate,
);
expect(resolveCloudInstallCandidate(sparkCloudItem, [])).toBeNull();
});
it("prefers same-source package fallback when the category changed", () => {
const sparkCloudItem = {
pkgname: "shared-app",
origin: "spark" as const,
category: "legacy-office",
version: "1.0.0",
packageArch: "amd64",
appName: "Shared App",
iconUrl: "",
};
const apmCandidate = createApp({
origin: "apm",
pkgname: "shared-app",
category: "office",
});
const sparkCandidate = createApp({
origin: "spark",
pkgname: "shared-app",
category: "productivity",
});
expect(
resolveCloudInstallCandidate(sparkCloudItem, [
apmCandidate,
sparkCandidate,
]),
).toBe(sparkCandidate);
});
});
+2
View File
@@ -19,6 +19,7 @@ describe("authState", () => {
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
},
@@ -26,6 +27,7 @@ describe("authState", () => {
expect(authSession.value?.accessToken).toBe("jwt");
expect(currentUser.value?.displayName).toBe("Momen");
expect(currentUser.value?.coverUrl).toContain("/assets/covers/");
expect(isLoggedIn.value).toBe(true);
expect(
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
+27
View File
@@ -128,4 +128,31 @@ describe("backend API auth exchange", () => {
}),
).rejects.toThrow("星火账号服务异常,请稍后重试。");
});
it("maps review submission 401 responses to a re-login prompt", async () => {
const error = Object.assign(
new Error("Request failed with status code 401"),
{
isAxiosError: true,
response: { status: 401 },
},
);
axiosMocks.post.mockRejectedValue(error);
await expect(
submitReview("apm:amd64-apm:office:wps", {
rating: 5,
content: "好用",
tags: {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
},
}),
).rejects.toThrow("登录状态已失效,请重新登录星火账号。");
});
});
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const testDir = dirname(fileURLToPath(import.meta.url));
const mainSource = readFileSync(
resolve(testDir, "../../../electron/main/index.ts"),
"utf-8",
);
const preloadSource = readFileSync(
resolve(testDir, "../../../electron/preload/index.ts"),
"utf-8",
);
const viteEnvSource = readFileSync(
resolve(testDir, "../../vite-env.d.ts"),
"utf-8",
);
describe("frameless window shell config", () => {
it("creates the main BrowserWindow without a native frame", () => {
expect(mainSource).toMatch(/new BrowserWindow\(\{[\s\S]*frame:\s*false/);
});
it("routes titlebar close through the guarded BrowserWindow close path", () => {
expect(mainSource).toContain('ipcMain.on("window-control-close"');
expect(mainSource).toMatch(
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.close\(\)/,
);
expect(mainSource).not.toMatch(
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.destroy\(\)/,
);
});
it("exposes typed window controls from preload", () => {
expect(preloadSource).toContain('exposeInMainWorld("windowControls"');
expect(viteEnvSource).toContain("windowControls: WindowControlBridge");
});
});
+10 -5
View File
@@ -8,7 +8,7 @@
@click="emit('open-user-management')"
>
<i class="fas fa-user-cog"></i>
<span>用户管理</span>
<span class="min-w-0 truncate">用户管理</span>
</button>
<button
type="button"
@@ -16,15 +16,15 @@
@click="emit('open-favorites')"
>
<i class="fas fa-heart"></i>
<span>我的收藏</span>
<span class="min-w-0 truncate">我的收藏</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('open-forum')">
<i class="fas fa-comments"></i>
<span>论坛首页</span>
<span class="min-w-0 truncate">论坛首页</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('edit-profile')">
<i class="fas fa-id-card"></i>
<span>修改论坛资料</span>
<span class="min-w-0 truncate">修改论坛资料</span>
</button>
<button
type="button"
@@ -32,7 +32,7 @@
@click="emit('logout')"
>
<i class="fas fa-sign-out-alt"></i>
<span>退出登录</span>
<span class="min-w-0 truncate">退出登录</span>
</button>
</div>
</template>
@@ -53,6 +53,7 @@ const emit = defineEmits<{
width: 100%;
align-items: center;
gap: 0.75rem;
min-width: 0;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
text-align: left;
@@ -62,6 +63,10 @@ const emit = defineEmits<{
transition: all 0.15s ease;
}
.quick-menu-item i {
flex-shrink: 0;
}
.quick-menu-item:hover {
background: rgba(0, 113, 227, 0.06);
color: #0071e3;
+21 -4
View File
@@ -103,7 +103,7 @@
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'spark'"
@click="selectOrigin('spark')"
>
Spark
</button>
@@ -116,7 +116,7 @@
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'apm'"
@click="selectOrigin('apm')"
>
APM
</button>
@@ -186,7 +186,7 @@
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>收藏</span>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
@@ -332,6 +332,7 @@
:logged-in="loggedIn"
:can-submit="isinstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
@@ -502,7 +503,7 @@ import {
getHybridDefaultOrigin,
} from "../global/storeConfig";
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
import type { App, ReviewTags } from "../global/typedefinition";
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
const attrs = useAttrs();
@@ -515,6 +516,8 @@ const props = defineProps<{
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
@@ -526,6 +529,8 @@ const emit = defineEmits<{
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
(e: "check-install", app: App): void;
(e: "select-origin", origin: "spark" | "apm"): void;
(e: "show-user", review: AppReview): void;
}>();
const appPkgname = computed(() => props.app?.pkgname);
@@ -655,6 +660,13 @@ const activeReviewTags = computed<ReviewTags | null>(() => {
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const downloadCount = ref<string>("");
// 监听 app 变化,获取新app的下载量
@@ -708,6 +720,11 @@ const handleFavorite = () => {
emit("favorite", displayApp.value);
};
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
};
const openPreview = (index: number) => {
emit("open-preview", index);
};
+15 -2
View File
@@ -123,7 +123,7 @@
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>收藏</span>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
@@ -202,6 +202,7 @@
:logged-in="loggedIn"
:can-submit="isInstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
@@ -251,7 +252,7 @@ import {
buildReviewTags,
getDisplayApp,
} from "@/modules/appIdentity";
import type { App, ReviewTags } from "@/global/typedefinition";
import type { App, AppReview, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
app: App;
@@ -261,6 +262,8 @@ const props = defineProps<{
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
@@ -272,6 +275,8 @@ const emit = defineEmits<{
"open-preview": [index: number];
"open-app": [pkgname: string, origin?: "spark" | "apm"];
"check-install": [app: App];
"select-origin": [origin: "spark" | "apm"];
"show-user": [review: AppReview];
}>();
const viewingOrigin = ref<"spark" | "apm">(
@@ -351,8 +356,16 @@ const reviewTags = computed<ReviewTags | null>(() => {
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
if (displayApp.value) emit("check-install", displayApp.value);
};
+31 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col gap-6">
<div class="flex items-start justify-between gap-3">
<div class="relative min-w-0 flex-1">
<div ref="accountMenuRoot" class="relative min-w-0 flex-1">
<button
type="button"
class="flex w-full min-w-0 items-center gap-3 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
@@ -109,7 +109,7 @@
v-if="canManageApps"
type="button"
class="sidebar-tab"
@click="$emit('list')"
@click="emitSidebarAction('list')"
>
<span class="sidebar-tab-icon"><i class="fas fa-download"></i></span>
<span class="sidebar-tab-label">应用管理</span>
@@ -118,7 +118,7 @@
v-if="canOpenUpdateCenter"
type="button"
class="sidebar-tab"
@click="$emit('update')"
@click="emitSidebarAction('update')"
>
<span class="sidebar-tab-icon"><i class="fas fa-sync-alt"></i></span>
<span class="sidebar-tab-label">软件更新</span>
@@ -128,7 +128,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from "../assets/imgs/spark-store.svg";
@@ -161,6 +161,7 @@ const emit = defineEmits<{
}>();
const showAccountMenu = ref(false);
const accountMenuRoot = ref<HTMLElement | null>(null);
const accountLabel = computed(() => {
return props.currentUser
@@ -177,6 +178,21 @@ const handleAccountClick = () => {
showAccountMenu.value = !showAccountMenu.value;
};
const handleDocumentPointerDown = (event: MouseEvent) => {
if (!showAccountMenu.value) return;
const target = event.target;
if (target instanceof Node && accountMenuRoot.value?.contains(target)) return;
showAccountMenu.value = false;
};
onMounted(() => {
document.addEventListener("mousedown", handleDocumentPointerDown);
});
onUnmounted(() => {
document.removeEventListener("mousedown", handleDocumentPointerDown);
});
const emitAccountAction = (
action:
| "open-user-management"
@@ -207,8 +223,15 @@ const canManageApps = computed(() => {
const canOpenUpdateCenter = canManageApps;
const selectTab = (tab: string) => {
showAccountMenu.value = false;
emit("select-tab", tab);
};
const emitSidebarAction = (action: "list" | "update") => {
showAccountMenu.value = false;
if (action === "list") emit("list");
else emit("update");
};
</script>
<style scoped>
@@ -263,5 +286,9 @@ const selectTab = (tab: string) => {
.sidebar-tab-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+15
View File
@@ -109,6 +109,9 @@
>
选择可安装
</button>
<span class="self-center text-sm text-slate-500 dark:text-slate-400">
{{ installableSelectionMessage }}
</span>
<button
type="button"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900"
@@ -163,6 +166,18 @@ const selectedInstallableItems = computed(() =>
),
);
const installableItemCount = computed(
() => props.items.filter((item) => item.status === "installable").length,
);
const installableSelectionMessage = computed(() => {
if (selectedInstallableItems.value.length > 0) {
return `已选择 ${selectedInstallableItems.value.length} 个可安装应用`;
}
if (installableItemCount.value === 0) return "当前收藏夹没有可安装应用";
return `${installableItemCount.value} 个应用可安装`;
});
watch(
() => props.items,
() => {
+48 -19
View File
@@ -11,40 +11,57 @@
aria-label="选择收藏夹"
>
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
添加到收藏夹
管理收藏夹
</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
要保存当前应用的收藏夹
选要保存当前应用的收藏夹取消勾选可移出收藏
</p>
<div class="mt-5 space-y-2">
<button
<label
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')"
class="flex w-full items-center gap-3 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"
>
默认收藏夹
</button>
<button
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label="收藏到 默认收藏夹"
value="default"
/>
<span>默认收藏夹</span>
</label>
<label
v-for="folder in folders"
:key="folder.id"
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', folder.id)"
class="flex w-full items-center gap-3 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"
>
{{ folder.name }}
</button>
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
:aria-label="`收藏到 ${folder.name}`"
:value="folder.id"
/>
<span>{{ folder.name }}</span>
</label>
</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')"
@click="emit('create-folder', [...draftSelectedIds])"
>
新建收藏夹
</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"
@click="emit('save-selection', [...draftSelectedIds])"
>
保存收藏夹
</button>
<button
type="button"
class="mt-2 w-full rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('close')"
>
取消
@@ -54,21 +71,33 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import type { FavoriteFolder } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
folders: FavoriteFolder[];
selectedFolderIds?: Array<number | "default">;
}>();
const hasDefaultFolder = computed(() =>
props.folders.some((folder) => folder.name === "默认收藏夹"),
props.folders.some((folder) => folder.name.trim() === "默认收藏夹"),
);
const emit = defineEmits<{
close: [];
"select-folder": [folderId: number | "default"];
"create-folder": [];
"save-selection": [folderIds: Array<number | "default">];
"create-folder": [folderIds: Array<number | "default">];
}>();
const draftSelectedIds = ref<Array<number | "default">>([]);
watch(
() => [props.show, props.selectedFolderIds] as const,
() => {
if (!props.show) return;
draftSelectedIds.value = [...(props.selectedFolderIds ?? [])];
},
{ immediate: true },
);
</script>
+7
View File
@@ -100,6 +100,12 @@
<div
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
>
<div
v-if="syncMessage"
class="rounded-2xl border border-brand/20 bg-brand/5 px-4 py-3 text-sm font-medium text-brand dark:bg-brand/10"
>
{{ syncMessage }}
</div>
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
@@ -239,6 +245,7 @@ const props = defineProps<{
apmAvailable: boolean;
loggedIn: boolean;
syncing: boolean;
syncMessage: string;
}>();
const emit = defineEmits<{
+140
View File
@@ -0,0 +1,140 @@
<template>
<div
v-if="show && profile"
ref="dialogRef"
class="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户资料"
tabindex="-1"
@click.self="emit('close')"
@keydown.esc="emit('close')"
>
<section
class="w-full max-w-md overflow-hidden rounded-3xl border border-white/20 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<div
data-testid="review-user-cover"
class="relative h-32 bg-gradient-to-br from-brand via-sky-500 to-violet-500"
:style="coverStyle"
>
<button
ref="closeButtonRef"
type="button"
class="absolute top-3 right-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-black/45"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="px-6 pb-6">
<div class="-mt-10 flex items-end gap-4">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
:alt="profile.displayName"
class="h-20 w-20 rounded-3xl border-4 border-white bg-white object-cover shadow-lg dark:border-slate-900 dark:bg-slate-800"
/>
<div
v-else
class="flex h-20 w-20 items-center justify-center rounded-3xl border-4 border-white bg-slate-900 text-3xl font-semibold text-white shadow-lg dark:border-slate-900"
>
{{ initial }}
</div>
<div class="min-w-0 pb-1">
<h2
class="truncate text-2xl font-bold text-slate-900 dark:text-white"
>
{{ profile.displayName }}
</h2>
<p v-if="profile.username" class="text-sm text-slate-500">
@{{ profile.username }}
</p>
</div>
</div>
<div
v-if="profile.forumGroups?.length"
class="mt-5 flex flex-wrap gap-2"
>
<span
v-for="group in profile.forumGroups"
:key="group"
class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{{ group }}
</span>
</div>
<button
v-if="forumUrl"
type="button"
class="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
@click="emit('open-forum-profile', forumUrl)"
>
<i class="fas fa-up-right-from-square"></i>
查看论坛资料
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { FLARUM_PROFILE_URL } from "@/global/storeConfig";
import type { ReviewUserProfile } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
profile: ReviewUserProfile | null;
}>();
const emit = defineEmits<{
close: [];
"open-forum-profile": [url: string];
}>();
const dialogRef = ref<HTMLElement | null>(null);
const closeButtonRef = ref<HTMLButtonElement | null>(null);
const initial = computed(() => (props.profile?.displayName || "?").slice(0, 1));
const safeCoverUrl = computed(() => {
const raw = props.profile?.coverUrl;
if (!raw) return "";
try {
const parsed = new URL(raw);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: "";
} catch {
return "";
}
});
const coverStyle = computed(() => ({
backgroundImage: safeCoverUrl.value
? `url(${JSON.stringify(safeCoverUrl.value)})`
: "",
}));
const forumUrl = computed(() =>
props.profile?.username
? `${FLARUM_PROFILE_URL}/${props.profile.username}`
: "",
);
watch(
() => props.show && props.profile !== null,
async (visible) => {
if (!visible) return;
await nextTick();
if (closeButtonRef.value) {
closeButtonRef.value.focus();
} else {
dialogRef.value?.focus();
}
},
{ immediate: true },
);
</script>
+466 -38
View File
@@ -64,19 +64,32 @@
</p>
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
<label
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
>
<div class="block text-sm font-medium text-slate-600 dark:text-slate-300">
评分
<select
v-model.number="rating"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
<div
role="slider"
aria-label="评分"
:aria-valuemin="1"
:aria-valuemax="5"
:aria-valuenow="rating"
tabindex="0"
class="mt-2 inline-flex touch-none select-none items-center gap-1 text-2xl text-amber-400 outline-none transition focus:ring-2 focus:ring-amber-300"
@pointerdown="startRatingSlide"
@pointermove="moveRatingSlide"
@pointerup="stopRatingSlide"
@pointercancel="stopRatingSlide"
@keydown="handleRatingKeydown"
>
<option v-for="value in ratingOptions" :key="value" :value="value">
{{ value }}
</option>
</select>
</label>
<span
v-for="value in ratingOptions"
:key="value"
class="leading-none"
aria-hidden="true"
>
{{ value <= rating ? "★" : "☆" }}
</span>
</div>
</div>
<label
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
>
@@ -97,36 +110,81 @@
</button>
</form>
<p v-if="actionMessage" class="mb-3 text-sm text-blue-500">
{{ actionMessage }}
</p>
<div
v-if="reviews.length"
class="mb-3 grid gap-2 text-sm text-slate-600 sm:grid-cols-2 dark:text-slate-300"
>
<label class="block">
按架构筛选
<select
v-model="selectedPackageArch"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部架构</option>
<option v-for="arch in packageArchOptions" :key="arch" :value="arch">
{{ arch }}
</option>
</select>
</label>
<label class="block">
按发行版筛选
<select
v-model="selectedDistro"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部发行版</option>
<option v-for="distro in distroOptions" :key="distro" :value="distro">
{{ distro }}
</option>
</select>
</label>
</div>
<p v-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</p>
<div v-else-if="reviews.length" class="space-y-3">
<div v-else-if="filteredReviews.length" class="space-y-3">
<article
v-for="review in reviews"
v-for="review in filteredReviews"
:key="review.id"
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
>
<div class="flex gap-3">
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<div
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
<button
type="button"
class="h-9 w-9 flex-shrink-0 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400"
:aria-label="`查看${reviewerName(review)}的资料`"
@click="showReviewer(review)"
>
{{ review.userDisplayName?.slice(0, 1) || "星" }}
</div>
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${reviewerName(review)} 的头像`"
class="h-9 w-9 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<span
v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{{ reviewerName(review).slice(0, 1) }}
</span>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-slate-700 dark:text-slate-200">
{{ review.userDisplayName || "星火用户" }}
</strong>
<button
type="button"
class="min-w-0 truncate text-left font-semibold text-slate-700 hover:text-blue-600 dark:text-slate-200 dark:hover:text-blue-300"
@click="showReviewer(review)"
>
{{ reviewerName(review) }}
</button>
<span class="flex-shrink-0 text-xs text-slate-400"
>{{ review.rating }} </span
>
@@ -136,11 +194,148 @@
>
{{ review.content || "暂无评论内容" }}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReviewLike(review)"
>
点赞{{ review.likeCount ? ` ${review.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id)"
>
回复
</button>
<button
v-if="canDeleteReview(review)"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReview(review)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === undefined
"
class="mt-3 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
<div
v-if="review.replies?.length"
class="mt-3 space-y-2 border-l border-slate-200 pl-3 dark:border-slate-700"
>
<div
v-for="reply in flattenReplies(review.replies)"
:key="reply.id"
class="rounded-xl bg-slate-50 px-3 py-2 dark:bg-slate-800/60"
:style="{ marginLeft: `${reply.depth * 12}px` }"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-slate-700 dark:text-slate-200">
{{ reply.userDisplayName || "星火用户" }}
</span>
<span v-if="reply.isDeleted" class="text-xs text-slate-400"
>已删除</span
>
</div>
<p
class="mt-1 whitespace-pre-wrap text-slate-600 dark:text-slate-300"
>
{{ reply.content || "该回复已删除" }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReplyLike(review.id, reply)"
>
点赞{{ reply.likeCount ? ` ${reply.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id, reply.id)"
>
回复
</button>
<button
v-if="reply.canDelete"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReply(review.id, reply)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === reply.id
"
class="mt-2 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</article>
</div>
<p v-else class="text-sm text-slate-400">暂无评价</p>
<p v-else class="text-sm text-slate-400">
{{ reviews.length ? "没有符合筛选条件的评价" : "暂无评价" }}
</p>
</section>
</template>
@@ -148,12 +343,18 @@
import { computed, onMounted, ref, watch } from "vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
@@ -164,23 +365,37 @@ const props = withDefaults(
tags: ReviewTags;
loggedIn: boolean;
canSubmit?: boolean;
currentUserId?: number;
currentUserIsAdmin?: boolean;
}>(),
{ canSubmit: true },
{ canSubmit: true, currentUserId: undefined, currentUserIsAdmin: false },
);
const emit = defineEmits<{
"request-login": [message: string];
"show-user": [review: AppReview];
}>();
const ratingOptions = [5, 4, 3, 2, 1];
const ratingOptions = [1, 2, 3, 4, 5];
const rating = ref(5);
const ratingSliding = ref(false);
const content = ref("");
const reviews = ref<AppReview[]>([]);
const summary = ref<RatingSummary | null>(null);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
const actionMessage = ref("");
const loadGeneration = ref(0);
const selectedPackageArch = ref("");
const selectedDistro = ref("");
const replyContent = ref("");
const replySubmitting = ref(false);
const replyTarget = ref<{ reviewId: number; parentId?: number } | null>(null);
interface FlatReply extends AppReviewReply {
depth: number;
}
const ratingText = computed(() => {
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
@@ -189,24 +404,157 @@ const ratingText = computed(() => {
const canSubmit = computed(() => props.canSubmit);
const toReviewErrorMessage = (caught: unknown): string => {
const packageArchOptions = computed(() =>
[
...new Set(
reviews.value.map((review) => review.packageArch).filter(Boolean),
),
].sort(),
);
const distroOptions = computed(() =>
[
...new Set(reviews.value.map((review) => review.distro).filter(Boolean)),
].sort(),
);
const filteredReviews = computed(() =>
reviews.value.filter(
(review) =>
(!selectedPackageArch.value ||
review.packageArch === selectedPackageArch.value) &&
(!selectedDistro.value || review.distro === selectedDistro.value),
),
);
const toReviewErrorMessage = (
caught: unknown,
fallback = "发表评论失败",
): string => {
const message = caught instanceof Error ? caught.message : "";
if (message === "Network Error") {
return "无法连接星火账号服务,请稍后重试。";
}
return message || "发表评论失败";
if (message.includes("401")) {
return "登录状态已失效,请重新登录星火账号。";
}
return message || fallback;
};
const clampRating = (value: number): number => Math.min(5, Math.max(1, value));
const updateRatingFromPointer = (event: PointerEvent): void => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
const rect = target.getBoundingClientRect();
if (rect.width <= 0) return;
const eventWithClientX = event as PointerEvent & { clientX: number };
const ratio = (eventWithClientX.clientX - rect.left) / rect.width;
rating.value = clampRating(Math.ceil(ratio * 5));
};
const startRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = true;
const target = event.currentTarget;
if (target instanceof HTMLElement && "setPointerCapture" in target) {
target.setPointerCapture(event.pointerId);
}
updateRatingFromPointer(event);
};
const moveRatingSlide = (event: PointerEvent): void => {
updateRatingFromPointer(event);
};
const stopRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = false;
const target = event.currentTarget;
if (
target instanceof HTMLElement &&
"hasPointerCapture" in target &&
target.hasPointerCapture(event.pointerId)
) {
target.releasePointerCapture(event.pointerId);
}
};
const handleRatingKeydown = (event: KeyboardEvent): void => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
rating.value = clampRating(rating.value - 1);
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
rating.value = clampRating(rating.value + 1);
}
};
const hideAvatar = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
const reviewerName = (review: AppReview): string =>
review.userDisplayName || "星火用户";
const showReviewer = (review: AppReview) => {
actionMessage.value = `正在查看 ${reviewerName(review)} 的资料`;
emit("show-user", review);
};
const canDeleteReview = (review: AppReview): boolean =>
review.canDelete === true;
const flattenReplies = (items: AppReviewReply[] = [], depth = 0): FlatReply[] =>
items.flatMap((reply) => [
{ ...reply, depth },
...flattenReplies(reply.replies, depth + 1),
]);
const startReply = (reviewId: number, parentId?: number) => {
replyTarget.value = { reviewId, parentId };
replyContent.value = "";
actionMessage.value = "";
};
const cancelReply = () => {
replyTarget.value = null;
replyContent.value = "";
};
const toReviewActionErrorMessage = (
caught: unknown,
fallback = "操作失败,请稍后重试。",
): string => {
return toReviewErrorMessage(caught, fallback);
};
const resetFilters = () => {
selectedPackageArch.value = "";
selectedDistro.value = "";
};
const resetStaleFilters = () => {
if (
selectedPackageArch.value &&
!packageArchOptions.value.includes(selectedPackageArch.value)
) {
selectedPackageArch.value = "";
}
if (
selectedDistro.value &&
!distroOptions.value.includes(selectedDistro.value)
) {
selectedDistro.value = "";
}
};
const clearReviewState = () => {
loadGeneration.value += 1;
reviews.value = [];
summary.value = null;
loading.value = false;
error.value = "";
actionMessage.value = "";
};
const loadReviews = async () => {
@@ -227,9 +575,10 @@ const loadReviews = async () => {
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
summary.value = nextSummary;
reviews.value = nextReviews;
resetStaleFilters();
} catch (caught: unknown) {
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
error.value = (caught as Error)?.message || "加载评价失败";
error.value = toReviewErrorMessage(caught, "加载评价失败");
} finally {
if (generation === loadGeneration.value && appKey === props.appKey) {
loading.value = false;
@@ -262,6 +611,85 @@ const submit = async () => {
}
};
const toggleReviewLike = async (review: AppReview) => {
try {
await likeReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const toggleReplyLike = async (reviewId: number, reply: AppReviewReply) => {
try {
await likeReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const submitReply = async () => {
const target = replyTarget.value;
const trimmed = replyContent.value.trim();
if (!target || trimmed === "") return;
replySubmitting.value = true;
try {
await createReviewReply(props.appKey, target.reviewId, {
content: trimmed,
...(target.parentId === undefined ? {} : { parentId: target.parentId }),
});
cancelReply();
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再回复。",
);
} finally {
replySubmitting.value = false;
}
};
const removeReview = async (review: AppReview) => {
if (!canDeleteReview(review)) return;
try {
await deleteReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
const removeReply = async (reviewId: number, reply: AppReviewReply) => {
if (!reply.canDelete) return;
try {
await deleteReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
onMounted(loadReviews);
watch(() => [props.appKey, props.loggedIn], loadReviews);
watch(
() => props.appKey,
() => {
resetFilters();
void loadReviews();
},
);
watch(() => props.loggedIn, loadReviews);
</script>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户管理"
>
<button
type="button"
class="absolute inset-0 cursor-default"
aria-label="关闭用户管理"
@click="emit('close')"
></button>
<div
class="relative z-10 flex h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950"
>
<header
class="flex shrink-0 items-center justify-between gap-4 border-b border-slate-200 px-5 py-4 dark:border-slate-800"
>
<div class="min-w-0">
<p class="text-xs uppercase tracking-[0.3em] text-slate-500">
Account Center
</p>
<h2
class="truncate text-lg font-semibold text-slate-900 dark:text-white"
>
星火账号用户管理
</h2>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 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"
aria-label="刷新"
@click="refreshFrame"
>
<i class="fas fa-rotate-right"></i>
<span class="hidden sm:inline">刷新</span>
</button>
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 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"
aria-label="在浏览器中打开"
@click="openInBrowser"
>
<i class="fas fa-up-right-from-square"></i>
<span class="hidden sm:inline">浏览器打开</span>
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 transition hover:text-slate-900 dark:bg-slate-800 dark:text-slate-300 dark:hover:text-white"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
</header>
<div class="relative min-h-0 flex-1 bg-slate-100 dark:bg-slate-900">
<iframe
v-if="!loadFailed"
:key="frameVersion"
class="h-full w-full border-0 bg-white"
title="星火账号用户管理"
:src="accountFrameUrl"
referrerpolicy="no-referrer"
sandbox="allow-forms allow-popups allow-same-origin allow-scripts"
@error="handleFrameError"
></iframe>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-4 p-8 text-center"
>
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-300"
>
<i class="fas fa-triangle-exclamation"></i>
</div>
<div>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
账号页面加载失败
</p>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
请检查网络连接后重试
</p>
</div>
<button
type="button"
class="rounded-full bg-sky-600 px-5 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
@click="retryFrame"
>
重试
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { SPARK_ACCOUNT_CENTER_URL } from "@/global/storeConfig";
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
user: SparkUser;
downloadedApps: DownloadedAppRecord[];
syncEnabled: boolean;
loading: boolean;
error: string;
syncing?: boolean;
syncMessage?: string;
}>();
const emit = defineEmits<{
close: [];
"open-forum": [];
"edit-profile": [];
"toggle-sync": [enabled: boolean];
"sync-now": [];
"refresh-downloads": [];
}>();
const loadFailed = ref(false);
const frameVersion = ref(0);
const accountFrameUrl = computed(() =>
buildAccountFrameUrl(SPARK_ACCOUNT_CENTER_URL, props.user.username),
);
const refreshFrame = () => {
loadFailed.value = false;
frameVersion.value += 1;
};
const retryFrame = () => {
refreshFrame();
};
const handleFrameError = () => {
loadFailed.value = true;
};
const openInBrowser = () => {
window.open(accountFrameUrl.value, "_blank", "noopener,noreferrer");
};
</script>
+10
View File
@@ -2,6 +2,12 @@
<section
class="space-y-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div
v-if="user.coverUrl"
data-testid="profile-cover"
class="h-32 rounded-2xl bg-cover bg-center sm:h-40"
:style="coverStyle"
></div>
<div
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
>
@@ -179,6 +185,10 @@ const userInitial = computed(() =>
(props.user.displayName || props.user.username || "?").slice(0, 1),
);
const coverStyle = computed(() => ({
backgroundImage: props.user.coverUrl ? `url("${props.user.coverUrl}")` : "",
}));
const visibleForumGroups = computed(() =>
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
);
+65
View File
@@ -0,0 +1,65 @@
<template>
<header
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white/95 px-3 text-slate-700 backdrop-blur dark:border-slate-800/70 dark:bg-slate-950/95 dark:text-slate-200"
>
<div class="flex min-w-0 items-center gap-2">
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span>
<span class="truncate text-sm font-semibold">星火应用商店</span>
</div>
<div class="window-titlebar-controls flex items-center gap-1">
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最小化"
title="最小化"
@click="minimize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最大化或还原"
title="最大化或还原"
@click="toggleMaximize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-red-500 hover:text-white"
aria-label="关闭"
title="关闭"
@click="close"
>
×
</button>
</div>
</header>
</template>
<script setup lang="ts">
const minimize = () => {
window.windowControls.minimize();
};
const toggleMaximize = () => {
window.windowControls.toggleMaximize();
};
const close = () => {
window.windowControls.close();
};
</script>
<style scoped>
.window-titlebar {
-webkit-app-region: drag;
}
.window-titlebar-controls,
.window-titlebar-controls button {
-webkit-app-region: no-drag;
}
</style>
+1
View File
@@ -14,6 +14,7 @@ const isSparkUser = (value: unknown): value is SparkUser => {
typeof user.username === "string" &&
typeof user.displayName === "string" &&
typeof user.avatarUrl === "string" &&
(user.coverUrl === undefined || typeof user.coverUrl === "string") &&
typeof user.forumLevel === "string" &&
Array.isArray(user.forumGroups) &&
user.forumGroups.every((group) => typeof group === "string")
+4
View File
@@ -13,6 +13,10 @@ export const SPARK_BACKEND_BASE_URL: string =
import.meta.env.VITE_SPARK_BACKEND_BASE_URL ||
DEFAULT_SPARK_BACKEND_BASE_URL;
export const SPARK_ACCOUNT_CENTER_URL: string =
import.meta.env.VITE_SPARK_ACCOUNT_CENTER_URL ||
"https://account.spark-app.store/account";
export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
+33
View File
@@ -256,10 +256,19 @@ export interface SparkUser {
username: string;
displayName: string;
avatarUrl: string;
coverUrl?: string;
forumLevel: string;
forumGroups: string[];
}
export interface ReviewUserProfile {
displayName: string;
username?: string;
avatarUrl?: string;
coverUrl?: string;
forumGroups?: string[];
}
export interface AuthSession {
accessToken: string;
tokenType: "bearer";
@@ -287,8 +296,26 @@ export interface RatingSummary {
starCounts: Record<number, number>;
}
export interface AppReviewReply {
id: number;
reviewId: number;
parentId: number | null;
content: string;
createdAt: string;
updatedAt: string;
userDisplayName: string;
userAvatarUrl: string;
likeCount: number;
likedByCurrentUser: boolean;
canDelete: boolean;
isAuthor: boolean;
isDeleted: boolean;
replies: AppReviewReply[];
}
export interface AppReview {
id: number;
userId?: number;
rating: number;
content: string;
version: string;
@@ -301,6 +328,12 @@ export interface AppReview {
updatedAt: string;
userDisplayName: string;
userAvatarUrl: string;
likeCount?: number;
likedByCurrentUser?: boolean;
canDelete?: boolean;
isAuthor?: boolean;
isDeleted?: boolean;
replies?: AppReviewReply[];
}
export interface FavoriteFolder {
+26
View File
@@ -0,0 +1,26 @@
export const FALLBACK_ACCOUNT_CENTER_URL =
"https://account.spark-app.store/account";
export const buildAccountFrameUrl = (
baseUrl: string,
username: string,
): string => {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
url = new URL(FALLBACK_ACCOUNT_CENTER_URL);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
url = new URL(FALLBACK_ACCOUNT_CENTER_URL);
}
const allowedParams = new URLSearchParams();
allowedParams.set("view", "management");
allowedParams.set("user", username);
url.search = allowedParams.toString();
return url.toString();
};
+23
View File
@@ -51,3 +51,26 @@ export const mergeInstalledApps = (
...refreshedApps,
];
};
export const resolveCloudInstallCandidate = (
item: SyncedAppListItem,
apps: App[],
): App | null => {
const exactMatch = apps.find(
(app) =>
app.pkgname === item.pkgname &&
app.origin === item.origin &&
app.category === item.category,
);
const sameOriginPackageMatch = apps.find(
(app) => app.pkgname === item.pkgname && app.origin === item.origin,
);
return (
exactMatch ??
sameOriginPackageMatch ??
apps.find((app) => app.pkgname === item.pkgname) ??
null
);
};
+128 -1
View File
@@ -4,6 +4,7 @@ import pino from "pino";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type {
AppReview,
AppReviewReply,
AuthSession,
DownloadedAppList,
DownloadedAppRecord,
@@ -75,7 +76,10 @@ const normalizeBackendMutationError = (error: unknown): Error => {
}
const status = error.response.status;
if (status === 401 || status === 403) {
if (status === 401) {
return new Error("登录状态已失效,请重新登录星火账号。");
}
if (status === 403) {
return new Error("请登录星火账号后重试。");
}
if (status === 422) {
@@ -122,12 +126,14 @@ const toUser = (raw: ApiRecord): SparkUser => ({
username: String(raw.username || ""),
displayName: String(raw.display_name || raw.username || ""),
avatarUrl: String(raw.avatar_url || ""),
coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined,
forumLevel: String(raw.forum_level || "论坛用户"),
forumGroups: parseForumGroups(raw.forum_groups),
});
const toReview = (raw: ApiRecord): AppReview => ({
id: Number(raw.id),
userId: raw.user_id === undefined ? undefined : Number(raw.user_id),
rating: Number(raw.rating),
content: String(raw.content || ""),
version: String(raw.version || "unknown"),
@@ -140,6 +146,47 @@ const toReview = (raw: ApiRecord): AppReview => ({
updatedAt: String(raw.updated_at || ""),
userDisplayName: String(raw.user_display_name || ""),
userAvatarUrl: String(raw.user_avatar_url || ""),
likeCount: Number(raw.like_count || 0),
likedByCurrentUser: Boolean(raw.liked_by_current_user),
canDelete: raw.can_delete === undefined ? undefined : Boolean(raw.can_delete),
isAuthor: raw.is_author === undefined ? undefined : Boolean(raw.is_author),
isDeleted: Boolean(raw.is_deleted),
replies: asApiRecordArray(raw.replies).map(toReviewReply),
});
const toReviewReply = (raw: ApiRecord): AppReviewReply => ({
id: Number(raw.id),
reviewId: Number(raw.review_id),
parentId:
raw.parent_id === null || raw.parent_id === undefined
? null
: Number(raw.parent_id),
content: String(raw.content || ""),
createdAt: String(raw.created_at || ""),
updatedAt: String(raw.updated_at || ""),
userDisplayName: String(raw.user_display_name || ""),
userAvatarUrl: String(raw.user_avatar_url || ""),
likeCount: Number(raw.like_count || 0),
likedByCurrentUser: Boolean(raw.liked_by_current_user),
canDelete: Boolean(raw.can_delete),
isAuthor: Boolean(raw.is_author),
isDeleted: Boolean(raw.is_deleted),
replies: asApiRecordArray(raw.replies).map(toReviewReply),
});
export interface ReviewActionState {
likedByCurrentUser: boolean;
likeCount: number;
}
export interface CreateReviewReplyPayload {
content: string;
parentId?: number;
}
const toReviewActionState = (raw: ApiRecord): ReviewActionState => ({
likedByCurrentUser: Boolean(raw.liked_by_current_user),
likeCount: Number(raw.like_count || 0),
});
const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
@@ -289,6 +336,86 @@ export const submitReview = async (
return toReview(asApiRecord(response.data));
};
export const likeReview = async (
appKey: string,
reviewId: number,
): Promise<ReviewActionState> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/like`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewActionState(asApiRecord(response.data));
};
export const createReviewReply = async (
appKey: string,
reviewId: number,
payload: CreateReviewReplyPayload,
): Promise<AppReviewReply> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies`,
{
content: payload.content,
...(payload.parentId === undefined
? {}
: { parent_id: payload.parentId }),
},
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewReply(asApiRecord(response.data));
};
export const deleteReview = async (
appKey: string,
reviewId: number,
): Promise<void> => {
try {
await backend.delete(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
};
export const likeReviewReply = async (
appKey: string,
reviewId: number,
replyId: number,
): Promise<ReviewActionState> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}/like`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewActionState(asApiRecord(response.data));
};
export const deleteReviewReply = async (
appKey: string,
reviewId: number,
replyId: number,
): Promise<void> => {
try {
await backend.delete(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
};
export const listFavoriteFolders = async (): Promise<FavoriteFolder[]> => {
const response = await backend.get("/me/favorite-folders");
return asApiRecordArray(response.data).map(toFavoriteFolder);
+7
View File
@@ -20,6 +20,7 @@ declare global {
apm_store: {
arch: string;
};
windowControls: WindowControlBridge;
updateCenter: UpdateCenterBridge;
}
}
@@ -31,6 +32,12 @@ interface IpcRendererFacade {
invoke: import("electron").IpcRenderer["invoke"];
}
interface WindowControlBridge {
minimize: () => void;
toggleMaximize: () => void;
close: () => void;
}
// IPC channel type definitions
declare interface IpcChannels {
"get-app-version": () => string;