Files
vmall/apps/admin/pages/aftersales.vue
T

420 lines
18 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 { 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>