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

557 lines
22 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="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
@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="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
>
<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-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="errorMessage" class="text-danger my-2 text-sm" 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="bg-bg rounded 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-muted text-xs" :title="row.shop_id">{{
shortId(row.shop_id)
}}</code>
</td>
<td>
<code class="bg-bg rounded 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-muted text-sm">
{{ $t("common.loading") }}
</p>
<p v-else-if="detailError" class="text-danger text-sm" 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-muted text-sm">
{{ $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="text-primary break-all underline"
>{{ url }}</a
>
</li>
</ul>
<p v-else class="text-muted text-sm">
{{ $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="border-border bg-bg rounded-md border 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="text-primary break-all underline"
>{{ url }}</a
>
</li>
</ul>
</article>
</div>
<p v-else class="text-muted text-sm">
{{ $t("admin.aftersaleNoMessages") }}
</p>
</VPanel>
<div class="border-border mt-4 border-t pt-4">
<p v-if="notice" class="text-success mb-3 text-sm" role="status">
{{ notice }}
</p>
<p v-if="canArbitrate(detail.status)" class="text-muted mb-3 text-sm">
{{ $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-success text-sm">
{{ $t("admin.aftersaleOutcomeRefunded") }}
</p>
<p v-else-if="detail.status === 'rejected'" class="text-danger text-sm">
{{ $t("admin.aftersaleOutcomeRejected") }}
</p>
<p v-else class="text-muted text-sm">
{{ $t("admin.aftersaleOutcomeCancelled") }}
</p>
</div>
</VPanel>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>