265 lines
11 KiB
Vue
265 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ApiError, t as localized } from "@vmall/shared";
|
|
import type { AftersaleDetail, AftersaleMessage, AftersaleStatus, LocalizedText } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { $api } = useNuxtApp();
|
|
const { locale, t: translate } = useI18n();
|
|
const { load: loadMoney, fmt } = useMoney();
|
|
const route = useRoute();
|
|
const aftersaleId = computed(() => String(route.params.id));
|
|
const detail = ref<AftersaleDetail | null>(null);
|
|
const loading = ref(true);
|
|
const busy = ref(false);
|
|
const error = ref("");
|
|
const notice = ref("");
|
|
const messageEn = ref("");
|
|
const messageZh = ref("");
|
|
const messageEvidence = ref("");
|
|
|
|
function statusClass(value: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" {
|
|
if (value === "refunded") return "green";
|
|
if (value === "rejected") return "red";
|
|
if (value === "cancelled") return "gray";
|
|
if (value === "pending") return "orange";
|
|
return "blue";
|
|
}
|
|
|
|
function roleClass(value: AftersaleMessage["author_role"]): "green" | "blue" | "gray" {
|
|
return value === "merchant" ? "blue" : value === "platform" ? "gray" : "green";
|
|
}
|
|
|
|
function formatDate(value: string): string {
|
|
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
|
}
|
|
|
|
function textEn(value: LocalizedText): string {
|
|
return localized(value, "en");
|
|
}
|
|
|
|
function textZh(value: LocalizedText): string {
|
|
return localized(value, "zh");
|
|
}
|
|
|
|
function sortedMessages(messages: AftersaleMessage[]): AftersaleMessage[] {
|
|
return [...messages].sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
}
|
|
|
|
function showError(err: unknown): void {
|
|
if (err instanceof ApiError && err.status === 409) {
|
|
error.value = translate("aftersale.staleAction");
|
|
} else {
|
|
error.value = err instanceof Error ? err.message : translate("common.error");
|
|
}
|
|
}
|
|
|
|
async function fetchDetail(): Promise<void> {
|
|
detail.value = await $api.shop.getAftersale(aftersaleId.value);
|
|
}
|
|
|
|
async function refresh(): Promise<void> {
|
|
try {
|
|
await fetchDetail();
|
|
} catch (err: unknown) {
|
|
showError(err);
|
|
}
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
await fetchDetail();
|
|
} catch (err: unknown) {
|
|
showError(err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
const canDecide = computed(() => detail.value?.status === "pending");
|
|
const canConfirmReceipt = computed(() => detail.value?.status === "buyer_shipping");
|
|
const canRefund = computed(() => {
|
|
const current = detail.value;
|
|
if (!current) return false;
|
|
return (
|
|
current.status === "merchant_confirmed" ||
|
|
(current.status === "approved" && current.kind === "refund_only")
|
|
);
|
|
});
|
|
const hasActions = computed(() => canDecide.value || canConfirmReceipt.value || canRefund.value);
|
|
|
|
type ShopAction = "approve" | "reject" | "confirmReceipt" | "refund";
|
|
|
|
const doneKey: Record<ShopAction, string> = {
|
|
approve: "aftersale.approved",
|
|
reject: "aftersale.rejected",
|
|
confirmReceipt: "aftersale.receiptConfirmed",
|
|
refund: "aftersale.refunded",
|
|
};
|
|
|
|
async function run(action: ShopAction): Promise<void> {
|
|
busy.value = true;
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
if (action === "approve") await $api.shop.approveAftersale(aftersaleId.value);
|
|
else if (action === "reject") await $api.shop.rejectAftersale(aftersaleId.value);
|
|
else if (action === "confirmReceipt") await $api.shop.confirmAftersaleReceipt(aftersaleId.value);
|
|
else await $api.shop.refundAftersale(aftersaleId.value);
|
|
notice.value = translate(doneKey[action]);
|
|
} catch (err: unknown) {
|
|
showError(err);
|
|
} finally {
|
|
busy.value = false;
|
|
await refresh();
|
|
}
|
|
}
|
|
|
|
async function sendMessage(): Promise<void> {
|
|
const en = messageEn.value.trim();
|
|
const zh = messageZh.value.trim();
|
|
if (!en || !zh) {
|
|
error.value = translate("shop.bilingualRequired");
|
|
return;
|
|
}
|
|
const evidenceUrl = messageEvidence.value.trim();
|
|
busy.value = true;
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
await $api.shop.addAftersaleMessage(aftersaleId.value, {
|
|
content: { en, zh },
|
|
evidence: evidenceUrl ? [evidenceUrl] : undefined,
|
|
});
|
|
messageEn.value = "";
|
|
messageZh.value = "";
|
|
messageEvidence.value = "";
|
|
notice.value = translate("aftersale.messageSent");
|
|
} catch (err: unknown) {
|
|
showError(err);
|
|
} finally {
|
|
busy.value = false;
|
|
await refresh();
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadMoney();
|
|
load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<VPage :title="$t('aftersale.detail')">
|
|
<template #actions>
|
|
<NuxtLink class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text hover:bg-bg" to="/aftersales">{{ $t("common.back") }}</NuxtLink>
|
|
</template>
|
|
<div v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</div>
|
|
<div v-if="notice" class="my-2 text-sm text-success" role="status">{{ notice }}</div>
|
|
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
|
<template v-else-if="detail">
|
|
<VCard>
|
|
<div class="mb-4 flex items-center justify-between gap-3">
|
|
<h2 class="text-lg font-semibold">{{ $t("shop.details") }}</h2>
|
|
<div class="flex items-center gap-2">
|
|
<VBadge tone="blue">{{ $t(`aftersale.kind.${detail.kind}`) }}</VBadge>
|
|
<VBadge :tone="statusClass(detail.status)">{{ $t(`aftersale.status.${detail.status}`) }}</VBadge>
|
|
<VBadge v-if="detail.reopened" tone="orange">{{ $t("aftersale.reopened") }}</VBadge>
|
|
</div>
|
|
</div>
|
|
<div class="mb-5 grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
|
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("shop.created") }}</span><strong>{{ formatDate(detail.created_at) }}</strong></div>
|
|
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.buyer") }}</span><strong class="font-mono text-xs">{{ detail.user_id }}</strong></div>
|
|
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.amount") }}</span><strong>{{ fmt(detail.amount_minor, detail.currency) }}</strong></div>
|
|
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.remaining") }}</span><strong>{{ fmt(detail.remaining_refundable_minor, detail.currency) }}</strong></div>
|
|
<div class="grid gap-1.5">
|
|
<span class="text-sm text-muted">{{ $t("aftersale.order") }}</span>
|
|
<NuxtLink :to="`/orders/${detail.order_id}`" class="text-primary hover:underline">{{ $t("aftersale.viewOrder") }}</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="mb-3 text-base font-semibold">{{ $t("aftersale.item") }}</h3>
|
|
<div class="flex items-start gap-3">
|
|
<img
|
|
v-if="detail.item.image"
|
|
:src="detail.item.image"
|
|
:alt="textEn(detail.item.product_name)"
|
|
class="h-16 w-16 rounded-md border border-border object-cover"
|
|
/>
|
|
<div class="grid gap-1">
|
|
<strong>{{ textEn(detail.item.product_name) }}</strong>
|
|
<span class="text-sm text-muted">{{ textZh(detail.item.product_name) }}</span>
|
|
<span class="text-sm text-muted">{{ $t("product.skuCode") }}: {{ detail.item.sku_code }} · {{ $t("common.qty") }}: {{ detail.item.qty }} · {{ $t("common.price") }}: {{ fmt(detail.item.unit_price_minor, detail.currency) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.reason") }}</h3>
|
|
<div class="grid gap-1">
|
|
<p>{{ textEn(detail.reason) }}</p>
|
|
<p class="text-sm text-muted">{{ textZh(detail.reason) }}</p>
|
|
</div>
|
|
|
|
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.evidence") }}</h3>
|
|
<div v-if="!detail.evidence.length" class="text-muted">{{ $t("aftersale.noEvidence") }}</div>
|
|
<ul v-else class="grid gap-1">
|
|
<li v-for="url in detail.evidence" :key="url">
|
|
<a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary hover:underline">{{ url }}</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<template v-if="detail.return_carrier || detail.return_tracking_no">
|
|
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.returnShipping") }}</h3>
|
|
<div class="grid gap-1">
|
|
<div>{{ $t("shop.carrier") }}: {{ detail.return_carrier ?? "—" }}</div>
|
|
<div>{{ $t("shop.trackingNo") }}: {{ detail.return_tracking_no ?? "—" }}</div>
|
|
</div>
|
|
</template>
|
|
</VCard>
|
|
|
|
<VCard v-if="hasActions" class="mt-4">
|
|
<h2 class="mb-4 text-lg font-semibold">{{ $t("common.actions") }}</h2>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template v-if="canDecide">
|
|
<VBtn variant="primary" :disabled="busy" @click="run('approve')">{{ $t("aftersale.approve") }}</VBtn>
|
|
<VBtn variant="danger" :disabled="busy" @click="run('reject')">{{ $t("aftersale.reject") }}</VBtn>
|
|
</template>
|
|
<VBtn v-if="canConfirmReceipt" variant="primary" :disabled="busy" @click="run('confirmReceipt')">{{ $t("aftersale.confirmReceipt") }}</VBtn>
|
|
<VBtn v-if="canRefund" variant="primary" :disabled="busy" @click="run('refund')">{{ $t("aftersale.refund") }}</VBtn>
|
|
</div>
|
|
</VCard>
|
|
|
|
<VCard class="mt-4">
|
|
<h2 class="mb-4 text-lg font-semibold">{{ $t("aftersale.messages") }}</h2>
|
|
<div v-if="!detail.messages.length" class="text-muted">{{ $t("aftersale.noMessages") }}</div>
|
|
<ul v-else class="grid gap-3">
|
|
<li v-for="message in sortedMessages(detail.messages)" :key="message.id" class="rounded-md border border-border bg-bg px-3 py-2">
|
|
<div class="mb-1 flex items-center gap-2 text-sm">
|
|
<VBadge :tone="roleClass(message.author_role)">{{ $t(`aftersale.role.${message.author_role}`) }}</VBadge>
|
|
<span class="text-muted">{{ formatDate(message.created_at) }}</span>
|
|
</div>
|
|
<p>{{ textEn(message.content) }}</p>
|
|
<p class="text-sm text-muted">{{ textZh(message.content) }}</p>
|
|
<ul v-if="message.evidence.length" class="mt-1 grid gap-0.5">
|
|
<li v-for="url in message.evidence" :key="url">
|
|
<a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-sm text-primary hover:underline">{{ url }}</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
|
|
<form class="mt-5 grid gap-3" @submit.prevent="sendMessage">
|
|
<h3 class="text-base font-semibold">{{ $t("aftersale.sendMessage") }}</h3>
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<VField :label="$t('aftersale.messageEn')"><VInput v-model="messageEn" type="text" /></VField>
|
|
<VField :label="$t('aftersale.messageZh')"><VInput v-model="messageZh" type="text" /></VField>
|
|
</div>
|
|
<VField :label="$t('aftersale.messageEvidence')"><VInput v-model="messageEvidence" type="text" /></VField>
|
|
<VBtn variant="primary" type="submit" :disabled="busy">{{ $t("aftersale.sendMessage") }}</VBtn>
|
|
</form>
|
|
</VCard>
|
|
</template>
|
|
</VPage>
|
|
</template>
|