261 lines
8.7 KiB
Vue
261 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import { ApiError } from "@vmall/shared";
|
|
import type { LocalizedText, Review, ReviewStatus, Shop } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { $api } = useNuxtApp();
|
|
const { locale, t } = useI18n();
|
|
|
|
const reviews = ref<Review[]>([]);
|
|
const shops = ref<Shop[]>([]);
|
|
const productNames = reactive<Record<string, LocalizedText>>({});
|
|
const page = ref(1);
|
|
const total = ref(0);
|
|
const perPage = ref(20);
|
|
const loading = ref(true);
|
|
const errorMessage = ref("");
|
|
const notice = ref("");
|
|
const actingId = ref<string | null>(null);
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
|
|
|
function formatDate(value: string): string {
|
|
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
|
}
|
|
|
|
function shortId(value: string): string {
|
|
return value.slice(0, 8);
|
|
}
|
|
|
|
function stars(rating: number): string {
|
|
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
|
}
|
|
|
|
/** Locale pick with fallback to the other locale when the active one is empty. */
|
|
function reviewText(content: LocalizedText | null | undefined): string {
|
|
if (!content) return "";
|
|
return content[locale.value] || content.en || Object.values(content).find((v) => v.trim()) || "";
|
|
}
|
|
|
|
function productName(productId: string): string {
|
|
const name = productNames[productId];
|
|
return name ? reviewText(name) : "";
|
|
}
|
|
|
|
function shopName(shopId: string): string {
|
|
const shop = shops.value.find((item) => item.id === shopId);
|
|
return shop ? reviewText(shop.name) : shopId;
|
|
}
|
|
|
|
function statusTone(status: ReviewStatus): "green" | "gray" {
|
|
return status === "visible" ? "green" : "gray";
|
|
}
|
|
|
|
function statusLabel(status: ReviewStatus): string {
|
|
return t(`admin.reviewStatuses.${status}`);
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : t("common.error");
|
|
return error instanceof ApiError && error.status === 409
|
|
? `${t("admin.reviewConflict")}: ${message}`
|
|
: message;
|
|
}
|
|
|
|
async function resolveProductNames(rows: Review[]): Promise<void> {
|
|
const pending = [...new Set(rows.map((row) => row.product_id))].filter(
|
|
(id) => !(id in productNames),
|
|
);
|
|
await Promise.all(
|
|
pending.map(async (id) => {
|
|
try {
|
|
const product = await $api.getProduct(id);
|
|
productNames[id] = product.name;
|
|
} catch {
|
|
// Keep the id fallback; unresolved names retry on the next load.
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function loadReviews(): Promise<void> {
|
|
loading.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
const [paged, shopList] = await Promise.all([
|
|
$api.admin.listReviews(page.value),
|
|
$api.admin.listShops(),
|
|
]);
|
|
reviews.value = paged.items;
|
|
total.value = paged.total;
|
|
perPage.value = paged.per_page;
|
|
shops.value = shopList;
|
|
void resolveProductNames(paged.items);
|
|
} catch (error: unknown) {
|
|
errorMessage.value = errorText(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function hideReview(row: Review): Promise<void> {
|
|
if (!confirm(t("admin.reviewConfirmHide"))) return;
|
|
actingId.value = row.id;
|
|
errorMessage.value = "";
|
|
notice.value = "";
|
|
try {
|
|
await $api.admin.hideReview(row.id);
|
|
await loadReviews();
|
|
notice.value = t("admin.reviewHiddenNotice");
|
|
} catch (error: unknown) {
|
|
if (error instanceof ApiError && error.status === 409) await loadReviews();
|
|
errorMessage.value = errorText(error);
|
|
} finally {
|
|
actingId.value = null;
|
|
}
|
|
}
|
|
|
|
async function deleteReview(row: Review): Promise<void> {
|
|
if (!confirm(t("admin.reviewConfirmDelete"))) return;
|
|
actingId.value = row.id;
|
|
errorMessage.value = "";
|
|
notice.value = "";
|
|
try {
|
|
await $api.admin.deleteReview(row.id);
|
|
await loadReviews();
|
|
notice.value = t("admin.reviewDeletedNotice");
|
|
} catch (error: unknown) {
|
|
errorMessage.value = errorText(error);
|
|
} finally {
|
|
actingId.value = null;
|
|
}
|
|
}
|
|
|
|
async function changePage(nextPage: number): Promise<void> {
|
|
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
|
|
page.value = nextPage;
|
|
await loadReviews();
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadReviews();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<VPage :title="$t('nav.reviews')">
|
|
<template #actions>
|
|
<span v-if="!loading" class="text-muted text-sm">{{
|
|
$t("admin.pageOf", { page, total: totalPages })
|
|
}}</span>
|
|
</template>
|
|
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
|
<p v-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
|
|
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
|
<VCard v-else-if="reviews.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
|
|
<div v-else class="overflow-x-auto">
|
|
<div class="min-w-[1080px]">
|
|
<VTable>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ $t("admin.reviewProduct") }}</th>
|
|
<th>{{ $t("admin.reviewShop") }}</th>
|
|
<th>{{ $t("admin.reviewBuyer") }}</th>
|
|
<th>{{ $t("admin.reviewRating") }}</th>
|
|
<th>{{ $t("admin.reviewContent") }}</th>
|
|
<th>{{ $t("common.status") }}</th>
|
|
<th>{{ $t("common.actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in reviews" :key="row.id">
|
|
<td>
|
|
<span v-if="productName(row.product_id)" class="block">{{
|
|
productName(row.product_id)
|
|
}}</span>
|
|
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.product_id">{{
|
|
shortId(row.product_id)
|
|
}}</code>
|
|
</td>
|
|
<td>
|
|
<span class="block">{{ shopName(row.shop_id) }}</span>
|
|
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.shop_id">{{
|
|
shortId(row.shop_id)
|
|
}}</code>
|
|
</td>
|
|
<td>
|
|
<span class="block">{{ row.reviewer_name }}</span>
|
|
<code class="bg-bg text-muted rounded px-1.5 py-0.5 text-xs" :title="row.user_id">{{
|
|
shortId(row.user_id)
|
|
}}</code>
|
|
</td>
|
|
<td>
|
|
<span
|
|
class="text-warning tracking-tight"
|
|
role="img"
|
|
:aria-label="`${row.rating} / 5`"
|
|
>{{ stars(row.rating) }}</span
|
|
>
|
|
</td>
|
|
<td class="max-w-[360px]">
|
|
<p class="text-sm">{{ reviewText(row.content) }}</p>
|
|
<ul v-if="row.images.length" class="mt-2 flex flex-wrap gap-2">
|
|
<li v-for="url in row.images" :key="url">
|
|
<a :href="url" target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
:src="url"
|
|
:alt="$t('admin.reviewImages')"
|
|
class="h-12 w-12 rounded object-cover"
|
|
/>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
<div v-if="row.reply" class="border-border bg-bg mt-2 rounded-md border p-2">
|
|
<p class="text-muted mb-1 text-xs">
|
|
{{ $t("admin.reviewReply") }}
|
|
<time v-if="row.reply_at" class="ml-2">{{ formatDate(row.reply_at) }}</time>
|
|
</p>
|
|
<p class="text-sm">{{ reviewText(row.reply) }}</p>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge>
|
|
</td>
|
|
<td>
|
|
<div class="flex flex-wrap gap-2">
|
|
<VBtn
|
|
v-if="row.status === 'visible'"
|
|
size="sm"
|
|
:disabled="actingId === row.id"
|
|
@click="hideReview(row)"
|
|
>
|
|
{{ $t("admin.reviewHide") }}
|
|
</VBtn>
|
|
<VBtn
|
|
size="sm"
|
|
variant="danger"
|
|
:disabled="actingId === row.id"
|
|
@click="deleteReview(row)"
|
|
>
|
|
{{ $t("admin.reviewDelete") }}
|
|
</VBtn>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</div>
|
|
</div>
|
|
<div v-if="!loading && reviews.length > 0" class="mt-4 flex items-center justify-center gap-3">
|
|
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
|
$t("common.prev")
|
|
}}</VBtn>
|
|
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
|
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
|
$t("common.next")
|
|
}}</VBtn>
|
|
</div>
|
|
</VPage>
|
|
</template>
|