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
+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>