557 lines
22 KiB
Vue
557 lines
22 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="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>
|