feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user