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
+163 -41
View File
@@ -16,7 +16,13 @@ const carrier = ref("");
const trackingNo = ref("");
const message = ref("");
const evidence = ref("");
const progress: AftersaleStatus[] = ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refunded"];
const progress: AftersaleStatus[] = [
"pending",
"approved",
"buyer_shipping",
"merchant_confirmed",
"refunded",
];
const id = computed(() => String(route.params.id ?? ""));
@@ -35,7 +41,9 @@ function progressTone(status: AftersaleStatus): string {
if (!detail.value) return "border-border bg-surface text-muted";
const current = progress.indexOf(detail.value.status);
const step = progress.indexOf(status);
return step >= 0 && current >= step ? "border-primary bg-primary text-white" : "border-border bg-surface text-muted";
return step >= 0 && current >= step
? "border-primary bg-primary text-white"
: "border-border bg-surface text-muted";
}
async function load(): Promise<void> {
@@ -111,7 +119,10 @@ async function sendMessage(): Promise<void> {
try {
await $api.addAftersaleMessage(detail.value.id, {
content: { en: content, zh: content },
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
evidence: evidence.value
.split("\n")
.map((url) => url.trim())
.filter(Boolean),
});
message.value = "";
evidence.value = "";
@@ -129,89 +140,200 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/aftersales">{{ t("user.aftersalesTitle") }}</NuxtLink>
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.aftersaleDetail") }}</h1>
<NuxtLink class="text-primary text-sm no-underline" to="/user/aftersales">{{
t("user.aftersalesTitle")
}}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed || !detail" class="text-danger py-5 text-sm">
{{ t("user.aftersaleLoadFailed") }}
</p>
<div v-else class="space-y-4">
<header class="flex items-center justify-between gap-3 border border-border bg-bg p-4 max-sm:flex-col max-sm:items-start">
<header
class="border-border bg-bg flex items-center justify-between gap-3 border p-4 max-sm:flex-col max-sm:items-start"
>
<div>
<p class="m-0 text-sm text-muted">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="mt-1 text-xs text-muted">{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}</p>
<p class="text-muted m-0 text-sm">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
<p class="text-muted mt-1 text-xs">
{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}
</p>
</div>
<VBadge :tone="tone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
</header>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleItem") }}</h2>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleItem") }}</h2>
<div class="flex items-center gap-3">
<img class="h-14 w-14 border border-border object-contain" :src="detail.item.image || '/mock/product-1.svg'" :alt="pick(detail.item.product_name, locale)" />
<img
class="border-border h-14 w-14 border object-contain"
:src="detail.item.image || '/mock/product-1.svg'"
:alt="pick(detail.item.product_name, locale)"
/>
<div class="min-w-0 flex-1">
<p class="m-0 truncate text-sm text-text">{{ pick(detail.item.product_name, locale) }}</p>
<p class="mt-1 text-xs text-muted">{{ detail.item.sku_code }} × {{ detail.item.qty }}</p>
<p class="text-text m-0 truncate text-sm">
{{ pick(detail.item.product_name, locale) }}
</p>
<p class="text-muted mt-1 text-xs">
{{ detail.item.sku_code }} × {{ detail.item.qty }}
</p>
</div>
<PriceText :amount-minor="detail.item.unit_price_minor * detail.item.qty" :currency="detail.currency" />
<PriceText
:amount-minor="detail.item.unit_price_minor * detail.item.qty"
:currency="detail.currency"
/>
</div>
<div class="mt-4 grid gap-2 text-sm text-muted sm:grid-cols-2">
<span>{{ t("user.aftersaleType") }}: {{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span>
<span>{{ t("user.aftersaleAmount") }}: <PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" /></span>
<span>{{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="detail.remaining_refundable_minor" :currency="detail.currency" /></span>
<div class="text-muted mt-4 grid gap-2 text-sm sm:grid-cols-2">
<span
>{{ t("user.aftersaleType") }}:
{{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span
>
<span
>{{ t("user.aftersaleAmount") }}:
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency"
/></span>
<span
>{{ t("user.aftersaleRemaining") }}:
<PriceText
:amount-minor="detail.remaining_refundable_minor"
:currency="detail.currency"
/></span>
<span>{{ t("user.aftersaleReason") }}: {{ pick(detail.reason, locale) }}</span>
</div>
<div v-if="detail.evidence.length" class="mt-4 flex flex-wrap gap-2 text-sm">
<a v-for="url in detail.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a>
<a
v-for="url in detail.evidence"
:key="url"
class="text-primary"
:href="url"
target="_blank"
rel="noreferrer"
>{{ t("user.aftersaleViewEvidence") }}</a
>
</div>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-4 text-[15px] font-semibold text-text">{{ t("user.aftersaleStatus") }}</h2>
<h2 class="text-text mb-4 text-[15px] font-semibold">{{ t("user.aftersaleStatus") }}</h2>
<div class="flex flex-wrap items-center gap-2">
<template v-for="(step, index) in progress" :key="step">
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{ statusLabel(step) }}</span>
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{
statusLabel(step)
}}</span>
<span v-if="index < progress.length - 1" class="text-muted">→</span>
</template>
</div>
<p v-if="detail.status === 'rejected' || detail.status === 'cancelled'" class="mt-3 text-sm text-danger">{{ statusLabel(detail.status) }}</p>
<div v-if="detail.return_carrier || detail.return_tracking_no" class="mt-3 text-sm text-muted">
{{ t("user.returnShipping") }}: {{ detail.return_carrier }} {{ detail.return_tracking_no }}
<p
v-if="detail.status === 'rejected' || detail.status === 'cancelled'"
class="text-danger mt-3 text-sm"
>
{{ statusLabel(detail.status) }}
</p>
<div
v-if="detail.return_carrier || detail.return_tracking_no"
class="text-muted mt-3 text-sm"
>
{{ t("user.returnShipping") }}: {{ detail.return_carrier }}
{{ detail.return_tracking_no }}
</div>
<div v-if="detail.status === 'refunded'" class="mt-3 text-sm text-success">
<div v-if="detail.status === 'refunded'" class="text-success mt-3 text-sm">
{{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}:
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<VBtn v-if="['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(detail.status)" variant="danger" size="sm" type="button" :disabled="working" @click="cancel">{{ t("user.cancelAftersale") }}</VBtn>
<VBtn v-if="detail.status === 'rejected'" variant="primary" size="sm" type="button" :disabled="working" @click="reopen">{{ t("user.reopenAftersale") }}</VBtn>
<VBtn
v-if="
['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(
detail.status,
)
"
variant="danger"
size="sm"
type="button"
:disabled="working"
@click="cancel"
>{{ t("user.cancelAftersale") }}</VBtn
>
<VBtn
v-if="detail.status === 'rejected'"
variant="primary"
size="sm"
type="button"
:disabled="working"
@click="reopen"
>{{ t("user.reopenAftersale") }}</VBtn
>
</div>
</VCard>
<VCard v-if="detail.status === 'approved' && detail.kind === 'return_refund'" :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.returnShipping") }}</h2>
<VCard
v-if="detail.status === 'approved' && detail.kind === 'return_refund'"
:padded="false"
class="p-5"
>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.returnShipping") }}</h2>
<form class="grid max-w-[520px] gap-3" @submit.prevent="submitTracking">
<VField :label="t('user.aftersaleCarrier')"><VInput v-model="carrier" /></VField>
<VField :label="t('user.aftersaleTrackingNo')"><VInput v-model="trackingNo" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitReturnTracking") }}</VBtn>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.submitReturnTracking")
}}</VBtn>
</form>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="mb-4 text-sm text-muted">{{ t("user.noAftersales") }}</div>
<h2 class="text-text mb-3 text-[15px] font-semibold">{{ t("user.aftersaleMessages") }}</h2>
<div v-if="detail.messages.length === 0" class="text-muted mb-4 text-sm">
{{ t("user.noAftersales") }}
</div>
<div v-else class="mb-4 space-y-2">
<div v-for="entry in detail.messages" :key="entry.id" class="rounded border border-border p-3" :class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'">
<div class="flex items-center justify-between gap-2 text-xs text-muted"><span>{{ t(`user.${entry.author_role}`) }}</span><time>{{ entry.created_at.slice(0, 16).replace('T', ' ') }}</time></div>
<p class="m-0 mt-2 whitespace-pre-wrap text-sm text-text">{{ pick(entry.content, locale) }}</p>
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs"><a v-for="url in entry.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a></div>
<div
v-for="entry in detail.messages"
:key="entry.id"
class="border-border rounded border p-3"
:class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'"
>
<div class="text-muted flex items-center justify-between gap-2 text-xs">
<span>{{ t(`user.${entry.author_role}`) }}</span
><time>{{ entry.created_at.slice(0, 16).replace("T", " ") }}</time>
</div>
<p class="text-text m-0 mt-2 text-sm whitespace-pre-wrap">
{{ pick(entry.content, locale) }}
</p>
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs">
<a
v-for="url in entry.evidence"
:key="url"
class="text-primary"
:href="url"
target="_blank"
rel="noreferrer"
>{{ t("user.aftersaleViewEvidence") }}</a
>
</div>
</div>
</div>
<form class="grid max-w-[640px] gap-3" @submit.prevent="sendMessage">
<VField :label="t('user.aftersaleMessagePlaceholder')"><textarea v-model="message" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleMessagePlaceholder')" /></VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-16 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.sendMessage") }}</VBtn>
<VField :label="t('user.aftersaleMessagePlaceholder')">
<textarea
v-model="message"
class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleMessagePlaceholder')"
/>
</VField>
<VField :label="t('user.aftersaleEvidence')">
<textarea
v-model="evidence"
class="border-border bg-surface text-text min-h-16 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleEvidenceHint')"
/>
</VField>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.sendMessage")
}}</VBtn>
</form>
</VCard>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
</div>
</VCard>
</template>
+51 -15
View File
@@ -33,7 +33,10 @@ async function load(): Promise<void> {
loading.value = true;
try {
await ensureCurrencies();
const [loadedOrder, loadedAftersales] = await Promise.all([$api.getOrder(orderId.value), $api.listMyAftersales()]);
const [loadedOrder, loadedAftersales] = await Promise.all([
$api.getOrder(orderId.value),
$api.listMyAftersales(),
]);
order.value = loadedOrder;
aftersales.value = loadedAftersales;
} catch {
@@ -70,7 +73,10 @@ async function submit(): Promise<void> {
kind: kind.value,
reason: { en: content, zh: content },
amount_minor: amount,
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
evidence: evidence.value
.split("\n")
.map((url) => url.trim())
.filter(Boolean),
});
await navigateTo(`/user/aftersales/${created.id}`);
} catch {
@@ -85,26 +91,56 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<form v-else class="grid max-w-[640px] gap-4" @submit.prevent="submit">
<VCard :padded="false" class="flex items-center gap-3 border border-border p-4">
<img class="h-14 w-14 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="item.product_name.en" />
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm text-text">{{ item.product_name.en }}</p><p class="mt-1 text-xs text-muted">{{ item.sku_code }} × {{ item.qty }}</p></div>
<VCard :padded="false" class="border-border flex items-center gap-3 border p-4">
<img
class="border-border h-14 w-14 border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="item.product_name.en"
/>
<div class="min-w-0 flex-1">
<p class="text-text m-0 truncate text-sm">{{ item.product_name.en }}</p>
<p class="text-muted mt-1 text-xs">{{ item.sku_code }} × {{ item.qty }}</p>
</div>
</VCard>
<fieldset class="grid gap-2 border-0 p-0">
<legend class="mb-1 block text-[13px] text-muted">{{ t("user.aftersaleType") }}</legend>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="refund_only" /> {{ t("user.refundOnly") }}</label>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="return_refund" /> {{ t("user.returnRefund") }}</label>
<legend class="text-muted mb-1 block text-[13px]">{{ t("user.aftersaleType") }}</legend>
<label class="text-text text-sm"
><input v-model="kind" class="accent-primary mr-1" type="radio" value="refund_only" />
{{ t("user.refundOnly") }}</label
>
<label class="text-text text-sm"
><input v-model="kind" class="accent-primary mr-1" type="radio" value="return_refund" />
{{ t("user.returnRefund") }}</label
>
</fieldset>
<VField :label="t('user.aftersaleReason')"><textarea v-model="reason" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleReasonHint')" /></VField>
<VField :label="t('user.aftersaleReason')">
<textarea
v-model="reason"
class="border-border bg-surface text-text min-h-24 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleReasonHint')"
/>
</VField>
<VField :label="t('user.aftersaleAmount')">
<VInput v-model="amountMajor" />
<p class="m-0 mt-1 text-xs text-muted">{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="remainingMinor" :currency="order.currency" /></p>
<p class="text-muted m-0 mt-1 text-xs">
{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}:
<PriceText :amount-minor="remainingMinor" :currency="order.currency" />
</p>
</VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-20 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitAftersale") }}</VBtn>
<VField :label="t('user.aftersaleEvidence')">
<textarea
v-model="evidence"
class="border-border bg-surface text-text min-h-20 rounded-md border p-2.5 text-sm"
:placeholder="t('user.aftersaleEvidenceHint')"
/>
</VField>
<p v-if="errorKey" class="text-danger m-0 text-xs">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{
t("user.submitAftersale")
}}</VBtn>
</form>
</VCard>
</template>
+17 -7
View File
@@ -39,11 +39,13 @@ onMounted(() => void load());
<template>
<VCard class="min-h-[560px]">
<div class="mb-4 flex items-center justify-between gap-3">
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersalesTitle") }}</h1>
<NuxtLink class="text-sm text-primary no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
<h1 class="text-text m-0 text-xl font-bold">{{ t("user.aftersalesTitle") }}</h1>
<NuxtLink class="text-primary text-sm no-underline" to="/user/orders">{{
t("user.myOrders")
}}</NuxtLink>
</div>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<p v-else-if="failed" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
<p v-else-if="failed" class="text-danger py-5 text-sm">{{ t("user.aftersaleLoadFailed") }}</p>
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noAftersales')" />
<VTable v-else>
<thead>
@@ -59,13 +61,21 @@ onMounted(() => void load());
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ row.order_id }}</NuxtLink>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{
row.order_id
}}</NuxtLink>
</td>
<td>{{ t(row.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</td>
<td><PriceText :amount-minor="row.amount_minor" :currency="row.currency" /></td>
<td><VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
<td>
<VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge>
</td>
<td class="text-muted">{{ row.created_at.slice(0, 10) }}</td>
<td><NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ t("user.viewDetails") }}</NuxtLink></td>
<td>
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{
t("user.viewDetails")
}}</NuxtLink>
</td>
</tr>
</tbody>
</VTable>