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