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: "星火应用商店",
|
title: "星火应用商店",
|
||||||
width: 1366,
|
width: 1366,
|
||||||
height: 768,
|
height: 768,
|
||||||
|
frame: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -307,6 +308,27 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
|
|||||||
nativeTheme.themeSource = theme;
|
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(
|
const SPARK_CONFIG_DIR = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ type IpcRendererFacade = {
|
|||||||
invoke: typeof ipcRenderer.invoke;
|
invoke: typeof ipcRenderer.invoke;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WindowControlBridge = {
|
||||||
|
minimize: () => void;
|
||||||
|
toggleMaximize: () => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||||
type UpdateCenterStartTask = {
|
type UpdateCenterStartTask = {
|
||||||
taskKey: string;
|
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", {
|
contextBridge.exposeInMainWorld("updateCenter", {
|
||||||
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
|
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
|
||||||
ipcRenderer.invoke("update-center-open", storeFilter),
|
ipcRenderer.invoke("update-center-open", storeFilter),
|
||||||
|
|||||||
+311
-47
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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"
|
||||||
>
|
>
|
||||||
|
<WindowTitleBar />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col lg:flex-row">
|
||||||
<!-- 移动端侧边栏遮罩 -->
|
<!-- 移动端侧边栏遮罩 -->
|
||||||
<div
|
<div
|
||||||
v-if="isSidebarOpen"
|
v-if="isSidebarOpen"
|
||||||
@@ -10,7 +13,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<aside
|
<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="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="
|
:class="
|
||||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
"
|
"
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
|
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<div
|
<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"
|
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
|
<AppHeader
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
@@ -55,30 +58,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CategoryBar
|
<CategoryBar
|
||||||
v-if="activeTab !== 'home' && Object.keys(categories).length > 0"
|
v-if="
|
||||||
|
currentView === 'default' &&
|
||||||
|
activeTab !== 'home' &&
|
||||||
|
Object.keys(categories).length > 0
|
||||||
|
"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
:selected-category="selectedCategory"
|
:selected-category="selectedCategory"
|
||||||
:category-counts="categoryCounts"
|
:category-counts="categoryCounts"
|
||||||
@select-category="selectSubCategory"
|
@select-category="selectSubCategory"
|
||||||
/>
|
/>
|
||||||
<div class="px-4 py-6 lg:px-10">
|
<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"
|
|
||||||
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
|
|
||||||
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
|
|
||||||
@toggle-sync="setInstalledSyncEnabled"
|
|
||||||
@sync-now="syncInstalledAppsNow"
|
|
||||||
@refresh-downloads="loadDownloadedHistory"
|
|
||||||
/>
|
|
||||||
<FavoriteFolderManager
|
<FavoriteFolderManager
|
||||||
v-else-if="currentView === 'favorites'"
|
v-if="currentView === 'favorites'"
|
||||||
:folders="favoriteFolders"
|
:folders="favoriteFolders"
|
||||||
:active-folder-id="activeFavoriteFolderId"
|
:active-folder-id="activeFavoriteFolderId"
|
||||||
:items="resolvedFavoriteItems"
|
:items="resolvedFavoriteItems"
|
||||||
@@ -111,6 +103,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AppDetailModal
|
<AppDetailModal
|
||||||
data-app-modal="detail"
|
data-app-modal="detail"
|
||||||
@@ -122,6 +115,9 @@
|
|||||||
:logged-in="isLoggedIn"
|
:logged-in="isLoggedIn"
|
||||||
:review-app-key="currentReviewAppKey"
|
:review-app-key="currentReviewAppKey"
|
||||||
:review-tags="currentReviewTags"
|
:review-tags="currentReviewTags"
|
||||||
|
:favorited="currentFavoriteMetadata.favorited"
|
||||||
|
:favorite-folder-name="currentFavoriteMetadata.folderName"
|
||||||
|
@select-origin="selectDetailOrigin"
|
||||||
@close="closeDetail"
|
@close="closeDetail"
|
||||||
@install="onDetailInstall"
|
@install="onDetailInstall"
|
||||||
@remove="onDetailRemove"
|
@remove="onDetailRemove"
|
||||||
@@ -130,6 +126,7 @@
|
|||||||
@open-preview="openScreenPreview"
|
@open-preview="openScreenPreview"
|
||||||
@open-app="openDownloadedApp"
|
@open-app="openDownloadedApp"
|
||||||
@check-install="checkAppInstalled"
|
@check-install="checkAppInstalled"
|
||||||
|
@show-user="openReviewUserProfile"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScreenPreview
|
<ScreenPreview
|
||||||
@@ -173,6 +170,7 @@
|
|||||||
:apm-available="apmAvailable"
|
:apm-available="apmAvailable"
|
||||||
:logged-in="isLoggedIn"
|
:logged-in="isLoggedIn"
|
||||||
:syncing="syncLoading"
|
:syncing="syncLoading"
|
||||||
|
:sync-message="syncStatusMessage"
|
||||||
@close="closeInstalledModal"
|
@close="closeInstalledModal"
|
||||||
@refresh="refreshInstalledApps"
|
@refresh="refreshInstalledApps"
|
||||||
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
|
||||||
@@ -243,11 +241,37 @@
|
|||||||
@register="openExternalUrl(FLARUM_REGISTER_URL)"
|
@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
|
<FavoriteFolderSelector
|
||||||
:show="showFavoriteSelector"
|
:show="showFavoriteSelector"
|
||||||
:folders="favoriteFolders"
|
:folders="favoriteFolders"
|
||||||
|
:selected-folder-ids="currentFavoriteFolderIds"
|
||||||
@close="showFavoriteSelector = false"
|
@close="showFavoriteSelector = false"
|
||||||
@select-folder="addCurrentFavoriteToFolder"
|
@save-selection="saveCurrentFavoriteFolders"
|
||||||
@create-folder="createFavoriteFolderFromSelector"
|
@create-folder="createFavoriteFolderFromSelector"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +301,9 @@ import LoginModal from "./components/LoginModal.vue";
|
|||||||
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
||||||
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
|
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
|
||||||
import FavoriteFolderManager from "./components/FavoriteFolderManager.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 {
|
import {
|
||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
FLARUM_BASE_URL,
|
FLARUM_BASE_URL,
|
||||||
@@ -310,6 +336,7 @@ import {
|
|||||||
addFavoriteItem,
|
addFavoriteItem,
|
||||||
bulkDeleteFavoriteItems,
|
bulkDeleteFavoriteItems,
|
||||||
createFavoriteFolder,
|
createFavoriteFolder,
|
||||||
|
deleteFavoriteItem,
|
||||||
exchangeFlarumToken,
|
exchangeFlarumToken,
|
||||||
fetchSyncedAppList,
|
fetchSyncedAppList,
|
||||||
listDownloadedApps,
|
listDownloadedApps,
|
||||||
@@ -345,6 +372,7 @@ import {
|
|||||||
cloudItemKey,
|
cloudItemKey,
|
||||||
cloudPackageKey,
|
cloudPackageKey,
|
||||||
mergeInstalledApps,
|
mergeInstalledApps,
|
||||||
|
resolveCloudInstallCandidate,
|
||||||
} from "./modules/appListSync";
|
} from "./modules/appListSync";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
@@ -366,6 +394,8 @@ import type {
|
|||||||
SystemInfo,
|
SystemInfo,
|
||||||
DownloadedAppRecord,
|
DownloadedAppRecord,
|
||||||
SyncedAppListItem,
|
SyncedAppListItem,
|
||||||
|
AppReview,
|
||||||
|
ReviewUserProfile,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -407,7 +437,7 @@ const isDarkTheme = computed(() => {
|
|||||||
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||||
const apps: Ref<App[]> = ref([]);
|
const apps: Ref<App[]> = ref([]);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
type MainView = "default" | "account" | "favorites";
|
type MainView = "default" | "favorites";
|
||||||
const currentView = ref<MainView>("default");
|
const currentView = ref<MainView>("default");
|
||||||
const selectedCategory = ref("all");
|
const selectedCategory = ref("all");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -434,14 +464,21 @@ const loginLoading = ref(false);
|
|||||||
const loginError = ref("");
|
const loginError = ref("");
|
||||||
const showLoginPrompt = ref(false);
|
const showLoginPrompt = ref(false);
|
||||||
const loginPromptMessage = ref("请登录星火账号后继续操作。");
|
const loginPromptMessage = ref("请登录星火账号后继续操作。");
|
||||||
|
const showUserManagementModal = ref(false);
|
||||||
|
const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
|
||||||
|
const showReviewUserProfileModal = ref(false);
|
||||||
const sparkAvailable = ref(false);
|
const sparkAvailable = ref(false);
|
||||||
const apmAvailable = ref(false);
|
const apmAvailable = ref(false);
|
||||||
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
||||||
const favoriteFolders = ref<FavoriteFolder[]>([]);
|
const favoriteFolders = ref<FavoriteFolder[]>([]);
|
||||||
const activeFavoriteFolderId = ref<number | null>(null);
|
const activeFavoriteFolderId = ref<number | null>(null);
|
||||||
const favoriteItems = ref<FavoriteItem[]>([]);
|
const favoriteItems = ref<FavoriteItem[]>([]);
|
||||||
|
const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
|
||||||
const showFavoriteSelector = ref(false);
|
const showFavoriteSelector = ref(false);
|
||||||
const favoriteTargetApp = ref<App | null>(null);
|
const favoriteTargetApp = ref<App | null>(null);
|
||||||
|
const favoriteSelectorDraftFolderIds = ref<Array<number | "default"> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const favoriteLoading = ref(false);
|
const favoriteLoading = ref(false);
|
||||||
const favoriteError = ref("");
|
const favoriteError = ref("");
|
||||||
const favoriteRequestGeneration = ref(0);
|
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[]>(() =>
|
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
||||||
resolveFavoriteItems(
|
resolveFavoriteItems(
|
||||||
favoriteItems.value,
|
favoriteItems.value,
|
||||||
@@ -851,6 +929,13 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
|||||||
currentAppSparkInstalled.value = false;
|
currentAppSparkInstalled.value = false;
|
||||||
currentAppApmInstalled.value = false;
|
currentAppApmInstalled.value = false;
|
||||||
checkAppInstalled(finalApp);
|
checkAppInstalled(finalApp);
|
||||||
|
if (
|
||||||
|
isLoggedIn.value &&
|
||||||
|
favoriteFolders.value.length === 0 &&
|
||||||
|
!favoriteLoading.value
|
||||||
|
) {
|
||||||
|
void loadFavoriteMetadataForDetail();
|
||||||
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const modal = document.querySelector(
|
const modal = document.querySelector(
|
||||||
@@ -1368,6 +1453,12 @@ const onDetailFavorite = async (app: App) => {
|
|||||||
await openFavoriteSelector(app);
|
await openFavoriteSelector(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectDetailOrigin = (origin: "spark" | "apm") => {
|
||||||
|
if (currentApp.value?.isMerged) {
|
||||||
|
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDetailRequestLogin = (message: string) => {
|
const handleDetailRequestLogin = (message: string) => {
|
||||||
requireLogin(message);
|
requireLogin(message);
|
||||||
};
|
};
|
||||||
@@ -1432,6 +1523,25 @@ const openExternalUrl = (url: string) => {
|
|||||||
window.open(url, "_blank", "noopener,noreferrer");
|
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 => {
|
const requireLogin = (message: string): boolean => {
|
||||||
if (isLoggedIn.value) return true;
|
if (isLoggedIn.value) return true;
|
||||||
loginPromptMessage.value = message;
|
loginPromptMessage.value = message;
|
||||||
@@ -1449,8 +1559,10 @@ const clearFavoriteState = () => {
|
|||||||
favoriteFolders.value = [];
|
favoriteFolders.value = [];
|
||||||
activeFavoriteFolderId.value = null;
|
activeFavoriteFolderId.value = null;
|
||||||
favoriteItems.value = [];
|
favoriteItems.value = [];
|
||||||
|
favoriteItemsByFolder.value = {};
|
||||||
showFavoriteSelector.value = false;
|
showFavoriteSelector.value = false;
|
||||||
favoriteTargetApp.value = null;
|
favoriteTargetApp.value = null;
|
||||||
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
favoriteLoading.value = false;
|
favoriteLoading.value = false;
|
||||||
favoriteError.value = "";
|
favoriteError.value = "";
|
||||||
};
|
};
|
||||||
@@ -1512,7 +1624,8 @@ const handleLogout = () => {
|
|||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
if (currentView.value === "favorites" || currentView.value === "account") {
|
showUserManagementModal.value = false;
|
||||||
|
if (currentView.value === "favorites") {
|
||||||
currentView.value = "default";
|
currentView.value = "default";
|
||||||
activeTab.value = "home";
|
activeTab.value = "home";
|
||||||
selectedCategory.value = "all";
|
selectedCategory.value = "all";
|
||||||
@@ -1691,13 +1804,7 @@ const openRestoreFromAccount = async (): Promise<void> => {
|
|||||||
|
|
||||||
const installCloudItems = (items: SyncedAppListItem[]): void => {
|
const installCloudItems = (items: SyncedAppListItem[]): void => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const candidates = apps.value.filter(
|
const app = resolveCloudInstallCandidate(item, apps.value);
|
||||||
(candidate) =>
|
|
||||||
candidate.pkgname === item.pkgname && candidate.origin === item.origin,
|
|
||||||
);
|
|
||||||
const app =
|
|
||||||
candidates.find((candidate) => candidate.category === item.category) ??
|
|
||||||
candidates[0];
|
|
||||||
if (!app) continue;
|
if (!app) continue;
|
||||||
void onDetailInstall(app);
|
void onDetailInstall(app);
|
||||||
}
|
}
|
||||||
@@ -1727,8 +1834,7 @@ const maybePromptInstalledSync = async (): Promise<void> => {
|
|||||||
|
|
||||||
const openUserManagement = async () => {
|
const openUserManagement = async () => {
|
||||||
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||||
currentView.value = "account";
|
showUserManagementModal.value = true;
|
||||||
activeTab.value = "account";
|
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
await loadDownloadedHistory();
|
await loadDownloadedHistory();
|
||||||
@@ -1762,9 +1868,57 @@ const loadActiveFavoriteItems = async (
|
|||||||
if (!isCurrentFavoriteRequest(generation)) return false;
|
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||||
|
|
||||||
favoriteItems.value = items;
|
favoriteItems.value = items;
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[activeFavoriteFolderId.value]: items,
|
||||||
|
};
|
||||||
return true;
|
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 refreshFavorites = async (): Promise<void> => {
|
||||||
const generation = nextFavoriteRequestGeneration();
|
const generation = nextFavoriteRequestGeneration();
|
||||||
favoriteLoading.value = true;
|
favoriteLoading.value = true;
|
||||||
@@ -1775,7 +1929,7 @@ const refreshFavorites = async (): Promise<void> => {
|
|||||||
loadFavoriteFolders(generation),
|
loadFavoriteFolders(generation),
|
||||||
]);
|
]);
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
await loadActiveFavoriteItems(generation);
|
await loadAllFavoriteItems(generation);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
@@ -1786,12 +1940,15 @@ const refreshFavorites = async (): Promise<void> => {
|
|||||||
|
|
||||||
const openFavoriteSelector = async (app: App) => {
|
const openFavoriteSelector = async (app: App) => {
|
||||||
if (!requireLogin("收藏应用需要登录星火账号。")) return;
|
if (!requireLogin("收藏应用需要登录星火账号。")) return;
|
||||||
const generation = favoriteRequestGeneration.value;
|
const generation = nextFavoriteRequestGeneration();
|
||||||
favoriteTargetApp.value = app;
|
favoriteTargetApp.value = app;
|
||||||
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
favoriteError.value = "";
|
favoriteError.value = "";
|
||||||
try {
|
try {
|
||||||
const foldersLoaded = await loadFavoriteFolders(generation);
|
const foldersLoaded = await loadFavoriteFolders(generation);
|
||||||
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
|
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||||
|
const itemsLoaded = await loadAllFavoriteItems(generation);
|
||||||
|
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||||
showFavoriteSelector.value = true;
|
showFavoriteSelector.value = true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
@@ -1799,33 +1956,126 @@ const openFavoriteSelector = async (app: App) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
|
const toFavoritePayload = (
|
||||||
const generation = favoriteRequestGeneration.value;
|
app: App,
|
||||||
const app = favoriteTargetApp.value;
|
): Omit<FavoriteItem, "id" | "createdAt"> => ({
|
||||||
if (!app) return;
|
|
||||||
try {
|
|
||||||
await addFavoriteItem(folderId, {
|
|
||||||
appKey: buildFavoriteAppKey(app),
|
appKey: buildFavoriteAppKey(app),
|
||||||
pkgname: app.pkgname,
|
pkgname: app.pkgname,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
category: app.category,
|
category: app.category,
|
||||||
iconUrl: app.icons,
|
iconUrl: app.icons,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveCurrentFavoriteFolders = async (
|
||||||
|
folderIds: Array<number | "default">,
|
||||||
|
) => {
|
||||||
|
const generation = favoriteRequestGeneration.value;
|
||||||
|
const app = favoriteTargetApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
try {
|
||||||
|
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;
|
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;
|
showFavoriteSelector.value = false;
|
||||||
favoriteTargetApp.value = null;
|
favoriteTargetApp.value = null;
|
||||||
await refreshFavorites();
|
favoriteSelectorDraftFolderIds.value = null;
|
||||||
|
if (includesFallbackDefault) await refreshFavorites();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
favoriteError.value = (error as Error)?.message || "添加收藏失败";
|
favoriteError.value = (error as Error)?.message || "更新收藏失败";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFavoriteFolderFromSelector = async () => {
|
const createFavoriteFolderFromSelector = async (
|
||||||
await createFavoriteFolderFromPrompt();
|
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;
|
const app = favoriteTargetApp.value;
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
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;
|
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 () => {
|
const openFavoriteManagement = async () => {
|
||||||
@@ -1862,8 +2112,16 @@ const createFavoriteFolderFromPrompt = async () => {
|
|||||||
try {
|
try {
|
||||||
const folder = await createFavoriteFolder(folderName);
|
const folder = await createFavoriteFolder(folderName);
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
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;
|
activeFavoriteFolderId.value = folder.id;
|
||||||
await refreshFavorites();
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||||
@@ -1880,6 +2138,12 @@ const removeSelectedFavorites = async (ids: number[]) => {
|
|||||||
try {
|
try {
|
||||||
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
|
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
favoriteItemsByFolder.value = {
|
||||||
|
...favoriteItemsByFolder.value,
|
||||||
|
[activeFavoriteFolderId.value]: (
|
||||||
|
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
|
||||||
|
).filter((favorite) => !ids.includes(favorite.id)),
|
||||||
|
};
|
||||||
await refreshFavorites();
|
await refreshFavorites();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!isCurrentFavoriteRequest(generation)) return;
|
if (!isCurrentFavoriteRequest(generation)) return;
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import App from "@/App.vue";
|
import App from "@/App.vue";
|
||||||
import {
|
import {
|
||||||
|
addFavoriteItem,
|
||||||
|
deleteFavoriteItem,
|
||||||
|
fetchRatingSummary,
|
||||||
|
fetchReviews,
|
||||||
fetchSyncedAppList,
|
fetchSyncedAppList,
|
||||||
|
createFavoriteFolder,
|
||||||
listDownloadedApps,
|
listDownloadedApps,
|
||||||
listFavoriteFolders,
|
listFavoriteFolders,
|
||||||
|
listFavoriteItems,
|
||||||
uploadSyncedAppList,
|
uploadSyncedAppList,
|
||||||
} from "@/modules/backendApi";
|
} from "@/modules/backendApi";
|
||||||
import { setAuthSession } from "@/global/authState";
|
import { setAuthSession } from "@/global/authState";
|
||||||
@@ -158,8 +164,39 @@ vi.mock("@/modules/backendApi", () => ({
|
|||||||
addFavoriteItem: vi.fn(),
|
addFavoriteItem: vi.fn(),
|
||||||
bulkDeleteFavoriteItems: vi.fn(),
|
bulkDeleteFavoriteItems: vi.fn(),
|
||||||
createFavoriteFolder: vi.fn(),
|
createFavoriteFolder: vi.fn(),
|
||||||
|
deleteFavoriteItem: vi.fn(),
|
||||||
exchangeFlarumToken: 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),
|
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([])),
|
listDownloadedApps: vi.fn(async () => downloadedList([])),
|
||||||
listFavoriteFolders: vi.fn(async () => favoriteFolders),
|
listFavoriteFolders: vi.fn(async () => favoriteFolders),
|
||||||
listFavoriteItems: vi.fn(async () => favoriteItems),
|
listFavoriteItems: vi.fn(async () => favoriteItems),
|
||||||
@@ -197,10 +234,36 @@ describe("App account placeholders", () => {
|
|||||||
username: "momen",
|
username: "momen",
|
||||||
displayName: "Momen",
|
displayName: "Momen",
|
||||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
coverUrl:
|
||||||
|
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
|
||||||
forumLevel: "管理员",
|
forumLevel: "管理员",
|
||||||
forumGroups: ["管理员"],
|
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(
|
vi.stubGlobal(
|
||||||
"matchMedia",
|
"matchMedia",
|
||||||
@@ -219,18 +282,91 @@ describe("App account placeholders", () => {
|
|||||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
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);
|
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(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByText("用户管理"));
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "用户管理" }),
|
await screen.findByRole("dialog", { name: "用户管理" }),
|
||||||
).toBeTruthy();
|
).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();
|
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 () => {
|
it("shows the favorites placeholder from the logged-in quick menu", async () => {
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
@@ -245,6 +381,301 @@ describe("App account placeholders", () => {
|
|||||||
expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
|
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 () => {
|
it("refreshes installed apps before resolving favorite management state", async () => {
|
||||||
invoke.mockImplementation(async (channel: string) => {
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
if (channel === "get-store-filter") return "both";
|
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(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByText("用户管理"));
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
expect(await screen.findByText("正在加载下载历史...")).toBeTruthy();
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
|
|
||||||
await fireEvent.click(
|
await fireEvent.click(
|
||||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
@@ -437,7 +868,8 @@ describe("App account placeholders", () => {
|
|||||||
}
|
}
|
||||||
await fireEvent.click(await screen.findByText("用户管理"));
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
secondHistory.resolve(downloadedList([]));
|
secondHistory.resolve(downloadedList([]));
|
||||||
expect(await screen.findByText("暂无下载记录。")).toBeTruthy();
|
const secondUserFrame = await screen.findByTitle("星火账号用户管理");
|
||||||
|
expect((secondUserFrame as HTMLIFrameElement).src).toContain("user=second");
|
||||||
|
|
||||||
firstHistory.resolve(
|
firstHistory.resolve(
|
||||||
downloadedList([
|
downloadedList([
|
||||||
@@ -459,7 +891,7 @@ describe("App account placeholders", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(screen.queryByText("旧账号应用")).toBeNull();
|
expect(screen.queryByText("旧账号应用")).toBeNull();
|
||||||
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
|
expect(screen.getByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores older downloaded history refreshes for the same user", async () => {
|
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(
|
firstHistory.resolve(
|
||||||
downloadedList([
|
downloadedList([
|
||||||
@@ -511,7 +943,7 @@ describe("App account placeholders", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(screen.queryByText("旧下载应用")).toBeNull();
|
expect(screen.queryByText("旧下载应用")).toBeNull();
|
||||||
expect(screen.getByText("新下载应用")).toBeTruthy();
|
expect(screen.queryByText("新下载应用")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores older favorite folder refreshes for the same user", async () => {
|
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(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByText("用户管理"));
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
await fireEvent.click(
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
await screen.findByRole("button", { name: "立即同步" }),
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
syncUpload.resolve(syncedList([]));
|
syncUpload.resolve(syncedList([]));
|
||||||
expect(await screen.findByText("同步完成")).toBeTruthy();
|
expect(uploadSyncedAppList).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears manual sync feedback before another user opens account management", async () => {
|
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(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByText("用户管理"));
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
await fireEvent.click(
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
await screen.findByRole("button", { name: "立即同步" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
syncUpload.resolve(syncedList([]));
|
syncUpload.resolve(syncedList([]));
|
||||||
expect(await screen.findByText("同步完成")).toBeTruthy();
|
expect(screen.queryByText("同步完成")).toBeNull();
|
||||||
|
|
||||||
await fireEvent.click(
|
await fireEvent.click(
|
||||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
@@ -735,6 +1154,7 @@ describe("App account placeholders", () => {
|
|||||||
}
|
}
|
||||||
await fireEvent.click(await screen.findByText("用户管理"));
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
|
|
||||||
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
expect(screen.queryByText("同步完成")).toBeNull();
|
expect(screen.queryByText("同步完成")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -769,9 +1189,7 @@ describe("App account placeholders", () => {
|
|||||||
|
|
||||||
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByText("用户管理"));
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
await fireEvent.click(
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
await screen.findByRole("button", { name: "立即同步" }),
|
|
||||||
);
|
|
||||||
await fireEvent.click(
|
await fireEvent.click(
|
||||||
await screen.findByRole("button", { name: /^Momen$/ }),
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
);
|
);
|
||||||
@@ -806,15 +1224,7 @@ describe("App account placeholders", () => {
|
|||||||
await fireEvent.click(secondUserButton);
|
await fireEvent.click(secondUserButton);
|
||||||
}
|
}
|
||||||
await fireEvent.click(await screen.findByText("用户管理"));
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
await fireEvent.click(
|
expect(await screen.findByTitle("星火账号用户管理")).toBeTruthy();
|
||||||
await screen.findByRole("button", { name: "立即同步" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(uploadSyncedAppList).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ items: [] }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const uploadedItemNames = vi
|
const uploadedItemNames = vi
|
||||||
.mocked(uploadSyncedAppList)
|
.mocked(uploadSyncedAppList)
|
||||||
.mock.calls.flatMap(([payload]) =>
|
.mock.calls.flatMap(([payload]) =>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ vi.mock("@/components/ReviewsPanel.vue", () => ({
|
|||||||
default: {
|
default: {
|
||||||
name: "ReviewsPanel",
|
name: "ReviewsPanel",
|
||||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||||
|
emits: ["request-login", "show-user"],
|
||||||
template:
|
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 () => {
|
it("updates review identity when switching a merged app origin", async () => {
|
||||||
render(AppDetailModal, {
|
const rendered = render(AppDetailModal, {
|
||||||
props: {
|
props: {
|
||||||
show: true,
|
show: true,
|
||||||
app: mergedApp,
|
app: mergedApp,
|
||||||
@@ -142,6 +143,7 @@ describe("AppDetailModal", () => {
|
|||||||
"data-version",
|
"data-version",
|
||||||
"1.0.0",
|
"1.0.0",
|
||||||
);
|
);
|
||||||
|
expect(rendered.emitted("select-origin")?.[0]?.[0]).toBe("apm");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks reviews read-only when the selected origin is not installed", () => {
|
it("marks reviews read-only when the selected origin is not installed", () => {
|
||||||
@@ -163,4 +165,48 @@ describe("AppDetailModal", () => {
|
|||||||
"false",
|
"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: {
|
default: {
|
||||||
name: "ReviewsPanel",
|
name: "ReviewsPanel",
|
||||||
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
props: ["appKey", "tags", "loggedIn", "canSubmit"],
|
||||||
|
emits: ["request-login", "show-user"],
|
||||||
template:
|
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",
|
"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();
|
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", () => {
|
it("keeps long account names inside the sidebar account entry", () => {
|
||||||
const longUser: SparkUser = {
|
const longUser: SparkUser = {
|
||||||
...user,
|
...user,
|
||||||
displayName: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
username: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
|
||||||
|
displayName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container } = render(AppSidebar, {
|
const { container } = render(AppSidebar, {
|
||||||
@@ -62,22 +74,43 @@ describe("AppSidebar account entry", () => {
|
|||||||
const textWrapper = accountButton.querySelector(
|
const textWrapper = accountButton.querySelector(
|
||||||
"[data-testid='account-text']",
|
"[data-testid='account-text']",
|
||||||
);
|
);
|
||||||
const accountName = screen.getByText(longUser.displayName);
|
const accountName = screen.getByText(longUser.username);
|
||||||
|
|
||||||
expect(textWrapper?.className).toContain("min-w-0");
|
expect(textWrapper?.className).toContain("min-w-0");
|
||||||
expect(accountName.className).toContain("truncate");
|
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, {
|
const rendered = render(AppSidebar, {
|
||||||
props: { ...baseProps, currentUser: user },
|
props: { ...baseProps, currentUser: user },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "用户管理" }));
|
await fireEvent.click(screen.getByRole("button", { name: label }));
|
||||||
|
|
||||||
expect(rendered.emitted("open-user-management")).toHaveLength(1);
|
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: "应用管理" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("list")).toHaveLength(1);
|
||||||
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,4 +88,69 @@ describe("FavoriteFolderManager", () => {
|
|||||||
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
|
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
|
||||||
expect(rendered.emitted("remove-selected")).toBeUndefined();
|
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(
|
expect(
|
||||||
1,
|
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 () => {
|
it("offers creating a folder while selecting favorites", async () => {
|
||||||
@@ -51,4 +64,75 @@ describe("FavoriteFolderSelector", () => {
|
|||||||
|
|
||||||
expect(rendered.emitted("create-folder")).toHaveLength(1);
|
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,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,6 +133,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,6 +153,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +173,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,6 +196,7 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,9 +220,30 @@ describe("InstalledAppsModal", () => {
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
syncing: true,
|
syncing: true,
|
||||||
|
syncMessage: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
|
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 ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
import {
|
import {
|
||||||
|
createReviewReply,
|
||||||
|
deleteReview,
|
||||||
|
deleteReviewReply,
|
||||||
fetchRatingSummary,
|
fetchRatingSummary,
|
||||||
fetchReviews,
|
fetchReviews,
|
||||||
|
likeReview,
|
||||||
|
likeReviewReply,
|
||||||
submitReview,
|
submitReview,
|
||||||
} from "@/modules/backendApi";
|
} from "@/modules/backendApi";
|
||||||
import type {
|
import type {
|
||||||
AppReview,
|
AppReview,
|
||||||
|
AppReviewReply,
|
||||||
RatingSummary,
|
RatingSummary,
|
||||||
ReviewTags,
|
ReviewTags,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
@@ -20,8 +26,13 @@ const emptySummary: RatingSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/modules/backendApi", () => ({
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
createReviewReply: vi.fn(),
|
||||||
|
deleteReview: vi.fn(),
|
||||||
|
deleteReviewReply: vi.fn(),
|
||||||
fetchRatingSummary: vi.fn(async () => emptySummary),
|
fetchRatingSummary: vi.fn(async () => emptySummary),
|
||||||
fetchReviews: vi.fn(async () => []),
|
fetchReviews: vi.fn(async () => []),
|
||||||
|
likeReview: vi.fn(),
|
||||||
|
likeReviewReply: vi.fn(),
|
||||||
submitReview: vi.fn(),
|
submitReview: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -35,13 +46,70 @@ const tags: ReviewTags = {
|
|||||||
distro: "deepin 25",
|
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", () => {
|
describe("ReviewsPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchRatingSummary).mockReset();
|
vi.mocked(fetchRatingSummary).mockReset();
|
||||||
vi.mocked(fetchReviews).mockReset();
|
vi.mocked(fetchReviews).mockReset();
|
||||||
vi.mocked(submitReview).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(fetchRatingSummary).mockResolvedValue(emptySummary);
|
||||||
vi.mocked(fetchReviews).mockResolvedValue([]);
|
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", () => {
|
it("shows anonymous login prompt and read-only review tags", () => {
|
||||||
@@ -171,6 +239,402 @@ describe("ReviewsPanel", () => {
|
|||||||
).toBeTruthy();
|
).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 () => {
|
it("shows reviewer avatars when available", async () => {
|
||||||
vi.mocked(fetchRatingSummary).mockResolvedValue({
|
vi.mocked(fetchRatingSummary).mockResolvedValue({
|
||||||
averageRating: 5,
|
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();
|
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",
|
username: "momen",
|
||||||
displayName: "Momen",
|
displayName: "Momen",
|
||||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
|
||||||
forumLevel: "管理员",
|
forumLevel: "管理员",
|
||||||
forumGroups: ["管理员"],
|
forumGroups: ["管理员"],
|
||||||
};
|
};
|
||||||
@@ -91,6 +92,7 @@ describe("account shared types", () => {
|
|||||||
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
||||||
expect(FLARUM_REGISTER_URL).toContain("register");
|
expect(FLARUM_REGISTER_URL).toContain("register");
|
||||||
expect(user.forumGroups).toEqual(["管理员"]);
|
expect(user.forumGroups).toEqual(["管理员"]);
|
||||||
|
expect(user.coverUrl).toContain("/assets/covers/");
|
||||||
expect(folder.itemCount).toBe(1);
|
expect(folder.itemCount).toBe(1);
|
||||||
expect(favorite.appKey).toBe("app:office:wps");
|
expect(favorite.appKey).toBe("app:office:wps");
|
||||||
expect(download.selectedOrigin).toBe("apm");
|
expect(download.selectedOrigin).toBe("apm");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
cloudItemKey,
|
cloudItemKey,
|
||||||
cloudPackageKey,
|
cloudPackageKey,
|
||||||
mergeInstalledApps,
|
mergeInstalledApps,
|
||||||
|
resolveCloudInstallCandidate,
|
||||||
} from "@/modules/appListSync";
|
} from "@/modules/appListSync";
|
||||||
import type { App } from "@/global/typedefinition";
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
@@ -95,4 +96,66 @@ describe("appListSync", () => {
|
|||||||
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
|
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",
|
username: "momen",
|
||||||
displayName: "Momen",
|
displayName: "Momen",
|
||||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
|
||||||
forumLevel: "管理员",
|
forumLevel: "管理员",
|
||||||
forumGroups: ["管理员"],
|
forumGroups: ["管理员"],
|
||||||
},
|
},
|
||||||
@@ -26,6 +27,7 @@ describe("authState", () => {
|
|||||||
|
|
||||||
expect(authSession.value?.accessToken).toBe("jwt");
|
expect(authSession.value?.accessToken).toBe("jwt");
|
||||||
expect(currentUser.value?.displayName).toBe("Momen");
|
expect(currentUser.value?.displayName).toBe("Momen");
|
||||||
|
expect(currentUser.value?.coverUrl).toContain("/assets/covers/");
|
||||||
expect(isLoggedIn.value).toBe(true);
|
expect(isLoggedIn.value).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
|
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
|
||||||
|
|||||||
@@ -128,4 +128,31 @@ describe("backend API auth exchange", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow("星火账号服务异常,请稍后重试。");
|
).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')"
|
@click="emit('open-user-management')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-cog"></i>
|
<i class="fas fa-user-cog"></i>
|
||||||
<span>用户管理</span>
|
<span class="min-w-0 truncate">用户管理</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -16,15 +16,15 @@
|
|||||||
@click="emit('open-favorites')"
|
@click="emit('open-favorites')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-heart"></i>
|
<i class="fas fa-heart"></i>
|
||||||
<span>我的收藏</span>
|
<span class="min-w-0 truncate">我的收藏</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="quick-menu-item" @click="emit('open-forum')">
|
<button type="button" class="quick-menu-item" @click="emit('open-forum')">
|
||||||
<i class="fas fa-comments"></i>
|
<i class="fas fa-comments"></i>
|
||||||
<span>论坛首页</span>
|
<span class="min-w-0 truncate">论坛首页</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="quick-menu-item" @click="emit('edit-profile')">
|
<button type="button" class="quick-menu-item" @click="emit('edit-profile')">
|
||||||
<i class="fas fa-id-card"></i>
|
<i class="fas fa-id-card"></i>
|
||||||
<span>修改论坛资料</span>
|
<span class="min-w-0 truncate">修改论坛资料</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
@click="emit('logout')"
|
@click="emit('logout')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
<span>退出登录</span>
|
<span class="min-w-0 truncate">退出登录</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,6 +53,7 @@ const emit = defineEmits<{
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -62,6 +63,10 @@ const emit = defineEmits<{
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-menu-item i {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-menu-item:hover {
|
.quick-menu-item:hover {
|
||||||
background: rgba(0, 113, 227, 0.06);
|
background: rgba(0, 113, 227, 0.06);
|
||||||
color: #0071e3;
|
color: #0071e3;
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
? 'bg-orange-500 text-white'
|
? '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'
|
: '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
|
Spark
|
||||||
</button>
|
</button>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
? 'bg-blue-500 text-white'
|
? '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'
|
: '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
|
APM
|
||||||
</button>
|
</button>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
@click="handleFavorite"
|
@click="handleFavorite"
|
||||||
>
|
>
|
||||||
<i class="fas fa-star text-xs"></i>
|
<i class="fas fa-star text-xs"></i>
|
||||||
<span>收藏</span>
|
<span>{{ favoriteButtonText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -332,6 +332,7 @@
|
|||||||
:logged-in="loggedIn"
|
:logged-in="loggedIn"
|
||||||
:can-submit="isinstalled"
|
:can-submit="isinstalled"
|
||||||
@request-login="$emit('request-login', $event)"
|
@request-login="$emit('request-login', $event)"
|
||||||
|
@show-user="emit('show-user', $event)"
|
||||||
/>
|
/>
|
||||||
<section
|
<section
|
||||||
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
||||||
@@ -502,7 +503,7 @@ import {
|
|||||||
getHybridDefaultOrigin,
|
getHybridDefaultOrigin,
|
||||||
} from "../global/storeConfig";
|
} from "../global/storeConfig";
|
||||||
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
|
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
|
||||||
import type { App, ReviewTags } from "../global/typedefinition";
|
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
|
||||||
@@ -515,6 +516,8 @@ const props = defineProps<{
|
|||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
reviewAppKey: string;
|
reviewAppKey: string;
|
||||||
reviewTags: ReviewTags | null;
|
reviewTags: ReviewTags | null;
|
||||||
|
favorited?: boolean;
|
||||||
|
favoriteFolderName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -526,6 +529,8 @@ const emit = defineEmits<{
|
|||||||
(e: "open-preview", index: number): void;
|
(e: "open-preview", index: number): void;
|
||||||
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
|
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
|
||||||
(e: "check-install", app: App): 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);
|
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>("");
|
const downloadCount = ref<string>("");
|
||||||
|
|
||||||
// 监听 app 变化,获取新app的下载量
|
// 监听 app 变化,获取新app的下载量
|
||||||
@@ -708,6 +720,11 @@ const handleFavorite = () => {
|
|||||||
emit("favorite", displayApp.value);
|
emit("favorite", displayApp.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
|
viewingOrigin.value = origin;
|
||||||
|
emit("select-origin", origin);
|
||||||
|
};
|
||||||
|
|
||||||
const openPreview = (index: number) => {
|
const openPreview = (index: number) => {
|
||||||
emit("open-preview", index);
|
emit("open-preview", index);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
@click="handleFavorite"
|
@click="handleFavorite"
|
||||||
>
|
>
|
||||||
<i class="fas fa-star text-xs"></i>
|
<i class="fas fa-star text-xs"></i>
|
||||||
<span>收藏</span>
|
<span>{{ favoriteButtonText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -202,6 +202,7 @@
|
|||||||
:logged-in="loggedIn"
|
:logged-in="loggedIn"
|
||||||
:can-submit="isInstalled"
|
:can-submit="isInstalled"
|
||||||
@request-login="$emit('request-login', $event)"
|
@request-login="$emit('request-login', $event)"
|
||||||
|
@show-user="emit('show-user', $event)"
|
||||||
/>
|
/>
|
||||||
<section
|
<section
|
||||||
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
v-else-if="!loggedIn && reviewAppKey && reviewTags"
|
||||||
@@ -251,7 +252,7 @@ import {
|
|||||||
buildReviewTags,
|
buildReviewTags,
|
||||||
getDisplayApp,
|
getDisplayApp,
|
||||||
} from "@/modules/appIdentity";
|
} from "@/modules/appIdentity";
|
||||||
import type { App, ReviewTags } from "@/global/typedefinition";
|
import type { App, AppReview, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
app: App;
|
app: App;
|
||||||
@@ -261,6 +262,8 @@ const props = defineProps<{
|
|||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
reviewAppKey: string;
|
reviewAppKey: string;
|
||||||
reviewTags: ReviewTags | null;
|
reviewTags: ReviewTags | null;
|
||||||
|
favorited?: boolean;
|
||||||
|
favoriteFolderName?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -272,6 +275,8 @@ const emit = defineEmits<{
|
|||||||
"open-preview": [index: number];
|
"open-preview": [index: number];
|
||||||
"open-app": [pkgname: string, origin?: "spark" | "apm"];
|
"open-app": [pkgname: string, origin?: "spark" | "apm"];
|
||||||
"check-install": [app: App];
|
"check-install": [app: App];
|
||||||
|
"select-origin": [origin: "spark" | "apm"];
|
||||||
|
"show-user": [review: AppReview];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const viewingOrigin = ref<"spark" | "apm">(
|
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") => {
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
viewingOrigin.value = origin;
|
viewingOrigin.value = origin;
|
||||||
|
emit("select-origin", origin);
|
||||||
if (displayApp.value) emit("check-install", displayApp.value);
|
if (displayApp.value) emit("check-install", displayApp.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6">
|
<div class="flex h-full flex-col gap-6">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<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
|
<button
|
||||||
type="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"
|
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"
|
v-if="canManageApps"
|
||||||
type="button"
|
type="button"
|
||||||
class="sidebar-tab"
|
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-icon"><i class="fas fa-download"></i></span>
|
||||||
<span class="sidebar-tab-label">应用管理</span>
|
<span class="sidebar-tab-label">应用管理</span>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
v-if="canOpenUpdateCenter"
|
v-if="canOpenUpdateCenter"
|
||||||
type="button"
|
type="button"
|
||||||
class="sidebar-tab"
|
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-icon"><i class="fas fa-sync-alt"></i></span>
|
||||||
<span class="sidebar-tab-label">软件更新</span>
|
<span class="sidebar-tab-label">软件更新</span>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
import AccountQuickMenu from "./AccountQuickMenu.vue";
|
import AccountQuickMenu from "./AccountQuickMenu.vue";
|
||||||
import ThemeToggle from "./ThemeToggle.vue";
|
import ThemeToggle from "./ThemeToggle.vue";
|
||||||
import amberLogo from "../assets/imgs/spark-store.svg";
|
import amberLogo from "../assets/imgs/spark-store.svg";
|
||||||
@@ -161,6 +161,7 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showAccountMenu = ref(false);
|
const showAccountMenu = ref(false);
|
||||||
|
const accountMenuRoot = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const accountLabel = computed(() => {
|
const accountLabel = computed(() => {
|
||||||
return props.currentUser
|
return props.currentUser
|
||||||
@@ -177,6 +178,21 @@ const handleAccountClick = () => {
|
|||||||
showAccountMenu.value = !showAccountMenu.value;
|
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 = (
|
const emitAccountAction = (
|
||||||
action:
|
action:
|
||||||
| "open-user-management"
|
| "open-user-management"
|
||||||
@@ -207,8 +223,15 @@ const canManageApps = computed(() => {
|
|||||||
const canOpenUpdateCenter = canManageApps;
|
const canOpenUpdateCenter = canManageApps;
|
||||||
|
|
||||||
const selectTab = (tab: string) => {
|
const selectTab = (tab: string) => {
|
||||||
|
showAccountMenu.value = false;
|
||||||
emit("select-tab", tab);
|
emit("select-tab", tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emitSidebarAction = (action: "list" | "update") => {
|
||||||
|
showAccountMenu.value = false;
|
||||||
|
if (action === "list") emit("list");
|
||||||
|
else emit("update");
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -263,5 +286,9 @@ const selectTab = (tab: string) => {
|
|||||||
|
|
||||||
.sidebar-tab-label {
|
.sidebar-tab-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -109,6 +109,9 @@
|
|||||||
>
|
>
|
||||||
选择可安装
|
选择可安装
|
||||||
</button>
|
</button>
|
||||||
|
<span class="self-center text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ installableSelectionMessage }}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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(
|
watch(
|
||||||
() => props.items,
|
() => props.items,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -11,40 +11,57 @@
|
|||||||
aria-label="选择收藏夹"
|
aria-label="选择收藏夹"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
添加到收藏夹
|
管理收藏夹
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
选择要保存当前应用的收藏夹。
|
勾选要保存当前应用的收藏夹,取消勾选可移出收藏。
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-5 space-y-2">
|
<div class="mt-5 space-y-2">
|
||||||
<button
|
<label
|
||||||
v-if="!hasDefaultFolder"
|
v-if="!hasDefaultFolder"
|
||||||
type="button"
|
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"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
默认收藏夹
|
<input
|
||||||
</button>
|
v-model="draftSelectedIds"
|
||||||
<button
|
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"
|
v-for="folder in folders"
|
||||||
:key="folder.id"
|
:key="folder.id"
|
||||||
type="button"
|
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"
|
||||||
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)"
|
|
||||||
>
|
>
|
||||||
{{ folder.name }}
|
<input
|
||||||
</button>
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
|
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
@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')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -54,21 +71,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import type { FavoriteFolder } from "@/global/typedefinition";
|
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
folders: FavoriteFolder[];
|
folders: FavoriteFolder[];
|
||||||
|
selectedFolderIds?: Array<number | "default">;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hasDefaultFolder = computed(() =>
|
const hasDefaultFolder = computed(() =>
|
||||||
props.folders.some((folder) => folder.name === "默认收藏夹"),
|
props.folders.some((folder) => folder.name.trim() === "默认收藏夹"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
"select-folder": [folderId: number | "default"];
|
"save-selection": [folderIds: Array<number | "default">];
|
||||||
"create-folder": [];
|
"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>
|
</script>
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
<div
|
<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"
|
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
|
<div
|
||||||
v-if="loading"
|
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"
|
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;
|
apmAvailable: boolean;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
|
syncMessage: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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>
|
||||||
+457
-29
@@ -64,19 +64,32 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
||||||
<label
|
<div class="block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||||
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
评分
|
评分
|
||||||
<select
|
<div
|
||||||
v-model.number="rating"
|
role="slider"
|
||||||
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"
|
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">
|
<span
|
||||||
{{ value }} 星
|
v-for="value in ratingOptions"
|
||||||
</option>
|
:key="value"
|
||||||
</select>
|
class="leading-none"
|
||||||
</label>
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ value <= rating ? "★" : "☆" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
@@ -97,36 +110,81 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
|
||||||
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</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
|
<article
|
||||||
v-for="review in reviews"
|
v-for="review in filteredReviews"
|
||||||
:key="review.id"
|
:key="review.id"
|
||||||
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
|
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
|
||||||
>
|
>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
<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)"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="review.userAvatarUrl"
|
v-if="review.userAvatarUrl"
|
||||||
:src="review.userAvatarUrl"
|
:src="review.userAvatarUrl"
|
||||||
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
|
:alt="`${reviewerName(review)} 的头像`"
|
||||||
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
|
class="h-9 w-9 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@error="hideAvatar"
|
@error="hideAvatar"
|
||||||
/>
|
/>
|
||||||
<div
|
<span
|
||||||
v-else
|
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"
|
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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{{ review.userDisplayName?.slice(0, 1) || "星" }}
|
{{ reviewerName(review).slice(0, 1) }}
|
||||||
</div>
|
</span>
|
||||||
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<strong class="truncate text-slate-700 dark:text-slate-200">
|
<button
|
||||||
{{ review.userDisplayName || "星火用户" }}
|
type="button"
|
||||||
</strong>
|
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"
|
<span class="flex-shrink-0 text-xs text-slate-400"
|
||||||
>{{ review.rating }} 星</span
|
>{{ review.rating }} 星</span
|
||||||
>
|
>
|
||||||
@@ -136,11 +194,148 @@
|
|||||||
>
|
>
|
||||||
{{ review.content || "暂无评论内容" }}
|
{{ review.content || "暂无评论内容" }}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-sm text-slate-400">暂无评价</p>
|
<p v-else class="text-sm text-slate-400">
|
||||||
|
{{ reviews.length ? "没有符合筛选条件的评价" : "暂无评价" }}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -148,12 +343,18 @@
|
|||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createReviewReply,
|
||||||
|
deleteReview,
|
||||||
|
deleteReviewReply,
|
||||||
fetchRatingSummary,
|
fetchRatingSummary,
|
||||||
fetchReviews,
|
fetchReviews,
|
||||||
|
likeReview,
|
||||||
|
likeReviewReply,
|
||||||
submitReview,
|
submitReview,
|
||||||
} from "@/modules/backendApi";
|
} from "@/modules/backendApi";
|
||||||
import type {
|
import type {
|
||||||
AppReview,
|
AppReview,
|
||||||
|
AppReviewReply,
|
||||||
RatingSummary,
|
RatingSummary,
|
||||||
ReviewTags,
|
ReviewTags,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
@@ -164,23 +365,37 @@ const props = withDefaults(
|
|||||||
tags: ReviewTags;
|
tags: ReviewTags;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
canSubmit?: boolean;
|
canSubmit?: boolean;
|
||||||
|
currentUserId?: number;
|
||||||
|
currentUserIsAdmin?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{ canSubmit: true },
|
{ canSubmit: true, currentUserId: undefined, currentUserIsAdmin: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"request-login": [message: string];
|
"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 rating = ref(5);
|
||||||
|
const ratingSliding = ref(false);
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const reviews = ref<AppReview[]>([]);
|
const reviews = ref<AppReview[]>([]);
|
||||||
const summary = ref<RatingSummary | null>(null);
|
const summary = ref<RatingSummary | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const actionMessage = ref("");
|
||||||
const loadGeneration = ref(0);
|
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(() => {
|
const ratingText = computed(() => {
|
||||||
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
||||||
@@ -189,24 +404,157 @@ const ratingText = computed(() => {
|
|||||||
|
|
||||||
const canSubmit = computed(() => props.canSubmit);
|
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 : "";
|
const message = caught instanceof Error ? caught.message : "";
|
||||||
if (message === "Network Error") {
|
if (message === "Network Error") {
|
||||||
return "无法连接星火账号服务,请稍后重试。";
|
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) => {
|
const hideAvatar = (event: Event) => {
|
||||||
(event.target as HTMLElement).style.display = "none";
|
(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 = () => {
|
const clearReviewState = () => {
|
||||||
loadGeneration.value += 1;
|
loadGeneration.value += 1;
|
||||||
reviews.value = [];
|
reviews.value = [];
|
||||||
summary.value = null;
|
summary.value = null;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
actionMessage.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadReviews = async () => {
|
const loadReviews = async () => {
|
||||||
@@ -227,9 +575,10 @@ const loadReviews = async () => {
|
|||||||
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
summary.value = nextSummary;
|
summary.value = nextSummary;
|
||||||
reviews.value = nextReviews;
|
reviews.value = nextReviews;
|
||||||
|
resetStaleFilters();
|
||||||
} catch (caught: unknown) {
|
} catch (caught: unknown) {
|
||||||
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
error.value = (caught as Error)?.message || "加载评价失败";
|
error.value = toReviewErrorMessage(caught, "加载评价失败");
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === loadGeneration.value && appKey === props.appKey) {
|
if (generation === loadGeneration.value && appKey === props.appKey) {
|
||||||
loading.value = false;
|
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);
|
onMounted(loadReviews);
|
||||||
watch(() => [props.appKey, props.loggedIn], loadReviews);
|
watch(
|
||||||
|
() => props.appKey,
|
||||||
|
() => {
|
||||||
|
resetFilters();
|
||||||
|
void loadReviews();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(() => props.loggedIn, loadReviews);
|
||||||
</script>
|
</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
|
<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"
|
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
|
<div
|
||||||
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
|
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
@@ -179,6 +185,10 @@ const userInitial = computed(() =>
|
|||||||
(props.user.displayName || props.user.username || "?").slice(0, 1),
|
(props.user.displayName || props.user.username || "?").slice(0, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const coverStyle = computed(() => ({
|
||||||
|
backgroundImage: props.user.coverUrl ? `url("${props.user.coverUrl}")` : "",
|
||||||
|
}));
|
||||||
|
|
||||||
const visibleForumGroups = computed(() =>
|
const visibleForumGroups = computed(() =>
|
||||||
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
|
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.username === "string" &&
|
||||||
typeof user.displayName === "string" &&
|
typeof user.displayName === "string" &&
|
||||||
typeof user.avatarUrl === "string" &&
|
typeof user.avatarUrl === "string" &&
|
||||||
|
(user.coverUrl === undefined || typeof user.coverUrl === "string") &&
|
||||||
typeof user.forumLevel === "string" &&
|
typeof user.forumLevel === "string" &&
|
||||||
Array.isArray(user.forumGroups) &&
|
Array.isArray(user.forumGroups) &&
|
||||||
user.forumGroups.every((group) => typeof group === "string")
|
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 ||
|
import.meta.env.VITE_SPARK_BACKEND_BASE_URL ||
|
||||||
DEFAULT_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_BASE_URL = "https://bbs.spark-app.store";
|
||||||
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
|
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
|
||||||
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
|
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
|
||||||
|
|||||||
@@ -256,10 +256,19 @@ export interface SparkUser {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
coverUrl?: string;
|
||||||
forumLevel: string;
|
forumLevel: string;
|
||||||
forumGroups: string[];
|
forumGroups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewUserProfile {
|
||||||
|
displayName: string;
|
||||||
|
username?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
forumGroups?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
tokenType: "bearer";
|
tokenType: "bearer";
|
||||||
@@ -287,8 +296,26 @@ export interface RatingSummary {
|
|||||||
starCounts: Record<number, number>;
|
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 {
|
export interface AppReview {
|
||||||
id: number;
|
id: number;
|
||||||
|
userId?: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
content: string;
|
content: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -301,6 +328,12 @@ export interface AppReview {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
userDisplayName: string;
|
userDisplayName: string;
|
||||||
userAvatarUrl: string;
|
userAvatarUrl: string;
|
||||||
|
likeCount?: number;
|
||||||
|
likedByCurrentUser?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
isAuthor?: boolean;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
replies?: AppReviewReply[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FavoriteFolder {
|
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,
|
...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 { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
|
||||||
import type {
|
import type {
|
||||||
AppReview,
|
AppReview,
|
||||||
|
AppReviewReply,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
DownloadedAppList,
|
DownloadedAppList,
|
||||||
DownloadedAppRecord,
|
DownloadedAppRecord,
|
||||||
@@ -75,7 +76,10 @@ const normalizeBackendMutationError = (error: unknown): Error => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401) {
|
||||||
|
return new Error("登录状态已失效,请重新登录星火账号。");
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
return new Error("请登录星火账号后重试。");
|
return new Error("请登录星火账号后重试。");
|
||||||
}
|
}
|
||||||
if (status === 422) {
|
if (status === 422) {
|
||||||
@@ -122,12 +126,14 @@ const toUser = (raw: ApiRecord): SparkUser => ({
|
|||||||
username: String(raw.username || ""),
|
username: String(raw.username || ""),
|
||||||
displayName: String(raw.display_name || raw.username || ""),
|
displayName: String(raw.display_name || raw.username || ""),
|
||||||
avatarUrl: String(raw.avatar_url || ""),
|
avatarUrl: String(raw.avatar_url || ""),
|
||||||
|
coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined,
|
||||||
forumLevel: String(raw.forum_level || "论坛用户"),
|
forumLevel: String(raw.forum_level || "论坛用户"),
|
||||||
forumGroups: parseForumGroups(raw.forum_groups),
|
forumGroups: parseForumGroups(raw.forum_groups),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toReview = (raw: ApiRecord): AppReview => ({
|
const toReview = (raw: ApiRecord): AppReview => ({
|
||||||
id: Number(raw.id),
|
id: Number(raw.id),
|
||||||
|
userId: raw.user_id === undefined ? undefined : Number(raw.user_id),
|
||||||
rating: Number(raw.rating),
|
rating: Number(raw.rating),
|
||||||
content: String(raw.content || ""),
|
content: String(raw.content || ""),
|
||||||
version: String(raw.version || "unknown"),
|
version: String(raw.version || "unknown"),
|
||||||
@@ -140,6 +146,47 @@ const toReview = (raw: ApiRecord): AppReview => ({
|
|||||||
updatedAt: String(raw.updated_at || ""),
|
updatedAt: String(raw.updated_at || ""),
|
||||||
userDisplayName: String(raw.user_display_name || ""),
|
userDisplayName: String(raw.user_display_name || ""),
|
||||||
userAvatarUrl: String(raw.user_avatar_url || ""),
|
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 => ({
|
const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
|
||||||
@@ -289,6 +336,86 @@ export const submitReview = async (
|
|||||||
return toReview(asApiRecord(response.data));
|
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[]> => {
|
export const listFavoriteFolders = async (): Promise<FavoriteFolder[]> => {
|
||||||
const response = await backend.get("/me/favorite-folders");
|
const response = await backend.get("/me/favorite-folders");
|
||||||
return asApiRecordArray(response.data).map(toFavoriteFolder);
|
return asApiRecordArray(response.data).map(toFavoriteFolder);
|
||||||
|
|||||||
Vendored
+7
@@ -20,6 +20,7 @@ declare global {
|
|||||||
apm_store: {
|
apm_store: {
|
||||||
arch: string;
|
arch: string;
|
||||||
};
|
};
|
||||||
|
windowControls: WindowControlBridge;
|
||||||
updateCenter: UpdateCenterBridge;
|
updateCenter: UpdateCenterBridge;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,12 @@ interface IpcRendererFacade {
|
|||||||
invoke: import("electron").IpcRenderer["invoke"];
|
invoke: import("electron").IpcRenderer["invoke"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WindowControlBridge {
|
||||||
|
minimize: () => void;
|
||||||
|
toggleMaximize: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// IPC channel type definitions
|
// IPC channel type definitions
|
||||||
declare interface IpcChannels {
|
declare interface IpcChannels {
|
||||||
"get-app-version": () => string;
|
"get-app-version": () => string;
|
||||||
|
|||||||
Reference in New Issue
Block a user