213 lines
6.9 KiB
Vue
213 lines
6.9 KiB
Vue
<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>
|