feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -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
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ const menuGroups = computed(() => [
|
||||
title: t("user.orderCenter"),
|
||||
items: [
|
||||
{ label: t("user.myOrders"), to: "/user/orders" },
|
||||
{ label: t("user.pendingReviews"), to: "/user/reviews" },
|
||||
{ label: t("user.addresses"), to: "/user/addresses" },
|
||||
{ label: t("user.coupons"), to: "/user/coupons" },
|
||||
{ label: t("user.aftersalesTitle"), to: "/user/aftersales" },
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { ReviewableItem } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const items = ref<ReviewableItem[]>([]);
|
||||
const activeItemId = ref("");
|
||||
const rating = ref(0);
|
||||
const content = ref("");
|
||||
const imageUrls = ref<string[]>([""]);
|
||||
const loading = ref(true);
|
||||
const working = ref(false);
|
||||
const errorKey = ref("");
|
||||
const successKey = ref("");
|
||||
|
||||
const activeItem = computed(
|
||||
() => items.value.find((item) => item.order_item_id === activeItemId.value) ?? null,
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
items.value = await $api.listReviewableItems();
|
||||
} catch {
|
||||
errorKey.value = "user.reviewLoadFailed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function begin(item: ReviewableItem): void {
|
||||
activeItemId.value = item.order_item_id;
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
errorKey.value = "";
|
||||
successKey.value = "";
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
activeItemId.value = "";
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
errorKey.value = "";
|
||||
}
|
||||
|
||||
function addImageUrl(): void {
|
||||
imageUrls.value.push("");
|
||||
}
|
||||
|
||||
function removeImageUrl(index: number): void {
|
||||
if (imageUrls.value.length === 1) {
|
||||
imageUrls.value[0] = "";
|
||||
return;
|
||||
}
|
||||
imageUrls.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorKey.value = "";
|
||||
successKey.value = "";
|
||||
const item = activeItem.value;
|
||||
const text = content.value.trim();
|
||||
if (!item || rating.value < 1 || !text) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
working.value = true;
|
||||
try {
|
||||
await $api.createReview({
|
||||
order_item_id: item.order_item_id,
|
||||
rating: rating.value,
|
||||
content: { [locale.value]: text },
|
||||
images: imageUrls.value.map((url) => url.trim()).filter(Boolean),
|
||||
});
|
||||
items.value = items.value.filter((entry) => entry.order_item_id !== item.order_item_id);
|
||||
activeItemId.value = "";
|
||||
rating.value = 0;
|
||||
content.value = "";
|
||||
imageUrls.value = [""];
|
||||
successKey.value = "user.reviewSubmitSuccess";
|
||||
} catch {
|
||||
errorKey.value = "user.reviewSubmitFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.reviewsTitle") }}</h1>
|
||||
<p class="text-muted mt-1 text-xs">{{ t("user.reviewCount", { n: items.length }) }}</p>
|
||||
</div>
|
||||
<NuxtLink class="text-primary text-sm no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
|
||||
</div>
|
||||
<p v-if="successKey" class="text-success mb-4 text-sm">{{ t(successKey) }}</p>
|
||||
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="errorKey" class="text-danger py-5 text-sm">{{ t(errorKey) }}</p>
|
||||
<UiEmptyState v-else-if="items.length === 0" :text="t('user.noPendingReviews')" />
|
||||
<div v-else class="space-y-3">
|
||||
<VCard v-for="item in items" :key="item.order_item_id" :padded="false" class="border-border border p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<img class="border-border h-16 w-16 border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-text m-0 truncate text-sm font-medium">{{ pick(item.product_name, locale) }}</p>
|
||||
<p class="text-muted mt-1 text-xs">{{ item.sku_code }}</p>
|
||||
<dl class="text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex gap-1"><dt>{{ t("user.reviewOrder") }}:</dt><dd class="m-0">{{ item.order_no }}</dd></div>
|
||||
<div class="flex gap-1"><dt>{{ t("user.reviewTime") }}:</dt><dd class="m-0">{{ item.created_at.slice(0, 10) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<VBtn v-if="activeItemId !== item.order_item_id" variant="primary" type="button" @click="begin(item)">{{ t("user.reviewNow") }}</VBtn>
|
||||
</div>
|
||||
<form v-if="activeItemId === item.order_item_id" class="border-border mt-4 grid gap-3 border-t pt-4" @submit.prevent="submit">
|
||||
<VField :label="t('user.reviewRating')">
|
||||
<div class="flex items-center gap-1" role="radiogroup" :aria-label="t('user.reviewRating')">
|
||||
<button v-for="star in [1, 2, 3, 4, 5]" :key="star" class="text-2xl leading-none" :class="star <= rating ? 'text-primary' : 'text-[#ddd]'" type="button" role="radio" :aria-checked="star === rating" :aria-label="String(star)" @click="rating = star">★</button>
|
||||
</div>
|
||||
</VField>
|
||||
<VField :label="t('user.reviewContent')">
|
||||
<textarea v-model="content" class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm" :placeholder="t('user.reviewContentHint')" required />
|
||||
</VField>
|
||||
<VField :label="t('user.reviewImages')">
|
||||
<div class="grid gap-2">
|
||||
<div v-for="(url, index) in imageUrls" :key="index" class="flex gap-2">
|
||||
<VInput v-model="imageUrls[index]" :placeholder="t('user.reviewImagePlaceholder')" />
|
||||
<VBtn type="button" @click="removeImageUrl(index)">{{ t("user.reviewRemoveImage") }}</VBtn>
|
||||
</div>
|
||||
<VBtn class="w-fit" type="button" @click="addImageUrl">{{ t("user.reviewAddImage") }}</VBtn>
|
||||
</div>
|
||||
</VField>
|
||||
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
|
||||
<div class="flex gap-2">
|
||||
<VBtn variant="primary" type="submit" :disabled="working">{{ t("user.reviewSubmit") }}</VBtn>
|
||||
<VBtn type="button" :disabled="working" @click="cancel">{{ t("user.reviewCancel") }}</VBtn>
|
||||
</div>
|
||||
</form>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user