feat(account): polish reviews favorites and account UI

This commit is contained in:
2026-05-29 21:34:42 +08:00
parent abeb511c06
commit 439af8c26f
40 changed files with 3158 additions and 250 deletions
+10 -5
View File
@@ -8,7 +8,7 @@
@click="emit('open-user-management')"
>
<i class="fas fa-user-cog"></i>
<span>用户管理</span>
<span class="min-w-0 truncate">用户管理</span>
</button>
<button
type="button"
@@ -16,15 +16,15 @@
@click="emit('open-favorites')"
>
<i class="fas fa-heart"></i>
<span>我的收藏</span>
<span class="min-w-0 truncate">我的收藏</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('open-forum')">
<i class="fas fa-comments"></i>
<span>论坛首页</span>
<span class="min-w-0 truncate">论坛首页</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('edit-profile')">
<i class="fas fa-id-card"></i>
<span>修改论坛资料</span>
<span class="min-w-0 truncate">修改论坛资料</span>
</button>
<button
type="button"
@@ -32,7 +32,7 @@
@click="emit('logout')"
>
<i class="fas fa-sign-out-alt"></i>
<span>退出登录</span>
<span class="min-w-0 truncate">退出登录</span>
</button>
</div>
</template>
@@ -53,6 +53,7 @@ const emit = defineEmits<{
width: 100%;
align-items: center;
gap: 0.75rem;
min-width: 0;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
text-align: left;
@@ -62,6 +63,10 @@ const emit = defineEmits<{
transition: all 0.15s ease;
}
.quick-menu-item i {
flex-shrink: 0;
}
.quick-menu-item:hover {
background: rgba(0, 113, 227, 0.06);
color: #0071e3;
+21 -4
View File
@@ -103,7 +103,7 @@
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'spark'"
@click="selectOrigin('spark')"
>
Spark
</button>
@@ -116,7 +116,7 @@
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'apm'"
@click="selectOrigin('apm')"
>
APM
</button>
@@ -186,7 +186,7 @@
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>收藏</span>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
@@ -332,6 +332,7 @@
:logged-in="loggedIn"
:can-submit="isinstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
@@ -502,7 +503,7 @@ import {
getHybridDefaultOrigin,
} from "../global/storeConfig";
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
import type { App, ReviewTags } from "../global/typedefinition";
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
const attrs = useAttrs();
@@ -515,6 +516,8 @@ const props = defineProps<{
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
@@ -526,6 +529,8 @@ const emit = defineEmits<{
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
(e: "check-install", app: App): void;
(e: "select-origin", origin: "spark" | "apm"): void;
(e: "show-user", review: AppReview): void;
}>();
const appPkgname = computed(() => props.app?.pkgname);
@@ -655,6 +660,13 @@ const activeReviewTags = computed<ReviewTags | null>(() => {
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const downloadCount = ref<string>("");
// 监听 app 变化,获取新app的下载量
@@ -708,6 +720,11 @@ const handleFavorite = () => {
emit("favorite", displayApp.value);
};
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
};
const openPreview = (index: number) => {
emit("open-preview", index);
};
+15 -2
View File
@@ -123,7 +123,7 @@
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>收藏</span>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
@@ -202,6 +202,7 @@
:logged-in="loggedIn"
:can-submit="isInstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
@@ -251,7 +252,7 @@ import {
buildReviewTags,
getDisplayApp,
} from "@/modules/appIdentity";
import type { App, ReviewTags } from "@/global/typedefinition";
import type { App, AppReview, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
app: App;
@@ -261,6 +262,8 @@ const props = defineProps<{
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
@@ -272,6 +275,8 @@ const emit = defineEmits<{
"open-preview": [index: number];
"open-app": [pkgname: string, origin?: "spark" | "apm"];
"check-install": [app: App];
"select-origin": [origin: "spark" | "apm"];
"show-user": [review: AppReview];
}>();
const viewingOrigin = ref<"spark" | "apm">(
@@ -351,8 +356,16 @@ const reviewTags = computed<ReviewTags | null>(() => {
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
if (displayApp.value) emit("check-install", displayApp.value);
};
+31 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col gap-6">
<div class="flex items-start justify-between gap-3">
<div class="relative min-w-0 flex-1">
<div ref="accountMenuRoot" class="relative min-w-0 flex-1">
<button
type="button"
class="flex w-full min-w-0 items-center gap-3 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
@@ -109,7 +109,7 @@
v-if="canManageApps"
type="button"
class="sidebar-tab"
@click="$emit('list')"
@click="emitSidebarAction('list')"
>
<span class="sidebar-tab-icon"><i class="fas fa-download"></i></span>
<span class="sidebar-tab-label">应用管理</span>
@@ -118,7 +118,7 @@
v-if="canOpenUpdateCenter"
type="button"
class="sidebar-tab"
@click="$emit('update')"
@click="emitSidebarAction('update')"
>
<span class="sidebar-tab-icon"><i class="fas fa-sync-alt"></i></span>
<span class="sidebar-tab-label">软件更新</span>
@@ -128,7 +128,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from "../assets/imgs/spark-store.svg";
@@ -161,6 +161,7 @@ const emit = defineEmits<{
}>();
const showAccountMenu = ref(false);
const accountMenuRoot = ref<HTMLElement | null>(null);
const accountLabel = computed(() => {
return props.currentUser
@@ -177,6 +178,21 @@ const handleAccountClick = () => {
showAccountMenu.value = !showAccountMenu.value;
};
const handleDocumentPointerDown = (event: MouseEvent) => {
if (!showAccountMenu.value) return;
const target = event.target;
if (target instanceof Node && accountMenuRoot.value?.contains(target)) return;
showAccountMenu.value = false;
};
onMounted(() => {
document.addEventListener("mousedown", handleDocumentPointerDown);
});
onUnmounted(() => {
document.removeEventListener("mousedown", handleDocumentPointerDown);
});
const emitAccountAction = (
action:
| "open-user-management"
@@ -207,8 +223,15 @@ const canManageApps = computed(() => {
const canOpenUpdateCenter = canManageApps;
const selectTab = (tab: string) => {
showAccountMenu.value = false;
emit("select-tab", tab);
};
const emitSidebarAction = (action: "list" | "update") => {
showAccountMenu.value = false;
if (action === "list") emit("list");
else emit("update");
};
</script>
<style scoped>
@@ -263,5 +286,9 @@ const selectTab = (tab: string) => {
.sidebar-tab-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+15
View File
@@ -109,6 +109,9 @@
>
选择可安装
</button>
<span class="self-center text-sm text-slate-500 dark:text-slate-400">
{{ installableSelectionMessage }}
</span>
<button
type="button"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900"
@@ -163,6 +166,18 @@ const selectedInstallableItems = computed(() =>
),
);
const installableItemCount = computed(
() => props.items.filter((item) => item.status === "installable").length,
);
const installableSelectionMessage = computed(() => {
if (selectedInstallableItems.value.length > 0) {
return `已选择 ${selectedInstallableItems.value.length} 个可安装应用`;
}
if (installableItemCount.value === 0) return "当前收藏夹没有可安装应用";
return `${installableItemCount.value} 个应用可安装`;
});
watch(
() => props.items,
() => {
+48 -19
View File
@@ -11,40 +11,57 @@
aria-label="选择收藏夹"
>
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
添加到收藏夹
管理收藏夹
</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
要保存当前应用的收藏夹
选要保存当前应用的收藏夹取消勾选可移出收藏
</p>
<div class="mt-5 space-y-2">
<button
<label
v-if="!hasDefaultFolder"
type="button"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="emit('select-folder', 'default')"
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
默认收藏夹
</button>
<button
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label="收藏到 默认收藏夹"
value="default"
/>
<span>默认收藏夹</span>
</label>
<label
v-for="folder in folders"
:key="folder.id"
type="button"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="emit('select-folder', folder.id)"
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{{ folder.name }}
</button>
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
:aria-label="`收藏到 ${folder.name}`"
:value="folder.id"
/>
<span>{{ folder.name }}</span>
</label>
</div>
<button
type="button"
class="mt-4 w-full rounded-xl border border-dashed border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-slate-700 dark:text-slate-300 dark:hover:border-blue-500 dark:hover:text-blue-300"
@click="emit('create-folder')"
@click="emit('create-folder', [...draftSelectedIds])"
>
新建收藏夹
</button>
<button
type="button"
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
@click="emit('save-selection', [...draftSelectedIds])"
>
保存收藏夹
</button>
<button
type="button"
class="mt-2 w-full rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('close')"
>
取消
@@ -54,21 +71,33 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import type { FavoriteFolder } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
folders: FavoriteFolder[];
selectedFolderIds?: Array<number | "default">;
}>();
const hasDefaultFolder = computed(() =>
props.folders.some((folder) => folder.name === "默认收藏夹"),
props.folders.some((folder) => folder.name.trim() === "默认收藏夹"),
);
const emit = defineEmits<{
close: [];
"select-folder": [folderId: number | "default"];
"create-folder": [];
"save-selection": [folderIds: Array<number | "default">];
"create-folder": [folderIds: Array<number | "default">];
}>();
const draftSelectedIds = ref<Array<number | "default">>([]);
watch(
() => [props.show, props.selectedFolderIds] as const,
() => {
if (!props.show) return;
draftSelectedIds.value = [...(props.selectedFolderIds ?? [])];
},
{ immediate: true },
);
</script>
+7
View File
@@ -100,6 +100,12 @@
<div
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
>
<div
v-if="syncMessage"
class="rounded-2xl border border-brand/20 bg-brand/5 px-4 py-3 text-sm font-medium text-brand dark:bg-brand/10"
>
{{ syncMessage }}
</div>
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
@@ -239,6 +245,7 @@ const props = defineProps<{
apmAvailable: boolean;
loggedIn: boolean;
syncing: boolean;
syncMessage: string;
}>();
const emit = defineEmits<{
+140
View File
@@ -0,0 +1,140 @@
<template>
<div
v-if="show && profile"
ref="dialogRef"
class="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户资料"
tabindex="-1"
@click.self="emit('close')"
@keydown.esc="emit('close')"
>
<section
class="w-full max-w-md overflow-hidden rounded-3xl border border-white/20 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<div
data-testid="review-user-cover"
class="relative h-32 bg-gradient-to-br from-brand via-sky-500 to-violet-500"
:style="coverStyle"
>
<button
ref="closeButtonRef"
type="button"
class="absolute top-3 right-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-black/45"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="px-6 pb-6">
<div class="-mt-10 flex items-end gap-4">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
:alt="profile.displayName"
class="h-20 w-20 rounded-3xl border-4 border-white bg-white object-cover shadow-lg dark:border-slate-900 dark:bg-slate-800"
/>
<div
v-else
class="flex h-20 w-20 items-center justify-center rounded-3xl border-4 border-white bg-slate-900 text-3xl font-semibold text-white shadow-lg dark:border-slate-900"
>
{{ initial }}
</div>
<div class="min-w-0 pb-1">
<h2
class="truncate text-2xl font-bold text-slate-900 dark:text-white"
>
{{ profile.displayName }}
</h2>
<p v-if="profile.username" class="text-sm text-slate-500">
@{{ profile.username }}
</p>
</div>
</div>
<div
v-if="profile.forumGroups?.length"
class="mt-5 flex flex-wrap gap-2"
>
<span
v-for="group in profile.forumGroups"
:key="group"
class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{{ group }}
</span>
</div>
<button
v-if="forumUrl"
type="button"
class="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
@click="emit('open-forum-profile', forumUrl)"
>
<i class="fas fa-up-right-from-square"></i>
查看论坛资料
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { FLARUM_PROFILE_URL } from "@/global/storeConfig";
import type { ReviewUserProfile } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
profile: ReviewUserProfile | null;
}>();
const emit = defineEmits<{
close: [];
"open-forum-profile": [url: string];
}>();
const dialogRef = ref<HTMLElement | null>(null);
const closeButtonRef = ref<HTMLButtonElement | null>(null);
const initial = computed(() => (props.profile?.displayName || "?").slice(0, 1));
const safeCoverUrl = computed(() => {
const raw = props.profile?.coverUrl;
if (!raw) return "";
try {
const parsed = new URL(raw);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: "";
} catch {
return "";
}
});
const coverStyle = computed(() => ({
backgroundImage: safeCoverUrl.value
? `url(${JSON.stringify(safeCoverUrl.value)})`
: "",
}));
const forumUrl = computed(() =>
props.profile?.username
? `${FLARUM_PROFILE_URL}/${props.profile.username}`
: "",
);
watch(
() => props.show && props.profile !== null,
async (visible) => {
if (!visible) return;
await nextTick();
if (closeButtonRef.value) {
closeButtonRef.value.focus();
} else {
dialogRef.value?.focus();
}
},
{ immediate: true },
);
</script>
+466 -38
View File
@@ -64,19 +64,32 @@
</p>
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
<label
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
>
<div class="block text-sm font-medium text-slate-600 dark:text-slate-300">
评分
<select
v-model.number="rating"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
<div
role="slider"
aria-label="评分"
:aria-valuemin="1"
:aria-valuemax="5"
:aria-valuenow="rating"
tabindex="0"
class="mt-2 inline-flex touch-none select-none items-center gap-1 text-2xl text-amber-400 outline-none transition focus:ring-2 focus:ring-amber-300"
@pointerdown="startRatingSlide"
@pointermove="moveRatingSlide"
@pointerup="stopRatingSlide"
@pointercancel="stopRatingSlide"
@keydown="handleRatingKeydown"
>
<option v-for="value in ratingOptions" :key="value" :value="value">
{{ value }}
</option>
</select>
</label>
<span
v-for="value in ratingOptions"
:key="value"
class="leading-none"
aria-hidden="true"
>
{{ value <= rating ? "★" : "☆" }}
</span>
</div>
</div>
<label
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
>
@@ -97,36 +110,81 @@
</button>
</form>
<p v-if="actionMessage" class="mb-3 text-sm text-blue-500">
{{ actionMessage }}
</p>
<div
v-if="reviews.length"
class="mb-3 grid gap-2 text-sm text-slate-600 sm:grid-cols-2 dark:text-slate-300"
>
<label class="block">
按架构筛选
<select
v-model="selectedPackageArch"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部架构</option>
<option v-for="arch in packageArchOptions" :key="arch" :value="arch">
{{ arch }}
</option>
</select>
</label>
<label class="block">
按发行版筛选
<select
v-model="selectedDistro"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部发行版</option>
<option v-for="distro in distroOptions" :key="distro" :value="distro">
{{ distro }}
</option>
</select>
</label>
</div>
<p v-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</p>
<div v-else-if="reviews.length" class="space-y-3">
<div v-else-if="filteredReviews.length" class="space-y-3">
<article
v-for="review in reviews"
v-for="review in filteredReviews"
:key="review.id"
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
>
<div class="flex gap-3">
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<div
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
<button
type="button"
class="h-9 w-9 flex-shrink-0 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400"
:aria-label="`查看${reviewerName(review)}的资料`"
@click="showReviewer(review)"
>
{{ review.userDisplayName?.slice(0, 1) || "星" }}
</div>
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${reviewerName(review)} 的头像`"
class="h-9 w-9 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<span
v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{{ reviewerName(review).slice(0, 1) }}
</span>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-slate-700 dark:text-slate-200">
{{ review.userDisplayName || "星火用户" }}
</strong>
<button
type="button"
class="min-w-0 truncate text-left font-semibold text-slate-700 hover:text-blue-600 dark:text-slate-200 dark:hover:text-blue-300"
@click="showReviewer(review)"
>
{{ reviewerName(review) }}
</button>
<span class="flex-shrink-0 text-xs text-slate-400"
>{{ review.rating }} </span
>
@@ -136,11 +194,148 @@
>
{{ review.content || "暂无评论内容" }}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReviewLike(review)"
>
点赞{{ review.likeCount ? ` ${review.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id)"
>
回复
</button>
<button
v-if="canDeleteReview(review)"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReview(review)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === undefined
"
class="mt-3 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
<div
v-if="review.replies?.length"
class="mt-3 space-y-2 border-l border-slate-200 pl-3 dark:border-slate-700"
>
<div
v-for="reply in flattenReplies(review.replies)"
:key="reply.id"
class="rounded-xl bg-slate-50 px-3 py-2 dark:bg-slate-800/60"
:style="{ marginLeft: `${reply.depth * 12}px` }"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-slate-700 dark:text-slate-200">
{{ reply.userDisplayName || "星火用户" }}
</span>
<span v-if="reply.isDeleted" class="text-xs text-slate-400"
>已删除</span
>
</div>
<p
class="mt-1 whitespace-pre-wrap text-slate-600 dark:text-slate-300"
>
{{ reply.content || "该回复已删除" }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReplyLike(review.id, reply)"
>
点赞{{ reply.likeCount ? ` ${reply.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id, reply.id)"
>
回复
</button>
<button
v-if="reply.canDelete"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReply(review.id, reply)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === reply.id
"
class="mt-2 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</article>
</div>
<p v-else class="text-sm text-slate-400">暂无评价</p>
<p v-else class="text-sm text-slate-400">
{{ reviews.length ? "没有符合筛选条件的评价" : "暂无评价" }}
</p>
</section>
</template>
@@ -148,12 +343,18 @@
import { computed, onMounted, ref, watch } from "vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
@@ -164,23 +365,37 @@ const props = withDefaults(
tags: ReviewTags;
loggedIn: boolean;
canSubmit?: boolean;
currentUserId?: number;
currentUserIsAdmin?: boolean;
}>(),
{ canSubmit: true },
{ canSubmit: true, currentUserId: undefined, currentUserIsAdmin: false },
);
const emit = defineEmits<{
"request-login": [message: string];
"show-user": [review: AppReview];
}>();
const ratingOptions = [5, 4, 3, 2, 1];
const ratingOptions = [1, 2, 3, 4, 5];
const rating = ref(5);
const ratingSliding = ref(false);
const content = ref("");
const reviews = ref<AppReview[]>([]);
const summary = ref<RatingSummary | null>(null);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
const actionMessage = ref("");
const loadGeneration = ref(0);
const selectedPackageArch = ref("");
const selectedDistro = ref("");
const replyContent = ref("");
const replySubmitting = ref(false);
const replyTarget = ref<{ reviewId: number; parentId?: number } | null>(null);
interface FlatReply extends AppReviewReply {
depth: number;
}
const ratingText = computed(() => {
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
@@ -189,24 +404,157 @@ const ratingText = computed(() => {
const canSubmit = computed(() => props.canSubmit);
const toReviewErrorMessage = (caught: unknown): string => {
const packageArchOptions = computed(() =>
[
...new Set(
reviews.value.map((review) => review.packageArch).filter(Boolean),
),
].sort(),
);
const distroOptions = computed(() =>
[
...new Set(reviews.value.map((review) => review.distro).filter(Boolean)),
].sort(),
);
const filteredReviews = computed(() =>
reviews.value.filter(
(review) =>
(!selectedPackageArch.value ||
review.packageArch === selectedPackageArch.value) &&
(!selectedDistro.value || review.distro === selectedDistro.value),
),
);
const toReviewErrorMessage = (
caught: unknown,
fallback = "发表评论失败",
): string => {
const message = caught instanceof Error ? caught.message : "";
if (message === "Network Error") {
return "无法连接星火账号服务,请稍后重试。";
}
return message || "发表评论失败";
if (message.includes("401")) {
return "登录状态已失效,请重新登录星火账号。";
}
return message || fallback;
};
const clampRating = (value: number): number => Math.min(5, Math.max(1, value));
const updateRatingFromPointer = (event: PointerEvent): void => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
const rect = target.getBoundingClientRect();
if (rect.width <= 0) return;
const eventWithClientX = event as PointerEvent & { clientX: number };
const ratio = (eventWithClientX.clientX - rect.left) / rect.width;
rating.value = clampRating(Math.ceil(ratio * 5));
};
const startRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = true;
const target = event.currentTarget;
if (target instanceof HTMLElement && "setPointerCapture" in target) {
target.setPointerCapture(event.pointerId);
}
updateRatingFromPointer(event);
};
const moveRatingSlide = (event: PointerEvent): void => {
updateRatingFromPointer(event);
};
const stopRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = false;
const target = event.currentTarget;
if (
target instanceof HTMLElement &&
"hasPointerCapture" in target &&
target.hasPointerCapture(event.pointerId)
) {
target.releasePointerCapture(event.pointerId);
}
};
const handleRatingKeydown = (event: KeyboardEvent): void => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
rating.value = clampRating(rating.value - 1);
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
rating.value = clampRating(rating.value + 1);
}
};
const hideAvatar = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
const reviewerName = (review: AppReview): string =>
review.userDisplayName || "星火用户";
const showReviewer = (review: AppReview) => {
actionMessage.value = `正在查看 ${reviewerName(review)} 的资料`;
emit("show-user", review);
};
const canDeleteReview = (review: AppReview): boolean =>
review.canDelete === true;
const flattenReplies = (items: AppReviewReply[] = [], depth = 0): FlatReply[] =>
items.flatMap((reply) => [
{ ...reply, depth },
...flattenReplies(reply.replies, depth + 1),
]);
const startReply = (reviewId: number, parentId?: number) => {
replyTarget.value = { reviewId, parentId };
replyContent.value = "";
actionMessage.value = "";
};
const cancelReply = () => {
replyTarget.value = null;
replyContent.value = "";
};
const toReviewActionErrorMessage = (
caught: unknown,
fallback = "操作失败,请稍后重试。",
): string => {
return toReviewErrorMessage(caught, fallback);
};
const resetFilters = () => {
selectedPackageArch.value = "";
selectedDistro.value = "";
};
const resetStaleFilters = () => {
if (
selectedPackageArch.value &&
!packageArchOptions.value.includes(selectedPackageArch.value)
) {
selectedPackageArch.value = "";
}
if (
selectedDistro.value &&
!distroOptions.value.includes(selectedDistro.value)
) {
selectedDistro.value = "";
}
};
const clearReviewState = () => {
loadGeneration.value += 1;
reviews.value = [];
summary.value = null;
loading.value = false;
error.value = "";
actionMessage.value = "";
};
const loadReviews = async () => {
@@ -227,9 +575,10 @@ const loadReviews = async () => {
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
summary.value = nextSummary;
reviews.value = nextReviews;
resetStaleFilters();
} catch (caught: unknown) {
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
error.value = (caught as Error)?.message || "加载评价失败";
error.value = toReviewErrorMessage(caught, "加载评价失败");
} finally {
if (generation === loadGeneration.value && appKey === props.appKey) {
loading.value = false;
@@ -262,6 +611,85 @@ const submit = async () => {
}
};
const toggleReviewLike = async (review: AppReview) => {
try {
await likeReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const toggleReplyLike = async (reviewId: number, reply: AppReviewReply) => {
try {
await likeReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const submitReply = async () => {
const target = replyTarget.value;
const trimmed = replyContent.value.trim();
if (!target || trimmed === "") return;
replySubmitting.value = true;
try {
await createReviewReply(props.appKey, target.reviewId, {
content: trimmed,
...(target.parentId === undefined ? {} : { parentId: target.parentId }),
});
cancelReply();
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再回复。",
);
} finally {
replySubmitting.value = false;
}
};
const removeReview = async (review: AppReview) => {
if (!canDeleteReview(review)) return;
try {
await deleteReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
const removeReply = async (reviewId: number, reply: AppReviewReply) => {
if (!reply.canDelete) return;
try {
await deleteReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
onMounted(loadReviews);
watch(() => [props.appKey, props.loggedIn], loadReviews);
watch(
() => props.appKey,
() => {
resetFilters();
void loadReviews();
},
);
watch(() => props.loggedIn, loadReviews);
</script>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户管理"
>
<button
type="button"
class="absolute inset-0 cursor-default"
aria-label="关闭用户管理"
@click="emit('close')"
></button>
<div
class="relative z-10 flex h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950"
>
<header
class="flex shrink-0 items-center justify-between gap-4 border-b border-slate-200 px-5 py-4 dark:border-slate-800"
>
<div class="min-w-0">
<p class="text-xs uppercase tracking-[0.3em] text-slate-500">
Account Center
</p>
<h2
class="truncate text-lg font-semibold text-slate-900 dark:text-white"
>
星火账号用户管理
</h2>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
aria-label="刷新"
@click="refreshFrame"
>
<i class="fas fa-rotate-right"></i>
<span class="hidden sm:inline">刷新</span>
</button>
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
aria-label="在浏览器中打开"
@click="openInBrowser"
>
<i class="fas fa-up-right-from-square"></i>
<span class="hidden sm:inline">浏览器打开</span>
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 transition hover:text-slate-900 dark:bg-slate-800 dark:text-slate-300 dark:hover:text-white"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
</header>
<div class="relative min-h-0 flex-1 bg-slate-100 dark:bg-slate-900">
<iframe
v-if="!loadFailed"
:key="frameVersion"
class="h-full w-full border-0 bg-white"
title="星火账号用户管理"
:src="accountFrameUrl"
referrerpolicy="no-referrer"
sandbox="allow-forms allow-popups allow-same-origin allow-scripts"
@error="handleFrameError"
></iframe>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-4 p-8 text-center"
>
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-300"
>
<i class="fas fa-triangle-exclamation"></i>
</div>
<div>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
账号页面加载失败
</p>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
请检查网络连接后重试
</p>
</div>
<button
type="button"
class="rounded-full bg-sky-600 px-5 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
@click="retryFrame"
>
重试
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { SPARK_ACCOUNT_CENTER_URL } from "@/global/storeConfig";
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
user: SparkUser;
downloadedApps: DownloadedAppRecord[];
syncEnabled: boolean;
loading: boolean;
error: string;
syncing?: boolean;
syncMessage?: string;
}>();
const emit = defineEmits<{
close: [];
"open-forum": [];
"edit-profile": [];
"toggle-sync": [enabled: boolean];
"sync-now": [];
"refresh-downloads": [];
}>();
const loadFailed = ref(false);
const frameVersion = ref(0);
const accountFrameUrl = computed(() =>
buildAccountFrameUrl(SPARK_ACCOUNT_CENTER_URL, props.user.username),
);
const refreshFrame = () => {
loadFailed.value = false;
frameVersion.value += 1;
};
const retryFrame = () => {
refreshFrame();
};
const handleFrameError = () => {
loadFailed.value = true;
};
const openInBrowser = () => {
window.open(accountFrameUrl.value, "_blank", "noopener,noreferrer");
};
</script>
+10
View File
@@ -2,6 +2,12 @@
<section
class="space-y-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div
v-if="user.coverUrl"
data-testid="profile-cover"
class="h-32 rounded-2xl bg-cover bg-center sm:h-40"
:style="coverStyle"
></div>
<div
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
>
@@ -179,6 +185,10 @@ const userInitial = computed(() =>
(props.user.displayName || props.user.username || "?").slice(0, 1),
);
const coverStyle = computed(() => ({
backgroundImage: props.user.coverUrl ? `url("${props.user.coverUrl}")` : "",
}));
const visibleForumGroups = computed(() =>
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
);
+65
View File
@@ -0,0 +1,65 @@
<template>
<header
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white/95 px-3 text-slate-700 backdrop-blur dark:border-slate-800/70 dark:bg-slate-950/95 dark:text-slate-200"
>
<div class="flex min-w-0 items-center gap-2">
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span>
<span class="truncate text-sm font-semibold">星火应用商店</span>
</div>
<div class="window-titlebar-controls flex items-center gap-1">
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最小化"
title="最小化"
@click="minimize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最大化或还原"
title="最大化或还原"
@click="toggleMaximize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-red-500 hover:text-white"
aria-label="关闭"
title="关闭"
@click="close"
>
×
</button>
</div>
</header>
</template>
<script setup lang="ts">
const minimize = () => {
window.windowControls.minimize();
};
const toggleMaximize = () => {
window.windowControls.toggleMaximize();
};
const close = () => {
window.windowControls.close();
};
</script>
<style scoped>
.window-titlebar {
-webkit-app-region: drag;
}
.window-titlebar-controls,
.window-titlebar-controls button {
-webkit-app-region: no-drag;
}
</style>