Files
vmall/apps/mall/pages/user/aftersales/[id].vue
T

340 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>