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>
+18 -8
View File
@@ -105,7 +105,6 @@ async function save(): Promise<void> {
}
}
onMounted(() => {
void loadBrands();
});
@@ -113,10 +112,14 @@ onMounted(() => {
<template>
<VPage :title="$t('nav.brands')">
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="my-2 text-sm text-danger" role="alert">{{ loadError }}</p>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
<VCard v-else>
<div v-for="(row, index) in rows" :key="index" class="mb-3 rounded-md border border-border p-3">
<div
v-for="(row, index) in rows"
:key="index"
class="border-border mb-3 rounded-md border p-3"
>
<div class="grid items-end gap-3 md:grid-cols-[1fr_1fr_1fr_auto_auto]">
<VField :label="$t('admin.brandSlug')" :error="rowErrors[index]">
<VInput v-model="row.slug" placeholder="acme" />
@@ -128,12 +131,14 @@ onMounted(() => {
<VInput v-model="row.nameZh" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow(index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === rows.length - 1" @click="moveRow(index, 1)">↓</VBtn>
<VBtn size="sm" :disabled="index === rows.length - 1" @click="moveRow(index, 1)"
>↓</VBtn
>
<VBtn size="sm" @click="removeRow(index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
@@ -143,10 +148,15 @@ onMounted(() => {
<VBtn variant="primary" :disabled="saving" @click="save">
{{ saving ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback" role="status" class="text-sm" :class="feedback.ok ? 'text-success' : 'text-danger'">
<p
v-if="feedback"
role="status"
class="text-sm"
:class="feedback.ok ? 'text-success' : 'text-danger'"
>
{{ feedback.message }}
</p>
</div>
</VCard>
</VPage>
</template>
</template>
+103 -27
View File
@@ -107,7 +107,12 @@ function validate(kind: ContentKind): boolean {
for (const [index, row] of list.entries()) {
if (kind === "quick-links") {
const link = row as QuickLinkRow;
if (!link.label.en.trim() || !link.label.zh.trim() || !link.url.trim() || !link.glyph.trim()) {
if (
!link.label.en.trim() ||
!link.label.zh.trim() ||
!link.url.trim() ||
!link.glyph.trim()
) {
errors[index] = t("admin.contentQuickLinkInvalid");
}
} else if (kind === "floor-adverts") {
@@ -150,7 +155,6 @@ async function saveKind(kind: ContentKind): Promise<void> {
}
}
onMounted(() => {
void loadContent();
});
@@ -158,14 +162,23 @@ onMounted(() => {
<template>
<VPage :title="$t('nav.content')">
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="my-2 text-sm text-danger" role="alert">{{ loadError }}</p>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
<template v-else>
<VCard v-for="kind in (['banners', 'promos'] as ContentKind[])" :key="kind" class="mb-5">
<VCard v-for="kind in ['banners', 'promos'] as ContentKind[]" :key="kind" class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t(kindLabelKey[kind]) }}</h2>
<div v-for="(row, index) in rowsOf(kind)" :key="index" class="mb-3 rounded-md border border-border p-3">
<div
v-for="(row, index) in rowsOf(kind)"
:key="index"
class="border-border mb-3 rounded-md border p-3"
>
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_auto_auto]">
<img v-if="row.image" :src="row.image" :alt="$t(kindLabelKey[kind])" class="h-12 w-20 rounded object-cover" />
<img
v-if="row.image"
:src="row.image"
:alt="$t(kindLabelKey[kind])"
class="h-12 w-20 rounded object-cover"
/>
<VField :label="$t('admin.contentImage')" :error="rowErrors[kind][index]">
<VInput v-model="row.image" />
</VField>
@@ -173,12 +186,17 @@ onMounted(() => {
<VInput v-model="row.url" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow(kind, index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === rowsOf(kind).length - 1" @click="moveRow(kind, index, 1)">↓</VBtn>
<VBtn
size="sm"
:disabled="index === rowsOf(kind).length - 1"
@click="moveRow(kind, index, 1)"
>↓</VBtn
>
<VBtn size="sm" @click="removeRow(kind, index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
@@ -188,7 +206,12 @@ onMounted(() => {
<VBtn variant="primary" :disabled="saving === kind" @click="saveKind(kind)">
{{ saving === kind ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback[kind]" role="status" class="text-sm" :class="feedback[kind]!.ok ? 'text-success' : 'text-danger'">
<p
v-if="feedback[kind]"
role="status"
class="text-sm"
:class="feedback[kind]!.ok ? 'text-success' : 'text-danger'"
>
{{ feedback[kind]!.message }}
</p>
</div>
@@ -196,9 +219,17 @@ onMounted(() => {
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentQuickLinks") }}</h2>
<div v-for="(row, index) in quickLinks" :key="index" class="mb-3 rounded-md border border-border p-3">
<div
v-for="(row, index) in quickLinks"
:key="index"
class="border-border mb-3 rounded-md border p-3"
>
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_1fr_1fr_auto_auto]">
<svg viewBox="0 0 24 24" class="mb-3.5 h-8 w-8 fill-current text-primary" aria-hidden="true">
<svg
viewBox="0 0 24 24"
class="text-primary mb-3.5 h-8 w-8 fill-current"
aria-hidden="true"
>
<path :d="row.glyph" />
</svg>
<VField :label="$t('admin.contentLabelEn')" :error="rowErrors['quick-links'][index]">
@@ -214,22 +245,40 @@ onMounted(() => {
<VInput v-model="row.glyph" placeholder="M12 2L2 22h20z" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow('quick-links', index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === quickLinks.length - 1" @click="moveRow('quick-links', index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow('quick-links', index)">{{ $t("common.delete") }}</VBtn>
<VBtn size="sm" :disabled="index === 0" @click="moveRow('quick-links', index, -1)"
>↑</VBtn
>
<VBtn
size="sm"
:disabled="index === quickLinks.length - 1"
@click="moveRow('quick-links', index, 1)"
>↓</VBtn
>
<VBtn size="sm" @click="removeRow('quick-links', index)">{{
$t("common.delete")
}}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow('quick-links')">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving === 'quick-links'" @click="saveKind('quick-links')">
<VBtn
variant="primary"
:disabled="saving === 'quick-links'"
@click="saveKind('quick-links')"
>
{{ saving === "quick-links" ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback['quick-links']" role="status" class="text-sm" :class="feedback['quick-links']!.ok ? 'text-success' : 'text-danger'">
<p
v-if="feedback['quick-links']"
role="status"
class="text-sm"
:class="feedback['quick-links']!.ok ? 'text-success' : 'text-danger'"
>
{{ feedback["quick-links"]!.message }}
</p>
</div>
@@ -237,33 +286,60 @@ onMounted(() => {
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentFloorAdverts") }}</h2>
<div v-for="(row, index) in floorAdverts" :key="index" class="mb-3 rounded-md border border-border p-3">
<div
v-for="(row, index) in floorAdverts"
:key="index"
class="border-border mb-3 rounded-md border p-3"
>
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_auto_auto]">
<img v-if="row.image" :src="row.image" :alt="$t('admin.contentFloorAdverts')" class="h-12 w-20 rounded object-cover" />
<img
v-if="row.image"
:src="row.image"
:alt="$t('admin.contentFloorAdverts')"
class="h-12 w-20 rounded object-cover"
/>
<VField :label="$t('admin.contentImage')" :error="rowErrors['floor-adverts'][index]">
<VInput v-model="row.image" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow('floor-adverts', index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === floorAdverts.length - 1" @click="moveRow('floor-adverts', index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow('floor-adverts', index)">{{ $t("common.delete") }}</VBtn>
<VBtn size="sm" :disabled="index === 0" @click="moveRow('floor-adverts', index, -1)"
>↑</VBtn
>
<VBtn
size="sm"
:disabled="index === floorAdverts.length - 1"
@click="moveRow('floor-adverts', index, 1)"
>↓</VBtn
>
<VBtn size="sm" @click="removeRow('floor-adverts', index)">{{
$t("common.delete")
}}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow('floor-adverts')">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving === 'floor-adverts'" @click="saveKind('floor-adverts')">
<VBtn
variant="primary"
:disabled="saving === 'floor-adverts'"
@click="saveKind('floor-adverts')"
>
{{ saving === "floor-adverts" ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback['floor-adverts']" role="status" class="text-sm" :class="feedback['floor-adverts']!.ok ? 'text-success' : 'text-danger'">
<p
v-if="feedback['floor-adverts']"
role="status"
class="text-sm"
:class="feedback['floor-adverts']!.ok ? 'text-success' : 'text-danger'"
>
{{ feedback["floor-adverts"]!.message }}
</p>
</div>
</VCard>
</template>
</VPage>
</template>
</template>
+85 -42
View File
@@ -157,61 +157,104 @@ onMounted(() => {
<VInput id="currency-symbol" v-model="form.symbol" required />
</VField>
<VField :label="$t('admin.exponent')">
<VInput id="currency-exponent" v-model.number="form.exponent" min="0" max="6" type="number" required />
<VInput
id="currency-exponent"
v-model.number="form.exponent"
min="0"
max="6"
type="number"
required
/>
</VField>
<VField :label="$t('admin.rate')">
<VInput id="currency-rate" v-model="form.rateToBase" min="0" step="any" type="number" required />
<VInput
id="currency-rate"
v-model="form.rateToBase"
min="0"
step="any"
type="number"
required
/>
</VField>
</div>
<label class="my-0 mb-4 flex items-center gap-2 text-sm">
<input v-model="form.enabled" class="h-4 w-4 accent-primary" type="checkbox" />
<input v-model="form.enabled" class="accent-primary h-4 w-4" type="checkbox" />
{{ $t("admin.enabled") }}
</label>
<p v-if="formError" class="my-2 text-sm text-danger" role="alert">{{ formError }}</p>
<p v-if="formError" class="text-danger my-2 text-sm" role="alert">{{ formError }}</p>
<VBtn variant="primary" :disabled="creating" @click="createCurrency">
{{ creating ? $t("common.loading") : $t("admin.saveCurrency") }}
</VBtn>
</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="currencies.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("common.currency") }}</th>
<th>{{ $t("admin.currencyNameEn") }}</th>
<th>{{ $t("admin.symbol") }}</th>
<th>{{ $t("admin.exponent") }}</th>
<th>{{ $t("admin.rate") }}</th>
<th>{{ $t("admin.baseCurrency") }}</th>
<th>{{ $t("admin.enabled") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="currency in currencies" :key="currency.code">
<td><strong>{{ currency.code }}</strong></td>
<td>{{ localizedName(currency.name) }}</td>
<td>{{ currency.symbol }}</td>
<td>{{ currency.exponent }}</td>
<td><VInput v-model="rateDrafts[currency.code]" class="min-w-[110px]" type="number" min="0" step="any" /></td>
<td>
<VBadge v-if="currency.is_base" tone="blue">{{ $t("admin.baseCurrency") }}</VBadge>
<span v-else class="text-muted">—</span>
</td>
<td><VBadge :tone="currency.enabled ? 'green' : 'red'">{{ currency.enabled ? $t("common.yes") : $t("common.no") }}</VBadge></td>
<td><div class="flex gap-2">
<VBtn size="sm" variant="primary" :disabled="savingCode === currency.code" @click="saveRate(currency)">
{{ savingCode === currency.code ? $t("common.loading") : $t("admin.updateRate") }}
</VBtn>
<VBtn size="sm" :disabled="savingCode === currency.code" @click="toggleEnabled(currency)">
{{ currency.enabled ? $t("admin.suspend") : $t("admin.activate") }}
</VBtn>
</div></td>
</tr>
</tbody>
</VTable></div>
<div class="min-w-[1080px]">
<VTable>
<thead>
<tr>
<th>{{ $t("common.currency") }}</th>
<th>{{ $t("admin.currencyNameEn") }}</th>
<th>{{ $t("admin.symbol") }}</th>
<th>{{ $t("admin.exponent") }}</th>
<th>{{ $t("admin.rate") }}</th>
<th>{{ $t("admin.baseCurrency") }}</th>
<th>{{ $t("admin.enabled") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="currency in currencies" :key="currency.code">
<td>
<strong>{{ currency.code }}</strong>
</td>
<td>{{ localizedName(currency.name) }}</td>
<td>{{ currency.symbol }}</td>
<td>{{ currency.exponent }}</td>
<td>
<VInput
v-model="rateDrafts[currency.code]"
class="min-w-[110px]"
type="number"
min="0"
step="any"
/>
</td>
<td>
<VBadge v-if="currency.is_base" tone="blue">{{ $t("admin.baseCurrency") }}</VBadge>
<span v-else class="text-muted">—</span>
</td>
<td>
<VBadge :tone="currency.enabled ? 'green' : 'red'">{{
currency.enabled ? $t("common.yes") : $t("common.no")
}}</VBadge>
</td>
<td>
<div class="flex gap-2">
<VBtn
size="sm"
variant="primary"
:disabled="savingCode === currency.code"
@click="saveRate(currency)"
>
{{
savingCode === currency.code ? $t("common.loading") : $t("admin.updateRate")
}}
</VBtn>
<VBtn
size="sm"
:disabled="savingCode === currency.code"
@click="toggleEnabled(currency)"
>
{{ currency.enabled ? $t("admin.suspend") : $t("admin.activate") }}
</VBtn>
</div>
</td>
</tr>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>
+35 -18
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
const { t } = useI18n();
definePageMeta({ middleware: "auth" });
@@ -43,9 +42,13 @@ const cards = computed(() => [
{ label: "admin.totalUsers", value: stats.users, link: "/users", tone: "blue" },
{ label: "admin.totalShops", value: stats.shops, link: "/shops", tone: "green" },
{ label: "admin.totalOrders", value: stats.orders, link: "/orders", tone: "orange" },
{ label: "admin.enabledCurrencies", value: stats.currencies, link: "/currencies", tone: "purple" },
{
label: "admin.enabledCurrencies",
value: stats.currencies,
link: "/currencies",
tone: "purple",
},
]);
</script>
<template>
@@ -53,35 +56,49 @@ const cards = computed(() => [
<template #actions>
<VBtn size="sm" :disabled="loading" @click="loadDashboard">{{ $t("admin.refresh") }}</VBtn>
</template>
<p class="mb-4 text-xs font-bold uppercase tracking-[0.08em] text-primary">{{ $t("common.appName") }} · Admin</p>
<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 class="text-primary mb-4 text-xs font-bold tracking-[0.08em] uppercase">
{{ $t("common.appName") }} · Admin
</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>
<template v-else>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<NuxtLink
v-for="card in cards"
:key="card.link"
:to="card.link"
class="flex flex-col gap-2 rounded-lg border border-border bg-surface p-5 shadow-sm transition-transform hover:-translate-y-0.5 hover:border-primary"
class="border-border bg-surface hover:border-primary flex flex-col gap-2 rounded-lg border p-5 shadow-sm transition-transform hover:-translate-y-0.5"
>
<span class="text-[13px] text-muted">{{ $t(card.label) }}</span>
<span class="text-muted text-[13px]">{{ $t(card.label) }}</span>
<strong
class="text-[32px] leading-none"
:class="card.tone === 'green' ? 'text-success' : card.tone === 'orange' ? 'text-warning' : 'text-primary'"
>{{ card.value }}</strong>
<span class="text-xs text-primary">{{ $t("admin.open") }} →</span>
:class="
card.tone === 'green'
? 'text-success'
: card.tone === 'orange'
? 'text-warning'
: 'text-primary'
"
>{{ card.value }}</strong
>
<span class="text-primary text-xs">{{ $t("admin.open") }} →</span>
</NuxtLink>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<NuxtLink v-for="shortcut in [
{ to: '/users', title: 'admin.userManagement', label: 'nav.users' },
{ to: '/shops', title: 'admin.shopManagement', label: 'nav.shops' },
{ to: '/orders', title: 'admin.orderManagement', label: 'nav.orders' },
{ to: '/currencies', title: 'admin.currencyManagement', label: 'nav.currencies' },
]" :key="shortcut.to" :to="shortcut.to" class="block no-underline hover:no-underline">
<NuxtLink
v-for="shortcut in [
{ to: '/users', title: 'admin.userManagement', label: 'nav.users' },
{ to: '/shops', title: 'admin.shopManagement', label: 'nav.shops' },
{ to: '/orders', title: 'admin.orderManagement', label: 'nav.orders' },
{ to: '/currencies', title: 'admin.currencyManagement', label: 'nav.currencies' },
]"
:key="shortcut.to"
:to="shortcut.to"
class="block no-underline hover:no-underline"
>
<VCard>
<strong class="block">{{ $t(shortcut.title) }}</strong>
<span class="text-sm text-muted">{{ $t(shortcut.label) }}</span>
<span class="text-muted text-sm">{{ $t(shortcut.label) }}</span>
</VCard>
</NuxtLink>
</div>
+14 -6
View File
@@ -37,23 +37,31 @@ async function submit(): Promise<void> {
</script>
<template>
<div class="grid min-h-screen place-items-center bg-bg px-6 py-8 font-sans text-text">
<div class="bg-bg text-text grid min-h-screen place-items-center px-6 py-8 font-sans">
<form class="w-full max-w-[420px]" @submit.prevent="submit">
<VCard>
<p class="mb-2 text-xs font-bold uppercase tracking-[0.08em] text-primary">{{ $t("common.appName") }} · Admin</p>
<p class="text-primary mb-2 text-xs font-bold tracking-[0.08em] uppercase">
{{ $t("common.appName") }} · Admin
</p>
<h1 class="mb-2 text-xl font-bold">{{ $t("auth.welcome") }}</h1>
<p class="mb-5 text-sm text-muted">{{ $t("admin.dashboardTitle") }}</p>
<p class="text-muted mb-5 text-sm">{{ $t("admin.dashboardTitle") }}</p>
<VField :label="$t('common.email')">
<VInput id="email" v-model="email" type="email" autocomplete="username" required />
</VField>
<VField :label="$t('common.password')">
<VInput id="password" v-model="password" type="password" autocomplete="current-password" required />
<VInput
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
/>
</VField>
<p v-if="errorMessage" class="my-2 text-sm text-danger" role="alert">{{ errorMessage }}</p>
<p v-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
<VBtn class="w-full" variant="primary" type="submit" :disabled="loading">
{{ loading ? $t("common.loading") : $t("common.login") }}
</VBtn>
<p class="mt-4 text-center text-xs text-muted">{{ $t("auth.adminHint") }}</p>
<p class="text-muted mt-4 text-center text-xs">{{ $t("auth.adminHint") }}</p>
</VCard>
</form>
</div>
+58 -30
View File
@@ -29,7 +29,12 @@ function currencyExponent(code: string): number {
}
function formatTotal(order: Order): string {
return formatMoney(order.total_minor, order.currency, currencyExponent(order.currency), locale.value);
return formatMoney(
order.total_minor,
order.currency,
currencyExponent(order.currency),
locale.value,
);
}
function formatDate(value: string): string {
@@ -78,37 +83,60 @@ onMounted(() => {
<template>
<VPage :title="$t('nav.orders')">
<template #actions>
<span v-if="!loading" class="text-sm text-muted">{{ $t("admin.pageOf", { page, total: totalPages }) }}</span>
<span v-if="!loading" class="text-muted text-sm">{{
$t("admin.pageOf", { page, total: totalPages })
}}</span>
</template>
<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="orders.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto"><div class="min-w-[900px]"><VTable>
<thead>
<tr>
<th>{{ $t("order.orderNo") }}</th>
<th>{{ $t("admin.orderShop") }}</th>
<th>{{ $t("admin.orderUser") }}</th>
<th>{{ $t("admin.orderTotal") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("admin.created") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id">
<td><strong>{{ order.order_no }}</strong></td>
<td>{{ shopName(order.shop_id) }}</td>
<td><code class="rounded bg-bg px-1.5 py-0.5" :title="order.user_id">{{ order.user_id.slice(0, 8) }}</code></td>
<td>{{ formatTotal(order) }}</td>
<td><VBadge :tone="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</VBadge></td>
<td>{{ formatDate(order.created_at) }}</td>
</tr>
</tbody>
</VTable></div></div>
<div v-if="!loading && !errorMessage && orders.length > 0" class="mt-4 flex items-center justify-center gap-3">
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</VBtn>
<span class="text-sm text-muted">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</VBtn>
<div v-else class="overflow-x-auto">
<div class="min-w-[900px]">
<VTable>
<thead>
<tr>
<th>{{ $t("order.orderNo") }}</th>
<th>{{ $t("admin.orderShop") }}</th>
<th>{{ $t("admin.orderUser") }}</th>
<th>{{ $t("admin.orderTotal") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("admin.created") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id">
<td>
<strong>{{ order.order_no }}</strong>
</td>
<td>{{ shopName(order.shop_id) }}</td>
<td>
<code class="bg-bg rounded px-1.5 py-0.5" :title="order.user_id">{{
order.user_id.slice(0, 8)
}}</code>
</td>
<td>{{ formatTotal(order) }}</td>
<td>
<VBadge :tone="statusClass(order.status)">{{
$t(`order.status.${order.status}`)
}}</VBadge>
</td>
<td>{{ formatDate(order.created_at) }}</td>
</tr>
</tbody>
</VTable>
</div>
</div>
<div
v-if="!loading && !errorMessage && orders.length > 0"
class="mt-4 flex items-center justify-center gap-3"
>
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
$t("common.prev")
}}</VBtn>
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
$t("common.next")
}}</VBtn>
</div>
</VPage>
</template>
+57 -43
View File
@@ -35,10 +35,7 @@ async function load(): Promise<void> {
}
}
async function transition(
order: IntegralOrder,
action: "fulfill" | "cancel",
): Promise<void> {
async function transition(order: IntegralOrder, action: "fulfill" | "cancel"): Promise<void> {
actionId.value = order.id;
error.value = "";
notice.value = "";
@@ -46,9 +43,7 @@ async function transition(
if (action === "fulfill") await $api.admin.fulfillRedemption(order.id);
else await $api.admin.cancelRedemption(order.id);
notice.value =
action === "fulfill"
? t("admin.redemptionFulfilled")
: t("admin.redemptionCancelled");
action === "fulfill" ? t("admin.redemptionFulfilled") : t("admin.redemptionCancelled");
await load();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t("common.error");
@@ -62,42 +57,61 @@ onMounted(() => void load());
<template>
<VPage :title="$t('admin.pointsOrderManagement')">
<p v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</p>
<p v-if="notice" class="text-sm text-muted" role="status">{{ notice }}</p>
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</p>
<p v-if="notice" class="text-muted text-sm" role="status">{{ notice }}</p>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<VCard v-else-if="orders.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto"><div class="min-w-[980px]"><VTable>
<thead>
<tr>
<th>{{ $t("admin.redemptionNo") }}</th>
<th>{{ $t("admin.redemptionCustomer") }}</th>
<th>{{ $t("admin.redemptionReward") }}</th>
<th>{{ $t("admin.redemptionPoints") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("admin.redemptionStatus") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id">
<td>{{ order.order_no }}</td>
<td class="text-muted">{{ order.user_id }}</td>
<td>{{ rewardName(order) }}</td>
<td>{{ order.total_points }}</td>
<td>{{ order.created_at.slice(0, 10) }}</td>
<td><VBadge :tone="statusClass(order.status)">{{ $t(`admin.redemptionStatuses.${order.status}`) }}</VBadge></td>
<td><div class="flex gap-2">
<template v-if="order.status === 'pending_fulfillment'">
<VBtn size="sm" variant="primary" :disabled="actionId === order.id" @click="transition(order, 'fulfill')">
{{ $t("admin.fulfillRedemption") }}
</VBtn>
<VBtn size="sm" :disabled="actionId === order.id" @click="transition(order, 'cancel')">
{{ $t("admin.cancelRedemption") }}
</VBtn>
</template>
</div></td>
</tr>
</tbody>
</VTable></div></div>
<div v-else class="overflow-x-auto">
<div class="min-w-[980px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.redemptionNo") }}</th>
<th>{{ $t("admin.redemptionCustomer") }}</th>
<th>{{ $t("admin.redemptionReward") }}</th>
<th>{{ $t("admin.redemptionPoints") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("admin.redemptionStatus") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id">
<td>{{ order.order_no }}</td>
<td class="text-muted">{{ order.user_id }}</td>
<td>{{ rewardName(order) }}</td>
<td>{{ order.total_points }}</td>
<td>{{ order.created_at.slice(0, 10) }}</td>
<td>
<VBadge :tone="statusClass(order.status)">{{
$t(`admin.redemptionStatuses.${order.status}`)
}}</VBadge>
</td>
<td>
<div class="flex gap-2">
<template v-if="order.status === 'pending_fulfillment'">
<VBtn
size="sm"
variant="primary"
:disabled="actionId === order.id"
@click="transition(order, 'fulfill')"
>
{{ $t("admin.fulfillRedemption") }}
</VBtn>
<VBtn
size="sm"
:disabled="actionId === order.id"
@click="transition(order, 'cancel')"
>
{{ $t("admin.cancelRedemption") }}
</VBtn>
</template>
</div>
</td>
</tr>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>
+70 -43
View File
@@ -128,25 +128,39 @@ onMounted(() => void load());
<template>
<VPage :title="$t('admin.pointsProductManagement')">
<p v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</p>
<p v-if="notice" class="text-sm text-muted" role="status">{{ notice }}</p>
<p v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</p>
<p v-if="notice" class="text-muted text-sm" role="status">{{ notice }}</p>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ editingId ? $t("admin.editPointsProduct") : $t("admin.newPointsProduct") }}</h2>
<h2 class="mb-4 text-base font-semibold">
{{ editingId ? $t("admin.editPointsProduct") : $t("admin.newPointsProduct") }}
</h2>
<div class="grid gap-3 md:grid-cols-3">
<VField :label="$t('admin.pointsNameEn')"><VInput id="pp-name-en" v-model="form.nameEn" /></VField>
<VField :label="$t('admin.pointsNameZh')"><VInput id="pp-name-zh" v-model="form.nameZh" /></VField>
<VField :label="$t('admin.pointsImage')"><VInput id="pp-image" v-model="form.image" /></VField>
<VField :label="$t('admin.pointsPrice')"><VInput id="pp-price" v-model.number="form.pointsPrice" min="1" step="1" type="number" /></VField>
<VField :label="$t('admin.pointsStock')"><VInput id="pp-stock" v-model.number="form.stock" min="0" step="1" type="number" /></VField>
<VField :label="$t('admin.pointsPosition')"><VInput id="pp-position" v-model.number="form.position" step="1" type="number" /></VField>
<VField :label="$t('admin.pointsNameEn')"
><VInput id="pp-name-en" v-model="form.nameEn"
/></VField>
<VField :label="$t('admin.pointsNameZh')"
><VInput id="pp-name-zh" v-model="form.nameZh"
/></VField>
<VField :label="$t('admin.pointsImage')"
><VInput id="pp-image" v-model="form.image"
/></VField>
<VField :label="$t('admin.pointsPrice')"
><VInput id="pp-price" v-model.number="form.pointsPrice" min="1" step="1" type="number"
/></VField>
<VField :label="$t('admin.pointsStock')"
><VInput id="pp-stock" v-model.number="form.stock" min="0" step="1" type="number"
/></VField>
<VField :label="$t('admin.pointsPosition')"
><VInput id="pp-position" v-model.number="form.position" step="1" type="number"
/></VField>
</div>
<label class="my-2 flex items-center gap-2 text-sm">
<input v-model="form.recommend" class="h-4 w-4 accent-primary" type="checkbox" />
<input v-model="form.recommend" class="accent-primary h-4 w-4" type="checkbox" />
{{ $t("admin.pointsRecommend") }}
</label>
<label class="mb-4 flex items-center gap-2 text-sm">
<input v-model="form.published" class="h-4 w-4 accent-primary" type="checkbox" />
<input v-model="form.published" class="accent-primary h-4 w-4" type="checkbox" />
{{ $t("admin.pointsPublished") }}
</label>
<div class="flex gap-2">
@@ -157,38 +171,51 @@ onMounted(() => void load());
</div>
</VCard>
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
<VCard v-else-if="products.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto"><div class="min-w-[900px]"><VTable>
<thead>
<tr>
<th>{{ $t("admin.pointsNameEn") }}</th>
<th>{{ $t("admin.pointsPrice") }}</th>
<th>{{ $t("admin.pointsStock") }}</th>
<th>{{ $t("admin.pointsPosition") }}</th>
<th>{{ $t("admin.pointsRecommend") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ localizedName(product.name) }}</td>
<td>{{ product.points_price }}</td>
<td>{{ product.stock }}</td>
<td>{{ product.position }}</td>
<td>{{ product.recommend ? $t("common.yes") : $t("common.no") }}</td>
<td><VBadge :tone="product.published ? 'green' : 'orange'">
{{ product.published ? $t("admin.publishedOn") : $t("admin.publishedOff") }}
</VBadge></td>
<td><div class="flex gap-2">
<VBtn size="sm" @click="edit(product)">{{ $t("common.edit") }}</VBtn>
<VBtn size="sm" variant="primary" :disabled="actionId === product.id" @click="togglePublished(product)">
{{ product.published ? $t("product.unpublish") : $t("product.publish") }}
</VBtn>
</div></td>
</tr>
</tbody>
</VTable></div></div>
<div v-else class="overflow-x-auto">
<div class="min-w-[900px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.pointsNameEn") }}</th>
<th>{{ $t("admin.pointsPrice") }}</th>
<th>{{ $t("admin.pointsStock") }}</th>
<th>{{ $t("admin.pointsPosition") }}</th>
<th>{{ $t("admin.pointsRecommend") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ localizedName(product.name) }}</td>
<td>{{ product.points_price }}</td>
<td>{{ product.stock }}</td>
<td>{{ product.position }}</td>
<td>{{ product.recommend ? $t("common.yes") : $t("common.no") }}</td>
<td>
<VBadge :tone="product.published ? 'green' : 'orange'">
{{ product.published ? $t("admin.publishedOn") : $t("admin.publishedOff") }}
</VBadge>
</td>
<td>
<div class="flex gap-2">
<VBtn size="sm" @click="edit(product)">{{ $t("common.edit") }}</VBtn>
<VBtn
size="sm"
variant="primary"
:disabled="actionId === product.id"
@click="togglePublished(product)"
>
{{ product.published ? $t("product.unpublish") : $t("product.publish") }}
</VBtn>
</div>
</td>
</tr>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>
+44 -27
View File
@@ -98,37 +98,54 @@ onMounted(() => {
<VInput id="shop-slug" v-model="slug" required />
</VField>
</div>
<p v-if="createError" class="my-2 text-sm text-danger" role="alert">{{ createError }}</p>
<p v-if="createError" class="text-danger my-2 text-sm" role="alert">{{ createError }}</p>
<VBtn variant="primary" :disabled="creating" @click="createShop">
{{ creating ? $t("common.loading") : $t("common.create") }}
</VBtn>
</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="shops.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto"><div class="min-w-[720px]"><VTable>
<thead>
<tr>
<th>{{ $t("admin.shopNameEn") }}</th>
<th>{{ $t("admin.shopSlug") }}</th>
<th>{{ $t("admin.shopStatus") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="shop in shops" :key="shop.id">
<td>{{ localizedName(shop.name) }}</td>
<td><code class="rounded bg-bg px-1.5 py-0.5">{{ shop.slug }}</code></td>
<td><VBadge :tone="statusClass(shop.status)">{{ $t(`admin.${shop.status}`) }}</VBadge></td>
<td>{{ formatDate(shop.created_at) }}</td>
<td>
<VBtn :variant="shop.status === 'active' ? 'danger' : 'default'" size="sm" :disabled="updatingId === shop.id" @click="setStatus(shop)">
{{ updatingId === shop.id ? $t("common.loading") : $t(shop.status === "active" ? "admin.suspend" : "admin.activate") }}
</VBtn>
</td>
</tr>
</tbody>
</VTable></div></div>
<div v-else class="overflow-x-auto">
<div class="min-w-[720px]">
<VTable>
<thead>
<tr>
<th>{{ $t("admin.shopNameEn") }}</th>
<th>{{ $t("admin.shopSlug") }}</th>
<th>{{ $t("admin.shopStatus") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="shop in shops" :key="shop.id">
<td>{{ localizedName(shop.name) }}</td>
<td>
<code class="bg-bg rounded px-1.5 py-0.5">{{ shop.slug }}</code>
</td>
<td>
<VBadge :tone="statusClass(shop.status)">{{ $t(`admin.${shop.status}`) }}</VBadge>
</td>
<td>{{ formatDate(shop.created_at) }}</td>
<td>
<VBtn
:variant="shop.status === 'active' ? 'danger' : 'default'"
size="sm"
:disabled="updatingId === shop.id"
@click="setStatus(shop)"
>
{{
updatingId === shop.id
? $t("common.loading")
: $t(shop.status === "active" ? "admin.suspend" : "admin.activate")
}}
</VBtn>
</td>
</tr>
</tbody>
</VTable>
</div>
</div>
</VPage>
</template>
+82 -58
View File
@@ -111,66 +111,90 @@ onMounted(() => {
<template>
<VPage :title="$t('nav.users')">
<template #actions>
<span v-if="!loading" class="text-sm text-muted">{{ $t("admin.pageOf", { page, total: totalPages }) }}</span>
<span v-if="!loading" class="text-muted text-sm">{{
$t("admin.pageOf", { page, total: totalPages })
}}</span>
</template>
<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="users.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
<div v-else class="overflow-x-auto"><div class="min-w-[920px]"><VTable>
<thead>
<tr>
<th>{{ $t("common.email") }}</th>
<th>{{ $t("common.displayName") }}</th>
<th>{{ $t("admin.role") }}</th>
<th>{{ $t("admin.assignShop") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.display_name }}</td>
<td><VBadge tone="blue">{{ $t(`admin.roles.${user.role}`) }}</VBadge></td>
<td>
<div class="grid min-w-[190px] gap-1.5">
<select
v-model="draftFor(user).role"
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"
:aria-label="$t('admin.role')"
@change="normalizeDraft(draftFor(user))"
>
<option value="platform_admin">{{ $t("admin.roles.platform_admin") }}</option>
<option value="shop_owner">{{ $t("admin.roles.shop_owner") }}</option>
<option value="shop_staff">{{ $t("admin.roles.shop_staff") }}</option>
<option value="customer">{{ $t("admin.roles.customer") }}</option>
</select>
<select
v-if="roleNeedsShop(draftFor(user).role)"
v-model="draftFor(user).shopId"
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"
:aria-label="$t('admin.assignShop')"
>
<option :value="null">{{ $t("admin.noAssignedShop") }}</option>
<option v-for="shop in shops" :key="shop.id" :value="shop.id">{{ tText(shop.name, locale) }}</option>
</select>
<span v-else class="text-sm text-muted">{{ shopName(user.shop_id) }}</span>
</div>
<p v-if="rowErrors[user.id]" class="my-1 text-xs text-danger" role="alert">{{ rowErrors[user.id] }}</p>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>
<VBtn size="sm" variant="primary" :disabled="savingId === user.id" @click="saveRole(user)">
{{ savingId === user.id ? $t("common.loading") : $t("common.save") }}
</VBtn>
</td>
</tr>
</tbody>
</VTable></div></div>
<div v-if="!loading && !errorMessage && users.length > 0" class="mt-4 flex items-center justify-center gap-3">
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</VBtn>
<span class="text-sm text-muted">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</VBtn>
<div v-else class="overflow-x-auto">
<div class="min-w-[920px]">
<VTable>
<thead>
<tr>
<th>{{ $t("common.email") }}</th>
<th>{{ $t("common.displayName") }}</th>
<th>{{ $t("admin.role") }}</th>
<th>{{ $t("admin.assignShop") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.display_name }}</td>
<td>
<VBadge tone="blue">{{ $t(`admin.roles.${user.role}`) }}</VBadge>
</td>
<td>
<div class="grid min-w-[190px] gap-1.5">
<select
v-model="draftFor(user).role"
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"
:aria-label="$t('admin.role')"
@change="normalizeDraft(draftFor(user))"
>
<option value="platform_admin">{{ $t("admin.roles.platform_admin") }}</option>
<option value="shop_owner">{{ $t("admin.roles.shop_owner") }}</option>
<option value="shop_staff">{{ $t("admin.roles.shop_staff") }}</option>
<option value="customer">{{ $t("admin.roles.customer") }}</option>
</select>
<select
v-if="roleNeedsShop(draftFor(user).role)"
v-model="draftFor(user).shopId"
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"
:aria-label="$t('admin.assignShop')"
>
<option :value="null">{{ $t("admin.noAssignedShop") }}</option>
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
{{ tText(shop.name, locale) }}
</option>
</select>
<span v-else class="text-muted text-sm">{{ shopName(user.shop_id) }}</span>
</div>
<p v-if="rowErrors[user.id]" class="text-danger my-1 text-xs" role="alert">
{{ rowErrors[user.id] }}
</p>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>
<VBtn
size="sm"
variant="primary"
:disabled="savingId === user.id"
@click="saveRole(user)"
>
{{ savingId === user.id ? $t("common.loading") : $t("common.save") }}
</VBtn>
</td>
</tr>
</tbody>
</VTable>
</div>
</div>
<div
v-if="!loading && !errorMessage && users.length > 0"
class="mt-4 flex items-center justify-center gap-3"
>
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
$t("common.prev")
}}</VBtn>
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
$t("common.next")
}}</VBtn>
</div>
</VPage>
</template>