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