feat(mall): classic B2B2C PC storefront with fixed mock API layer

- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted
  cart/orders/invoices), runtime switch via mockApi flag (default on)
- Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs,
  stores, brands, banners, floors, seckill/collective/integral, comments,
  coupons, addresses, seeded orders/shipments/invoices
- B2B2C mall shell: top bar, logo/search/cart header, dark nav + category
  mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme
- UI primitives replacing element-plus: carousel, pagination, breadcrumb,
  qty stepper, rating stars, modal, tabs, step bar, product card
- Pages: home floors, search (filters/sort/paging), goods detail (SKU
  picker, store rail, review tabs), cart -> checkout -> pay -> success,
  auth pages, user center (dashboard/orders/addresses/favorites/coupons/
  invoices), stores, seckill, collective, integral
- i18n split into per-domain locale modules (en+zh)
- OpenSpec change mall-pc-storefront-replica archived; all specs green
This commit is contained in:
Chengdong Zhang
2026-09-17 19:13:29 +08:00
parent 997c312cda
commit 21e99bb52b
111 changed files with 9458 additions and 1035 deletions
+223
View File
@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { MockAddress } from "~/mock/data";
import { MOCK_ADDRESSES } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
const rows = ref<MockAddress[]>(MOCK_ADDRESSES.map((address) => ({ ...address })));
const open = ref(false);
const editingId = ref<string | null>(null);
const form = reactive<MockAddress>({
id: "",
recipient: "",
phone: "",
region: "",
city: "",
line1: "",
postalCode: "",
isDefault: false,
});
const errorKey = ref("");
const modalTitle = computed(() => (editingId.value ? t("user.editAddress") : t("user.addAddress")));
function resetForm(): void {
form.id = "";
form.recipient = "";
form.phone = "";
form.region = "";
form.city = "";
form.line1 = "";
form.postalCode = "";
form.isDefault = false;
errorKey.value = "";
}
function openAdd(): void {
editingId.value = null;
resetForm();
open.value = true;
}
function openEdit(row: MockAddress): void {
editingId.value = row.id;
form.id = row.id;
form.recipient = row.recipient;
form.phone = row.phone;
form.region = row.region;
form.city = row.city;
form.line1 = row.line1;
form.postalCode = row.postalCode;
form.isDefault = row.isDefault;
errorKey.value = "";
open.value = true;
}
function setDefault(id: string): void {
rows.value = rows.value.map((row) => ({ ...row, isDefault: row.id === id }));
}
function save(): void {
if (!form.recipient || !form.phone || !form.region || !form.city || !form.line1 || !form.postalCode) {
errorKey.value = "user.validationRequired";
return;
}
if (editingId.value) {
rows.value = rows.value.map((row) =>
row.id === editingId.value
? {
...row,
recipient: form.recipient,
phone: form.phone,
region: form.region,
city: form.city,
line1: form.line1,
postalCode: form.postalCode,
isDefault: form.isDefault,
}
: form.isDefault
? { ...row, isDefault: false }
: row,
);
} else {
const nextId = `a-local-${Date.now()}`;
const next: MockAddress = {
id: nextId,
recipient: form.recipient,
phone: form.phone,
region: form.region,
city: form.city,
line1: form.line1,
postalCode: form.postalCode,
isDefault: form.isDefault,
};
rows.value = [next, ...rows.value.map((row) => ({ ...row, isDefault: form.isDefault ? false : row.isDefault }))];
}
open.value = false;
}
</script>
<template>
<section class="mpanel addresses-panel">
<h1 class="mpanel-title">
{{ t("user.addresses") }}
<button class="mbtn red" type="button" @click="openAdd">{{ t("user.addAddress") }}</button>
</h1>
<UiEmptyState v-if="rows.length === 0" :text="t('user.noAddresses')" />
<table v-else class="mtable">
<thead>
<tr>
<th>{{ t("user.recipient") }}</th>
<th>{{ t("user.phone") }}</th>
<th>{{ t("user.region") }}</th>
<th>{{ t("user.city") }}</th>
<th>{{ t("user.line1") }}</th>
<th>{{ t("user.postalCode") }}</th>
<th>{{ t("common.status") }}</th>
<th>{{ t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.recipient }}</td>
<td>{{ row.phone }}</td>
<td>{{ row.region }}</td>
<td>{{ row.city }}</td>
<td>{{ row.line1 }}</td>
<td>{{ row.postalCode }}</td>
<td>
<span v-if="row.isDefault" class="default-tag">{{ t("user.defaultAddress") }}</span>
<button v-else class="mbtn" type="button" @click="setDefault(row.id)">{{ t("user.setDefault") }}</button>
</td>
<td>
<button class="mbtn" type="button" @click="openEdit(row)">{{ t("common.edit") }}</button>
</td>
</tr>
</tbody>
</table>
<UiModal :open="open" :title="modalTitle" @close="open = false">
<div class="form-grid">
<label>
<span>{{ t("user.recipient") }}</span>
<input v-model.trim="form.recipient" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.phone") }}</span>
<input v-model.trim="form.phone" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.region") }}</span>
<input v-model.trim="form.region" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.city") }}</span>
<input v-model.trim="form.city" class="minput" type="text" />
</label>
<label class="full-line">
<span>{{ t("user.line1") }}</span>
<input v-model.trim="form.line1" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.postalCode") }}</span>
<input v-model.trim="form.postalCode" class="minput" type="text" />
</label>
<label class="checkbox">
<input v-model="form.isDefault" type="checkbox" />
<span>{{ t("user.defaultAddress") }}</span>
</label>
</div>
<p v-if="errorKey" class="error">{{ t(errorKey) }}</p>
<template #footer>
<button class="mbtn" type="button" @click="open = false">{{ t("common.cancel") }}</button>
<button class="mbtn red" type="button" @click="save">{{ t("user.saveAddress") }}</button>
</template>
</UiModal>
</section>
</template>
<style scoped>
.addresses-panel {
min-height: 560px;
}
.default-tag {
color: var(--mall-red);
font-size: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-grid label > span {
display: block;
margin-bottom: 6px;
color: var(--mall-muted);
font-size: 12px;
}
.full-line {
grid-column: 1 / -1;
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox > span {
margin: 0;
}
.error {
margin-top: 8px;
color: var(--mall-red);
font-size: 12px;
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { MOCK_COUPONS } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
</script>
<template>
<section class="mpanel coupons-panel">
<h1 class="mpanel-title">{{ t("user.couponsTitle") }}</h1>
<UiEmptyState v-if="MOCK_COUPONS.length === 0" :text="t('user.noCoupons')" />
<table v-else class="mtable">
<thead>
<tr>
<th>{{ t("user.couponTitle") }}</th>
<th>{{ t("user.amount") }}</th>
<th>{{ t("user.threshold") }}</th>
<th>{{ t("user.expiresAt") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="coupon in MOCK_COUPONS" :key="coupon.id">
<td>{{ pick(coupon.title, locale) }}</td>
<td><PriceText :amount-minor="coupon.amountMinor" :currency="coupon.currency" /></td>
<td><PriceText :amount-minor="coupon.thresholdMinor" :currency="coupon.currency" /></td>
<td>{{ coupon.expiresAt }}</td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.coupons-panel {
min-height: 560px;
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, lowestSku, productById, storeById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const activeTab = ref<"product" | "store">("product");
const favorites = ref(MOCK_FAVORITES.map((item) => ({ ...item })));
const tabs = computed(() => [
{ key: "product", label: t("user.favoriteProductsTab") },
{ key: "store", label: t("user.favoriteStoresTab") },
]);
const productRows = computed(() =>
favorites.value
.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
);
const storeRows = computed(() =>
favorites.value
.filter((favorite) => favorite.kind === "store")
.map((favorite) => storeById(favorite.refId))
.filter((store): store is NonNullable<ReturnType<typeof storeById>> => store !== null),
);
function removeProductFavorite(productId: string): void {
favorites.value = favorites.value.filter((item) => !(item.kind === "product" && item.refId === productId));
}
function removeStoreFavorite(storeId: string): void {
favorites.value = favorites.value.filter((item) => !(item.kind === "store" && item.refId === storeId));
}
</script>
<template>
<section class="mpanel favorites-panel">
<h1 class="mpanel-title">{{ t("user.favoritesTitle") }}</h1>
<UiTabs v-model="activeTab" :tabs="tabs">
<template #default>
<div v-if="activeTab === 'product'">
<UiEmptyState v-if="productRows.length === 0" :text="t('user.noFavorites')" />
<div v-else class="product-grid">
<article v-for="item in productRows" :key="item.product.id" class="product-card hover-lift">
<NuxtLink :to="`/goods/${item.product.slug}`" class="cover">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
</NuxtLink>
<div class="content">
<NuxtLink :to="`/goods/${item.product.slug}`" class="name">{{ pick(item.product.name, locale) }}</NuxtLink>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
<button class="mbtn" type="button" @click="removeProductFavorite(item.product.id)">{{ t("user.removeFavorite") }}</button>
</div>
</article>
</div>
</div>
<div v-else>
<UiEmptyState v-if="storeRows.length === 0" :text="t('user.noFavoriteStores')" />
<div v-else class="store-list">
<article v-for="store in storeRows" :key="store.id" class="store-row">
<img :src="store.logo" :alt="pick(store.name, locale)" />
<div class="store-main">
<strong>{{ pick(store.name, locale) }}</strong>
<span>{{ store.company }}</span>
</div>
<div class="store-actions">
<NuxtLink class="mbtn" :to="`/stores/${store.slug}`">{{ t("user.enterStore") }}</NuxtLink>
<button class="mbtn" type="button" @click="removeStoreFavorite(store.id)">{{ t("user.removeFavorite") }}</button>
</div>
</article>
</div>
</div>
</template>
</UiTabs>
</section>
</template>
<style scoped>
.favorites-panel {
min-height: 560px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.product-card {
border: 1px solid var(--mall-line);
background: #fff;
}
.cover {
display: block;
border-bottom: 1px solid var(--mall-line);
}
.cover img {
display: block;
width: 100%;
height: 150px;
object-fit: contain;
}
.content {
padding: 10px;
}
.name {
display: block;
margin-bottom: 8px;
color: var(--mall-ink);
text-decoration: none;
font-size: 13px;
line-height: 1.4;
height: 36px;
overflow: hidden;
}
.name:hover {
color: var(--mall-red);
}
.content :deep(.price) {
display: block;
margin-bottom: 10px;
color: var(--mall-red);
font-size: 16px;
font-weight: 600;
}
.store-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
border: 1px solid var(--mall-line);
margin-bottom: 10px;
}
.store-row:last-child {
margin-bottom: 0;
}
.store-row img {
width: 56px;
height: 56px;
border: 1px solid var(--mall-line);
border-radius: 4px;
object-fit: cover;
}
.store-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.store-main strong {
color: var(--mall-ink);
font-size: 14px;
}
.store-main span {
color: var(--mall-faint);
font-size: 12px;
}
.store-actions {
display: flex;
gap: 8px;
}
@media (max-width: 760px) {
.product-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.store-row {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<script setup lang="ts">
import type { Order, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_FAVORITES, USER_STATS, lowestSku, productById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const orders = ref<Order[]>([]);
const loading = ref(true);
async function loadOrders(): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders();
orders.value = result.items;
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadOrders();
});
const counts = computed(() => ({
pendingPayment: orders.value.filter((o) => o.status === "pending_payment").length,
pendingShipment: orders.value.filter((o) => o.status === "paid").length,
pendingReceipt: orders.value.filter((o) => o.status === "shipped").length,
completed: orders.value.filter((o) => o.status === "completed").length,
afterSale: 0,
}));
const statusLinks = computed(() => [
{ key: "pendingPayment", label: t("user.pendingPayment"), count: counts.value.pendingPayment, to: "/user/orders?status=pending_payment" },
{ key: "pendingShipment", label: t("user.pendingShipment"), count: counts.value.pendingShipment, to: "/user/orders?status=paid" },
{ key: "pendingReceipt", label: t("user.pendingReceipt"), count: counts.value.pendingReceipt, to: "/user/orders?status=shipped" },
{ key: "completed", label: t("user.completed"), count: counts.value.completed, to: "/user/orders?status=completed" },
{ key: "afterSale", label: t("user.afterSale"), count: counts.value.afterSale, to: "/user/orders?status=after-sale" },
]);
const favoriteProducts = computed(() =>
MOCK_FAVORITES.filter((favorite) => favorite.kind === "product")
.map((favorite) => productById(favorite.refId))
.filter((product): product is Product => product !== null)
.map((product) => {
const sku = lowestSku(product);
return {
product,
amountMinor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}),
);
</script>
<template>
<div>
<section class="mpanel welcome-panel">
<h1 class="mpanel-title">{{ t("user.dashboard") }}</h1>
<div class="stats-grid">
<div class="stat-card">
<span>{{ t("user.statsBalance") }}</span>
<strong><PriceText :amount-minor="USER_STATS.balanceMinor" currency="USD" /></strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsPoints") }}</span>
<strong>{{ USER_STATS.points }}</strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsFrozen") }}</span>
<strong><PriceText :amount-minor="USER_STATS.frozenMinor" currency="USD" /></strong>
</div>
</div>
<div class="status-links">
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="status-link">
<span>{{ item.label }}</span>
<b>{{ item.count }}</b>
</NuxtLink>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">
{{ t("user.recentOrders") }}
<NuxtLink class="more" to="/user/orders">{{ t("user.viewAll") }} </NuxtLink>
</h2>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
<div v-else class="recent-orders">
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="recent-order">
<header>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-body">
<div class="item-previews">
<img v-for="item in order.items" :key="item.id" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
</div>
<div class="order-date">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
<div class="order-total">
<span>{{ t("user.orderTotal") }}</span>
<PriceText :amount-minor="order.total_minor" :currency="order.currency" />
</div>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</article>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }}</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="favorite-grid">
<NuxtLink v-for="item in favoriteProducts" :key="item.product.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span>{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.currency" />
</NuxtLink>
</div>
</section>
</div>
</template>
<style scoped>
.welcome-panel {
padding-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px;
border: 1px solid var(--mall-line);
background: #fffafa;
}
.stat-card span {
display: block;
color: var(--mall-muted);
font-size: 12px;
}
.stat-card strong {
display: block;
margin-top: 10px;
color: var(--mall-red);
font-size: 20px;
}
.status-links {
display: grid;
grid-template-columns: repeat(5, 1fr);
border-top: 1px solid var(--mall-line);
}
.status-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
color: var(--mall-muted);
font-size: 12px;
text-decoration: none;
}
.status-link + .status-link {
border-left: 1px solid var(--mall-line);
}
.status-link b {
color: var(--mall-red);
font-size: 18px;
font-weight: 600;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
.recent-order {
border: 1px solid var(--mall-line);
margin-bottom: 12px;
}
.recent-order:last-child {
margin-bottom: 0;
}
.recent-order header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #fafafa;
color: var(--mall-muted);
font-size: 12px;
}
.order-body {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
}
.item-previews {
display: flex;
gap: 5px;
min-width: 140px;
}
.item-previews img {
width: 44px;
height: 44px;
object-fit: contain;
border: 1px solid var(--mall-line);
}
.order-date {
flex: 1;
color: var(--mall-faint);
font-size: 12px;
}
.order-total {
text-align: right;
font-size: 12px;
}
.order-total span {
display: block;
margin-bottom: 3px;
color: var(--mall-faint);
}
.order-total :deep(.price) {
color: var(--mall-red);
font-weight: 600;
}
.favorite-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.favorite-card {
display: block;
padding: 10px;
border: 1px solid var(--mall-line);
color: var(--mall-ink);
text-decoration: none;
}
.favorite-card img {
display: block;
width: 100%;
height: 120px;
object-fit: contain;
margin-bottom: 8px;
}
.favorite-card span {
display: block;
height: 34px;
overflow: hidden;
font-size: 12px;
line-height: 17px;
}
.favorite-card :deep(.price) {
display: block;
margin-top: 8px;
color: var(--mall-red);
font-size: 14px;
font-weight: 600;
}
@media (max-width: 760px) {
.status-links {
grid-template-columns: repeat(3, 1fr);
}
.order-body {
align-items: flex-start;
flex-wrap: wrap;
}
.order-date {
flex: 0 0 calc(100% - 156px);
}
.favorite-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { Invoice } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
const { $api } = useNuxtApp();
const rows = ref<Invoice[]>([]);
const loading = ref(true);
async function load(): Promise<void> {
loading.value = true;
try {
rows.value = await $api.listMyInvoices();
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="mpanel invoices-panel">
<h1 class="mpanel-title">{{ t("user.invoicesTitle") }}</h1>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noInvoices')" />
<table v-else class="mtable">
<thead>
<tr>
<th>{{ t("user.invoiceNo") }}</th>
<th>{{ t("user.invoiceOrderNo") }}</th>
<th>{{ t("user.invoiceTitle") }}</th>
<th>{{ t("user.invoiceKind") }}</th>
<th>{{ t("user.amount") }}</th>
<th>{{ t("common.status") }}</th>
<th>{{ t("user.invoiceIssuedAt") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in rows" :key="invoice.id">
<td>{{ invoice.invoice_no }}</td>
<td>{{ invoice.order_no || t("mall.notAvailable") }}</td>
<td>{{ invoice.title }}</td>
<td>{{ invoice.kind === "personal" ? t("user.personal") : t("user.company") }}</td>
<td><PriceText :amount-minor="invoice.amount_minor" :currency="invoice.currency" /></td>
<td><StatusBadge :status="invoice.status" kind="invoice" /></td>
<td>{{ invoice.issued_at ? invoice.issued_at.slice(0, 10) : t("mall.notAvailable") }}</td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.invoices-panel {
min-height: 560px;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
</style>
+294
View File
@@ -0,0 +1,294 @@
<script setup lang="ts">
import type { InvoiceKind, Order, Shipment } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const loading = ref(true);
const confirmingShipmentId = ref("");
const invoiceWorking = ref(false);
const invoiceSuccess = ref(false);
const invoiceErrorKey = ref("");
const invoiceTitle = ref("");
const invoiceTaxNo = ref("");
const invoiceKind = ref<InvoiceKind>("personal");
const orderId = computed(() => String(route.params.id ?? ""));
const orderShipments = computed(() => shipments.value.filter((shipment) => shipment.order_id === order.value?.id));
async function load(): Promise<void> {
loading.value = true;
try {
const [o, allShipments] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments()]);
order.value = o;
shipments.value = allShipments;
} finally {
loading.value = false;
}
}
async function confirmDelivery(shipment: Shipment): Promise<void> {
confirmingShipmentId.value = shipment.id;
try {
await $api.confirmDelivered(shipment.id);
await load();
} finally {
confirmingShipmentId.value = "";
}
}
async function submitInvoice(): Promise<void> {
invoiceErrorKey.value = "";
if (!order.value || !invoiceTitle.value.trim()) {
invoiceErrorKey.value = "user.validationRequired";
return;
}
invoiceWorking.value = true;
try {
await $api.requestInvoice(
order.value.id,
invoiceTitle.value.trim(),
invoiceTaxNo.value.trim() || null,
invoiceKind.value,
);
invoiceSuccess.value = true;
} catch {
invoiceErrorKey.value = "user.loadFailed";
} finally {
invoiceWorking.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="mpanel order-detail-panel">
<h1 class="mpanel-title">{{ t("user.orderDetail") }}</h1>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
<div v-else>
<header class="summary">
<div>
<strong>{{ t("user.orderNo") }} {{ order.order_no }}</strong>
<p>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.shippingAddress") }}</h2>
<p>{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
<p>
{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }}
{{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}
</p>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.orderItems") }}</h2>
<table class="mtable">
<thead>
<tr>
<th>{{ t("user.product") }}</th>
<th>{{ t("common.price") }}</th>
<th>{{ t("common.qty") }}</th>
<th>{{ t("common.total") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td class="prod-col">
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<span>{{ pick(item.product_name, locale) }}</span>
</td>
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
<td>{{ item.qty }}</td>
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
</tr>
</tbody>
</table>
<p class="order-total">{{ t("user.total") }}: <PriceText :amount-minor="order.total_minor" :currency="order.currency" /></p>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.shipments") }}</h2>
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
<div v-else>
<div v-for="shipment in orderShipments" :key="shipment.id" class="shipment-row">
<div>
<strong>{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong>
<p>{{ t("user.carrier") }}: {{ shipment.carrier }}</p>
<p>{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}</p>
</div>
<div class="shipment-right">
<StatusBadge :status="shipment.status" kind="shipment" />
<button
v-if="shipment.status === 'shipped'"
class="mbtn red"
type="button"
:disabled="confirmingShipmentId === shipment.id"
@click="confirmDelivery(shipment)"
>
{{ t("user.confirmDelivery") }}
</button>
</div>
</div>
</div>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.invoiceRequest") }}</h2>
<div v-if="invoiceSuccess" class="invoice-success">{{ t("user.invoiceSuccess") }}</div>
<form v-else class="invoice-form" @submit.prevent="submitInvoice">
<label>
<span>{{ t("user.invoiceTitle") }}</span>
<input v-model.trim="invoiceTitle" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.taxNo") }}</span>
<input v-model.trim="invoiceTaxNo" class="minput" type="text" />
</label>
<fieldset>
<legend>{{ t("user.invoiceKind") }}</legend>
<label><input v-model="invoiceKind" type="radio" value="personal" /> {{ t("user.personal") }}</label>
<label><input v-model="invoiceKind" type="radio" value="company" /> {{ t("user.company") }}</label>
</fieldset>
<p v-if="invoiceErrorKey" class="error">{{ t(invoiceErrorKey) }}</p>
<button class="mbtn red" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</button>
</form>
</article>
</div>
</section>
</template>
<style scoped>
.order-detail-panel {
min-height: 560px;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
.summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px;
border: 1px solid var(--mall-line);
background: #fafafa;
}
.summary strong {
color: var(--mall-ink);
font-size: 15px;
}
.summary p {
margin: 4px 0 0;
color: var(--mall-faint);
font-size: 12px;
}
.block {
margin-top: 16px;
margin-bottom: 0;
border: 1px solid var(--mall-line);
}
.block-title {
margin: 0 0 14px;
color: var(--mall-ink);
font-size: 15px;
}
.prod-col {
display: flex;
align-items: center;
gap: 10px;
}
.prod-col img {
width: 44px;
height: 44px;
border: 1px solid var(--mall-line);
object-fit: contain;
}
.order-total {
margin: 16px 0 0;
text-align: right;
font-size: 13px;
color: var(--mall-muted);
}
.order-total :deep(.price) {
color: var(--mall-red);
font-size: 18px;
font-weight: 600;
}
.shipment-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid var(--mall-line);
margin-bottom: 10px;
}
.shipment-row:last-child {
margin-bottom: 0;
}
.shipment-row p {
margin: 5px 0 0;
font-size: 12px;
color: var(--mall-muted);
}
.shipment-right {
display: flex;
align-items: center;
gap: 8px;
}
.invoice-form {
display: grid;
gap: 12px;
max-width: 520px;
}
.invoice-form label > span,
.invoice-form legend {
display: block;
margin-bottom: 6px;
color: var(--mall-muted);
font-size: 13px;
}
.invoice-form fieldset {
margin: 0;
padding: 0;
border: 0;
display: flex;
align-items: center;
gap: 16px;
}
.invoice-form fieldset label {
font-size: 13px;
color: var(--mall-ink);
}
.error {
margin: 0;
color: var(--mall-red);
font-size: 12px;
}
.invoice-success {
color: var(--mall-green);
font-size: 14px;
}
@media (max-width: 760px) {
.summary,
.shipment-row {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
</style>
+254
View File
@@ -0,0 +1,254 @@
<script setup lang="ts">
import type { Order, OrderStatus } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { storeById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const activeFilter = ref("all");
const orders = ref<Order[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(10);
const loading = ref(true);
const workingId = ref("");
const filterStatuses: Record<string, OrderStatus[]> = {
all: [],
pending_payment: ["pending_payment"],
pending_shipment: ["paid", "fulfilling"],
pending_receipt: ["shipped"],
completed: ["completed"],
cancelled: ["cancelled"],
};
const tabs = computed(() => [
{ key: "all", label: t("user.filterAll") },
{ key: "pending_payment", label: t("user.filterPendingPayment") },
{ key: "pending_shipment", label: t("user.filterPendingShipment") },
{ key: "pending_receipt", label: t("user.filterPendingReceipt") },
{ key: "completed", label: t("user.filterCompleted") },
{ key: "cancelled", label: t("user.filterCancelled") },
]);
const filteredOrders = computed(() => {
const statuses = filterStatuses[activeFilter.value] ?? [];
return statuses.length === 0 ? orders.value : orders.value.filter((order) => statuses.includes(order.status));
});
function setInitialFilter(): void {
const queryStatus = String(route.query.status ?? "");
const mapped = queryStatus === "paid" || queryStatus === "fulfilling"
? "pending_shipment"
: queryStatus === "shipped"
? "pending_receipt"
: queryStatus === "pending_payment" || queryStatus === "completed" || queryStatus === "cancelled"
? queryStatus
: "all";
activeFilter.value = mapped;
}
async function loadOrders(nextPage = page.value): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders(nextPage);
orders.value = result.items;
page.value = result.page;
total.value = result.total;
perPage.value = result.per_page;
} finally {
loading.value = false;
}
}
async function pay(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.payOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
async function cancel(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.cancelOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
function changeFilter(key: string): void {
activeFilter.value = key;
}
function shopName(shopId: string): string {
const store = storeById(shopId);
return store ? pick(store.name, locale.value) : t("user.shop");
}
onMounted(() => {
setInitialFilter();
void loadOrders();
});
</script>
<template>
<section class="mpanel orders-panel">
<h1 class="mpanel-title">{{ t("user.ordersTitle") }}</h1>
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
<template #default>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
<div v-else class="order-list">
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
<header class="order-header">
<div>
<strong>{{ shopName(order.shop_id) }}</strong>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-items">
<div v-for="item in order.items" :key="item.id" class="order-item">
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<div class="item-name">
<span>{{ pick(item.product_name, locale) }}</span>
<small>{{ item.sku_code }} × {{ item.qty }}</small>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
</div>
</div>
<footer class="order-footer">
<span>{{ t("user.orderTotal") }} <strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
<div class="actions">
<button v-if="order.status === 'pending_payment'" class="mbtn red" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</button>
<button v-if="order.status === 'pending_payment'" class="mbtn" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</button>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</footer>
</article>
</div>
<UiPagination :page="page" :total="total" :per-page="perPage" @change="loadOrders" />
</template>
</UiTabs>
</section>
</template>
<style scoped>
.orders-panel {
min-height: 560px;
}
.muted {
padding: 30px 0;
color: var(--mall-faint);
}
.order-card {
margin-bottom: 14px;
border: 1px solid var(--mall-line);
}
.order-card:last-child {
margin-bottom: 0;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: #fafafa;
border-bottom: 1px solid var(--mall-line);
}
.order-header > div {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
color: var(--mall-faint);
font-size: 12px;
}
.order-header strong {
color: var(--mall-ink);
font-size: 13px;
font-weight: 600;
}
.order-items {
padding: 4px 14px;
}
.order-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
}
.order-item + .order-item {
border-top: 1px solid var(--mall-line);
}
.order-item img {
width: 54px;
height: 54px;
border: 1px solid var(--mall-line);
object-fit: contain;
}
.item-name {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
font-size: 13px;
}
.item-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-name small {
color: var(--mall-faint);
font-size: 11px;
}
.order-item :deep(.price) {
color: var(--mall-muted);
font-size: 13px;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid var(--mall-line);
color: var(--mall-muted);
font-size: 12px;
}
.order-footer strong :deep(.price) {
color: var(--mall-red);
font-size: 16px;
}
.actions {
display: flex;
gap: 8px;
}
.actions .mbtn {
padding: 5px 11px;
}
@media (max-width: 760px) {
.order-header > div {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.order-footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>