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
+156 -29
View File
@@ -57,14 +57,10 @@ async function loadCatalog(): Promise<void> {
async function loadAccountData(): Promise<void> {
if (!session.isLoggedIn) return;
try {
const [orders, list] = await Promise.all([
$api.listMyRedemptions(),
$api.listMyAddresses(),
]);
const [orders, list] = await Promise.all([$api.listMyRedemptions(), $api.listMyAddresses()]);
redemptions.value = orders;
addresses.value = list;
selectedAddressId.value =
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
selectedAddressId.value = list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
} catch {
redemptions.value = [];
}
@@ -123,48 +119,179 @@ onMounted(async () => {
</script>
<template>
<div class="min-h-screen bg-bg pb-14 font-sans text-text">
<div class="bg-bg text-text min-h-screen pb-14 font-sans">
<UiBreadcrumb :items="breadcrumb" />
<section class="mx-auto max-w-mall bg-surface px-4">
<img class="block h-[350px] w-full object-cover" src="/mock/integral-banner.svg" :alt="t('marketing.integralBannerAlt')" />
<section class="max-w-mall bg-surface mx-auto px-4">
<img
class="block h-[350px] w-full object-cover"
src="/mock/integral-banner.svg"
:alt="t('marketing.integralBannerAlt')"
/>
<div class="my-5 grid gap-3 md:grid-cols-[1.4fr_repeat(3,1fr)]">
<section class="flex min-h-[106px] items-center gap-3 border border-border bg-surface px-4 py-3">
<section
class="border-border bg-surface flex min-h-[106px] items-center gap-3 border px-4 py-3"
>
<template v-if="session.isLoggedIn">
<img class="h-14 w-14 rounded-full object-cover" src="/mock/avatar.svg" :alt="session.user?.display_name ?? t('marketing.pointsBalance')" />
<div><p class="m-0 text-sm">{{ session.user?.display_name }}</p><p class="mb-1 mt-1 text-xs text-muted">{{ t("marketing.pointsBalance") }}</p><strong class="text-xl text-primary">{{ stats ? stats.points : t("common.loading") }}</strong></div>
<img
class="h-14 w-14 rounded-full object-cover"
src="/mock/avatar.svg"
:alt="session.user?.display_name ?? t('marketing.pointsBalance')"
/>
<div>
<p class="m-0 text-sm">{{ session.user?.display_name }}</p>
<p class="text-muted mt-1 mb-1 text-xs">{{ t("marketing.pointsBalance") }}</p>
<strong class="text-primary text-xl">{{
stats ? stats.points : t("common.loading")
}}</strong>
</div>
</template>
<template v-else>
<div class="flex-1"><h2 class="m-0 text-base">{{ t("marketing.pointsBalance") }}</h2><p class="my-1 text-sm text-muted">{{ t("marketing.loginPrompt") }}</p></div>
<div class="flex gap-2"><NuxtLink class="inline-flex items-center justify-center rounded-md border border-primary bg-primary px-3 py-1.5 text-sm text-white hover:bg-primary-hover" to="/login">{{ t("marketing.login") }}</NuxtLink><NuxtLink class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm hover:bg-bg" to="/register">{{ t("marketing.register") }}</NuxtLink></div>
<div class="flex-1">
<h2 class="m-0 text-base">{{ t("marketing.pointsBalance") }}</h2>
<p class="text-muted my-1 text-sm">{{ t("marketing.loginPrompt") }}</p>
</div>
<div class="flex gap-2">
<NuxtLink
class="border-primary bg-primary hover:bg-primary-hover inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm text-white"
to="/login"
>{{ t("marketing.login") }}</NuxtLink
><NuxtLink
class="border-border bg-surface hover:bg-bg inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm"
to="/register"
>{{ t("marketing.register") }}</NuxtLink
>
</div>
</template>
</section>
<div v-for="(label, index) in [t('marketing.customize'), t('marketing.shipping'), t('marketing.support')]" :key="label" class="flex min-h-[106px] items-center gap-3 border border-border bg-surface px-4"><span class="text-2xl text-primary">0{{ index + 1 }}</span><strong>{{ label }}</strong></div>
<div
v-for="(label, index) in [
t('marketing.customize'),
t('marketing.shipping'),
t('marketing.support'),
]"
:key="label"
class="border-border bg-surface flex min-h-[106px] items-center gap-3 border px-4"
>
<span class="text-primary text-2xl">0{{ index + 1 }}</span
><strong>{{ label }}</strong>
</div>
</div>
<header class="border border-border bg-bg px-4 py-3.5"><h1 class="m-0 border-l-[3px] border-primary pl-2.5 text-[17px] font-medium">{{ t("marketing.recommendation") }}</h1></header>
<header class="border-border bg-bg border px-4 py-3.5">
<h1 class="border-primary m-0 border-l-[3px] pl-2.5 text-[17px] font-medium">
{{ t("marketing.recommendation") }}
</h1>
</header>
<div v-if="session.isLoggedIn" class="my-4 flex items-center gap-3">
<label class="flex flex-1 flex-wrap items-center gap-2 text-sm"><span>{{ t("marketing.addressLabel") }}</span><select v-if="addresses.length" v-model="selectedAddressId" class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30"><option v-for="address in addresses" :key="address.id" :value="address.id">{{ address.recipient }} · {{ address.city }} {{ address.line1 }}</option></select><span v-else class="text-muted">{{ t("marketing.noAddress") }} <NuxtLink class="text-primary" to="/user/addresses">{{ t("marketing.addAddress") }}</NuxtLink></span></label>
<label class="flex flex-1 flex-wrap items-center gap-2 text-sm"
><span>{{ t("marketing.addressLabel") }}</span
><select
v-if="addresses.length"
v-model="selectedAddressId"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
>
<option v-for="address in addresses" :key="address.id" :value="address.id">
{{ address.recipient }} · {{ address.city }} {{ address.line1 }}
</option></select
><span v-else class="text-muted"
>{{ t("marketing.noAddress") }}
<NuxtLink class="text-primary" to="/user/addresses">{{
t("marketing.addAddress")
}}</NuxtLink></span
></label
>
</div>
<p v-if="error" class="my-2 border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-danger" role="alert">{{ error }}</p>
<p v-if="notice" class="my-2 border border-success/30 bg-success/10 px-3.5 py-2.5 text-success" aria-live="polite">{{ notice }}</p>
<div v-if="loading" class="py-6 text-muted">{{ t("common.loading") }}</div>
<div v-else-if="products.length" class="grid gap-4 py-5 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]">
<VCard v-for="item in products" :key="item.id" :padded="false" class="min-w-0 overflow-hidden">
<div class="flex h-52 items-center justify-center bg-bg"><img class="h-full w-full object-contain" :src="item.image || '/mock/product-9.svg'" :alt="pick(item.name, locale)" loading="lazy" /></div>
<div class="p-3.5"><h2 class="m-0 line-clamp-2 text-base font-medium">{{ pick(item.name, locale) }}</h2><div class="mt-2 text-lg font-semibold text-primary">{{ t("marketing.points", { n: item.points_price }) }}</div><p class="my-2 text-sm text-muted">{{ t("marketing.stock", { n: item.stock }) }}</p><div class="mb-3 flex items-center gap-2 text-sm"><span>{{ t("marketing.qty") }}</span><VInput v-model.number="quantities[item.id]" class="w-20" type="number" min="1" :max="item.stock" /></div><VBtn class="w-full" variant="primary" type="button" :disabled="item.stock <= 0 || redeemingId === item.id" @click="redeem(item)">{{ redeemingId === item.id ? t("common.loading") : t("marketing.redeem") }}</VBtn></div>
<p
v-if="error"
class="border-danger/30 bg-danger/10 text-danger my-2 border px-3.5 py-2.5"
role="alert"
>
{{ error }}
</p>
<p
v-if="notice"
class="border-success/30 bg-success/10 text-success my-2 border px-3.5 py-2.5"
aria-live="polite"
>
{{ notice }}
</p>
<div v-if="loading" class="text-muted py-6">{{ t("common.loading") }}</div>
<div
v-else-if="products.length"
class="grid [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))] gap-4 py-5"
>
<VCard
v-for="item in products"
:key="item.id"
:padded="false"
class="min-w-0 overflow-hidden"
>
<div class="bg-bg flex h-52 items-center justify-center">
<img
class="h-full w-full object-contain"
:src="item.image || '/mock/product-9.svg'"
:alt="pick(item.name, locale)"
loading="lazy"
/>
</div>
<div class="p-3.5">
<h2 class="m-0 line-clamp-2 text-base font-medium">{{ pick(item.name, locale) }}</h2>
<div class="text-primary mt-2 text-lg font-semibold">
{{ t("marketing.points", { n: item.points_price }) }}
</div>
<p class="text-muted my-2 text-sm">{{ t("marketing.stock", { n: item.stock }) }}</p>
<div class="mb-3 flex items-center gap-2 text-sm">
<span>{{ t("marketing.qty") }}</span
><VInput
v-model.number="quantities[item.id]"
class="w-20"
type="number"
min="1"
:max="item.stock"
/>
</div>
<VBtn
class="w-full"
variant="primary"
type="button"
:disabled="item.stock <= 0 || redeemingId === item.id"
@click="redeem(item)"
>{{ redeemingId === item.id ? t("common.loading") : t("marketing.redeem") }}</VBtn
>
</div>
</VCard>
</div>
<UiEmptyState v-else :text="t('marketing.noIntegralProducts')" />
<template v-if="session.isLoggedIn && redemptions.length">
<header class="mt-5 border border-border bg-bg px-4 py-3.5"><h1 class="m-0 border-l-[3px] border-primary pl-2.5 text-[17px] font-medium">{{ t("marketing.redemptionHistory") }}</h1></header>
<header class="border-border bg-bg mt-5 border px-4 py-3.5">
<h1 class="border-primary m-0 border-l-[3px] pl-2.5 text-[17px] font-medium">
{{ t("marketing.redemptionHistory") }}
</h1>
</header>
<VTable>
<thead><tr><th>{{ t("marketing.orderNo") }}</th><th>{{ t("marketing.reward") }}</th><th>{{ t("marketing.qty") }}</th><th>{{ t("marketing.pointsCost") }}</th><th>{{ t("common.status") }}</th><th>{{ t("marketing.createdAt") }}</th></tr></thead>
<tbody><tr v-for="order in redemptions" :key="order.id"><td>{{ order.order_no }}</td><td>{{ order.items.map((line) => pick(line.name, locale)).join(", ") }}</td><td>{{ order.items.reduce((n, line) => n + line.qty, 0) }}</td><td>{{ order.total_points }}</td><td>{{ t(`marketing.redemptionStatus.${order.status}`) }}</td><td>{{ order.created_at.slice(0, 10) }}</td></tr></tbody>
<thead>
<tr>
<th>{{ t("marketing.orderNo") }}</th>
<th>{{ t("marketing.reward") }}</th>
<th>{{ t("marketing.qty") }}</th>
<th>{{ t("marketing.pointsCost") }}</th>
<th>{{ t("common.status") }}</th>
<th>{{ t("marketing.createdAt") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in redemptions" :key="order.id">
<td>{{ order.order_no }}</td>
<td>{{ order.items.map((line) => pick(line.name, locale)).join(", ") }}</td>
<td>{{ order.items.reduce((n, line) => n + line.qty, 0) }}</td>
<td>{{ order.total_points }}</td>
<td>{{ t(`marketing.redemptionStatus.${order.status}`) }}</td>
<td>{{ order.created_at.slice(0, 10) }}</td>
</tr>
</tbody>
</VTable>
</template>
</section>
</div>
</template>