feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -16,6 +16,7 @@ const navItems = [
|
||||
{ to: "/shops", label: "nav.shops" },
|
||||
{ to: "/orders", label: "nav.orders" },
|
||||
{ to: "/aftersales", label: "nav.aftersales" },
|
||||
{ to: "/reviews", label: "nav.reviews" },
|
||||
{ to: "/content", label: "nav.content" },
|
||||
{ to: "/brands", label: "nav.brands" },
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const enExtra = {
|
||||
content: "Content",
|
||||
brands: "Brands",
|
||||
aftersales: "After-sales",
|
||||
reviews: "Reviews",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "Platform overview",
|
||||
@@ -148,6 +149,24 @@ export const enExtra = {
|
||||
merchant: "Merchant",
|
||||
platform: "Platform",
|
||||
},
|
||||
reviewProduct: "Product",
|
||||
reviewShop: "Shop",
|
||||
reviewBuyer: "Buyer",
|
||||
reviewRating: "Rating",
|
||||
reviewContent: "Content",
|
||||
reviewImages: "Images",
|
||||
reviewReply: "Merchant reply",
|
||||
reviewHide: "Hide",
|
||||
reviewDelete: "Delete",
|
||||
reviewConfirmHide: "Hide this review? It disappears from the storefront list and rating summary.",
|
||||
reviewConfirmDelete: "Permanently delete this review? This cannot be undone.",
|
||||
reviewHiddenNotice: "Review hidden.",
|
||||
reviewDeletedNotice: "Review deleted.",
|
||||
reviewConflict: "The review state changed; refresh and try again",
|
||||
reviewStatuses: {
|
||||
visible: "Visible",
|
||||
hidden: "Hidden",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -162,6 +181,7 @@ export const zhExtra = {
|
||||
content: "内容",
|
||||
brands: "品牌",
|
||||
aftersales: "售后仲裁",
|
||||
reviews: "评价管理",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "平台概览",
|
||||
@@ -296,5 +316,23 @@ export const zhExtra = {
|
||||
merchant: "商家",
|
||||
platform: "平台",
|
||||
},
|
||||
reviewProduct: "商品",
|
||||
reviewShop: "店铺",
|
||||
reviewBuyer: "买家",
|
||||
reviewRating: "星级",
|
||||
reviewContent: "内容",
|
||||
reviewImages: "图片",
|
||||
reviewReply: "商家回复",
|
||||
reviewHide: "隐藏",
|
||||
reviewDelete: "删除",
|
||||
reviewConfirmHide: "确定隐藏该评价吗?隐藏后将从商品评价列表与评分汇总中移除。",
|
||||
reviewConfirmDelete: "确定永久删除该评价吗?删除后不可恢复。",
|
||||
reviewHiddenNotice: "评价已隐藏。",
|
||||
reviewDeletedNotice: "评价已删除。",
|
||||
reviewConflict: "评价状态已变化,请刷新后重试",
|
||||
reviewStatuses: {
|
||||
visible: "可见",
|
||||
hidden: "已隐藏",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user