feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)

This commit is contained in:
Chengdong Zhang
2026-09-23 16:56:30 +08:00
parent 93e5a05d48
commit 2c2b54c21c
43 changed files with 4889 additions and 32 deletions
+1
View File
@@ -28,6 +28,7 @@ watchEffect(() => {
<NuxtLink to="/products" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.products") }}</NuxtLink>
<NuxtLink to="/shop-profile" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.shopProfile") }}</NuxtLink>
<NuxtLink to="/orders" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.orders") }}</NuxtLink>
<NuxtLink to="/aftersales" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.aftersales") }}</NuxtLink>
<NuxtLink to="/shipments" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.shipments") }}</NuxtLink>
<NuxtLink to="/invoices" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.invoices") }}</NuxtLink>
<NuxtLink to="/coupons" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.coupons") }}</NuxtLink>
+100
View File
@@ -9,6 +9,7 @@ export const enExtra = {
flashSales: "Flash sales",
groupBuying: "Group buying",
shopProfile: "Shop profile",
aftersales: "After-sales",
},
shop: {
profileSaved: "Shop profile saved.",
@@ -159,6 +160,55 @@ export const enExtra = {
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
groupWindowInvalid: "End must not precede start.",
},
aftersale: {
title: "After-sales",
detail: "After-sale detail",
buyer: "Buyer",
amount: "Refund amount",
remaining: "Remaining refundable",
reason: "Reason",
item: "Item",
order: "Order",
viewOrder: "View order",
evidence: "Evidence",
noEvidence: "No evidence provided.",
returnShipping: "Return shipping",
messages: "Messages",
noMessages: "No messages yet.",
messageEn: "Message (English)",
messageZh: "Message (中文)",
messageEvidence: "Evidence URL (optional)",
sendMessage: "Send message",
messageSent: "Message sent.",
approve: "Approve",
reject: "Reject",
confirmReceipt: "Confirm return receipt",
refund: "Issue refund",
approved: "Application approved.",
rejected: "Application rejected.",
receiptConfirmed: "Return receipt confirmed.",
refunded: "Refund completed.",
staleAction: "The after-sale state changed while you were acting; the latest state is shown below.",
reopened: "Reopened",
role: {
buyer: "Buyer",
merchant: "Merchant",
platform: "Platform",
},
kind: {
refund_only: "Refund only",
return_refund: "Return & refund",
},
status: {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
buyer_shipping: "Return in transit",
merchant_confirmed: "Return received",
refunded: "Refunded",
cancelled: "Cancelled",
},
},
} as Record<string, unknown>;
export const zhExtra = {
@@ -170,6 +220,7 @@ export const zhExtra = {
flashSales: "秒杀",
groupBuying: "拼团",
shopProfile: "店铺资料",
aftersales: "售后",
},
shop: {
profileSaved: "店铺资料已保存。",
@@ -320,4 +371,53 @@ export const zhExtra = {
groupLifetimeInvalid: "团有效期必须为正数小时。",
groupWindowInvalid: "结束时间不能早于开始时间。",
},
aftersale: {
title: "售后",
detail: "售后详情",
buyer: "买家",
amount: "退款金额",
remaining: "剩余可退",
reason: "申请原因",
item: "商品",
order: "订单",
viewOrder: "查看订单",
evidence: "凭证",
noEvidence: "暂无凭证。",
returnShipping: "退货物流",
messages: "留言",
noMessages: "暂无留言。",
messageEn: "留言(英文)",
messageZh: "留言(中文)",
messageEvidence: "凭证链接(可选)",
sendMessage: "发送留言",
messageSent: "留言已发送。",
approve: "批准",
reject: "拒绝",
confirmReceipt: "确认收货",
refund: "退款",
approved: "已批准申请。",
rejected: "已拒绝申请。",
receiptConfirmed: "已确认收货。",
refunded: "退款已完成。",
staleAction: "售后状态已发生变化,下方为最新状态。",
reopened: "已重新申请",
role: {
buyer: "买家",
merchant: "商家",
platform: "平台",
},
kind: {
refund_only: "仅退款",
return_refund: "退货退款",
},
status: {
pending: "待处理",
approved: "已批准",
rejected: "已拒绝",
buyer_shipping: "买家退货中",
merchant_confirmed: "商家已收货",
refunded: "已退款",
cancelled: "已取消",
},
},
} as Record<string, unknown>;
+264
View File
@@ -0,0 +1,264 @@
<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>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { Aftersale, AftersaleStatus } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const { load: loadMoney, fmt } = useMoney();
const aftersales = ref<Aftersale[]>([]);
const status = ref<"" | AftersaleStatus>("");
const loading = ref(true);
const error = 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 shortId(id: string): string {
return id.slice(0, 8);
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
async function loadAftersales(): Promise<void> {
loading.value = true;
error.value = "";
try {
aftersales.value = await $api.shop.listAftersales(status.value || undefined);
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
onMounted(() => {
loadMoney();
loadAftersales();
});
</script>
<template>
<VPage :title="$t('aftersale.title')">
<div class="mb-4 flex items-center gap-3">
<label for="aftersale-status" class="text-sm font-medium">{{ $t("shop.filterStatus") }}</label>
<select
id="aftersale-status"
v-model="status"
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
@change="loadAftersales"
>
<option value="">{{ $t("common.all") }}</option>
<option value="pending">{{ $t("aftersale.status.pending") }}</option>
<option value="approved">{{ $t("aftersale.status.approved") }}</option>
<option value="buyer_shipping">{{ $t("aftersale.status.buyer_shipping") }}</option>
<option value="merchant_confirmed">{{ $t("aftersale.status.merchant_confirmed") }}</option>
<option value="refunded">{{ $t("aftersale.status.refunded") }}</option>
<option value="rejected">{{ $t("aftersale.status.rejected") }}</option>
<option value="cancelled">{{ $t("aftersale.status.cancelled") }}</option>
</select>
</div>
<div v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</div>
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
<VCard v-else-if="!aftersales.length" class="text-muted">{{ $t("common.empty") }}</VCard>
<VTable v-else>
<thead>
<tr>
<th>{{ $t("aftersale.buyer") }}</th>
<th>{{ $t("shop.kind") }}</th>
<th>{{ $t("aftersale.amount") }}</th>
<th>{{ $t("shop.created") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="aftersale in aftersales" :key="aftersale.id">
<td class="font-mono text-xs" :title="aftersale.user_id">{{ shortId(aftersale.user_id) }}</td>
<td>{{ $t(`aftersale.kind.${aftersale.kind}`) }}</td>
<td>{{ fmt(aftersale.amount_minor, aftersale.currency) }}</td>
<td>{{ formatDate(aftersale.created_at) }}</td>
<td><VBadge :tone="statusClass(aftersale.status)">{{ $t(`aftersale.status.${aftersale.status}`) }}</VBadge></td>
<td>
<NuxtLink :to="`/aftersales/${aftersale.id}`" class="text-primary hover:underline">{{ $t("shop.details") }}</NuxtLink>
</td>
</tr>
</tbody>
</VTable>
</VPage>
</template>