feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs

This commit is contained in:
Chengdong Zhang
2026-09-17 13:34:38 +08:00
parent dc9fd31c5e
commit 653f13161a
85 changed files with 4327 additions and 106 deletions
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
import type { Currency, CurrencyUpsertBody, LocalizedText } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
interface NewCurrencyForm {
code: string;
nameEn: string;
nameZh: string;
symbol: string;
exponent: number;
rateToBase: string;
enabled: boolean;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const currencies = ref<Currency[]>([]);
const rateDrafts = reactive<Record<string, string>>({});
const loading = ref(true);
const errorMessage = ref("");
const formError = ref("");
const savingCode = ref<string | null>(null);
const creating = ref(false);
const form = reactive<NewCurrencyForm>({
code: "",
nameEn: "",
nameZh: "",
symbol: "",
exponent: 2,
rateToBase: "1",
enabled: true,
});
function localizedName(name: LocalizedText): string {
return name[locale.value] ?? name.en ?? Object.values(name)[0] ?? "";
}
async function loadCurrencies(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
currencies.value = await $api.admin.listCurrencies();
for (const currency of currencies.value) {
rateDrafts[currency.code] = currency.rate_to_base;
}
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function saveRate(currency: Currency): Promise<void> {
// v-model on type=number inputs yields a number; the API contract wants a string.
const rate = String(rateDrafts[currency.code] ?? currency.rate_to_base);
savingCode.value = currency.code;
errorMessage.value = "";
try {
await $api.admin.setRate(currency.code, rate);
await loadCurrencies();
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
savingCode.value = null;
}
}
async function toggleEnabled(currency: Currency): Promise<void> {
savingCode.value = currency.code;
errorMessage.value = "";
try {
await upsert({
code: currency.code,
name: currency.name,
symbol: currency.symbol,
exponent: currency.exponent,
rate_to_base: currency.rate_to_base,
enabled: !currency.enabled,
});
await loadCurrencies();
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
savingCode.value = null;
}
}
async function upsert(body: CurrencyUpsertBody): Promise<void> {
await $api.admin.upsertCurrency(body);
}
async function createCurrency(): Promise<void> {
// type=number v-model yields a number at runtime; normalize before use.
const rateInput = String(form.rateToBase).trim();
formError.value = "";
const code = form.code.trim().toUpperCase();
if (!/^[A-Z]{3}$/.test(code)) {
formError.value = t("admin.codeInvalid");
return;
}
if (!form.nameEn.trim() || !form.nameZh.trim() || !form.symbol.trim() || !rateInput) {
formError.value = t("common.required");
return;
}
if (!Number.isInteger(form.exponent) || form.exponent < 0 || form.exponent > 6) {
formError.value = t("common.error");
return;
}
creating.value = true;
try {
await upsert({
code,
name: { en: form.nameEn.trim(), zh: form.nameZh.trim() },
symbol: form.symbol.trim(),
exponent: form.exponent,
rate_to_base: rateInput,
enabled: form.enabled,
});
form.code = "";
form.nameEn = "";
form.nameZh = "";
form.symbol = "";
form.exponent = 2;
form.rateToBase = "1";
form.enabled = true;
await loadCurrencies();
} catch (error: unknown) {
formError.value = error instanceof Error ? error.message : t("common.error");
} finally {
creating.value = false;
}
}
onMounted(() => {
void loadCurrencies();
});
</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>
</div>
<label class="checkbox-field">
<input v-model="form.enabled" 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">
{{ 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">
<thead>
<tr>
<th>{{ $t("common.currency") }}</th>
<th>{{ $t("admin.currencyNameEn") }}</th>
<th>{{ $t("admin.symbol") }}</th>
<th>{{ $t("admin.exponent") }}</th>
<th>{{ $t("admin.rate") }}</th>
<th>{{ $t("admin.baseCurrency") }}</th>
<th>{{ $t("admin.enabled") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="currency in currencies" :key="currency.code">
<td><strong>{{ currency.code }}</strong></td>
<td>{{ localizedName(currency.name) }}</td>
<td>{{ currency.symbol }}</td>
<td>{{ currency.exponent }}</td>
<td>
<input v-model="rateDrafts[currency.code]" class="rate-input" type="number" min="0" step="any" />
</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)">
{{ savingCode === currency.code ? $t("common.loading") : $t("admin.updateRate") }}
</button>
<button class="btn sm" type="button" :disabled="savingCode === currency.code" @click="toggleEnabled(currency)">
{{ currency.enabled ? $t("admin.suspend") : $t("admin.activate") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</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>
+155 -3
View File
@@ -1,6 +1,158 @@
<script setup lang="ts">
const { t } = useI18n();
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const loading = ref(true);
const errorMessage = ref("");
const stats = reactive({
users: 0,
shops: 0,
orders: 0,
currencies: 0,
});
async function loadDashboard(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [users, shops, orders, currencies] = await Promise.all([
$api.admin.listUsers(1),
$api.admin.listShops(),
$api.admin.listOrders(1),
$api.admin.listCurrencies(),
]);
stats.users = users.total;
stats.shops = shops.length;
stats.orders = orders.total;
stats.currencies = currencies.filter((currency) => currency.enabled).length;
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadDashboard();
});
const cards = computed(() => [
{ label: "admin.totalUsers", value: stats.users, link: "/users", tone: "blue" },
{ label: "admin.totalShops", value: stats.shops, link: "/shops", tone: "green" },
{ label: "admin.totalOrders", value: stats.orders, link: "/orders", tone: "orange" },
{ label: "admin.enabledCurrencies", value: stats.currencies, link: "/currencies", tone: "purple" },
]);
</script>
<template>
<div>
<h1 class="page-title">{{ $t("common.appName") }}</h1>
<p class="muted">{{ $t("common.loading") }}</p>
<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>
<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>
</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>
</NuxtLink>
</div>
</template>
</div>
</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>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { AuthTokens } from "@vmall/shared";
definePageMeta({ layout: false });
const { $api } = useNuxtApp();
const session = useSessionStore();
const { t } = useI18n();
const email = ref("admin@vmall.local");
const password = ref("admin1234");
const loading = ref(false);
const errorMessage = ref("");
async function submit(): Promise<void> {
if (!email.value.trim() || !password.value) {
errorMessage.value = t("common.required");
return;
}
loading.value = true;
errorMessage.value = "";
try {
const auth: AuthTokens = await $api.login(email.value.trim(), password.value);
if (auth.user.role !== "platform_admin") {
errorMessage.value = t("auth.wrongRole");
return;
}
session.setAuth(auth);
await navigateTo("/");
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
</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>
</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>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { formatMoney, t as localizedText } from "@vmall/shared";
import type { Currency, Order, Shop } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const orders = ref<Order[]>([]);
const shops = ref<Shop[]>([]);
const currencies = ref<Currency[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(20);
const loading = ref(true);
const errorMessage = ref("");
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
function shopName(shopId: string): string {
const shop = shops.value.find((item) => item.id === shopId);
return shop ? localizedText(shop.name, locale.value) : shopId;
}
function currencyExponent(code: string): number {
const currency = currencies.value.find((item) => item.code === code);
return currency?.exponent ?? 2;
}
function formatTotal(order: Order): string {
return formatMoney(order.total_minor, order.currency, currencyExponent(order.currency), locale.value);
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function statusClass(status: Order["status"]): string {
if (status === "completed") return "green";
if (status === "cancelled") return "red";
if (status === "pending_payment") return "orange";
return "blue";
}
async function loadOrders(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [paged, shopList, currencyList] = await Promise.all([
$api.admin.listOrders(page.value),
$api.admin.listShops(),
$api.admin.listCurrencies(),
]);
orders.value = paged.items;
total.value = paged.total;
perPage.value = paged.per_page;
shops.value = shopList;
currencies.value = currencyList;
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function changePage(nextPage: number): Promise<void> {
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
page.value = nextPage;
await loadOrders();
}
onMounted(() => {
void loadOrders();
});
</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>
</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>
</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>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import type { LocalizedText, Shop } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const shops = ref<Shop[]>([]);
const loading = ref(true);
const errorMessage = ref("");
const createError = ref("");
const creating = ref(false);
const updatingId = ref<string | null>(null);
const nameEn = ref("");
const nameZh = ref("");
const slug = ref("");
function localizedName(name: LocalizedText): string {
return name[locale.value] ?? name.en ?? Object.values(name)[0] ?? "";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function statusClass(status: Shop["status"]): string {
return status === "active" ? "green" : "red";
}
async function loadShops(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
shops.value = await $api.admin.listShops();
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function createShop(): Promise<void> {
createError.value = "";
if (!nameEn.value.trim() || !nameZh.value.trim() || !slug.value.trim()) {
createError.value = t("common.required");
return;
}
creating.value = true;
try {
await $api.admin.createShop(
{ en: nameEn.value.trim(), zh: nameZh.value.trim() },
slug.value.trim(),
);
nameEn.value = "";
nameZh.value = "";
slug.value = "";
await loadShops();
} catch (error: unknown) {
createError.value = error instanceof Error ? error.message : t("common.error");
} finally {
creating.value = false;
}
}
async function setStatus(shop: Shop): Promise<void> {
updatingId.value = shop.id;
errorMessage.value = "";
const nextStatus = shop.status === "active" ? "suspended" : "active";
try {
await $api.admin.setShopStatus(shop.id, nextStatus);
await loadShops();
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
updatingId.value = null;
}
}
onMounted(() => {
void loadShops();
});
</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>
</div>
<p v-if="createError" class="error-text" role="alert">{{ createError }}</p>
<button class="btn primary" type="button" :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>
</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>
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import type { Shop, User, UserRole } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
interface UserDraft {
role: UserRole;
shopId: string | null;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const users = ref<User[]>([]);
const shops = ref<Shop[]>([]);
const drafts = reactive<Record<string, UserDraft>>({});
const rowErrors = reactive<Record<string, string>>({});
const page = ref(1);
const total = ref(0);
const perPage = ref(20);
const loading = ref(true);
const errorMessage = ref("");
const savingId = ref<string | null>(null);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function draftFor(user: User): UserDraft {
const existing = drafts[user.id];
if (existing) return existing;
const draft: UserDraft = { role: user.role, shopId: user.shop_id };
drafts[user.id] = draft;
return draft;
}
function shopName(shopId: string | null): string {
if (!shopId) return t("admin.noAssignedShop");
const shop = shops.value.find((item) => item.id === shopId);
return shop ? tText(shop.name, locale.value) : t("admin.noAssignedShop");
}
function tText(text: Record<string, string>, currentLocale: string): string {
return text[currentLocale] ?? text.en ?? Object.values(text)[0] ?? "";
}
function roleNeedsShop(role: UserRole): boolean {
return role === "shop_owner" || role === "shop_staff";
}
function normalizeDraft(draft: UserDraft): void {
if (!roleNeedsShop(draft.role)) draft.shopId = null;
}
async function loadUsers(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [paged, shopList] = await Promise.all([
$api.admin.listUsers(page.value),
$api.admin.listShops(),
]);
users.value = paged.items;
total.value = paged.total;
perPage.value = paged.per_page;
shops.value = shopList;
for (const user of users.value) {
drafts[user.id] = { role: user.role, shopId: user.shop_id };
delete rowErrors[user.id];
}
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function saveRole(user: User): Promise<void> {
const draft = draftFor(user);
normalizeDraft(draft);
rowErrors[user.id] = "";
if (roleNeedsShop(draft.role) && !draft.shopId) {
rowErrors[user.id] = t("admin.shopRequired");
return;
}
savingId.value = user.id;
try {
await $api.admin.setUserRole(user.id, draft.role, draft.shopId);
await loadUsers();
} catch (error: unknown) {
rowErrors[user.id] = error instanceof Error ? error.message : t("common.error");
} finally {
savingId.value = null;
}
}
async function changePage(nextPage: number): Promise<void> {
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
page.value = nextPage;
await loadUsers();
}
onMounted(() => {
void loadUsers();
});
</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>
</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>
</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>