feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds

This commit is contained in:
Chengdong Zhang
2026-09-17 12:43:22 +08:00
commit dc9fd31c5e
96 changed files with 17550 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import type { ApiClient } from "@vmall/shared";
declare module "#app" {
interface NuxtApp {
$api: ApiClient;
}
}
export {};
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
const session = useSessionStore();
const { locale, locales, setLocale } = useI18n();
const { currency } = usePrefs();
const { $api } = useNuxtApp();
onMounted(() => session.hydrate());
const currencies = ref<string[]>(["USD"]);
onMounted(async () => {
try {
const list = await $api.listCurrencies();
if (list.length > 0) currencies.value = list.map((c) => c.code);
} catch {
/* API may be down during SSR-less dev */
}
});
</script>
<template>
<div>
<header class="topnav">
<div class="container topnav-inner">
<NuxtLink to="/" class="brand">{{ $t("common.appName") }}</NuxtLink>
<nav>
<NuxtLink to="/">{{ $t("nav.home") }}</NuxtLink>
<NuxtLink to="/cart">{{ $t("nav.cart") }}</NuxtLink>
<NuxtLink to="/orders">{{ $t("nav.orders") }}</NuxtLink>
<NuxtLink to="/invoices">{{ $t("nav.invoices") }}</NuxtLink>
</nav>
<select
:value="currency"
:aria-label="$t('common.currency')"
@change="currency = ($event.target as HTMLSelectElement).value"
>
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>
<select
:value="locale"
: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>
<template v-if="session.isLoggedIn">
<span class="muted">{{ session.user?.display_name }}</span>
<button class="btn sm" @click="session.logout()">{{ $t("common.logout") }}</button>
</template>
<NuxtLink v-else to="/login" class="btn sm primary">{{ $t("common.login") }}</NuxtLink>
</div>
</header>
<main class="container page">
<NuxtPage />
</main>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
/** Shopper preferences: display currency, persisted in a cookie (SSR-safe). */
export function usePrefs() {
const currency = useCookie<string>("vmall.currency", { default: () => "USD" });
return { currency };
}
+8
View File
@@ -0,0 +1,8 @@
import { en, zh } from "@vmall/shared/locales";
export default defineI18nConfig(() => ({
legacy: false,
locale: "en",
fallbackLocale: "en",
messages: { en, zh },
}));
+22
View File
@@ -0,0 +1,22 @@
export default defineNuxtConfig({
modules: ["@pinia/nuxt", "@nuxtjs/i18n"],
css: ["@vmall/shared/ui.css"],
devtools: { enabled: false },
devServer: { port: 3000 },
runtimeConfig: {
public: {
apiBase: "http://localhost:8080/api",
appName: "mall",
},
},
i18n: {
strategy: "no_prefix",
defaultLocale: "en",
locales: [
{ code: "en", name: "English" },
{ code: "zh", name: "中文" },
],
vueI18n: "./i18n.config.ts",
},
compatibilityDate: "2025-01-01",
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@vmall/mall",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview"
},
"dependencies": {
"@nuxtjs/i18n": "^9.5.3",
"@pinia/nuxt": "^0.10.1",
"@vmall/shared": "workspace:*",
"nuxt": "^3.15.4",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
<template>
<div>
<h1 class="page-title">{{ $t("common.appName") }}</h1>
<p class="muted">{{ $t("common.loading") }}</p>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApi } from "@vmall/shared";
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const api = createApi({
baseUrl: config.public.apiBase as string,
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
});
return { provide: { api } };
});
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from "pinia";
import type { AuthTokens, User } from "@vmall/shared";
const USER_KEY = "vmall.user";
const TOKEN_KEY = "vmall.token";
function isUser(v: unknown): v is User {
if (!v || typeof v !== "object") return false;
const o = v as Record<string, unknown>;
return (
typeof o.id === "string" &&
typeof o.email === "string" &&
typeof o.display_name === "string" &&
typeof o.role === "string"
);
}
export function readStoredUser(): User | null {
if (!import.meta.client) return null;
try {
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isUser(parsed) ? parsed : null;
} catch {
return null;
}
}
interface SessionState {
user: User | null;
token: string | null;
}
export const useSessionStore = defineStore("session", {
state: (): SessionState => ({ user: null, token: null }),
getters: {
isLoggedIn: (s): boolean => s.token !== null,
},
actions: {
hydrate(): void {
if (!import.meta.client) return;
this.token = localStorage.getItem(TOKEN_KEY);
this.user = readStoredUser();
},
setAuth(auth: AuthTokens): void {
this.token = auth.token;
this.user = auth.user;
if (import.meta.client) {
localStorage.setItem(TOKEN_KEY, auth.token);
localStorage.setItem(USER_KEY, JSON.stringify(auth.user));
}
},
logout(): void {
this.token = null;
this.user = null;
if (import.meta.client) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
navigateTo("/login");
},
},
});