420 lines
18 KiB
Vue
420 lines
18 KiB
Vue
<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>
|