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