feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user