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
@@ -15,6 +15,7 @@ const navItems = [
{ to: "/users", label: "nav.users" },
{ to: "/shops", label: "nav.shops" },
{ to: "/orders", label: "nav.orders" },
{ to: "/aftersales", label: "nav.aftersales" },
{ to: "/content", label: "nav.content" },
{ to: "/brands", label: "nav.brands" },
{ to: "/currencies", label: "nav.currencies" },
+114
View File
@@ -10,6 +10,7 @@ export const enExtra = {
pointsOrders: "Point redemptions",
content: "Content",
brands: "Brands",
aftersales: "After-sales",
},
admin: {
dashboardTitle: "Platform overview",
@@ -88,6 +89,62 @@ export const enExtra = {
brandNameRequired: "Both language names are required.",
brandSlugDuplicate: "Duplicate slug.",
brandsSaved: "Brands saved.",
aftersaleFilterShop: "Shop ID",
aftersaleShopIdPlaceholder: "shop id",
aftersaleBuyerIdPlaceholder: "buyer id",
aftersaleFilterBuyer: "Buyer ID",
aftersaleKind: "Type",
aftersaleAmount: "Amount",
aftersaleId: "Application",
aftersaleShop: "Shop",
aftersaleBuyer: "Buyer",
aftersaleDetail: "After-sale detail",
aftersaleView: "Detail",
aftersaleClose: "Close",
aftersaleCreated: "Created",
aftersaleUpdated: "Updated",
aftersaleReason: "Reason",
aftersaleEvidence: "Evidence",
aftersaleNoEvidence: "No evidence attached",
aftersaleItem: "Order item",
aftersaleOrder: "Order",
aftersaleOrderTotal: "Order total",
aftersaleOrderRefunded: "Order refunded total",
aftersaleRemaining: "Remaining refundable",
aftersaleReturnTracking: "Return tracking",
aftersaleMessages: "Messages",
aftersaleNoMessages: "No messages yet",
aftersaleReopened: "Reopened once",
aftersaleArbitrate: "Arbitration",
aftersaleArbitrateRefund: "Arbitrate: refund",
aftersaleArbitrateReject: "Arbitrate: reject",
aftersaleConfirmRefund: "Grant a terminal refund to the buyer? This completes the refund and cannot be undone.",
aftersaleConfirmReject: "Reject this dispute terminally? Only the buyer's one-time reopen can resume it.",
aftersaleArbitratedRefund: "Arbitration complete: refunded.",
aftersaleArbitratedReject: "Arbitration complete: rejected.",
aftersaleOutcomeRefunded: "Refunded: the customer ledger is credited and the order refund total updated.",
aftersaleOutcomeRejected: "Rejected: only the buyer's one-time reopen can resume it.",
aftersaleOutcomeCancelled: "Cancelled by the buyer.",
aftersaleConflict: "The application state changed; refresh and try again",
aftersaleOrderNotFound: "Order not found in the order list.",
aftersaleKinds: {
refund_only: "Refund only",
return_refund: "Return & refund",
},
aftersaleStatuses: {
pending: "Pending review",
approved: "Approved",
rejected: "Rejected",
buyer_shipping: "Buyer shipping",
merchant_confirmed: "Return received",
refunded: "Refunded",
cancelled: "Cancelled",
},
aftersaleRoles: {
buyer: "Buyer",
merchant: "Merchant",
platform: "Platform",
},
},
} as const;
@@ -101,6 +158,7 @@ export const zhExtra = {
pointsOrders: "积分兑换单",
content: "内容",
brands: "品牌",
aftersales: "售后仲裁",
},
admin: {
dashboardTitle: "平台概览",
@@ -179,5 +237,61 @@ export const zhExtra = {
brandNameRequired: "中英文名称均为必填项。",
brandSlugDuplicate: "别名重复。",
brandsSaved: "品牌已保存。",
aftersaleFilterShop: "店铺 ID",
aftersaleShopIdPlaceholder: "店铺 ID",
aftersaleBuyerIdPlaceholder: "买家 ID",
aftersaleFilterBuyer: "买家 ID",
aftersaleKind: "类型",
aftersaleAmount: "金额",
aftersaleId: "售后单",
aftersaleShop: "店铺",
aftersaleBuyer: "买家",
aftersaleDetail: "售后详情",
aftersaleView: "详情",
aftersaleClose: "收起",
aftersaleCreated: "创建时间",
aftersaleUpdated: "更新时间",
aftersaleReason: "原因",
aftersaleEvidence: "凭证",
aftersaleNoEvidence: "无凭证",
aftersaleItem: "订单商品",
aftersaleOrder: "订单",
aftersaleOrderTotal: "订单总额",
aftersaleOrderRefunded: "订单已退款总额",
aftersaleRemaining: "剩余可退金额",
aftersaleReturnTracking: "退货物流",
aftersaleMessages: "留言记录",
aftersaleNoMessages: "暂无留言",
aftersaleReopened: "已重新申请",
aftersaleArbitrate: "仲裁",
aftersaleArbitrateRefund: "仲裁退款",
aftersaleArbitrateReject: "仲裁驳回",
aftersaleConfirmRefund: "确定仲裁退款给买家吗?退款将立即完成且不可撤销。",
aftersaleConfirmReject: "确定仲裁驳回吗?仅买家一次重新申请可恢复。",
aftersaleArbitratedRefund: "仲裁完成:已退款。",
aftersaleArbitratedReject: "仲裁完成:已驳回。",
aftersaleOutcomeRefunded: "已退款:买家账户已入账,订单退款总额已更新。",
aftersaleOutcomeRejected: "已驳回:仅买家一次重新申请可恢复。",
aftersaleOutcomeCancelled: "买家已取消。",
aftersaleConflict: "售后单状态已变化,请刷新后重试",
aftersaleOrderNotFound: "订单列表中未找到该订单。",
aftersaleKinds: {
refund_only: "仅退款",
return_refund: "退货退款",
},
aftersaleStatuses: {
pending: "待处理",
approved: "已通过",
rejected: "已驳回",
buyer_shipping: "买家退货中",
merchant_confirmed: "商家已收货",
refunded: "已退款",
cancelled: "已取消",
},
aftersaleRoles: {
buyer: "买家",
merchant: "商家",
platform: "平台",
},
},
} as const;
+419
View File
@@ -0,0 +1,419 @@
<script setup lang="ts">
import { ApiError, formatMoney, t as localizedText } from "@vmall/shared";
import type {
Aftersale,
AftersaleArbitration,
AftersaleDetail,
AftersaleKind,
AftersaleMessage,
AftersaleStatus,
Currency,
Shop,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
interface OrderSummary {
orderNo: string;
totalMinor: number;
refundTotalMinor: number;
currency: string;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const aftersales = ref<Aftersale[]>([]);
const currencies = ref<Currency[]>([]);
const shops = ref<Shop[]>([]);
const statusFilter = ref<"" | AftersaleStatus>("");
const kindFilter = ref<"" | AftersaleKind>("");
const shopFilter = ref("");
const buyerFilter = ref("");
const loading = ref(true);
const errorMessage = ref("");
const openId = ref<string | null>(null);
const detail = ref<AftersaleDetail | null>(null);
const orderSummary = ref<OrderSummary | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const notice = ref("");
const acting = ref(false);
const orderCache = new Map<string, OrderSummary>();
const statuses: AftersaleStatus[] = [
"pending",
"approved",
"buyer_shipping",
"merchant_confirmed",
"refunded",
"rejected",
"cancelled",
];
const kinds: AftersaleKind[] = ["refund_only", "return_refund"];
const arbitrableStatuses: AftersaleStatus[] = [
"pending",
"approved",
"buyer_shipping",
"merchant_confirmed",
];
const filteredAftersales = computed(() => {
const shopQuery = shopFilter.value.trim().toLowerCase();
const buyerQuery = buyerFilter.value.trim().toLowerCase();
return aftersales.value.filter((aftersale) => {
if (kindFilter.value && aftersale.kind !== kindFilter.value) return false;
if (shopQuery && !aftersale.shop_id.toLowerCase().includes(shopQuery)) return false;
if (buyerQuery && !aftersale.user_id.toLowerCase().includes(buyerQuery)) return false;
return true;
});
});
function currencyExponent(code: string): number | undefined {
return currencies.value.find((currency) => currency.code === code)?.exponent;
}
function money(amountMinor: number, currency: string): string {
const exponent = currencyExponent(currency);
return exponent === undefined
? "—"
: formatMoney(amountMinor, currency, exponent, locale.value);
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function statusTone(status: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" {
if (status === "refunded") return "green";
if (status === "rejected") return "red";
if (status === "cancelled") return "gray";
if (status === "pending") return "orange";
return "blue";
}
function roleTone(role: AftersaleMessage["author_role"]): "green" | "blue" | "gray" {
if (role === "buyer") return "green";
if (role === "merchant") return "blue";
return "gray";
}
function statusLabel(status: AftersaleStatus): string {
return t(`admin.aftersaleStatuses.${status}`);
}
function kindLabel(kind: AftersaleKind): string {
return t(`admin.aftersaleKinds.${kind}`);
}
function roleLabel(role: AftersaleMessage["author_role"]): string {
return t(`admin.aftersaleRoles.${role}`);
}
function shopName(shopId: string): string {
const shop = shops.value.find((item) => item.id === shopId);
return shop ? localizedText(shop.name, locale.value) : shopId;
}
function shortId(value: string): string {
return value.slice(0, 8);
}
function canArbitrate(status: AftersaleStatus): boolean {
return arbitrableStatuses.includes(status);
}
function errorText(error: unknown): string {
const message = error instanceof Error ? error.message : t("common.error");
return error instanceof ApiError && error.status === 409
? `${t("admin.aftersaleConflict")}: ${message}`
: message;
}
async function loadAftersales(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [rows, currencyList, shopList] = await Promise.all([
$api.admin.listAftersales(statusFilter.value || undefined),
$api.admin.listCurrencies(),
$api.admin.listShops(),
]);
aftersales.value = rows;
currencies.value = currencyList;
shops.value = shopList;
} catch (error: unknown) {
errorMessage.value = errorText(error);
} finally {
loading.value = false;
}
}
async function loadOrderSummary(orderId: string): Promise<void> {
const cached = orderCache.get(orderId);
if (cached) {
orderSummary.value = cached;
return;
}
let page = 1;
for (;;) {
const paged = await $api.admin.listOrders(page);
const found = paged.items.find((order) => order.id === orderId);
if (found) {
const summary: OrderSummary = {
orderNo: found.order_no,
totalMinor: found.total_minor,
refundTotalMinor: found.refund_total_minor,
currency: found.currency,
};
orderCache.set(orderId, summary);
orderSummary.value = summary;
return;
}
if (page * paged.per_page >= paged.total) break;
page += 1;
}
orderSummary.value = null;
}
async function openDetail(id: string): Promise<void> {
openId.value = id;
detail.value = null;
orderSummary.value = null;
detailError.value = "";
notice.value = "";
detailLoading.value = true;
try {
const loaded = await $api.admin.getAftersale(id);
detail.value = loaded;
await loadOrderSummary(loaded.order_id);
} catch (error: unknown) {
detailError.value = errorText(error);
} finally {
detailLoading.value = false;
}
}
async function toggleDetail(id: string): Promise<void> {
if (openId.value === id) {
openId.value = null;
detail.value = null;
orderSummary.value = null;
detailError.value = "";
return;
}
await openDetail(id);
}
async function arbitrate(row: Aftersale, outcome: AftersaleArbitration): Promise<void> {
const confirmation = outcome === "refund"
? t("admin.aftersaleConfirmRefund")
: t("admin.aftersaleConfirmReject");
if (!confirm(confirmation)) return;
acting.value = true;
detailError.value = "";
notice.value = "";
try {
await $api.admin.arbitrateAftersale(row.id, outcome);
await loadAftersales();
orderCache.delete(row.order_id);
await openDetail(row.id);
notice.value = outcome === "refund"
? t("admin.aftersaleArbitratedRefund")
: t("admin.aftersaleArbitratedReject");
} catch (error: unknown) {
detailError.value = errorText(error);
} finally {
acting.value = false;
}
}
onMounted(() => {
void loadAftersales();
});
</script>
<template>
<VPage :title="$t('nav.aftersales')">
<VCard class="mb-5">
<div class="flex flex-wrap items-end gap-3">
<label class="grid gap-1 text-sm font-medium" for="aftersale-status">
{{ $t("common.status") }}
<select
id="aftersale-status"
v-model="statusFilter"
class="rounded-md border border-border bg-surface px-3 py-2 font-normal text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
@change="loadAftersales"
>
<option value="">{{ $t("common.all") }}</option>
<option v-for="status in statuses" :key="status" :value="status">
{{ statusLabel(status) }}
</option>
</select>
</label>
<label class="grid gap-1 text-sm font-medium" for="aftersale-kind">
{{ $t("admin.aftersaleKind") }}
<select
id="aftersale-kind"
v-model="kindFilter"
class="rounded-md border border-border bg-surface px-3 py-2 font-normal text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
>
<option value="">{{ $t("common.all") }}</option>
<option v-for="kind in kinds" :key="kind" :value="kind">{{ kindLabel(kind) }}</option>
</select>
</label>
<VField :label="$t('admin.aftersaleFilterShop')">
<VInput v-model="shopFilter" :placeholder="$t('admin.aftersaleShopIdPlaceholder')" />
</VField>
<VField :label="$t('admin.aftersaleFilterBuyer')">
<VInput v-model="buyerFilter" :placeholder="$t('admin.aftersaleBuyerIdPlaceholder')" />
</VField>
<VBtn size="sm" :disabled="loading" @click="loadAftersales">
{{ $t("admin.refresh") }}
</VBtn>
</div>
</VCard>
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="errorMessage" class="my-2 text-sm text-danger" role="alert">{{ errorMessage }}</p>
<VCard v-else-if="filteredAftersales.length === 0" class="text-muted">
{{ $t("common.empty") }}
</VCard>
<div v-else class="overflow-x-auto">
<div class="min-w-[1080px]"><VTable>
<thead>
<tr>
<th>{{ $t("admin.aftersaleId") }}</th>
<th>{{ $t("admin.aftersaleShop") }}</th>
<th>{{ $t("admin.aftersaleBuyer") }}</th>
<th>{{ $t("admin.aftersaleKind") }}</th>
<th>{{ $t("admin.aftersaleAmount") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="row in filteredAftersales" :key="row.id">
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
<td><code class="rounded bg-bg px-1.5 py-0.5" :title="row.id">{{ shortId(row.id) }}</code></td>
<td>
<span class="block">{{ shopName(row.shop_id) }}</span>
<code class="text-xs text-muted" :title="row.shop_id">{{ shortId(row.shop_id) }}</code>
</td>
<td><code class="rounded bg-bg px-1.5 py-0.5" :title="row.user_id">{{ shortId(row.user_id) }}</code></td>
<td>{{ kindLabel(row.kind) }}</td>
<td>{{ money(row.amount_minor, row.currency) }}</td>
<td>{{ formatDate(row.created_at) }}</td>
<td><VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
<td>
<VBtn size="sm" @click="toggleDetail(row.id)">
{{ openId === row.id ? $t("admin.aftersaleClose") : $t("admin.aftersaleView") }}
</VBtn>
</td>
</tr>
<tr v-if="openId === row.id" class="bg-bg">
<td colspan="8" class="p-0">
<div class="p-4">
<p v-if="detailLoading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="detailError" class="text-sm text-danger" role="alert">{{ detailError }}</p>
<VPanel v-else-if="detail" :title="$t('admin.aftersaleDetail')">
<template #actions>
<VBadge :tone="statusTone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
</template>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-3 text-sm sm:grid-cols-2">
<div><span class="text-muted">{{ $t("admin.aftersaleId") }}</span><code class="ml-2">{{ detail.id }}</code></div>
<div><span class="text-muted">{{ $t("admin.aftersaleOrder") }}</span><code class="ml-2">{{ detail.order_id }}</code></div>
<div><span class="text-muted">{{ $t("admin.aftersaleFilterShop") }}</span><code class="ml-2">{{ detail.shop_id }}</code></div>
<div><span class="text-muted">{{ $t("admin.aftersaleFilterBuyer") }}</span><code class="ml-2">{{ detail.user_id }}</code></div>
<div><span class="text-muted">{{ $t("admin.aftersaleKind") }}</span><span class="ml-2">{{ kindLabel(detail.kind) }}</span></div>
<div><span class="text-muted">{{ $t("admin.aftersaleCreated") }}</span><span class="ml-2">{{ formatDate(detail.created_at) }}</span></div>
<div><span class="text-muted">{{ $t("admin.aftersaleUpdated") }}</span><span class="ml-2">{{ formatDate(detail.updated_at) }}</span></div>
<div v-if="detail.reopened"><VBadge tone="orange">{{ $t("admin.aftersaleReopened") }}</VBadge></div>
</div>
<VCard class="bg-bg" :padded="true">
<h4 class="mb-3 text-sm font-semibold">{{ $t("admin.aftersaleOrder") }}</h4>
<p v-if="!orderSummary" class="text-sm text-muted">{{ $t("admin.aftersaleOrderNotFound") }}</p>
<dl v-else class="grid gap-2 text-sm">
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("order.orderNo") }}</dt><dd>{{ orderSummary.orderNo }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleOrderTotal") }}</dt><dd>{{ money(orderSummary.totalMinor, orderSummary.currency) }}</dd></div>
<div class="flex justify-between gap-3 font-semibold"><dt class="text-muted">{{ $t("admin.aftersaleOrderRefunded") }}</dt><dd>{{ money(orderSummary.refundTotalMinor, orderSummary.currency) }}</dd></div>
</dl>
</VCard>
</div>
<VPanel class="mt-4" :title="$t('admin.aftersaleItem')">
<div class="flex gap-3">
<img v-if="detail.item.image" :src="detail.item.image" :alt="localizedText(detail.item.product_name, locale)" class="h-16 w-16 rounded object-cover" />
<div class="min-w-0 text-sm">
<p class="font-medium">{{ localizedText(detail.item.product_name, locale) }}</p>
<p class="text-muted">{{ detail.item.sku_code }}</p>
<p class="mt-1">{{ money(detail.item.unit_price_minor, detail.currency) }} × {{ detail.item.qty }}</p>
</div>
</div>
</VPanel>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleReason") }}</h4>
<p class="text-sm">{{ localizedText(detail.reason, locale) }}</p>
<dl class="mt-3 grid gap-1 text-sm">
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleAmount") }}</dt><dd>{{ money(detail.amount_minor, detail.currency) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleRemaining") }}</dt><dd>{{ money(detail.remaining_refundable_minor, detail.currency) }}</dd></div>
</dl>
</VCard>
<VCard :padded="true">
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleEvidence") }}</h4>
<ul v-if="detail.evidence.length" class="grid gap-1 text-sm">
<li v-for="url in detail.evidence" :key="url"><a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary underline">{{ url }}</a></li>
</ul>
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleNoEvidence") }}</p>
</VCard>
</div>
<VCard v-if="detail.return_carrier || detail.return_tracking_no" class="mt-4" :padded="true">
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleReturnTracking") }}</h4>
<p class="text-sm">{{ detail.return_carrier || "—" }} · {{ detail.return_tracking_no || "—" }}</p>
</VCard>
<VPanel class="mt-4" :title="$t('admin.aftersaleMessages')">
<div v-if="detail.messages.length" class="grid gap-3">
<article v-for="message in [...detail.messages].sort((a, b) => a.created_at.localeCompare(b.created_at))" :key="message.id" class="rounded-md border border-border bg-bg p-3">
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs">
<VBadge :tone="roleTone(message.author_role)">{{ roleLabel(message.author_role) }}</VBadge>
<code :title="message.author_id">{{ shortId(message.author_id) }}</code>
<time class="text-muted">{{ formatDate(message.created_at) }}</time>
</div>
<p class="text-sm">{{ localizedText(message.content, locale) }}</p>
<ul v-if="message.evidence.length" class="mt-2 grid gap-1 text-xs">
<li v-for="url in message.evidence" :key="url"><a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary underline">{{ url }}</a></li>
</ul>
</article>
</div>
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleNoMessages") }}</p>
</VPanel>
<div class="mt-4 border-t border-border pt-4">
<p v-if="notice" class="mb-3 text-sm text-success" role="status">{{ notice }}</p>
<p v-if="canArbitrate(detail.status)" class="mb-3 text-sm text-muted">{{ $t("admin.aftersaleArbitrate") }}</p>
<div v-if="canArbitrate(detail.status)" class="flex flex-wrap gap-2">
<VBtn variant="primary" :disabled="acting" @click="arbitrate(detail, 'refund')">{{ $t("admin.aftersaleArbitrateRefund") }}</VBtn>
<VBtn variant="danger" :disabled="acting" @click="arbitrate(detail, 'reject')">{{ $t("admin.aftersaleArbitrateReject") }}</VBtn>
</div>
<p v-else-if="detail.status === 'refunded'" class="text-sm text-success">{{ $t("admin.aftersaleOutcomeRefunded") }}</p>
<p v-else-if="detail.status === 'rejected'" class="text-sm text-danger">{{ $t("admin.aftersaleOutcomeRejected") }}</p>
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleOutcomeCancelled") }}</p>
</div>
</VPanel>
</div>
</td>
</tr>
</template>
</tbody>
</VTable></div>
</div>
</VPage>
</template>