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
+114 -6
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
import type {
Category,
CouponTemplate,
LocalizedText,
Paged,
Product,
Review,
ReviewSummary,
Sku,
} from "@vmall/shared";
import { lowestSku } from "~/utils/product";
import { useCartStore } from "~/stores/cart";
import { useSessionStore } from "~/stores/session";
@@ -30,8 +39,8 @@ const { data: pageData } = await useAsyncData(
`product-detail-${routeId.value}`,
async () => {
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
// card, comments and coupons stay local display-only content (non-goals).
// The rail uses live catalogue products so its links resolve; coupons and
// store display content remain local fixtures while reviews use the shared API.
// Store best sellers, ranking the shop's own products. Ranking by category
// would empty the rail for any product alone in its leaf category.
const siblings = await $api.listProducts({
@@ -103,6 +112,51 @@ let favoriteRequestVersion = 0;
const cartSuccess = ref(false);
const activeTab = ref("detail");
const reviewSummary = ref<ReviewSummary>({ count: 0, avg_rating: 0, distribution: {} });
const reviewPage = ref<Paged<Review>>({ items: [], total: 0, page: 1, per_page: 20 });
const reviewLoading = ref(false);
const reviewError = ref(false);
function reviewText(content: LocalizedText): string {
const active = (content[locale.value] ?? "").trim();
if (active) return active;
const otherLocale = locale.value === "en" ? "zh" : "en";
return (content[otherLocale] ?? "").trim();
}
async function loadReviews(productId: string, page = 1): Promise<void> {
reviewLoading.value = true;
reviewError.value = false;
try {
const [summary, reviews] = await Promise.all([
$api.getProductReviewSummary(productId),
$api.listProductReviews(productId, page),
]);
if (product.value?.id !== productId) return;
reviewSummary.value = summary;
reviewPage.value = reviews;
} catch {
reviewError.value = true;
} finally {
reviewLoading.value = false;
}
}
function changeReviewPage(page: number): void {
const productId = product.value?.id;
if (productId) void loadReviews(productId, page);
}
watch(
() => product.value?.id,
(productId) => {
reviewPage.value = { items: [], total: 0, page: 1, per_page: 20 };
reviewSummary.value = { count: 0, avg_rating: 0, distribution: {} };
if (productId) void loadReviews(productId);
},
{ immediate: true },
);
const attributeGroups = computed<AttributeGroup[]>(() => {
const valuesByKey = new Map<string, Set<string>>();
for (const sku of product.value?.skus ?? []) {
@@ -273,10 +327,9 @@ const toggleFavorite = async (): Promise<void> => {
}
};
// No reviews tab: there is no reviews capability, and the mall will not present
// invented reviewers and ratings as fact. See the wave-6 design.
const tabs = computed(() => [
{ key: "detail", label: t("product.tabsDetail") },
{ key: "reviews", label: t("product.tabsReviews") },
{ key: "service", label: t("product.tabsAfterSale") },
]);
@@ -520,12 +573,67 @@ const detailImages = computed(() => product.value?.images ?? []);
loading="lazy"
/>
</div>
<div v-else-if="active === 'reviews'" class="space-y-5 p-5">
<h2 class="text-lg font-semibold">{{ t("product.reviewAll") }}</h2>
<div v-if="reviewLoading" class="text-muted py-5 text-sm">
{{ t("common.loading") }}
</div>
<p v-else-if="reviewError" class="text-danger text-sm">
{{ t("product.reviewLoadFailed") }}
</p>
<template v-else>
<div class="border-border grid gap-5 border-b pb-5 md:grid-cols-[180px_minmax(0,1fr)]">
<div class="text-center">
<strong class="text-primary block text-4xl">{{ reviewSummary.avg_rating.toFixed(1) }}</strong>
<UiRatingStars :value="reviewSummary.avg_rating" :size="18" />
<span class="text-muted mt-1 block text-xs">{{ t("product.commentCount", { n: reviewSummary.count }) }}</span>
</div>
<div class="space-y-1.5">
<div v-for="star in [5, 4, 3, 2, 1]" :key="star" class="flex items-center gap-2 text-xs">
<span class="text-muted w-12">{{ t("product.reviewStar", { n: star }) }}</span>
<div class="bg-bg h-2 flex-1 overflow-hidden rounded-full">
<div
class="bg-primary h-full rounded-full"
:style="{ width: `${reviewSummary.count ? ((reviewSummary.distribution[String(star)] ?? 0) / reviewSummary.count) * 100 : 0}%` }"
/>
</div>
<span class="text-muted w-5 text-right">{{ reviewSummary.distribution[String(star)] ?? 0 }}</span>
</div>
</div>
</div>
<UiEmptyState v-if="reviewPage.items.length === 0" :text="t('product.reviewEmpty')" />
<div v-else class="divide-border divide-y">
<article v-for="review in reviewPage.items" :key="review.id" class="py-5 first:pt-0 last:pb-0">
<div class="flex items-start gap-3">
<img class="h-10 w-10 rounded-full object-cover" src="/mock/avatar.svg" :alt="review.reviewer_name" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-2">
<strong class="text-sm">{{ review.reviewer_name }}</strong>
<time class="text-muted text-xs" :datetime="review.created_at">{{ review.created_at.slice(0, 10) }}</time>
</div>
<UiRatingStars :value="review.rating" :size="14" />
<p class="text-text mt-2 whitespace-pre-wrap text-sm leading-relaxed">{{ reviewText(review.content) }}</p>
<div v-if="review.images.length" class="mt-3 flex flex-wrap gap-2">
<img v-for="(image, index) in review.images" :key="`${review.id}-${image}-${index}`" class="border-border h-20 w-20 rounded border object-cover" :src="image" :alt="t('product.reviewImageAlt', { n: index + 1 })" loading="lazy" />
</div>
<div v-if="review.reply" class="bg-bg mt-3 rounded p-3 text-sm">
<strong class="text-primary text-xs">{{ t("product.reply") }}</strong>
<p class="text-muted mt-1 whitespace-pre-wrap">{{ reviewText(review.reply) }}</p>
</div>
</div>
</div>
</article>
</div>
<UiPagination :page="reviewPage.page" :total="reviewPage.total" :per-page="reviewPage.per_page" @change="changeReviewPage" />
</template>
</div>
<div v-else class="space-y-3 p-5">
<h2 class="text-lg font-semibold">{{ t("product.tabsAfterSale") }}</h2>
<p v-if="detail.store?.after_sale" class="text-text text-sm leading-relaxed">
{{ pick(detail.store.after_sale, locale) }}
</p>
</div></template
</div>
</template
></UiTabs
></VCard
>