Files
vmall/apps/mall/pages/user/index.vue
T
Chengdong Zhang 21e99bb52b 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
2026-09-17 19:13:29 +08:00

279 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>