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:
Chengdong Zhang
2026-09-22 18:22:50 +08:00
parent 4d2ee3b0bf
commit 0d0e10b97b
101 changed files with 2833 additions and 7207 deletions
+39 -18
View File
@@ -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>
+5 -2
View File
@@ -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: {
+6 -1
View File
@@ -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"
}
}
+45 -66
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+40 -72
View File
@@ -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>
+55 -128
View File
@@ -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
View File
@@ -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
View File
@@ -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>