Files
vmall/apps/admin/pages/reviews.vue
T

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>