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
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { Cart, CartItem } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const { currency, convertAmount, formatAmount } = usePrice();
const cart = ref<Cart | null>(null);
const converted = ref<Record<string, number>>({});
const subtotalDisplay = ref("");
const loading = ref(true);
const error = ref("");
const updating = ref<string | null>(null);
const subtotalMinor = computed(() => (cart.value?.items ?? []).reduce((sum, item) => sum + (converted.value[item.sku_id] ?? 0) * item.qty, 0));
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function refreshPrices(): Promise<void> {
if (!cart.value) return;
const entries = await Promise.all(cart.value.items.map(async (item) => [item.sku_id, await convertAmount(item.unit_price_minor, item.currency)] as const));
converted.value = Object.fromEntries(entries);
subtotalDisplay.value = await formatAmount(subtotalMinor.value, currency.value);
}
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
cart.value = await $api.getCart();
await refreshPrices();
} catch (value) {
error.value = errorMessage(value);
cart.value = null;
} finally {
loading.value = false;
}
}
async function updateQuantity(item: CartItem, value: string | number): Promise<void> {
const qty = Math.trunc(Number(value));
if (!Number.isFinite(qty) || qty < 1) {
error.value = $t("mall.invalidQuantity");
return;
}
updating.value = item.sku_id;
error.value = "";
try {
await $api.updateCartItem(item.sku_id, qty);
await loadCart();
} catch (value) {
error.value = errorMessage(value);
} finally {
updating.value = null;
}
}
async function removeItem(item: CartItem): Promise<void> {
updating.value = item.sku_id;
error.value = "";
try {
await $api.removeCartItem(item.sku_id);
await loadCart();
} catch (value) {
error.value = errorMessage(value);
} finally {
updating.value = null;
}
}
watch(currency, () => void refreshPrices());
onMounted(() => void loadCart());
definePageMeta({ middleware: "auth" });
</script>
<template>
<section>
<h1 class="page-title">{{ $t("cart.title") }}</h1>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-if="!loading && cart && cart.items.length === 0" class="card empty-state">{{ $t("cart.emptyCart") }}</div>
<template v-if="cart && cart.items.length > 0">
<div class="card cart-list">
<div v-for="item in cart.items" :key="item.sku_id" class="cart-line">
<div class="cart-product">
<img v-if="item.image" :src="item.image" :alt="localized(item.product_name, locale)">
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
<div>
<NuxtLink :to="`/products/${item.product_id}`" class="product-name">{{ localized(item.product_name, locale) }}</NuxtLink>
<div class="muted">{{ item.sku_code }}</div>
</div>
</div>
<div class="unit-price"><PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" /></div>
<div class="quantity-field">
<label class="sr-only" :for="`qty-${item.sku_id}`">{{ $t("common.qty") }}</label>
<input :id="`qty-${item.sku_id}`" :value="item.qty" type="number" min="1" :disabled="updating === item.sku_id" @change="updateQuantity(item, ($event.target as HTMLInputElement).value)">
</div>
<div class="line-total"><PriceText :amount-minor="(converted[item.sku_id] ?? 0) * item.qty" :currency="currency" /></div>
<button class="btn danger sm" :disabled="updating === item.sku_id" @click="removeItem(item)">{{ $t("cart.remove") }}</button>
</div>
</div>
<div class="card summary row between">
<strong>{{ $t("cart.subtotal") }}</strong>
<strong class="total">{{ subtotalDisplay || $t("common.loading") }}</strong>
<NuxtLink class="btn primary" to="/checkout">{{ $t("cart.checkout") }}</NuxtLink>
</div>
</template>
</section>
</template>
<style scoped>
.cart-list { padding: 0; }
.cart-line { display: grid; grid-template-columns: minmax(240px, 1fr) 120px 100px 120px auto; gap: 14px; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--border); }
.cart-line:last-child { border-bottom: 0; }
.cart-product { display: flex; align-items: center; gap: 10px; min-width: 0; }
.cart-product img, .image-placeholder { width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius); background: #eef1f5; }
.image-placeholder { display: grid; place-items: center; color: var(--muted); font-size: 10px; text-align: center; }
.product-name { font-weight: 600; }
.quantity-field { margin: 0; }
.quantity-field input { width: 80px; }
.line-total, .unit-price, .total { color: var(--primary); font-weight: 600; }
.summary { margin-top: 16px; }
.empty-state { text-align: center; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 800px) {
.cart-line { grid-template-columns: 1fr auto auto; }
.unit-price, .line-total { text-align: right; }
.cart-product { grid-column: 1 / -1; }
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import type { Address, Cart, Order } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const { currency, convertAmount, formatAmount } = usePrice();
const cart = ref<Cart | null>(null);
const converted = ref<Record<string, number>>({});
const subtotalDisplay = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const placedOrders = ref<Order[] | null>(null);
const address = reactive<Address>({ recipient: "", phone: "", country: "", region: "", city: "", line1: "", postal_code: "" });
const subtotalMinor = computed(() => (cart.value?.items ?? []).reduce((sum, item) => sum + (converted.value[item.sku_id] ?? 0) * item.qty, 0));
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function refreshPrices(): Promise<void> {
if (!cart.value) return;
const entries = await Promise.all(cart.value.items.map(async (item) => [item.sku_id, await convertAmount(item.unit_price_minor, item.currency)] as const));
converted.value = Object.fromEntries(entries);
subtotalDisplay.value = await formatAmount(subtotalMinor.value, currency.value);
}
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
cart.value = await $api.getCart();
await refreshPrices();
} catch (value) {
error.value = errorMessage(value);
} finally {
loading.value = false;
}
}
function validateAddress(): boolean {
const required: (keyof Address)[] = ["recipient", "phone", "country", "city", "line1"];
return required.every((key) => address[key].trim().length > 0);
}
async function placeOrder(): Promise<void> {
if (!validateAddress()) {
error.value = $t("common.required");
return;
}
if (!cart.value || cart.value.items.length === 0) return;
submitting.value = true;
error.value = "";
try {
placedOrders.value = await $api.checkout({ ...address }, currency.value);
await $api.getCart();
} catch (value) {
error.value = errorMessage(value);
} finally {
submitting.value = false;
}
}
watch(currency, () => void refreshPrices());
onMounted(() => void loadCart());
definePageMeta({ middleware: "auth" });
</script>
<template>
<section>
<h1 class="page-title">{{ placedOrders ? $t("mall.orderSuccess") : $t("mall.checkoutTitle") }}</h1>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-if="placedOrders" class="card success-card">
<h2>{{ $t("mall.orderNumbers") }}</h2>
<ul>
<li v-for="order in placedOrders" :key="order.id"><NuxtLink :to="`/orders/${order.id}`">{{ order.order_no }}</NuxtLink></li>
</ul>
<div class="row">
<NuxtLink class="btn primary" to="/orders">{{ $t("mall.viewOrders") }}</NuxtLink>
<NuxtLink class="btn" to="/">{{ $t("mall.backToShop") }}</NuxtLink>
</div>
</div>
<template v-else-if="cart && cart.items.length > 0">
<div class="checkout-layout">
<form class="card" @submit.prevent="placeOrder">
<h2>{{ $t("order.shippingAddress") }}</h2>
<div class="field-row">
<div class="field"><label for="recipient">{{ $t("order.recipient") }}</label><input id="recipient" v-model="address.recipient" required></div>
<div class="field"><label for="phone">{{ $t("order.phone") }}</label><input id="phone" v-model="address.phone" required></div>
</div>
<div class="field-row">
<div class="field"><label for="country">{{ $t("order.country") }}</label><input id="country" v-model="address.country" required></div>
<div class="field"><label for="region">{{ $t("order.region") }} <span class="muted">({{ $t("mall.shippingOptional") }})</span></label><input id="region" v-model="address.region"></div>
</div>
<div class="field-row">
<div class="field"><label for="city">{{ $t("order.city") }}</label><input id="city" v-model="address.city" required></div>
<div class="field"><label for="postal-code">{{ $t("order.postalCode") }} <span class="muted">({{ $t("mall.shippingOptional") }})</span></label><input id="postal-code" v-model="address.postal_code"></div>
</div>
<div class="field"><label for="line1">{{ $t("order.line1") }}</label><input id="line1" v-model="address.line1" required></div>
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("mall.placeOrder") }}</button>
</form>
<aside class="card preview">
<h2>{{ $t("mall.orderPreview") }}</h2>
<div v-for="item in cart.items" :key="item.sku_id" class="preview-line">
<div><strong>{{ localized(item.product_name, locale) }}</strong><span class="muted">{{ item.sku_code }} × {{ item.qty }}</span></div>
<PriceText :amount-minor="(converted[item.sku_id] ?? 0) * item.qty" :currency="currency" />
</div>
<div class="preview-total row between"><strong>{{ $t("cart.subtotal") }}</strong><strong>{{ subtotalDisplay || $t("common.loading") }}</strong></div>
</aside>
</div>
</template>
<div v-else-if="!loading" class="card empty-state">
{{ $t("cart.emptyCart") }}
<NuxtLink class="btn mt" to="/">{{ $t("mall.backToShop") }}</NuxtLink>
</div>
</section>
</template>
<style scoped>
.checkout-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr); gap: 20px; align-items: start; }
.card h2 { font-size: 17px; margin: 0 0 16px; }
.preview-line { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.preview-line > div { display: grid; gap: 2px; }
.preview-total { padding-top: 14px; color: var(--primary); }
.success-card { max-width: 580px; }
.empty-state { text-align: center; }
@media (max-width: 760px) { .checkout-layout { grid-template-columns: 1fr; } }
</style>
+142 -4
View File
@@ -1,6 +1,144 @@
<script setup lang="ts">
import type { Category, Product, Paged, Sku } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const route = useRoute();
const router = useRouter();
const categories = ref<Category[]>([]);
const result = ref<Paged<Product> | null>(null);
const search = ref(typeof route.query.q === "string" ? route.query.q : "");
const categoryId = ref(typeof route.query.category_id === "string" ? route.query.category_id : "");
const page = ref(Number(route.query.page) > 0 ? Number(route.query.page) : 1);
const loading = ref(false);
const error = ref("");
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
function lowestSku(product: Product): Sku | null {
const active = (product.skus ?? []).filter((sku) => sku.active && sku.stock > 0);
return active.reduce<Sku | null>((lowest, sku) => {
if (!lowest || sku.price_minor < lowest.price_minor) return sku;
return lowest;
}, null) ?? (product.skus ?? []).find((sku) => sku.active) ?? null;
}
async function loadCategories(): Promise<void> {
try {
categories.value = await $api.listCategories();
} catch (value) {
error.value = errorMessage(value);
}
}
async function loadProducts(): Promise<void> {
loading.value = true;
error.value = "";
try {
result.value = await $api.listProducts({
page: page.value,
per_page: 12,
category_id: categoryId.value || undefined,
q: search.value.trim() || undefined,
});
} catch (value) {
error.value = errorMessage(value);
result.value = null;
} finally {
loading.value = false;
}
}
async function applyFilters(): Promise<void> {
page.value = 1;
await router.replace({ query: {
...(search.value.trim() ? { q: search.value.trim() } : {}),
...(categoryId.value ? { category_id: categoryId.value } : {}),
page: "1",
} });
await loadProducts();
}
async function changePage(nextPage: number): Promise<void> {
const pages = Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 12)));
if (nextPage < 1 || nextPage > pages) return;
page.value = nextPage;
await router.replace({ query: { ...route.query, page: String(nextPage) } });
await loadProducts();
}
onMounted(async () => {
await Promise.all([loadCategories(), loadProducts()]);
});
const totalPages = computed(() => Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 12))));
</script>
<template>
<div>
<h1 class="page-title">{{ $t("common.appName") }}</h1>
<p class="muted">{{ $t("common.loading") }}</p>
</div>
<section>
<div class="page-head">
<h1 class="page-title">{{ $t("mall.catalogTitle") }}</h1>
</div>
<form class="card filters" @submit.prevent="applyFilters">
<div class="field search-field">
<label for="product-search">{{ $t("common.search") }}</label>
<input id="product-search" v-model="search" type="search" :placeholder="$t('mall.searchPlaceholder')">
</div>
<div class="field category-field">
<label for="product-category">{{ $t("mall.filter") }}</label>
<select id="product-category" v-model="categoryId">
<option value="">{{ $t("mall.allCategories") }}</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ localized(category.name, locale) }}
</option>
</select>
</div>
<button class="btn primary filter-button" type="submit">{{ $t("common.search") }}</button>
</form>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<p v-if="loading" class="muted" aria-live="polite">{{ $t("common.loading") }}</p>
<div v-else-if="result && result.items.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
<div v-else-if="result" class="grid products">
<article v-for="product in result.items" :key="product.id" class="card product-card">
<NuxtLink :to="`/products/${product.id}`">
<img v-if="product.images[0]" :src="product.images[0]" :alt="localized(product.name, locale)">
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
</NuxtLink>
<div class="body">
<div class="name">{{ localized(product.name, locale) }}</div>
<div v-if="lowestSku(product)" class="price">
<PriceText :amount-minor="lowestSku(product)!.price_minor" :currency="lowestSku(product)!.currency" />
</div>
<div v-else class="muted">{{ $t("mall.unavailable") }}</div>
<NuxtLink class="btn sm mt" :to="`/products/${product.id}`">{{ $t("mall.viewDetails") }}</NuxtLink>
</div>
</article>
</div>
<div v-if="result && result.items.length > 0" class="row between pagination">
<button class="btn" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ totalPages }}</span>
<button class="btn" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
</div>
</section>
</template>
<style scoped>
.filters { display: flex; align-items: end; gap: 12px; margin-bottom: 20px; }
.filters .field { margin: 0; }
.search-field { flex: 1; }
.category-field { min-width: 210px; }
.filter-button { white-space: nowrap; }
.image-placeholder { aspect-ratio: 1; display: grid; place-items: center; background: #eef1f5; color: var(--muted); }
.pagination { margin-top: 20px; }
.empty-state { text-align: center; }
@media (max-width: 640px) {
.filters { align-items: stretch; flex-direction: column; }
.category-field { min-width: 0; }
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { Invoice } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const { formatAmount } = usePrice();
const invoices = ref<Invoice[]>([]);
const amounts = ref<Record<string, string>>({});
const loading = ref(true);
const error = ref("");
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function loadInvoices(): Promise<void> {
loading.value = true;
error.value = "";
try {
invoices.value = await $api.listMyInvoices();
const entries = await Promise.all(invoices.value.map(async (invoice) => [invoice.id, await formatAmount(invoice.amount_minor, invoice.currency)] as const));
amounts.value = Object.fromEntries(entries);
} catch (value) {
error.value = errorMessage(value);
invoices.value = [];
} finally {
loading.value = false;
}
}
watch(locale, () => {
if (invoices.value.length > 0) void loadInvoices();
});
onMounted(() => void loadInvoices());
definePageMeta({ middleware: "auth" });
</script>
<template>
<section>
<h1 class="page-title">{{ $t("invoice.title") }}</h1>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-if="!loading && invoices.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
<table v-if="invoices.length > 0" class="table">
<thead><tr><th>{{ $t("invoice.invoiceNo") }}</th><th>{{ $t("order.orderNo") }}</th><th>{{ $t("invoice.invoiceTitle") }}</th><th>{{ $t("invoice.kind") }}</th><th>{{ $t("invoice.amount") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("invoice.status.issued") }}</th></tr></thead>
<tbody>
<tr v-for="item in invoices" :key="item.id">
<td>{{ item.invoice_no || $t("mall.notAvailable") }}</td>
<td>{{ item.order_no || $t("mall.notAvailable") }}</td>
<td>{{ item.title }}</td>
<td>{{ $t(`invoice.${item.kind}`) }}</td>
<td>{{ amounts[item.id] || $t("common.loading") }}</td>
<td><StatusBadge :status="item.status" kind="invoice" /></td>
<td>{{ item.issued_at ? new Date(item.issued_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") : $t("mall.notAvailable") }}</td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.empty-state { text-align: center; }
@media (max-width: 760px) { .table { display: block; overflow-x: auto; white-space: nowrap; } }
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
const { $api } = useNuxtApp();
const session = useSessionStore();
const email = ref("");
const password = ref("");
const error = ref("");
const submitting = ref(false);
async function submit(): Promise<void> {
submitting.value = true;
error.value = "";
try {
const auth = await $api.login(email.value.trim(), password.value);
if (auth.user.role !== "customer") {
error.value = $t("auth.wrongRole");
return;
}
session.setAuth(auth);
await navigateTo("/");
} catch (value) {
error.value = value instanceof Error ? value.message : $t("auth.badCredentials");
} finally {
submitting.value = false;
}
}
</script>
<template>
<section class="form-narrow card">
<h1 class="page-title">{{ $t("auth.welcome") }}</h1>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<form @submit.prevent="submit">
<div class="field"><label for="login-email">{{ $t("common.email") }}</label><input id="login-email" v-model="email" type="email" autocomplete="email" required></div>
<div class="field"><label for="login-password">{{ $t("common.password") }}</label><input id="login-password" v-model="password" type="password" autocomplete="current-password" required></div>
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("common.login") }}</button>
</form>
<p class="muted mt">{{ $t("auth.noAccount") }} <NuxtLink to="/register">{{ $t("common.register") }}</NuxtLink></p>
</section>
</template>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { Invoice, InvoiceKind, Order, Shipment } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const route = useRoute();
const { locale } = useI18n();
const { $api } = useNuxtApp();
const { formatAmount } = usePrice();
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const invoice = ref<Invoice | null>(null);
const totalDisplay = ref("");
const loading = ref(true);
const error = ref("");
const action = ref("");
const invoiceKind = ref<InvoiceKind>("personal");
const invoiceTitle = ref("");
const taxNo = ref("");
const invoiceSubmitting = ref(false);
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function loadOrder(): Promise<void> {
loading.value = true;
error.value = "";
try {
const orderId = String(route.params.id);
const [loadedOrder, loadedShipments, loadedInvoices] = await Promise.all([
$api.getOrder(orderId),
$api.listMyShipments(),
$api.listMyInvoices(),
]);
order.value = loadedOrder;
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === loadedOrder.id);
invoice.value = loadedInvoices.find((item) => item.order_id === loadedOrder.id) ?? null;
totalDisplay.value = await formatAmount(loadedOrder.total_minor, loadedOrder.currency);
} catch (value) {
error.value = errorMessage(value);
order.value = null;
} finally {
loading.value = false;
}
}
async function runAction(name: "pay" | "cancel"): Promise<void> {
if (!order.value) return;
action.value = name;
error.value = "";
try {
if (name === "pay") await $api.payOrder(order.value.id);
else await $api.cancelOrder(order.value.id);
await loadOrder();
} catch (value) {
error.value = errorMessage(value);
} finally {
action.value = "";
}
}
async function confirmDelivery(shipment: Shipment): Promise<void> {
action.value = shipment.id;
error.value = "";
try {
await $api.confirmDelivered(shipment.id);
await loadOrder();
} catch (value) {
error.value = errorMessage(value);
} finally {
action.value = "";
}
}
async function requestInvoice(): Promise<void> {
if (!order.value || !invoiceTitle.value.trim()) {
error.value = $t("common.required");
return;
}
if (invoiceKind.value === "company" && !taxNo.value.trim()) {
error.value = $t("mall.companyTaxRequired");
return;
}
invoiceSubmitting.value = true;
error.value = "";
try {
await $api.requestInvoice(order.value.id, invoiceTitle.value.trim(), invoiceKind.value === "company" ? taxNo.value.trim() : null, invoiceKind.value);
await loadOrder();
} catch (value) {
error.value = errorMessage(value);
} finally {
invoiceSubmitting.value = false;
}
}
watch(locale, () => {
if (order.value) void loadOrder();
});
onMounted(() => void loadOrder());
definePageMeta({ middleware: "auth" });
</script>
<template>
<section>
<NuxtLink to="/orders" class="muted"> {{ $t("common.back") }}</NuxtLink>
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<template v-if="order">
<div class="page-head mt">
<h1 class="page-title">{{ order.order_no }}</h1>
<StatusBadge :status="order.status" kind="order" />
</div>
<div class="card order-summary">
<div><span class="muted">{{ $t("mall.created") }}</span><strong>{{ new Date(order.created_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") }}</strong></div>
<div><span class="muted">{{ $t("common.total") }}</span><strong class="total">{{ totalDisplay }}</strong></div>
<div v-if="order.status === 'pending_payment'" class="row">
<button class="btn primary" :disabled="action !== ''" @click="runAction('pay')">{{ action === "pay" ? $t("common.loading") : $t("order.pay") }}</button>
<button class="btn danger" :disabled="action !== ''" @click="runAction('cancel')">{{ action === "cancel" ? $t("common.loading") : $t("order.cancelOrder") }}</button>
</div>
</div>
<div class="card mt">
<h2>{{ $t("mall.cartItems") }}</h2>
<table class="table">
<thead><tr><th>{{ $t("product.detail") }}</th><th>{{ $t("common.price") }}</th><th>{{ $t("common.qty") }}</th><th>{{ $t("mall.lineTotal") }}</th></tr></thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td><div>{{ localized(item.product_name, locale) }}</div><span class="muted">{{ item.sku_code }}</span></td>
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
<td>{{ item.qty }}</td>
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
</tr>
</tbody>
</table>
</div>
<div class="card mt">
<h2>{{ $t("order.shippingAddress") }}</h2>
<div class="address-grid">
<div><span class="muted">{{ $t("order.recipient") }}</span>{{ order.shipping_address.recipient }}</div>
<div><span class="muted">{{ $t("order.phone") }}</span>{{ order.shipping_address.phone }}</div>
<div><span class="muted">{{ $t("order.country") }}</span>{{ order.shipping_address.country }}</div>
<div><span class="muted">{{ $t("order.region") }}</span>{{ order.shipping_address.region || $t("mall.notAvailable") }}</div>
<div><span class="muted">{{ $t("order.city") }}</span>{{ order.shipping_address.city }}</div>
<div><span class="muted">{{ $t("order.postalCode") }}</span>{{ order.shipping_address.postal_code }}</div>
<div class="wide"><span class="muted">{{ $t("order.line1") }}</span>{{ order.shipping_address.line1 }}</div>
</div>
</div>
<div class="card mt">
<h2>{{ $t("mall.shipments") }}</h2>
<div v-if="shipments.length === 0" class="muted">{{ $t("common.empty") }}</div>
<table v-else class="table">
<thead><tr><th>{{ $t("shipment.shipmentNo") }}</th><th>{{ $t("shipment.carrier") }}</th><th>{{ $t("shipment.trackingNo") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
<tbody>
<tr v-for="shipment in shipments" :key="shipment.id">
<td>{{ shipment.shipment_no }}</td><td>{{ shipment.carrier }}</td><td>{{ shipment.tracking_no }}</td><td><StatusBadge :status="shipment.status" kind="shipment" /></td>
<td><button v-if="shipment.status === 'shipped'" class="btn sm" :disabled="action !== ''" @click="confirmDelivery(shipment)">{{ $t("order.confirmDelivery") }}</button><span v-else class="muted">{{ $t("mall.notAvailable") }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="card mt">
<h2>{{ $t("mall.invoices") }}</h2>
<div v-if="invoice" class="invoice-existing row between">
<div><strong>{{ invoice.invoice_no || $t("mall.notAvailable") }}</strong><span class="muted">{{ invoice.title }} · {{ $t(`invoice.${invoice.kind}`) }}</span></div>
<StatusBadge :status="invoice.status" kind="invoice" />
</div>
<div v-else>
<p class="muted">{{ $t("mall.noInvoice") }}</p>
<form class="invoice-form" @submit.prevent="requestInvoice">
<div class="field"><label for="invoice-kind">{{ $t("invoice.kind") }}</label><select id="invoice-kind" v-model="invoiceKind"><option value="personal">{{ $t("invoice.personal") }}</option><option value="company">{{ $t("invoice.company") }}</option></select></div>
<div class="field"><label for="invoice-title">{{ $t("invoice.invoiceTitle") }}</label><input id="invoice-title" v-model="invoiceTitle" required></div>
<div v-if="invoiceKind === 'company'" class="field"><label for="tax-no">{{ $t("invoice.taxNo") }}</label><input id="tax-no" v-model="taxNo"></div>
<button class="btn primary" type="submit" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? $t("common.loading") : $t("order.requestInvoice") }}</button>
</form>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.order-summary { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
.order-summary > div:not(.row) { display: grid; gap: 3px; }
.total { color: var(--primary); font-size: 18px; }
.card h2 { font-size: 17px; margin: 0 0 14px; }
.address-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 20px; }
.address-grid > div { display: grid; gap: 2px; }
.address-grid .wide { grid-column: 1 / -1; }
.invoice-existing > div { display: grid; gap: 3px; }
.invoice-form { max-width: 420px; }
@media (max-width: 640px) { .address-grid { grid-template-columns: 1fr; } .address-grid .wide { grid-column: auto; } .table { display: block; overflow-x: auto; white-space: nowrap; } }
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { Order, Paged } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const { formatAmount } = usePrice();
const page = ref(1);
const result = ref<Paged<Order> | null>(null);
const totals = ref<Record<string, string>>({});
const loading = ref(true);
const error = ref("");
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function loadOrders(): Promise<void> {
loading.value = true;
error.value = "";
try {
result.value = await $api.listMyOrders(page.value);
const entries = await Promise.all(result.value.items.map(async (order) => [order.id, await formatAmount(order.total_minor, order.currency)] as const));
totals.value = Object.fromEntries(entries);
} catch (value) {
error.value = errorMessage(value);
result.value = null;
} finally {
loading.value = false;
}
}
async function changePage(next: number): Promise<void> {
if (next < 1 || next > totalPages.value) return;
page.value = next;
await loadOrders();
}
const totalPages = computed(() => Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 20))));
watch(locale, () => {
if (result.value) void loadOrders();
});
onMounted(() => void loadOrders());
</script>
<template>
<section>
<h1 class="page-title">{{ $t("order.title") }}</h1>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-if="!loading && result && result.items.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
<table v-if="result && result.items.length > 0" class="table">
<thead><tr><th>{{ $t("order.orderNo") }}</th><th>{{ $t("mall.created") }}</th><th>{{ $t("mall.items") }}</th><th>{{ $t("common.total") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
<tbody>
<tr v-for="order in result.items" :key="order.id">
<td>{{ order.order_no }}</td>
<td>{{ new Date(order.created_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") }}</td>
<td>{{ order.items.length }}</td>
<td>{{ totals[order.id] || $t("common.loading") }}</td>
<td><StatusBadge :status="order.status" kind="order" /></td>
<td><NuxtLink :to="`/orders/${order.id}`">{{ $t("mall.details") }}</NuxtLink></td>
</tr>
</tbody>
</table>
<div v-if="result && result.items.length > 0" class="row between pagination">
<button class="btn" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ totalPages }}</span>
<button class="btn" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
</div>
</section>
</template>
<style scoped>
.pagination { margin-top: 16px; }
.empty-state { text-align: center; }
@media (max-width: 700px) { .table { display: block; overflow-x: auto; white-space: nowrap; } }
</style>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { Product, Sku } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const route = useRoute();
const { locale } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const product = ref<Product | null>(null);
const selectedSkuId = ref("");
const quantity = ref(1);
const mainImage = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const skus = computed(() => (product.value?.skus ?? []).filter((sku) => sku.active));
const selectedSku = computed<Sku | null>(() => skus.value.find((sku) => sku.id === selectedSkuId.value) ?? skus.value[0] ?? null);
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function loadProduct(): Promise<void> {
loading.value = true;
error.value = "";
try {
product.value = await $api.getProduct(String(route.params.id));
mainImage.value = product.value.images[0] ?? "";
selectedSkuId.value = skus.value[0]?.id ?? "";
} catch (value) {
error.value = errorMessage(value);
} finally {
loading.value = false;
}
}
async function addToCart(): Promise<void> {
const sku = selectedSku.value;
if (!sku || sku.stock < 1) return;
if (!localStorage.getItem("vmall.token") || session.user?.role !== "customer") {
await navigateTo("/login");
return;
}
const qty = Math.max(1, Math.min(sku.stock, Math.trunc(quantity.value)));
submitting.value = true;
error.value = "";
try {
await $api.addCartItem(sku.id, qty);
await navigateTo("/cart");
} catch (value) {
error.value = errorMessage(value);
} finally {
submitting.value = false;
}
}
watch(selectedSku, (sku) => {
if (sku && quantity.value > sku.stock) quantity.value = Math.max(1, sku.stock);
});
onMounted(async () => {
session.hydrate();
await loadProduct();
});
</script>
<template>
<section>
<NuxtLink to="/" class="muted"> {{ $t("common.back") }}</NuxtLink>
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
<p v-else-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-else-if="product" class="detail-layout mt">
<div>
<div class="main-image">
<img v-if="mainImage" :src="mainImage" :alt="localized(product.name, locale)">
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
</div>
<div v-if="product.images.length > 1" class="thumbs">
<button v-for="image in product.images" :key="image" class="thumb" :class="{ selected: image === mainImage }" type="button" @click="mainImage = image">
<img :src="image" :alt="localized(product.name, locale)">
</button>
</div>
</div>
<div class="card product-info">
<h1 class="page-title">{{ localized(product.name, locale) }}</h1>
<p class="muted">{{ localized(product.description, locale) }}</p>
<div class="field">
<label>{{ $t("mall.chooseSku") }}</label>
<div v-if="skus.length" class="sku-list">
<label v-for="sku in skus" :key="sku.id" class="sku-option" :class="{ selected: sku.id === selectedSkuId }">
<input v-model="selectedSkuId" type="radio" name="sku" :value="sku.id" :disabled="sku.stock < 1">
<span>
<strong>{{ sku.sku_code }}</strong>
<span v-for="(value, key) in sku.attributes" :key="key" class="attribute">{{ key }}: {{ value }}</span>
</span>
<span class="sku-meta">
<PriceText :amount-minor="sku.price_minor" :currency="sku.currency" />
<span class="muted">{{ sku.stock }} {{ $t("mall.available") }}</span>
</span>
</label>
</div>
<p v-else class="muted">{{ $t("mall.unavailable") }}</p>
</div>
<div v-if="selectedSku" class="purchase-row">
<div class="field quantity-field">
<label for="product-quantity">{{ $t("mall.quantity") }}</label>
<input id="product-quantity" v-model.number="quantity" type="number" min="1" :max="selectedSku.stock">
</div>
<div class="selected-price"><PriceText :amount-minor="selectedSku.price_minor" :currency="selectedSku.currency" /></div>
</div>
<button class="btn primary" :disabled="submitting || !selectedSku || selectedSku.stock < 1" @click="addToCart">
{{ submitting ? $t("common.loading") : $t("product.addToCart") }}
</button>
</div>
</div>
</section>
</template>
<style scoped>
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
.main-image { overflow: hidden; border-radius: var(--radius); background: #eef1f5; }
.main-image img, .image-placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
.image-placeholder { display: grid; place-items: center; color: var(--muted); }
.thumbs { display: flex; gap: 8px; margin-top: 10px; }
.thumb { padding: 0; border: 2px solid transparent; background: none; cursor: pointer; border-radius: var(--radius); overflow: hidden; }
.thumb.selected { border-color: var(--primary); }
.thumb img { width: 64px; height: 64px; display: block; object-fit: cover; }
.product-info .page-title { margin-bottom: 8px; }
.sku-list { display: grid; gap: 8px; }
.sku-option { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; cursor: pointer; }
.sku-option.selected { border-color: var(--primary); background: #f7f9ff; }
.sku-option input { width: auto; margin-top: 3px; }
.attribute { display: inline-block; margin-left: 10px; color: var(--muted); font-size: 12px; }
.sku-meta { display: grid; justify-items: end; gap: 2px; color: var(--primary); font-weight: 600; }
.sku-meta .muted { font-weight: 400; font-size: 12px; }
.purchase-row { display: flex; align-items: end; gap: 16px; margin: 18px 0; }
.quantity-field { width: 120px; margin: 0; }
.selected-price { color: var(--primary); font-size: 20px; font-weight: 700; padding-bottom: 8px; }
@media (max-width: 760px) { .detail-layout { grid-template-columns: 1fr; } }
</style>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
const { $api } = useNuxtApp();
const session = useSessionStore();
const email = ref("");
const password = ref("");
const displayName = ref("");
const error = ref("");
const submitting = ref(false);
async function submit(): Promise<void> {
submitting.value = true;
error.value = "";
try {
const auth = await $api.register(email.value.trim(), password.value, displayName.value.trim());
if (auth.user.role !== "customer") {
error.value = $t("auth.wrongRole");
return;
}
session.setAuth(auth);
await navigateTo("/");
} catch (value) {
error.value = value instanceof Error ? value.message : $t("common.error");
} finally {
submitting.value = false;
}
}
</script>
<template>
<section class="form-narrow card">
<h1 class="page-title">{{ $t("auth.createAccount") }}</h1>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<form @submit.prevent="submit">
<div class="field"><label for="register-name">{{ $t("common.displayName") }}</label><input id="register-name" v-model="displayName" autocomplete="name" required></div>
<div class="field"><label for="register-email">{{ $t("common.email") }}</label><input id="register-email" v-model="email" type="email" autocomplete="email" required></div>
<div class="field"><label for="register-password">{{ $t("common.password") }}</label><input id="register-password" v-model="password" type="password" autocomplete="new-password" minlength="8" required></div>
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("common.register") }}</button>
</form>
<p class="muted mt">{{ $t("auth.haveAccount") }} <NuxtLink to="/login">{{ $t("common.login") }}</NuxtLink></p>
</section>
</template>