mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 06:03:49 +08:00
feat(account): polish reviews favorites and account UI
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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]) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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")',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Vendored
+7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user