feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)

This commit is contained in:
Chengdong Zhang
2026-09-24 16:38:05 +08:00
parent 473d19d089
commit c532f87b03
34 changed files with 2055 additions and 36 deletions
+6
View File
@@ -60,6 +60,12 @@ watchEffect(() => {
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
>{{ $t("nav.aftersales") }}</NuxtLink
>
<NuxtLink
to="/reviews"
active-class="bg-primary-soft text-primary"
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
>{{ $t("nav.reviews") }}</NuxtLink
>
<NuxtLink
to="/shipments"
active-class="bg-primary-soft text-primary"
+36
View File
@@ -11,6 +11,7 @@ export const enExtra = {
shopProfile: "Shop profile",
aftersales: "After-sales",
freightTemplates: "Freight templates",
reviews: "Reviews",
},
shop: {
profileSaved: "Shop profile saved.",
@@ -259,6 +260,23 @@ export const enExtra = {
cancelled: "Cancelled",
},
},
review: {
title: "Product reviews",
product: "Product",
buyer: "Buyer",
rating: "Rating",
content: "Review",
createdAt: "Reviewed at",
replyStatus: "Reply status",
replied: "Replied",
unreplied: "Not replied",
reply: "Reply",
replyPlaceholder: "Type the reply to this customer…",
replyAt: "Replied at",
replySaved: "Reply sent.",
replyConflict: "This review already has a reply; the latest state is shown below.",
replyRequired: "Reply text is required.",
},
} as Record<string, unknown>;
export const zhExtra = {
@@ -269,6 +287,7 @@ export const zhExtra = {
coupons: "优惠券",
flashSales: "秒杀",
groupBuying: "拼团",
reviews: "评价",
shopProfile: "店铺资料",
aftersales: "售后",
freightTemplates: "运费模板",
@@ -518,4 +537,21 @@ export const zhExtra = {
cancelled: "已取消",
},
},
review: {
title: "商品评价",
product: "商品",
buyer: "买家",
rating: "星级",
content: "评价内容",
createdAt: "评价时间",
replyStatus: "回复状态",
replied: "已回复",
unreplied: "未回复",
reply: "回复",
replyPlaceholder: "输入给买家的回复…",
replyAt: "回复时间",
replySaved: "回复已发送。",
replyConflict: "该评价已有回复,已为你展示最新状态。",
replyRequired: "回复内容不能为空。",
},
} as Record<string, unknown>;
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ApiError, t as localized } from "@vmall/shared";
import type { LocalizedText, Paged, Review } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const reviews = ref<Paged<Review> | null>(null);
const page = ref(1);
const loading = ref(true);
const error = ref("");
const message = ref("");
const productNames = ref(new Map<string, LocalizedText>());
const replyingId = ref("");
const replyText = ref("");
const saving = ref(false);
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function reviewText(value: LocalizedText | null): string {
return localized(value, locale.value);
}
function productName(productId: string): string {
return localized(productNames.value.get(productId), locale.value) || productId;
}
function stars(rating: number): string {
return "★".repeat(rating) + "☆".repeat(5 - rating);
}
async function loadProductNames(items: Review[]): Promise<void> {
const missing = [...new Set(items.map((item) => item.product_id))].filter(
(id) => !productNames.value.has(id),
);
await Promise.all(
missing.map(async (id) => {
try {
const product = await $api.shop.getProduct(id);
productNames.value.set(id, product.name);
} catch {
productNames.value.set(id, {});
}
}),
);
}
async function loadReviews(): Promise<void> {
loading.value = true;
error.value = "";
try {
const result = await $api.shop.listReviews(page.value);
reviews.value = result;
await loadProductNames(result.items);
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function changePage(nextPage: number): Promise<void> {
if (
!reviews.value ||
nextPage < 1 ||
nextPage > Math.ceil(reviews.value.total / reviews.value.per_page)
)
return;
page.value = nextPage;
closeReply();
await loadReviews();
}
function startReply(review: Review): void {
replyingId.value = review.id;
replyText.value = "";
error.value = "";
message.value = "";
}
function closeReply(): void {
replyingId.value = "";
replyText.value = "";
}
async function submitReply(id: string): Promise<void> {
const text = replyText.value.trim();
if (!text) {
error.value = translate("review.replyRequired");
return;
}
saving.value = true;
error.value = "";
message.value = "";
let settled = false;
try {
await $api.shop.replyReview(id, { [locale.value]: text });
message.value = translate("review.replySaved");
settled = true;
} catch (err: unknown) {
if (err instanceof ApiError && err.status === 409) {
// Someone replied meanwhile; show the stored reply below.
message.value = translate("review.replyConflict");
settled = true;
} else {
error.value = err instanceof Error ? err.message : translate("common.error");
}
} finally {
saving.value = false;
}
if (settled) {
closeReply();
await loadReviews();
}
}
onMounted(loadReviews);
</script>
<template>
<VPage :title="$t('review.title')">
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
<VCard v-else-if="!reviews?.items.length" class="text-muted">{{ $t("common.empty") }}</VCard>
<template v-else-if="reviews">
<VTable>
<thead>
<tr>
<th>{{ $t("review.product") }}</th>
<th>{{ $t("review.buyer") }}</th>
<th>{{ $t("review.rating") }}</th>
<th>{{ $t("review.content") }}</th>
<th>{{ $t("review.createdAt") }}</th>
<th>{{ $t("review.replyStatus") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="review in reviews.items" :key="review.id">
<tr>
<td>{{ productName(review.product_id) }}</td>
<td>{{ review.reviewer_name }}</td>
<td>
<span class="text-warning">{{ stars(review.rating) }}</span>
<span class="text-muted ml-1 text-xs">{{ review.rating }}/5</span>
</td>
<td class="max-w-md whitespace-pre-wrap">{{ reviewText(review.content) }}</td>
<td>{{ formatDate(review.created_at) }}</td>
<td class="max-w-md">
<VBadge v-if="review.reply" tone="green">{{ $t("review.replied") }}</VBadge>
<VBadge v-else tone="orange">{{ $t("review.unreplied") }}</VBadge>
<template v-if="review.reply">
<p class="mt-1 whitespace-pre-wrap">{{ reviewText(review.reply) }}</p>
<p v-if="review.reply_at" class="text-muted text-xs">
{{ $t("review.replyAt") }} {{ formatDate(review.reply_at) }}
</p>
</template>
</td>
<td>
<VBtn
v-if="!review.reply && replyingId !== review.id"
size="sm"
variant="primary"
@click="startReply(review)"
>{{ $t("review.reply") }}</VBtn
>
</td>
</tr>
<tr v-if="replyingId === review.id">
<td colspan="7">
<textarea
v-model="replyText"
rows="3"
:placeholder="$t('review.replyPlaceholder')"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
/>
<div class="mt-2 flex gap-2">
<VBtn size="sm" variant="primary" :disabled="saving" @click="submitReply(review.id)">
{{ $t("common.submit") }}
</VBtn>
<VBtn size="sm" :disabled="saving" @click="closeReply">
{{ $t("common.cancel") }}
</VBtn>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
<div v-if="reviews.total > reviews.per_page" class="mt-4 flex items-center justify-between">
<VBtn size="sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{
$t("common.prev")
}}</VBtn>
<span class="text-muted text-sm"
>{{ $t("common.page") }} {{ page }} /
{{ Math.ceil(reviews.total / reviews.per_page) }}</span
>
<VBtn
size="sm"
:disabled="page >= Math.ceil(reviews.total / reviews.per_page) || loading"
@click="changePage(page + 1)"
>{{ $t("common.next") }}</VBtn
>
</div>
</template>
</VPage>
</template>