340 lines
12 KiB
Vue
340 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import type { AftersaleDetail, AftersaleStatus } from "@vmall/shared";
|
||
import { t as pick } from "@vmall/shared";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { locale, t } = useI18n();
|
||
const { $api } = useNuxtApp();
|
||
const route = useRoute();
|
||
const detail = ref<AftersaleDetail | null>(null);
|
||
const loading = ref(true);
|
||
const working = ref(false);
|
||
const failed = ref(false);
|
||
const errorKey = ref("");
|
||
const carrier = ref("");
|
||
const trackingNo = ref("");
|
||
const message = ref("");
|
||
const evidence = ref("");
|
||
const progress: AftersaleStatus[] = [
|
||
"pending",
|
||
"approved",
|
||
"buyer_shipping",
|
||
"merchant_confirmed",
|
||
"refunded",
|
||
];
|
||
|
||
const id = computed(() => String(route.params.id ?? ""));
|
||
|
||
function tone(status: AftersaleStatus): "green" | "blue" | "red" | "orange" {
|
||
if (status === "refunded") return "green";
|
||
if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue";
|
||
if (["rejected", "cancelled"].includes(status)) return "red";
|
||
return "orange";
|
||
}
|
||
|
||
function statusLabel(status: AftersaleStatus): string {
|
||
return t(`user.aftersaleStatus_${status}`);
|
||
}
|
||
|
||
function progressTone(status: AftersaleStatus): string {
|
||
if (!detail.value) return "border-border bg-surface text-muted";
|
||
const current = progress.indexOf(detail.value.status);
|
||
const step = progress.indexOf(status);
|
||
return step >= 0 && current >= step
|
||
? "border-primary bg-primary text-white"
|
||
: "border-border bg-surface text-muted";
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true;
|
||
failed.value = false;
|
||
try {
|
||
detail.value = await $api.getAftersale(id.value);
|
||
carrier.value = detail.value.return_carrier ?? "";
|
||
trackingNo.value = detail.value.return_tracking_no ?? "";
|
||
} catch {
|
||
failed.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function cancel(): Promise<void> {
|
||
if (!detail.value) return;
|
||
working.value = true;
|
||
errorKey.value = "";
|
||
try {
|
||
await $api.cancelAftersale(detail.value.id);
|
||
await load();
|
||
} catch {
|
||
errorKey.value = "user.aftersaleActionFailed";
|
||
} finally {
|
||
working.value = false;
|
||
}
|
||
}
|
||
|
||
async function reopen(): Promise<void> {
|
||
if (!detail.value) return;
|
||
working.value = true;
|
||
errorKey.value = "";
|
||
try {
|
||
await $api.reopenAftersale(detail.value.id);
|
||
await load();
|
||
} catch {
|
||
errorKey.value = "user.aftersaleActionFailed";
|
||
} finally {
|
||
working.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitTracking(): Promise<void> {
|
||
if (!detail.value || !carrier.value.trim() || !trackingNo.value.trim()) {
|
||
errorKey.value = "user.validationRequired";
|
||
return;
|
||
}
|
||
working.value = true;
|
||
errorKey.value = "";
|
||
try {
|
||
await $api.submitAftersaleReturnTracking(detail.value.id, {
|
||
carrier: carrier.value.trim(),
|
||
tracking_no: trackingNo.value.trim(),
|
||
});
|
||
await load();
|
||
} catch {
|
||
errorKey.value = "user.aftersaleActionFailed";
|
||
} finally {
|
||
working.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendMessage(): Promise<void> {
|
||
const content = message.value.trim();
|
||
if (!detail.value || !content) {
|
||
errorKey.value = "user.validationRequired";
|
||
return;
|
||
}
|
||
working.value = true;
|
||
errorKey.value = "";
|
||
try {
|
||
await $api.addAftersaleMessage(detail.value.id, {
|
||
content: { en: content, zh: content },
|
||
evidence: evidence.value
|
||
.split("\n")
|
||
.map((url) => url.trim())
|
||
.filter(Boolean),
|
||
});
|
||
message.value = "";
|
||
evidence.value = "";
|
||
await load();
|
||
} catch {
|
||
errorKey.value = "user.aftersaleActionFailed";
|
||
} 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">
|
||
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.aftersaleDetail") }}</h1>
|
||
<NuxtLink class="text-primary text-sm no-underline" to="/user/aftersales">{{
|
||
t("user.aftersalesTitle")
|
||
}}</NuxtLink>
|
||
</div>
|
||
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
||
<p v-else-if="failed || !detail" class="text-danger py-5 text-sm">
|
||
{{ t("user.aftersaleLoadFailed") }}
|
||
</p>
|
||
<div v-else class="space-y-4">
|
||
<header
|
||
class="border-border bg-bg flex items-center justify-between gap-3 border p-4 max-sm:flex-col max-sm:items-start"
|
||
>
|
||
<div>
|
||
<p class="text-muted m-0 text-sm">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
|
||
<p class="text-muted mt-1 text-xs">
|
||
{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}
|
||
</p>
|
||
</div>
|
||
<VBadge :tone="tone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
|
||
</header>
|
||
|
||
<VCard :padded="false" class="p-5">
|
||
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleItem") }}</h2>
|
||
<div class="flex items-center gap-3">
|
||
<img
|
||
class="border-border h-14 w-14 border object-contain"
|
||
:src="detail.item.image || '/mock/product-1.svg'"
|
||
:alt="pick(detail.item.product_name, locale)"
|
||
/>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-text m-0 truncate text-sm">
|
||
{{ pick(detail.item.product_name, locale) }}
|
||
</p>
|
||
<p class="text-muted mt-1 text-xs">
|
||
{{ detail.item.sku_code }} × {{ detail.item.qty }}
|
||
</p>
|
||
</div>
|
||
<PriceText
|
||
:amount-minor="detail.item.unit_price_minor * detail.item.qty"
|
||
:currency="detail.currency"
|
||
/>
|
||
</div>
|
||
<div class="text-muted mt-4 grid gap-2 text-sm sm:grid-cols-2">
|
||
<span
|
||
>{{ t("user.aftersaleType") }}:
|
||
{{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span
|
||
>
|
||
<span
|
||
>{{ t("user.aftersaleAmount") }}:
|
||
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency"
|
||
/></span>
|
||
<span
|
||
>{{ t("user.aftersaleRemaining") }}:
|
||
<PriceText
|
||
:amount-minor="detail.remaining_refundable_minor"
|
||
:currency="detail.currency"
|
||
/></span>
|
||
<span>{{ t("user.aftersaleReason") }}: {{ pick(detail.reason, locale) }}</span>
|
||
</div>
|
||
<div v-if="detail.evidence.length" class="mt-4 flex flex-wrap gap-2 text-sm">
|
||
<a
|
||
v-for="url in detail.evidence"
|
||
:key="url"
|
||
class="text-primary"
|
||
:href="url"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>{{ t("user.aftersaleViewEvidence") }}</a
|
||
>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard :padded="false" class="p-5">
|
||
<h2 class="text-text mb-4 text-[15px] font-semibold">{{ t("user.aftersaleStatus") }}</h2>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<template v-for="(step, index) in progress" :key="step">
|
||
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{
|
||
statusLabel(step)
|
||
}}</span>
|
||
<span v-if="index < progress.length - 1" class="text-muted">→</span>
|
||
</template>
|
||
</div>
|
||
<p
|
||
v-if="detail.status === 'rejected' || detail.status === 'cancelled'"
|
||
class="text-danger mt-3 text-sm"
|
||
>
|
||
{{ statusLabel(detail.status) }}
|
||
</p>
|
||
<div
|
||
v-if="detail.return_carrier || detail.return_tracking_no"
|
||
class="text-muted mt-3 text-sm"
|
||
>
|
||
{{ t("user.returnShipping") }}: {{ detail.return_carrier }}
|
||
{{ detail.return_tracking_no }}
|
||
</div>
|
||
<div v-if="detail.status === 'refunded'" class="text-success mt-3 text-sm">
|
||
{{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}:
|
||
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" />
|
||
</div>
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
<VBtn
|
||
v-if="
|
||
['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(
|
||
detail.status,
|
||
)
|
||
"
|
||
variant="danger"
|
||
size="sm"
|
||
type="button"
|
||
:disabled="working"
|
||
@click="cancel"
|
||
>{{ t("user.cancelAftersale") }}</VBtn
|
||
>
|
||
<VBtn
|
||
v-if="detail.status === 'rejected'"
|
||
variant="primary"
|
||
size="sm"
|
||
type="button"
|
||
:disabled="working"
|
||
@click="reopen"
|
||
>{{ t("user.reopenAftersale") }}</VBtn
|
||
>
|
||
</div>
|
||
</VCard>
|
||
|
||
<VCard
|
||
v-if="detail.status === 'approved' && detail.kind === 'return_refund'"
|
||
:padded="false"
|
||
class="p-5"
|
||
>
|
||
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.returnShipping") }}</h2>
|
||
<form class="grid max-w-[520px] gap-3" @submit.prevent="submitTracking">
|
||
<VField :label="t('user.aftersaleCarrier')"><VInput v-model="carrier" /></VField>
|
||
<VField :label="t('user.aftersaleTrackingNo')"><VInput v-model="trackingNo" /></VField>
|
||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
|
||
t("user.submitReturnTracking")
|
||
}}</VBtn>
|
||
</form>
|
||
</VCard>
|
||
|
||
<VCard :padded="false" class="p-5">
|
||
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleMessages") }}</h2>
|
||
<div v-if="detail.messages.length === 0" class="text-muted mb-4 text-sm">
|
||
{{ t("user.noAftersales") }}
|
||
</div>
|
||
<div v-else class="mb-4 space-y-2">
|
||
<div
|
||
v-for="entry in detail.messages"
|
||
:key="entry.id"
|
||
class="border-border rounded border p-3"
|
||
:class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'"
|
||
>
|
||
<div class="text-muted flex items-center justify-between gap-2 text-xs">
|
||
<span>{{ t(`user.${entry.author_role}`) }}</span
|
||
><time>{{ entry.created_at.slice(0, 16).replace("T", " ") }}</time>
|
||
</div>
|
||
<p class="text-text m-0 mt-2 text-sm whitespace-pre-wrap">
|
||
{{ pick(entry.content, locale) }}
|
||
</p>
|
||
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs">
|
||
<a
|
||
v-for="url in entry.evidence"
|
||
:key="url"
|
||
class="text-primary"
|
||
:href="url"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>{{ t("user.aftersaleViewEvidence") }}</a
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<form class="grid max-w-[640px] gap-3" @submit.prevent="sendMessage">
|
||
<VField :label="t('user.aftersaleMessagePlaceholder')">
|
||
<textarea
|
||
v-model="message"
|
||
class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm"
|
||
:placeholder="t('user.aftersaleMessagePlaceholder')"
|
||
/>
|
||
</VField>
|
||
<VField :label="t('user.aftersaleEvidence')">
|
||
<textarea
|
||
v-model="evidence"
|
||
class="border-border bg-surface text-text min-h-16 rounded-md border p-2.5 text-sm"
|
||
:placeholder="t('user.aftersaleEvidenceHint')"
|
||
/>
|
||
</VField>
|
||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
|
||
t("user.sendMessage")
|
||
}}</VBtn>
|
||
</form>
|
||
</VCard>
|
||
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
|
||
</div>
|
||
</VCard>
|
||
</template>
|