wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates
This commit is contained in:
+277
-140
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user