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