feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
This commit is contained in:
+39
-18
@@ -2,43 +2,64 @@
|
||||
const session = useSessionStore();
|
||||
const { locale, locales, setLocale } = useI18n();
|
||||
const route = useRoute();
|
||||
const { boot } = useAccent();
|
||||
|
||||
onMounted(() => session.hydrate());
|
||||
onMounted(() => {
|
||||
session.hydrate();
|
||||
boot();
|
||||
});
|
||||
|
||||
const isAuthPage = computed(() => route.path === "/login");
|
||||
const navItems = [
|
||||
{ to: "/", label: "nav.dashboard" },
|
||||
{ to: "/users", label: "nav.users" },
|
||||
{ to: "/shops", label: "nav.shops" },
|
||||
{ to: "/orders", label: "nav.orders" },
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
{ to: "/points-products", label: "nav.pointsProducts" },
|
||||
{ to: "/points-orders", label: "nav.pointsOrders" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isAuthPage">
|
||||
<div v-if="isAuthPage" class="min-h-screen bg-bg font-sans text-text">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<div v-else class="auth-layout">
|
||||
<aside>
|
||||
<div class="brand">{{ $t("common.appName") }} · Admin</div>
|
||||
<NuxtLink to="/">{{ $t("nav.dashboard") }}</NuxtLink>
|
||||
<NuxtLink to="/users">{{ $t("nav.users") }}</NuxtLink>
|
||||
<NuxtLink to="/shops">{{ $t("nav.shops") }}</NuxtLink>
|
||||
<NuxtLink to="/orders">{{ $t("nav.orders") }}</NuxtLink>
|
||||
<NuxtLink to="/currencies">{{ $t("nav.currencies") }}</NuxtLink>
|
||||
<NuxtLink to="/points-products">{{ $t("nav.pointsProducts") }}</NuxtLink>
|
||||
<NuxtLink to="/points-orders">{{ $t("nav.pointsOrders") }}</NuxtLink>
|
||||
<div v-else class="min-h-screen bg-bg font-sans text-text md:flex">
|
||||
<aside class="w-full shrink-0 border-b border-border bg-surface p-4 md:min-h-screen md:w-60 md:border-b-0 md:border-r">
|
||||
<div class="mb-6 text-lg font-bold text-primary">{{ $t("common.appName") }} · Admin</div>
|
||||
<nav class="grid gap-1" aria-label="Admin navigation">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
:class="route.path === item.to ? 'bg-primary-soft text-primary' : ''"
|
||||
>
|
||||
{{ $t(item.label) }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="main-head">
|
||||
<main class="min-w-0 flex-1">
|
||||
<header class="flex flex-wrap items-center justify-end gap-3 border-b border-border bg-surface px-4 py-3 md:px-6">
|
||||
<select
|
||||
:value="locale"
|
||||
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('common.language')"
|
||||
@change="setLocale(($event.target as HTMLSelectElement).value as 'en' | 'zh')"
|
||||
>
|
||||
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.name }}</option>
|
||||
</select>
|
||||
<VAccentSwatch />
|
||||
<template v-if="session.isLoggedIn">
|
||||
<span class="muted">{{ session.user?.display_name }}</span>
|
||||
<button class="btn sm" @click="session.logout()">{{ $t("common.logout") }}</button>
|
||||
<span class="text-sm text-muted">{{ session.user?.display_name }}</span>
|
||||
<VBtn size="sm" @click="session.logout()">{{ $t("common.logout") }}</VBtn>
|
||||
</template>
|
||||
<NuxtLink v-else to="/login" class="btn sm primary">{{ $t("common.login") }}</NuxtLink>
|
||||
<VBtn v-else size="sm" variant="primary" @click="navigateTo('/login')">{{ $t("common.login") }}</VBtn>
|
||||
</header>
|
||||
<div class="mx-auto w-full max-w-7xl px-4 md:px-6">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
modules: ["@pinia/nuxt", "@nuxtjs/i18n"],
|
||||
css: ["@vmall/shared/ui.css"],
|
||||
modules: ["@pinia/nuxt", "@nuxtjs/i18n", "@vmall/ui/nuxt"],
|
||||
css: ["@vmall/shared/theme.css"],
|
||||
vite: { plugins: [tailwindcss()] },
|
||||
devtools: { enabled: false },
|
||||
devServer: { port: 3002 },
|
||||
runtimeConfig: {
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"nuxt": "^3.15.4",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"@vmall/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"tailwindcss": "^4.1.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,52 +140,43 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("nav.currencies") }}</h1>
|
||||
</div>
|
||||
<section class="card create-card">
|
||||
<h2>{{ $t("admin.currencyFormTitle") }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="currency-code">{{ $t("admin.currencyCode") }}</label>
|
||||
<input id="currency-code" v-model="form.code" maxlength="3" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="currency-name-en">{{ $t("admin.currencyNameEn") }}</label>
|
||||
<input id="currency-name-en" v-model="form.nameEn" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="currency-name-zh">{{ $t("admin.currencyNameZh") }}</label>
|
||||
<input id="currency-name-zh" v-model="form.nameZh" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="currency-symbol">{{ $t("admin.symbol") }}</label>
|
||||
<input id="currency-symbol" v-model="form.symbol" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="currency-exponent">{{ $t("admin.exponent") }}</label>
|
||||
<input id="currency-exponent" v-model.number="form.exponent" min="0" max="6" type="number" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="currency-rate">{{ $t("admin.rate") }}</label>
|
||||
<input id="currency-rate" v-model="form.rateToBase" min="0" step="any" type="number" required />
|
||||
</div>
|
||||
<VPage :title="$t('nav.currencies')">
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.currencyFormTitle") }}</h2>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<VField :label="$t('admin.currencyCode')">
|
||||
<VInput id="currency-code" v-model="form.code" maxlength="3" required />
|
||||
</VField>
|
||||
<VField :label="$t('admin.currencyNameEn')">
|
||||
<VInput id="currency-name-en" v-model="form.nameEn" required />
|
||||
</VField>
|
||||
<VField :label="$t('admin.currencyNameZh')">
|
||||
<VInput id="currency-name-zh" v-model="form.nameZh" required />
|
||||
</VField>
|
||||
<VField :label="$t('admin.symbol')">
|
||||
<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 />
|
||||
</VField>
|
||||
<VField :label="$t('admin.rate')">
|
||||
<VInput id="currency-rate" v-model="form.rateToBase" min="0" step="any" type="number" required />
|
||||
</VField>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<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" />
|
||||
{{ $t("admin.enabled") }}
|
||||
</label>
|
||||
<p v-if="formError" class="error-text" role="alert">{{ formError }}</p>
|
||||
<button class="btn primary" type="button" :disabled="creating" @click="createCurrency">
|
||||
<p v-if="formError" class="my-2 text-sm text-danger" role="alert">{{ formError }}</p>
|
||||
<VBtn variant="primary" :disabled="creating" @click="createCurrency">
|
||||
{{ creating ? $t("common.loading") : $t("admin.saveCurrency") }}
|
||||
</button>
|
||||
</section>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<div v-else-if="currencies.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
</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>
|
||||
<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>
|
||||
@@ -204,35 +195,23 @@ onMounted(() => {
|
||||
<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>
|
||||
<input v-model="rateDrafts[currency.code]" class="rate-input" type="number" min="0" step="any" />
|
||||
<VBadge v-if="currency.is_base" tone="blue">{{ $t("admin.baseCurrency") }}</VBadge>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</td>
|
||||
<td><span v-if="currency.is_base" class="badge blue">{{ $t("admin.baseCurrency") }}</span><span v-else class="muted">—</span></td>
|
||||
<td><span class="badge" :class="currency.enabled ? 'green' : 'red'">{{ currency.enabled ? $t("common.yes") : $t("common.no") }}</span></td>
|
||||
<td class="action-row">
|
||||
<button class="btn sm primary" type="button" :disabled="savingCode === currency.code" @click="saveRate(currency)">
|
||||
<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") }}
|
||||
</button>
|
||||
<button class="btn sm" type="button" :disabled="savingCode === currency.code" @click="toggleEnabled(currency)">
|
||||
</VBtn>
|
||||
<VBtn size="sm" :disabled="savingCode === currency.code" @click="toggleEnabled(currency)">
|
||||
{{ currency.enabled ? $t("admin.suspend") : $t("admin.activate") }}
|
||||
</button>
|
||||
</td>
|
||||
</VBtn>
|
||||
</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</VTable></div>
|
||||
</div>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-card { margin-bottom: 20px; }
|
||||
.create-card h2 { font-size: 16px; margin: 0 0 16px; }
|
||||
.form-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.checkbox-field { align-items: center; display: flex; gap: 8px; margin: 2px 0 16px; }
|
||||
.checkbox-field input { width: auto; }
|
||||
.table-wrap { overflow-x: auto; padding: 0; }
|
||||
.table { min-width: 1080px; }
|
||||
.rate-input { min-width: 110px; }
|
||||
.action-row { display: flex; gap: 8px; }
|
||||
@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
+32
-100
@@ -49,110 +49,42 @@ const cards = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ $t("common.appName") }} · Admin</p>
|
||||
<h1 class="page-title">{{ $t("admin.dashboardTitle") }}</h1>
|
||||
</div>
|
||||
<button class="btn sm" type="button" :disabled="loading" @click="loadDashboard">
|
||||
{{ $t("admin.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<VPage :title="$t('admin.dashboardTitle')">
|
||||
<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>
|
||||
<template v-else>
|
||||
<div class="stat-grid">
|
||||
<NuxtLink v-for="card in cards" :key="card.link" :to="card.link" class="stat-card">
|
||||
<span class="stat-label">{{ $t(card.label) }}</span>
|
||||
<strong class="stat-value" :class="`tone-${card.tone}`">{{ card.value }}</strong>
|
||||
<span class="stat-link">{{ $t("admin.open") }} →</span>
|
||||
<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"
|
||||
>
|
||||
<span class="text-[13px] text-muted">{{ $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>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="grid shortcuts">
|
||||
<NuxtLink class="card shortcut" to="/users">
|
||||
<strong>{{ $t("admin.userManagement") }}</strong>
|
||||
<span class="muted">{{ $t("nav.users") }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="card shortcut" to="/shops">
|
||||
<strong>{{ $t("admin.shopManagement") }}</strong>
|
||||
<span class="muted">{{ $t("nav.shops") }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="card shortcut" to="/orders">
|
||||
<strong>{{ $t("admin.orderManagement") }}</strong>
|
||||
<span class="muted">{{ $t("nav.orders") }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="card shortcut" to="/currencies">
|
||||
<strong>{{ $t("admin.currencyManagement") }}</strong>
|
||||
<span class="muted">{{ $t("nav.currencies") }}</span>
|
||||
<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">
|
||||
<VCard>
|
||||
<strong class="block">{{ $t(shortcut.title) }}</strong>
|
||||
<span class="text-sm text-muted">{{ $t(shortcut.label) }}</span>
|
||||
</VCard>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eyebrow {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
transition: transform 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: var(--primary);
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tone-blue { color: var(--primary); }
|
||||
.tone-green { color: var(--success); }
|
||||
.tone-orange { color: var(--warning); }
|
||||
.tone-purple { color: #7257b8; }
|
||||
.stat-link {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shortcuts {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 24px;
|
||||
}
|
||||
.shortcut {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.shortcut:hover { text-decoration: none; }
|
||||
@media (max-width: 900px) {
|
||||
.stat-grid, .shortcuts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.stat-grid, .shortcuts { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+18
-48
@@ -37,54 +37,24 @@ async function submit(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-shell">
|
||||
<form class="card form-narrow login-card" @submit.prevent="submit">
|
||||
<p class="eyebrow">{{ $t("common.appName") }} · Admin</p>
|
||||
<h1 class="page-title">{{ $t("auth.welcome") }}</h1>
|
||||
<p class="muted">{{ $t("admin.dashboardTitle") }}</p>
|
||||
<div class="field">
|
||||
<label for="email">{{ $t("common.email") }}</label>
|
||||
<input id="email" v-model="email" type="email" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">{{ $t("common.password") }}</label>
|
||||
<input id="password" v-model="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<p v-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<button class="btn primary submit-button" type="submit" :disabled="loading">
|
||||
{{ loading ? $t("common.loading") : $t("common.login") }}
|
||||
</button>
|
||||
<p class="muted hint">{{ $t("auth.adminHint") }}</p>
|
||||
<div class="grid min-h-screen place-items-center bg-bg px-6 py-8 font-sans text-text">
|
||||
<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>
|
||||
<h1 class="mb-2 text-xl font-bold">{{ $t("auth.welcome") }}</h1>
|
||||
<p class="mb-5 text-sm text-muted">{{ $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 />
|
||||
</VField>
|
||||
<p v-if="errorMessage" class="my-2 text-sm text-danger" 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>
|
||||
</VCard>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
width: min(100%, 420px);
|
||||
margin: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
margin: 16px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
+35
-49
@@ -36,7 +36,7 @@ function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function statusClass(status: Order["status"]): string {
|
||||
function statusClass(status: Order["status"]): "green" | "red" | "orange" | "blue" {
|
||||
if (status === "completed") return "green";
|
||||
if (status === "cancelled") return "red";
|
||||
if (status === "pending_payment") return "orange";
|
||||
@@ -76,53 +76,39 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("nav.orders") }}</h1>
|
||||
<span v-if="!loading" class="muted">{{ $t("admin.pageOf", { page, total: totalPages }) }}</span>
|
||||
<VPage :title="$t('nav.orders')">
|
||||
<template #actions>
|
||||
<span v-if="!loading" class="text-sm text-muted">{{ $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>
|
||||
<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>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<div v-else-if="orders.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<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 :title="order.user_id">{{ order.user_id.slice(0, 8) }}</code></td>
|
||||
<td>{{ formatTotal(order) }}</td>
|
||||
<td><span class="badge" :class="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</span></td>
|
||||
<td>{{ formatDate(order.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!loading && !errorMessage && orders.length > 0" class="pagination">
|
||||
<button class="btn sm" type="button" :disabled="page <= 1" @click="changePage(page - 1)">
|
||||
{{ $t("common.prev") }}
|
||||
</button>
|
||||
<span class="muted">{{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn sm" type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">
|
||||
{{ $t("common.next") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap { overflow-x: auto; padding: 0; }
|
||||
.table { min-width: 900px; }
|
||||
.pagination { align-items: center; display: flex; gap: 12px; justify-content: center; margin-top: 16px; }
|
||||
code { background: #f0f2f5; border-radius: 4px; padding: 2px 5px; }
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@ function rewardName(order: IntegralOrder): string {
|
||||
return order.items.map((line) => line.name[locale.value] ?? line.name.en).join(", ");
|
||||
}
|
||||
|
||||
function statusClass(status: IntegralOrderStatus): string {
|
||||
function statusClass(status: IntegralOrderStatus): "green" | "red" | "orange" {
|
||||
if (status === "fulfilled") return "green";
|
||||
if (status === "cancelled") return "red";
|
||||
return "orange";
|
||||
@@ -61,75 +61,43 @@ onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("admin.pointsOrderManagement") }}</h1>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" class="muted" role="status">{{ notice }}</p>
|
||||
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="orders.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<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="muted">{{ order.user_id }}</td>
|
||||
<td>{{ rewardName(order) }}</td>
|
||||
<td>{{ order.total_points }}</td>
|
||||
<td>{{ order.created_at.slice(0, 10) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="statusClass(order.status)">
|
||||
{{ $t(`admin.redemptionStatuses.${order.status}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<template v-if="order.status === 'pending_fulfillment'">
|
||||
<button
|
||||
class="btn sm primary"
|
||||
type="button"
|
||||
:disabled="actionId === order.id"
|
||||
@click="transition(order, 'fulfill')"
|
||||
>
|
||||
{{ $t("admin.fulfillRedemption") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
type="button"
|
||||
:disabled="actionId === order.id"
|
||||
@click="transition(order, 'cancel')"
|
||||
>
|
||||
{{ $t("admin.cancelRedemption") }}
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.table {
|
||||
min-width: 980px;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -127,141 +127,68 @@ onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("admin.pointsProductManagement") }}</h1>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" class="muted" role="status">{{ notice }}</p>
|
||||
<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>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>{{ editingId ? $t("admin.editPointsProduct") : $t("admin.newPointsProduct") }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="pp-name-en">{{ $t("admin.pointsNameEn") }}</label>
|
||||
<input id="pp-name-en" v-model="form.nameEn" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pp-name-zh">{{ $t("admin.pointsNameZh") }}</label>
|
||||
<input id="pp-name-zh" v-model="form.nameZh" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pp-image">{{ $t("admin.pointsImage") }}</label>
|
||||
<input id="pp-image" v-model="form.image" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pp-price">{{ $t("admin.pointsPrice") }}</label>
|
||||
<input id="pp-price" v-model.number="form.pointsPrice" min="1" step="1" type="number" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pp-stock">{{ $t("admin.pointsStock") }}</label>
|
||||
<input id="pp-stock" v-model.number="form.stock" min="0" step="1" type="number" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pp-position">{{ $t("admin.pointsPosition") }}</label>
|
||||
<input id="pp-position" v-model.number="form.position" step="1" type="number" />
|
||||
</div>
|
||||
<VCard class="mb-5">
|
||||
<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>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input v-model="form.recommend" type="checkbox" />
|
||||
<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" />
|
||||
{{ $t("admin.pointsRecommend") }}
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input v-model="form.published" type="checkbox" />
|
||||
<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" />
|
||||
{{ $t("admin.pointsPublished") }}
|
||||
</label>
|
||||
<div class="action-row">
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||
<div class="flex gap-2">
|
||||
<VBtn variant="primary" :disabled="saving" @click="submit">
|
||||
{{ saving ? $t("common.loading") : $t("admin.savePointsProduct") }}
|
||||
</button>
|
||||
<button v-if="editingId" class="btn" type="button" @click="resetForm">
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</VBtn>
|
||||
<VBtn v-if="editingId" @click="resetForm">{{ $t("common.cancel") }}</VBtn>
|
||||
</div>
|
||||
</section>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="products.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<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>
|
||||
<span class="badge" :class="product.published ? 'green' : 'orange'">
|
||||
{{ product.published ? $t("admin.publishedOn") : $t("admin.publishedOff") }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<button class="btn sm" type="button" @click="edit(product)">
|
||||
{{ $t("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn sm primary"
|
||||
type="button"
|
||||
:disabled="actionId === product.id"
|
||||
@click="togglePublished(product)"
|
||||
>
|
||||
{{ product.published ? $t("product.unpublish") : $t("product.publish") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="loading" class="text-sm text-muted">{{ $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>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.create-card h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.checkbox-field {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.checkbox-field input {
|
||||
width: auto;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.table {
|
||||
min-width: 900px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+46
-64
@@ -24,7 +24,7 @@ function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function statusClass(status: Shop["status"]): string {
|
||||
function statusClass(status: Shop["status"]): "green" | "red" {
|
||||
return status === "active" ? "green" : "red";
|
||||
}
|
||||
|
||||
@@ -84,69 +84,51 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("nav.shops") }}</h1>
|
||||
</div>
|
||||
<section class="card create-card">
|
||||
<h2>{{ $t("admin.createShop") }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="shop-name-en">{{ $t("admin.shopNameEn") }}</label>
|
||||
<input id="shop-name-en" v-model="nameEn" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="shop-name-zh">{{ $t("admin.shopNameZh") }}</label>
|
||||
<input id="shop-name-zh" v-model="nameZh" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="shop-slug">{{ $t("admin.shopSlug") }}</label>
|
||||
<input id="shop-slug" v-model="slug" type="text" required />
|
||||
</div>
|
||||
<VPage :title="$t('nav.shops')">
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.createShop") }}</h2>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<VField :label="$t('admin.shopNameEn')">
|
||||
<VInput id="shop-name-en" v-model="nameEn" required />
|
||||
</VField>
|
||||
<VField :label="$t('admin.shopNameZh')">
|
||||
<VInput id="shop-name-zh" v-model="nameZh" required />
|
||||
</VField>
|
||||
<VField :label="$t('admin.shopSlug')">
|
||||
<VInput id="shop-slug" v-model="slug" required />
|
||||
</VField>
|
||||
</div>
|
||||
<p v-if="createError" class="error-text" role="alert">{{ createError }}</p>
|
||||
<button class="btn primary" type="button" :disabled="creating" @click="createShop">
|
||||
<p v-if="createError" class="my-2 text-sm text-danger" role="alert">{{ createError }}</p>
|
||||
<VBtn variant="primary" :disabled="creating" @click="createShop">
|
||||
{{ creating ? $t("common.loading") : $t("common.create") }}
|
||||
</button>
|
||||
</section>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<div v-else-if="shops.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<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>{{ shop.slug }}</code></td>
|
||||
<td><span class="badge" :class="statusClass(shop.status)">{{ $t(`admin.${shop.status}`) }}</span></td>
|
||||
<td>{{ formatDate(shop.created_at) }}</td>
|
||||
<td>
|
||||
<button class="btn sm" :class="{ danger: shop.status === 'active' }" type="button" :disabled="updatingId === shop.id" @click="setStatus(shop)">
|
||||
{{ updatingId === shop.id ? $t("common.loading") : $t(shop.status === "active" ? "admin.suspend" : "admin.activate") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-card { margin-bottom: 20px; }
|
||||
.create-card h2 { font-size: 16px; margin: 0 0 16px; }
|
||||
.form-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.table-wrap { overflow-x: auto; padding: 0; }
|
||||
.table { min-width: 720px; }
|
||||
code { background: #f0f2f5; border-radius: 4px; padding: 2px 5px; }
|
||||
@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
+63
-80
@@ -109,85 +109,68 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("nav.users") }}</h1>
|
||||
<span v-if="!loading" class="muted">{{ $t("admin.pageOf", { page, total: totalPages }) }}</span>
|
||||
<VPage :title="$t('nav.users')">
|
||||
<template #actions>
|
||||
<span v-if="!loading" class="text-sm text-muted">{{ $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>
|
||||
<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>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
|
||||
<div v-else-if="users.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<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>
|
||||
<span class="badge blue">{{ $t(`admin.roles.${user.role}`) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-editor">
|
||||
<select
|
||||
v-model="draftFor(user).role"
|
||||
: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"
|
||||
: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="muted">{{ shopName(user.shop_id) }}</span>
|
||||
</div>
|
||||
<p v-if="rowErrors[user.id]" class="error-text row-error" role="alert">{{ rowErrors[user.id] }}</p>
|
||||
</td>
|
||||
<td>{{ formatDate(user.created_at) }}</td>
|
||||
<td>
|
||||
<button class="btn sm primary" type="button" :disabled="savingId === user.id" @click="saveRole(user)">
|
||||
{{ savingId === user.id ? $t("common.loading") : $t("common.save") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!loading && !errorMessage && users.length > 0" class="pagination">
|
||||
<button class="btn sm" type="button" :disabled="page <= 1" @click="changePage(page - 1)">
|
||||
{{ $t("common.prev") }}
|
||||
</button>
|
||||
<span class="muted">{{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn sm" type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">
|
||||
{{ $t("common.next") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap { overflow-x: auto; padding: 0; }
|
||||
.table { min-width: 920px; }
|
||||
.role-editor { display: grid; gap: 6px; min-width: 190px; }
|
||||
.row-error { font-size: 12px; margin: 4px 0 0; }
|
||||
.pagination { align-items: center; display: flex; gap: 12px; justify-content: center; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user