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
+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>