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
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { Category, Product, ProductUpsertBody } from "@vmall/shared";
const props = defineProps<{
product?: Product;
categories: Category[];
busy?: boolean;
}>();
const emit = defineEmits<{ submit: [body: ProductUpsertBody] }>();
const { t: translate, locale } = useI18n();
const slug = ref("");
const nameEn = ref("");
const nameZh = ref("");
const descriptionEn = ref("");
const descriptionZh = ref("");
const categoryId = ref("");
const images = ref("");
const validationError = ref("");
function resetFromProduct(product: Product | undefined): void {
slug.value = product?.slug ?? "";
nameEn.value = product?.name.en ?? "";
nameZh.value = product?.name.zh ?? "";
descriptionEn.value = product?.description.en ?? "";
descriptionZh.value = product?.description.zh ?? "";
categoryId.value = product?.category_id ?? "";
images.value = product?.images.join("\n") ?? "";
validationError.value = "";
}
watch(() => props.product, resetFromProduct, { immediate: true });
function submit(): void {
const cleanSlug = slug.value.trim();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(cleanSlug)) {
validationError.value = translate("shop.slugInvalid");
return;
}
if (!nameEn.value.trim()) {
validationError.value = translate("shop.productNameRequired");
return;
}
validationError.value = "";
const body: ProductUpsertBody = {
category_id: categoryId.value || null,
slug: cleanSlug,
name: { en: nameEn.value.trim(), zh: nameZh.value.trim() },
description: { en: descriptionEn.value.trim(), zh: descriptionZh.value.trim() },
images: images.value
.split("\n")
.map((image) => image.trim())
.filter((image) => image.length > 0),
};
emit("submit", body);
}
</script>
<template>
<form class="card form-narrow product-form" @submit.prevent="submit">
<div v-if="validationError" class="error-text" role="alert">{{ validationError }}</div>
<div class="field">
<label for="product-slug">{{ $t("product.slug") }}</label>
<input id="product-slug" v-model="slug" required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" />
</div>
<div class="field">
<label for="product-name-en">{{ $t("product.nameEn") }}</label>
<input id="product-name-en" v-model="nameEn" required />
</div>
<div class="field">
<label for="product-name-zh">{{ $t("product.nameZh") }}</label>
<input id="product-name-zh" v-model="nameZh" />
</div>
<div class="field">
<label for="product-description-en">{{ $t("product.descEn") }}</label>
<textarea id="product-description-en" v-model="descriptionEn" rows="4" />
</div>
<div class="field">
<label for="product-description-zh">{{ $t("product.descZh") }}</label>
<textarea id="product-description-zh" v-model="descriptionZh" rows="4" />
</div>
<div class="field">
<label for="product-category">{{ $t("product.category") }}</label>
<select id="product-category" v-model="categoryId">
<option value="">{{ $t("shop.categoryOptional") }}</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ localized(category.name, locale) }}
</option>
</select>
</div>
<div class="field">
<label for="product-images">{{ $t("product.images") }}</label>
<textarea id="product-images" v-model="images" rows="4" :placeholder="$t('shop.imagesHint')" />
</div>
<button class="btn primary" type="submit" :disabled="busy">
{{ busy ? $t("common.loading") : product ? $t("shop.updateProduct") : $t("shop.saveProduct") }}
</button>
</form>
</template>
<style scoped>
.product-form {
max-width: 720px;
margin: 0;
}
.product-form textarea {
resize: vertical;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { formatMoney } from "@vmall/shared";
import type { Currency } from "@vmall/shared";
/** Currency-aware money formatter: exponents come from the API, cached app-wide. */
export function useMoney() {
const { $api } = useNuxtApp();
const { locale } = useI18n();
const currencies = useState<Currency[]>("vmall.currencies", () => []);
async function load(): Promise<void> {
if (currencies.value.length > 0) return;
try {
currencies.value = await $api.listCurrencies();
} catch {
/* keep empty; fmt falls back to exponent 2 */
}
}
function fmt(amountMinor: number, currency: string): string {
const exponent = currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
return formatMoney(amountMinor, currency, exponent, locale.value);
}
return { currencies, load, fmt };
}
+18 -2
View File
@@ -1,8 +1,24 @@
import { en, zh } from "@vmall/shared/locales";
import { en as enBase, zh as zhBase } from "@vmall/shared/locales";
import { enExtra, zhExtra } from "./locales-extra";
function deepMerge<T extends Record<string, unknown>>(base: T, extra: T): T {
const out: Record<string, unknown> = { ...base };
for (const [k, v] of Object.entries(extra)) {
const b = out[k];
out[k] =
b && v && typeof b === "object" && typeof v === "object"
? deepMerge(b as Record<string, unknown>, v as Record<string, unknown>)
: v;
}
return out as T;
}
export default defineI18nConfig(() => ({
legacy: false,
locale: "en",
fallbackLocale: "en",
messages: { en, zh },
messages: {
en: deepMerge(enBase, enExtra),
zh: deepMerge(zhBase, zhExtra),
},
}));
+125
View File
@@ -0,0 +1,125 @@
// App-local message extensions, deep-merged over @vmall/shared locales.
// Add keys here (never edit the shared locale files from an app agent).
export const enExtra = {
auth: {
shopRoleError: "This account is not authorized for the merchant console.",
},
shop: {
dashboardTitle: "Merchant dashboard",
profile: "Shop profile",
shopStatus: "Shop status",
slug: "Slug",
totalProducts: "Total products",
publishedProducts: "Published products",
pendingPaymentOrders: "Pending payment",
paidOrders: "Paid orders",
fulfillingOrders: "Fulfilling orders",
shippedOrders: "Shipped orders",
pendingInvoices: "Pending invoice requests",
productList: "Product catalog",
orderList: "Orders",
shipmentList: "Shipments",
invoiceList: "Invoices",
filterStatus: "Filter by status",
filterOrderStatus: "Filter by order status",
created: "Created",
skuCount: "SKUs",
noSkus: "No SKUs",
newProduct: "New product",
saveProduct: "Save product",
updateProduct: "Update product",
productSaved: "Product saved.",
slugInvalid: "Use lowercase letters, numbers, and dashes only.",
productNameRequired: "English product name is required.",
categoryOptional: "No category",
imagesHint: "One URL per line",
skuManager: "SKU manager",
skuPriceMajor: "Price (major units)",
stock: "Stock",
active: "Active",
skuSaved: "SKU saved.",
actions: "Actions",
edit: "Edit",
invoiceNo: "Invoice no.",
noInvoice: "—",
status: "Status",
orderNo: "Order no.",
items: "Items",
total: "Total",
details: "Details",
address: "Shipping address",
recipient: "Recipient",
phone: "Phone",
carrier: "Carrier",
trackingNo: "Tracking number",
shipmentQuantities: "Shipment quantities",
unshipped: "Unshipped",
createShipment: "Create shipment",
shipmentCreated: "Shipment created.",
issueInvoice: "Issue invoice",
taxNo: "Tax number",
kind: "Type",
},
} as Record<string, unknown>;
export const zhExtra = {
auth: {
shopRoleError: "此账号无权访问商家控制台。",
},
shop: {
dashboardTitle: "商家仪表盘",
profile: "店铺信息",
shopStatus: "店铺状态",
slug: "别名",
totalProducts: "商品总数",
publishedProducts: "已上架商品",
pendingPaymentOrders: "待支付订单",
paidOrders: "已支付订单",
fulfillingOrders: "履约中订单",
shippedOrders: "已发货订单",
pendingInvoices: "待处理发票申请",
productList: "商品目录",
orderList: "订单",
shipmentList: "发货单",
invoiceList: "发票",
filterStatus: "按状态筛选",
filterOrderStatus: "按订单状态筛选",
created: "创建时间",
skuCount: "SKU 数量",
noSkus: "暂无 SKU",
newProduct: "新建商品",
saveProduct: "保存商品",
updateProduct: "更新商品",
productSaved: "商品已保存。",
slugInvalid: "只能使用小写字母、数字和短横线。",
productNameRequired: "英文商品名称为必填项。",
categoryOptional: "无分类",
imagesHint: "每行一个链接",
skuManager: "SKU 管理",
skuPriceMajor: "价格(主单位)",
stock: "库存",
active: "启用",
skuSaved: "SKU 已保存。",
actions: "操作",
edit: "编辑",
addSku: "添加 SKU",
status: "状态",
invoiceNo: "发票号",
noInvoice: "—",
items: "商品数",
total: "合计",
details: "详情",
address: "收货地址",
recipient: "收货人",
phone: "电话",
carrier: "承运商",
trackingNo: "物流单号",
shipmentQuantities: "发货数量",
unshipped: "待发货",
createShipment: "创建发货单",
shipmentCreated: "发货单已创建。",
issueInvoice: "开具发票",
taxNo: "税号",
kind: "类型",
},
} as Record<string, unknown>;
+22
View File
@@ -0,0 +1,22 @@
const allowedRoles: Record<string, true> = { shop_owner: true, shop_staff: true };
function hasAllowedRole(value: unknown): boolean {
if (!value || typeof value !== "object" || !("role" in value)) return false;
const role = value.role;
return typeof role === "string" && allowedRoles[role] === true;
}
export default defineNuxtRouteMiddleware(() => {
if (import.meta.server) return;
const token = localStorage.getItem("vmall.token");
const rawUser = localStorage.getItem("vmall.user");
let user: unknown = null;
try {
user = rawUser ? JSON.parse(rawUser) : null;
} catch {
user = null;
}
if (!token || !hasAllowedRole(user)) return navigateTo("/login");
});
+2 -1
View File
@@ -16,7 +16,8 @@ export default defineNuxtConfig({
{ code: "en", name: "English" },
{ code: "zh", name: "中文" },
],
vueI18n: "./i18n.config.ts",
vueI18n: "../i18n.config.ts",
bundle: { optimizeTranslationDirective: false },
},
compatibilityDate: "2025-01-01",
});
+124 -3
View File
@@ -1,6 +1,127 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { OrderStatus, Shop } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const loading = ref(true);
const error = ref("");
const shop = ref<Shop | null>(null);
const totalProducts = ref(0);
const publishedProducts = ref(0);
const orderCounts = ref<Record<OrderStatus, number>>({
pending_payment: 0,
paid: 0,
fulfilling: 0,
shipped: 0,
completed: 0,
cancelled: 0,
});
const pendingInvoices = ref(0);
const orderStatuses: OrderStatus[] = ["pending_payment", "paid", "fulfilling", "shipped"];
async function loadDashboard(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [products, published, shopData, invoices, ...orders] = await Promise.all([
$api.shop.listMyProducts({ per_page: 100 }),
$api.shop.listMyProducts({ per_page: 100, status: "published" }),
$api.shop.getMyShop(),
$api.shop.listInvoices(),
...orderStatuses.map((status) => $api.shop.listOrders({ status, per_page: 100 })),
]);
totalProducts.value = products.total;
publishedProducts.value = published.total;
shop.value = shopData;
pendingInvoices.value = invoices.filter((invoice) => invoice.status === "requested").length;
orderStatuses.forEach((status, index) => {
orderCounts.value[status] = orders[index]?.total ?? 0;
});
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
onMounted(loadDashboard);
const stats = computed(() => [
{ key: "totalProducts", label: "shop.totalProducts", value: totalProducts.value },
{ key: "publishedProducts", label: "shop.publishedProducts", value: publishedProducts.value },
{ key: "pendingPayment", label: "shop.pendingPaymentOrders", value: orderCounts.value.pending_payment },
{ key: "paid", label: "shop.paidOrders", value: orderCounts.value.paid },
{ key: "fulfilling", label: "shop.fulfillingOrders", value: orderCounts.value.fulfilling },
{ key: "shipped", label: "shop.shippedOrders", value: orderCounts.value.shipped },
{ key: "invoices", label: "shop.pendingInvoices", value: pendingInvoices.value },
]);
</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">
<h1 class="page-title">{{ $t("shop.dashboardTitle") }}</h1>
<button class="btn sm" :disabled="loading" @click="loadDashboard">{{ $t("common.search") }}</button>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<template v-else>
<div class="stats-grid">
<div v-for="stat in stats" :key="stat.key" class="card stat-card">
<span class="muted">{{ $t(stat.label) }}</span>
<strong>{{ stat.value }}</strong>
</div>
</div>
<section v-if="shop" class="card shop-profile mt">
<h2>{{ $t("shop.profile") }}</h2>
<div class="profile-grid">
<div>
<span class="muted">{{ $t("product.shop") }}</span>
<strong>{{ localized(shop.name, locale) }}</strong>
</div>
<div>
<span class="muted">{{ $t("shop.slug") }}</span>
<strong>{{ shop.slug }}</strong>
</div>
<div>
<span class="muted">{{ $t("shop.shopStatus") }}</span>
<span class="badge" :class="shop.status === 'active' ? 'green' : 'red'">
{{ $t(`admin.${shop.status}`) }}
</span>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
}
.stat-card {
display: grid;
gap: 8px;
}
.stat-card strong {
font-size: 28px;
}
.shop-profile h2 {
margin: 0 0 16px;
font-size: 18px;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.profile-grid > div {
display: grid;
gap: 6px;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { Invoice } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const { load: loadMoney, fmt } = useMoney();
const invoices = ref<Invoice[]>([]);
const loading = ref(true);
const error = ref("");
const actionId = ref("");
function statusClass(value: Invoice["status"]): string {
return value === "issued" ? "green" : value === "cancelled" ? "red" : "orange";
}
async function loadInvoices(): Promise<void> {
loading.value = true;
error.value = "";
try {
invoices.value = await $api.shop.listInvoices();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function issueInvoice(invoice: Invoice): Promise<void> {
actionId.value = invoice.id;
error.value = "";
try {
await $api.shop.issueInvoice(invoice.id);
await loadInvoices();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
onMounted(() => {
loadMoney();
loadInvoices();
});
</script>
<template>
<div class="page">
<h1 class="page-title">{{ $t("shop.invoiceList") }}</h1>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<div v-else-if="!invoices.length" class="card muted">{{ $t("common.empty") }}</div>
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ $t("shop.invoiceNo") }}</th>
<th>{{ $t("shop.orderNo") }}</th>
<th>{{ $t("invoice.invoiceTitle") }}</th>
<th>{{ $t("shop.kind") }}</th>
<th>{{ $t("shop.taxNo") }}</th>
<th>{{ $t("invoice.amount") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id">
<td>{{ invoice.invoice_no || $t("shop.noInvoice") }}</td>
<td><NuxtLink :to="`/orders/${invoice.order_id}`">{{ invoice.order_no ?? invoice.order_id }}</NuxtLink></td>
<td>{{ invoice.title }}</td>
<td>{{ $t(`invoice.${invoice.kind}`) }}</td>
<td>{{ invoice.tax_no || $t("shop.noInvoice") }}</td>
<td>{{ fmt(invoice.amount_minor, invoice.currency) }}</td>
<td><span class="badge" :class="statusClass(invoice.status)">{{ $t(`invoice.status.${invoice.status}`) }}</span></td>
<td><button v-if="invoice.status === 'requested'" class="btn sm primary" :disabled="actionId === invoice.id" @click="issueInvoice(invoice)">{{ $t("shop.issueInvoice") }}</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.table-wrap {
overflow-x: auto;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
const { $api } = useNuxtApp();
const session = useSessionStore();
const { t: translate } = useI18n();
const email = ref("");
const password = ref("");
const error = ref("");
const busy = ref(false);
async function submit(): Promise<void> {
error.value = "";
busy.value = true;
try {
const auth = await $api.login(email.value.trim(), password.value);
if (auth.user.role !== "shop_owner" && auth.user.role !== "shop_staff") {
error.value = translate("auth.shopRoleError");
return;
}
session.setAuth(auth);
await navigateTo("/");
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
busy.value = false;
}
}
</script>
<template>
<main class="page">
<form class="card form-narrow" @submit.prevent="submit">
<h1 class="page-title">{{ $t("auth.welcome") }}</h1>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<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>
<button class="btn primary" type="submit" :disabled="busy">
{{ busy ? $t("common.loading") : $t("common.login") }}
</button>
</form>
</main>
</template>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { Order, OrderStatus, Shipment, ShipmentItemBody } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const { load: loadMoney, fmt } = useMoney();
const route = useRoute();
const orderId = computed(() => String(route.params.id));
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const quantities = ref<Record<string, number>>({});
const carrier = ref("");
const trackingNo = ref("");
const loading = ref(true);
const busy = ref(false);
const actionId = ref("");
const error = ref("");
function statusClass(value: OrderStatus): string {
if (value === "completed") return "green";
if (value === "cancelled") return "red";
if (value === "pending_payment") return "orange";
return "blue";
}
function shipmentStatusClass(value: Shipment["status"]): string {
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function shippedQuantity(itemId: string): number {
return shipments.value
.filter((shipment) => shipment.order_id === orderId.value)
.flatMap((shipment) => shipment.items)
.filter((item) => item.order_item_id === itemId)
.reduce((total, item) => total + item.qty, 0);
}
function unshippedQuantity(itemId: string, total: number): number {
return Math.max(0, total - shippedQuantity(itemId));
}
async function findShopOrder(): Promise<Order> {
let currentPage = 1;
let totalPages = 1;
while (currentPage <= totalPages) {
const result = await $api.shop.listOrders({ page: currentPage });
const found = result.items.find((item) => item.id === orderId.value);
if (found) return found;
totalPages = Math.max(1, Math.ceil(result.total / result.per_page));
currentPage += 1;
}
throw new Error(translate("common.empty"));
}
async function loadOrder(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [loadedOrder, loadedShipments] = await Promise.all([
findShopOrder(),
$api.shop.listShipments(),
]);
order.value = loadedOrder;
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === orderId.value);
const nextQuantities: Record<string, number> = {};
loadedOrder.items.forEach((item) => {
nextQuantities[item.id] = Math.max(
0,
item.qty - loadedShipments
.filter((shipment) => shipment.order_id === orderId.value)
.flatMap((shipment) => shipment.items)
.filter((shipmentItem) => shipmentItem.order_item_id === item.id)
.reduce((total, shipmentItem) => total + shipmentItem.qty, 0),
);
});
quantities.value = nextQuantities;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function createShipment(): Promise<void> {
if (!order.value || !carrier.value.trim() || !trackingNo.value.trim()) {
error.value = translate("common.required");
return;
}
const items: ShipmentItemBody[] = order.value.items
.map((item) => ({ order_item_id: item.id, qty: quantities.value[item.id] ?? 0 }))
.filter((item) => item.qty > 0);
if (!items.length) {
error.value = translate("common.required");
return;
}
busy.value = true;
error.value = "";
try {
await $api.shop.createShipment(order.value.id, carrier.value.trim(), trackingNo.value.trim(), items);
carrier.value = "";
trackingNo.value = "";
await loadOrder();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
busy.value = false;
}
}
async function markShipped(shipment: Shipment): Promise<void> {
actionId.value = shipment.id;
error.value = "";
try {
await $api.shop.markShipped(shipment.id);
await loadOrder();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
onMounted(() => {
loadMoney();
loadOrder();
});
</script>
<template>
<div class="page">
<div class="page-head">
<h1 class="page-title">{{ order ? order.order_no : $t("order.title") }}</h1>
<NuxtLink class="btn" to="/orders">{{ $t("common.back") }}</NuxtLink>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<template v-else-if="order">
<section class="card">
<div class="row between detail-heading">
<h2>{{ $t("shop.details") }}</h2>
<span class="badge" :class="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</span>
</div>
<div class="order-meta">
<div><span class="muted">{{ $t("shop.created") }}</span><strong>{{ formatDate(order.created_at) }}</strong></div>
<div><span class="muted">{{ $t("shop.total") }}</span><strong>{{ fmt(order.total_minor, order.currency) }}</strong></div>
</div>
<h3>{{ $t("shop.items") }}</h3>
<div class="table-wrap">
<table class="table">
<thead><tr><th>{{ $t("product.detail") }}</th><th>{{ $t("product.skuCode") }}</th><th>{{ $t("common.qty") }}</th><th>{{ $t("common.price") }}</th></tr></thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td>{{ localized(item.product_name, locale) }}</td>
<td>{{ item.sku_code }}</td>
<td>{{ item.qty }}</td>
<td>{{ fmt(item.unit_price_minor, order.currency) }}</td>
</tr>
</tbody>
</table>
</div>
<h3>{{ $t("shop.address") }}</h3>
<address class="address">
<div>{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</div>
<div>{{ order.shipping_address.country }}, {{ order.shipping_address.region }}, {{ order.shipping_address.city }}</div>
<div>{{ order.shipping_address.line1 }}, {{ order.shipping_address.postal_code }}</div>
</address>
</section>
<section v-if="order.status === 'paid' || order.status === 'fulfilling'" class="card mt">
<h2>{{ $t("shop.createShipment") }}</h2>
<form @submit.prevent="createShipment">
<div class="field-row">
<div class="field"><label for="carrier">{{ $t("shop.carrier") }}</label><input id="carrier" v-model="carrier" required /></div>
<div class="field"><label for="tracking-no">{{ $t("shop.trackingNo") }}</label><input id="tracking-no" v-model="trackingNo" required /></div>
</div>
<h3>{{ $t("shop.shipmentQuantities") }}</h3>
<div v-for="item in order.items" :key="`quantity-${item.id}`" class="field-row quantity-row">
<div class="quantity-name">{{ localized(item.product_name, locale) }} ({{ item.sku_code }})</div>
<div class="field"><label :for="`quantity-${item.id}`">{{ $t("shop.unshipped") }}: {{ unshippedQuantity(item.id, item.qty) }}</label><input :id="`quantity-${item.id}`" v-model.number="quantities[item.id]" type="number" min="0" :max="unshippedQuantity(item.id, item.qty)" step="1" /></div>
</div>
<button class="btn primary" type="submit" :disabled="busy">{{ $t("shop.createShipment") }}</button>
</form>
</section>
<section class="card mt">
<h2>{{ $t("shipment.title") }}</h2>
<div v-if="!shipments.length" class="muted">{{ $t("common.empty") }}</div>
<div v-else class="table-wrap">
<table class="table">
<thead><tr><th>{{ $t("shipment.shipmentNo") }}</th><th>{{ $t("shop.carrier") }}</th><th>{{ $t("shop.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><span class="badge" :class="shipmentStatusClass(shipment.status)">{{ $t(`shipment.status.${shipment.status}`) }}</span></td>
<td><button v-if="shipment.status === 'pending'" class="btn sm" :disabled="actionId === shipment.id" @click="markShipped(shipment)">{{ $t("shipment.markShipped") }}</button></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.detail-heading h2,
.card h3 {
margin-top: 0;
}
.order-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.order-meta > div {
display: grid;
gap: 6px;
}
.address {
font-style: normal;
line-height: 1.7;
}
.table-wrap {
overflow-x: auto;
}
.quantity-row {
align-items: end;
}
.quantity-name {
flex: 2;
padding-bottom: 14px;
}
.quantity-row .field {
flex: 1;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { Order, OrderStatus, Paged } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const { load: loadMoney, fmt } = useMoney();
const orders = ref<Paged<Order> | null>(null);
const page = ref(1);
const status = ref<"" | OrderStatus>("");
const loading = ref(true);
const error = ref("");
function statusClass(value: OrderStatus): string {
if (value === "completed") return "green";
if (value === "cancelled") return "red";
if (value === "pending_payment") return "orange";
return "blue";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
async function loadOrders(): Promise<void> {
loading.value = true;
error.value = "";
try {
orders.value = await $api.shop.listOrders({ page: page.value, status: status.value || undefined });
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function filterChanged(): Promise<void> {
page.value = 1;
await loadOrders();
}
async function changePage(nextPage: number): Promise<void> {
if (!orders.value || nextPage < 1 || nextPage > Math.ceil(orders.value.total / orders.value.per_page)) return;
page.value = nextPage;
await loadOrders();
}
onMounted(() => {
loadMoney();
loadOrders();
});
</script>
<template>
<div class="page">
<div class="page-head"><h1 class="page-title">{{ $t("shop.orderList") }}</h1></div>
<div class="row mb">
<label for="order-status">{{ $t("shop.filterOrderStatus") }}</label>
<select id="order-status" v-model="status" @change="filterChanged">
<option value="">{{ $t("common.all") }}</option>
<option value="pending_payment">{{ $t("order.status.pending_payment") }}</option>
<option value="paid">{{ $t("order.status.paid") }}</option>
<option value="fulfilling">{{ $t("order.status.fulfilling") }}</option>
<option value="shipped">{{ $t("order.status.shipped") }}</option>
<option value="completed">{{ $t("order.status.completed") }}</option>
<option value="cancelled">{{ $t("order.status.cancelled") }}</option>
</select>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<div v-else-if="!orders?.items.length" class="card muted">{{ $t("common.empty") }}</div>
<template v-else>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ $t("shop.orderNo") }}</th>
<th>{{ $t("shop.created") }}</th>
<th>{{ $t("shop.items") }}</th>
<th>{{ $t("shop.total") }}</th>
<th>{{ $t("common.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders.items" :key="order.id">
<td><NuxtLink :to="`/orders/${order.id}`">{{ order.order_no }}</NuxtLink></td>
<td>{{ formatDate(order.created_at) }}</td>
<td>{{ order.items.length }}</td>
<td>{{ fmt(order.total_minor, order.currency) }}</td>
<td><span class="badge" :class="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</span></td>
</tr>
</tbody>
</table>
</div>
<div v-if="orders.total > orders.per_page" class="row between mt">
<button class="btn sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ Math.ceil(orders.total / orders.per_page) }}</span>
<button class="btn sm" :disabled="page >= Math.ceil(orders.total / orders.per_page) || loading" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
</div>
</template>
</div>
</template>
<style scoped>
.table-wrap {
overflow-x: auto;
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import type { Category, Product, ProductUpsertBody, Sku, SkuUpsertBody } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const { load: loadMoney, fmt } = useMoney();
const route = useRoute();
const productId = computed(() => String(route.params.id));
const product = ref<Product | null>(null);
const categories = ref<Category[]>([]);
const loading = ref(true);
const busy = ref(false);
const skuBusy = ref(false);
const error = ref("");
const skuCode = ref("");
const skuPrice = ref("");
const skuCurrency = ref("USD");
const skuStock = ref("0");
const skuActive = ref(true);
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function formatSkuPrice(sku: Sku): string {
return fmt(sku.price_minor, sku.currency);
}
function majorToMinor(value: string): number | null {
const clean = value.trim();
if (!/^\d+(?:\.\d{1,2})?$/.test(clean)) return null;
const [whole, fraction = ""] = clean.split(".");
const minor = Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
return Number.isSafeInteger(minor) ? minor : null;
}
async function loadProduct(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [loadedProduct, loadedCategories] = await Promise.all([
$api.shop.getProduct(productId.value),
$api.listCategories(),
]);
product.value = loadedProduct;
categories.value = loadedCategories;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function updateProduct(body: ProductUpsertBody): Promise<void> {
busy.value = true;
error.value = "";
try {
await $api.shop.updateProduct(productId.value, body);
product.value = await $api.shop.getProduct(productId.value);
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
busy.value = false;
}
}
async function saveSku(): Promise<void> {
const priceMinor = majorToMinor(skuPrice.value);
const stock = Number(skuStock.value);
if (!skuCode.value.trim() || priceMinor === null || !Number.isInteger(stock) || stock < 0) {
error.value = translate("common.required");
return;
}
skuBusy.value = true;
error.value = "";
const body: SkuUpsertBody = {
sku_code: skuCode.value.trim(),
price_minor: priceMinor,
currency: skuCurrency.value,
stock,
active: skuActive.value,
};
try {
await $api.shop.upsertSku(productId.value, body);
product.value = await $api.shop.getProduct(productId.value);
skuCode.value = "";
skuPrice.value = "";
skuCurrency.value = "USD";
skuStock.value = "0";
skuActive.value = true;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
skuBusy.value = false;
}
}
onMounted(() => {
loadMoney();
loadProduct();
});
</script>
<template>
<div class="page">
<div class="page-head">
<h1 class="page-title">{{ $t("product.editProduct") }}</h1>
<NuxtLink class="btn" to="/products">{{ $t("common.back") }}</NuxtLink>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<template v-else-if="product">
<ProductForm :product="product" :categories="categories" :busy="busy" @submit="updateProduct" />
<section class="card mt sku-section">
<h2>{{ $t("shop.skuManager") }}</h2>
<div v-if="!product.skus?.length" class="muted mb">{{ $t("shop.noSkus") }}</div>
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ $t("product.skuCode") }}</th>
<th>{{ $t("common.price") }}</th>
<th>{{ $t("common.currency") }}</th>
<th>{{ $t("shop.stock") }}</th>
<th>{{ $t("shop.active") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sku in product.skus" :key="sku.id">
<td>{{ sku.sku_code }}</td>
<td>{{ formatSkuPrice(sku) }}</td>
<td>{{ sku.currency }}</td>
<td>{{ sku.stock }}</td>
<td><span class="badge" :class="sku.active ? 'green' : 'red'">{{ sku.active ? $t("common.yes") : $t("common.no") }}</span></td>
</tr>
</tbody>
</table>
</div>
<form class="sku-form mt" @submit.prevent="saveSku">
<h3>{{ $t("shop.addSku") }}</h3>
<div class="field-row">
<div class="field">
<label for="sku-code">{{ $t("product.skuCode") }}</label>
<input id="sku-code" v-model="skuCode" required />
</div>
<div class="field">
<label for="sku-price">{{ $t("shop.skuPriceMajor") }}</label>
<input id="sku-price" v-model="skuPrice" inputmode="decimal" required />
</div>
</div>
<div class="field-row">
<div class="field">
<label for="sku-currency">{{ $t("common.currency") }}</label>
<select id="sku-currency" v-model="skuCurrency">
<option value="USD">USD</option>
<option value="CNY">CNY</option>
<option value="EUR">EUR</option>
</select>
</div>
<div class="field">
<label for="sku-stock">{{ $t("shop.stock") }}</label>
<input id="sku-stock" v-model="skuStock" type="number" min="0" step="1" required />
</div>
</div>
<label class="checkbox-field"><input v-model="skuActive" type="checkbox" /> {{ $t("shop.active") }}</label>
<button class="btn primary" type="submit" :disabled="skuBusy">{{ $t("shop.addSku") }}</button>
</form>
</section>
</template>
</div>
</template>
<style scoped>
.sku-section {
max-width: 960px;
}
.sku-section h2,
.sku-form h3 {
margin-top: 0;
}
.table-wrap {
overflow-x: auto;
}
.sku-form {
max-width: 720px;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.checkbox-field input {
width: auto;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { Paged, Product, ProductStatus } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const products = ref<Paged<Product> | null>(null);
const page = ref(1);
const status = ref<"" | ProductStatus>("");
const loading = ref(true);
const error = ref("");
const actionId = ref("");
function statusClass(value: ProductStatus): string {
return value === "published" ? "green" : value === "unpublished" ? "orange" : "blue";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
async function loadProducts(): Promise<void> {
loading.value = true;
error.value = "";
try {
products.value = await $api.shop.listMyProducts({ page: page.value, status: status.value || undefined });
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function filterChanged(): Promise<void> {
page.value = 1;
await loadProducts();
}
async function togglePublished(product: Product): Promise<void> {
actionId.value = product.id;
error.value = "";
try {
if (product.status === "published") await $api.shop.unpublish(product.id);
else await $api.shop.publish(product.id);
await loadProducts();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
async function changePage(nextPage: number): Promise<void> {
if (!products.value || nextPage < 1 || nextPage > Math.ceil(products.value.total / products.value.per_page)) return;
page.value = nextPage;
await loadProducts();
}
onMounted(loadProducts);
</script>
<template>
<div class="page">
<div class="page-head">
<h1 class="page-title">{{ $t("shop.productList") }}</h1>
<NuxtLink class="btn primary" to="/products/new">{{ $t("shop.newProduct") }}</NuxtLink>
</div>
<div class="row mb">
<label for="product-status">{{ $t("shop.filterStatus") }}</label>
<select id="product-status" v-model="status" @change="filterChanged">
<option value="">{{ $t("common.all") }}</option>
<option value="draft">{{ $t("product.draft") }}</option>
<option value="published">{{ $t("product.published") }}</option>
<option value="unpublished">{{ $t("product.unpublished") }}</option>
</select>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<div v-else-if="!products?.items.length" class="card muted">{{ $t("common.empty") }}</div>
<template v-else>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ $t("product.detail") }}</th>
<th>{{ $t("product.slug") }}</th>
<th>{{ $t("shop.skuCount") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("shop.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products.items" :key="product.id">
<td>{{ localized(product.name, locale) }}</td>
<td>{{ product.slug }}</td>
<td>{{ product.skus?.length ?? 0 }}</td>
<td><span class="badge" :class="statusClass(product.status)">{{ $t(`product.${product.status}`) }}</span></td>
<td>{{ formatDate(product.created_at) }}</td>
<td class="row actions-cell">
<NuxtLink class="btn sm" :to="`/products/${product.id}`">{{ $t("shop.edit") }}</NuxtLink>
<button
v-if="product.status === 'published'"
class="btn sm"
:disabled="actionId === product.id"
@click="togglePublished(product)"
>{{ $t("product.unpublish") }}</button>
<button
v-else
class="btn sm primary"
:disabled="actionId === product.id"
@click="togglePublished(product)"
>{{ $t("product.publish") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="products.total > products.per_page" class="row between mt">
<button class="btn sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ Math.ceil(products.total / products.per_page) }}</span>
<button class="btn sm" :disabled="page >= Math.ceil(products.total / products.per_page) || loading" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
</div>
</template>
</div>
</template>
<style scoped>
.table-wrap {
overflow-x: auto;
}
.actions-cell {
flex-wrap: wrap;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Category, ProductUpsertBody } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { t: translate } = useI18n();
const categories = ref<Category[]>([]);
const loading = ref(true);
const busy = ref(false);
const error = ref("");
async function loadCategories(): Promise<void> {
try {
categories.value = await $api.listCategories();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function saveProduct(body: ProductUpsertBody): Promise<void> {
busy.value = true;
error.value = "";
try {
const product = await $api.shop.createProduct(body);
await navigateTo(`/products/${product.id}`);
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
busy.value = false;
}
}
onMounted(loadCategories);
</script>
<template>
<div class="page">
<div class="page-head">
<h1 class="page-title">{{ $t("product.newProduct") }}</h1>
<NuxtLink class="btn" to="/products">{{ $t("common.back") }}</NuxtLink>
</div>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<ProductForm v-else :categories="categories" :busy="busy" @submit="saveProduct" />
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { Shipment } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const shipments = ref<Shipment[]>([]);
const loading = ref(true);
const error = ref("");
const actionId = ref("");
function statusClass(value: Shipment["status"]): string {
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
async function loadShipments(): Promise<void> {
loading.value = true;
error.value = "";
try {
shipments.value = await $api.shop.listShipments();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function markShipped(shipment: Shipment): Promise<void> {
actionId.value = shipment.id;
error.value = "";
try {
await $api.shop.markShipped(shipment.id);
await loadShipments();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
onMounted(loadShipments);
</script>
<template>
<div class="page">
<h1 class="page-title">{{ $t("shop.shipmentList") }}</h1>
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<div v-else-if="!shipments.length" class="card muted">{{ $t("common.empty") }}</div>
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ $t("shipment.shipmentNo") }}</th>
<th>{{ $t("shop.orderNo") }}</th>
<th>{{ $t("shop.carrier") }}</th>
<th>{{ $t("shop.trackingNo") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("shop.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="shipment in shipments" :key="shipment.id">
<td>{{ shipment.shipment_no }}</td>
<td><NuxtLink :to="`/orders/${shipment.order_id}`">{{ shipment.order_no ?? shipment.order_id }}</NuxtLink></td>
<td>{{ shipment.carrier }}</td>
<td>{{ shipment.tracking_no }}</td>
<td><span class="badge" :class="statusClass(shipment.status)">{{ $t(`shipment.status.${shipment.status}`) }}</span></td>
<td>{{ formatDate(shipment.created_at) }}</td>
<td><button v-if="shipment.status === 'pending'" class="btn sm" :disabled="actionId === shipment.id" @click="markShipped(shipment)">{{ $t("shipment.markShipped") }}</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.table-wrap {
overflow-x: auto;
}
</style>