wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates

This commit is contained in:
Chengdong Zhang
2026-09-23 17:39:45 +08:00
parent a83e857bdf
commit 5b426486ac
105 changed files with 6193 additions and 1566 deletions
+277 -140
View File
@@ -75,9 +75,7 @@ function currencyExponent(code: string): number | undefined {
function money(amountMinor: number, currency: string): string {
const exponent = currencyExponent(currency);
return exponent === undefined
? "—"
: formatMoney(amountMinor, currency, exponent, locale.value);
return exponent === undefined ? "—" : formatMoney(amountMinor, currency, exponent, locale.value);
}
function formatDate(value: string): string {
@@ -207,9 +205,8 @@ async function toggleDetail(id: string): Promise<void> {
}
async function arbitrate(row: Aftersale, outcome: AftersaleArbitration): Promise<void> {
const confirmation = outcome === "refund"
? t("admin.aftersaleConfirmRefund")
: t("admin.aftersaleConfirmReject");
const confirmation =
outcome === "refund" ? t("admin.aftersaleConfirmRefund") : t("admin.aftersaleConfirmReject");
if (!confirm(confirmation)) return;
acting.value = true;
@@ -220,9 +217,10 @@ async function arbitrate(row: Aftersale, outcome: AftersaleArbitration): Promise
await loadAftersales();
orderCache.delete(row.order_id);
await openDetail(row.id);
notice.value = outcome === "refund"
? t("admin.aftersaleArbitratedRefund")
: t("admin.aftersaleArbitratedReject");
notice.value =
outcome === "refund"
? t("admin.aftersaleArbitratedRefund")
: t("admin.aftersaleArbitratedReject");
} catch (error: unknown) {
detailError.value = errorText(error);
} finally {
@@ -244,7 +242,7 @@ onMounted(() => {
<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"
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>
@@ -258,7 +256,7 @@ onMounted(() => {
<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"
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>
@@ -276,144 +274,283 @@ onMounted(() => {
</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>
<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="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>
<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>
<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>
</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>
<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>
<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>
<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>
<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>
<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>
<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>