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

218 lines
10 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="m-0 text-xl font-bold text-text">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/aftersales">{{ t("user.aftersalesTitle") }}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-else class="space-y-4">
<header class="flex items-center justify-between gap-3 border border-border bg-bg p-4 max-sm:flex-col max-sm:items-start">
<div>
<p class="m-0 text-sm text-muted">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="mt-1 text-xs text-muted">{{ 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="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleItem") }}</h2>
<div class="flex items-center gap-3">
<img class="h-14 w-14 border border-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="m-0 truncate text-sm text-text">{{ pick(detail.item.product_name, locale) }}</p>
<p class="mt-1 text-xs text-muted">{{ 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="mt-4 grid gap-2 text-sm text-muted 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="mb-4 text-[15px] font-semibold text-text">{{ 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="mt-3 text-sm text-danger">{{ statusLabel(detail.status) }}</p>
<div v-if="detail.return_carrier || detail.return_tracking_no" class="mt-3 text-sm text-muted">
{{ t("user.returnShipping") }}: {{ detail.return_carrier }} {{ detail.return_tracking_no }}
</div>
<div v-if="detail.status === 'refunded'" class="mt-3 text-sm text-success">
{{ 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="mb-3 text-[15px] font-semibold text-text">{{ 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="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="mb-4 text-sm text-muted">{{ t("user.noAftersales") }}</div>
<div v-else class="mb-4 space-y-2">
<div v-for="entry in detail.messages" :key="entry.id" class="rounded border border-border p-3" :class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'">
<div class="flex items-center justify-between gap-2 text-xs text-muted"><span>{{ t(`user.${entry.author_role}`) }}</span><time>{{ entry.created_at.slice(0, 16).replace('T', ' ') }}</time></div>
<p class="m-0 mt-2 whitespace-pre-wrap text-sm text-text">{{ 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="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleMessagePlaceholder')" /></VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-16 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :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="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
</div>
</VCard>
</template>