Files
vmall/apps/mall/pages/goods/[id].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

702 lines
19 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Category, Sku } from "@vmall/shared";
import {
MOCK_CATEGORIES,
lowestSku,
productDetail,
salesOf,
} from "~/mock/data";
import { useCartStore } from "~/stores/cart";
type AttributeGroup = { key: string; values: string[] };
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const cart = useCartStore();
const routeId = computed(() => {
const value = route.params.id;
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
});
const detail = computed(() => productDetail(routeId.value));
const product = computed(() => detail.value?.product ?? null);
const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
const galleryIndex = ref(0);
const favorite = ref(false);
const cartSuccess = ref(false);
const activeTab = ref("detail");
const attributeGroups = computed<AttributeGroup[]>(() => {
const valuesByKey = new Map<string, Set<string>>();
for (const sku of product.value?.skus ?? []) {
if (!sku.active) continue;
for (const [key, value] of Object.entries(sku.attributes)) {
const values = valuesByKey.get(key) ?? new Set<string>();
values.add(value);
valuesByKey.set(key, values);
}
}
return [...valuesByKey.entries()].map(([key, values]) => ({ key, values: [...values] }));
});
watch(
detail,
(current) => {
for (const key of Object.keys(selectedAttributes)) delete selectedAttributes[key];
const initial = current ? (current.product.skus?.find((sku) => sku.active) ?? lowestSku(current.product)) : null;
if (initial) Object.assign(selectedAttributes, initial.attributes);
quantity.value = 1;
galleryIndex.value = 0;
favorite.value = false;
cartSuccess.value = false;
activeTab.value = "detail";
},
{ immediate: true },
);
const matchedSku = computed<Sku | null>(() => {
const skus = product.value?.skus ?? [];
const keys = attributeGroups.value.map((group) => group.key);
if (!keys.every((key) => selectedAttributes[key])) return null;
return skus.find(
(sku) => sku.active && keys.every((key) => sku.attributes[key] === selectedAttributes[key]),
) ?? null;
});
const stock = computed(() => matchedSku.value?.stock ?? 0);
const marketPrice = computed(() => {
const amount = matchedSku.value?.price_minor;
return amount === undefined ? 0 : Math.round((amount * 115) / 100);
});
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id);
const categoryPath = computed(() => {
const path: Category[] = [];
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
while (current) {
path.unshift(current);
current = current.parent_id ? categoryById(current.parent_id) : undefined;
}
return path;
});
const breadcrumbItems = computed(() => [
{ label: t("product.home"), to: "/" },
...categoryPath.value.map((category) => ({
label: pick(category.name, locale.value),
to: `/search?category=${category.id}`,
})),
{ label: product.value ? pick(product.value.name, locale.value) : t("product.productNotFound") },
]);
const selectAttribute = (key: string, value: string): void => {
selectedAttributes[key] = value;
quantity.value = 1;
cartSuccess.value = false;
};
const addToCart = async (): Promise<boolean> => {
if (!matchedSku.value || stock.value <= 0) return false;
await $api.addCartItem(matchedSku.value.id, quantity.value);
await cart.refresh();
return true;
};
const buyNow = async (): Promise<void> => {
if (await addToCart()) await router.push("/checkout");
};
const addCart = async (): Promise<void> => {
if (await addToCart()) {
cartSuccess.value = true;
window.setTimeout(() => {
cartSuccess.value = false;
}, 1800);
}
};
const tabs = computed(() => [
{ key: "detail", label: t("product.tabsDetail") },
{ key: "reviews", label: t("product.tabsReviews") },
{ key: "service", label: t("product.tabsAfterSale") },
]);
const rateRows = computed(() => {
const rate = store.value?.rate;
return [
{ key: "overall", value: rate?.score ?? 0, label: t("product.overall") },
{ key: "service", value: rate?.service ?? 0, label: t("product.service") },
{ key: "delivery", value: rate?.speed ?? 0, label: t("product.delivery") },
{ key: "agree", value: rate?.agree ?? 0, label: t("product.quality") },
];
});
const detailImages = computed(() => product.value?.images ?? []);
</script>
<template>
<div class="product-page section-bg">
<UiBreadcrumb :items="breadcrumbItems" />
<main v-if="detail" class="w1200">
<section class="product-top mpanel">
<div class="gallery">
<div class="main-image">
<img class="zoom-image" :src="currentImage" :alt="pick(detail.product.name, locale)" />
</div>
<div class="thumbs" role="list">
<button
v-for="(image, index) in detail.product.images.slice(0, 3)"
:key="`${image}-${index}`"
type="button"
:class="{ active: galleryIndex === index }"
:aria-label="`${t('product.detail')} ${index + 1}`"
@click="galleryIndex = index"
>
<img :src="image" :alt="pick(detail.product.name, locale)" loading="lazy" />
</button>
</div>
</div>
<div class="summary">
<div class="summary-heading">
<div>
<h1>{{ pick(detail.product.name, locale) }}</h1>
<p class="subtitle">{{ pick(detail.product.description, locale) }}</p>
</div>
<button
type="button"
class="favorite"
:class="{ active: favorite }"
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
@click="favorite = !favorite"
>{{ favorite ? "♥" : "♡" }}</button>
</div>
<div class="price-panel">
<span class="price-label">{{ t("product.currentPrice") }}</span>
<strong v-if="matchedSku" class="price"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong>
<strong v-else class="price">{{ t("product.unavailable") }}</strong>
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
</div>
<div class="summary-meta">
<span>{{ t("product.sold", { n: salesOf(detail.product) }) }}</span>
<span>{{ t("product.commentCount", { n: detail.commentStats.all }) }}</span>
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
<div class="coupon-row">
<span class="label">{{ t("product.coupons") }}</span>
<div class="coupon-list">
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
{{ pick(coupon.title, locale) }}
<small>{{ t("product.expires", { date: coupon.expiresAt }) }}</small>
</span>
</div>
</div>
<div v-for="group in attributeGroups" :key="group.key" class="attribute-row">
<span class="label">{{ group.key }}</span>
<div class="attribute-values">
<button
v-for="value in group.values"
:key="value"
type="button"
:class="{ active: selectedAttributes[group.key] === value }"
@click="selectAttribute(group.key, value)"
>{{ value }}</button>
</div>
</div>
<div class="quantity-row">
<span class="label">{{ t("product.quantity") }}</span>
<UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" />
</div>
<div class="actions">
<button type="button" class="primary-action" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</button>
<button type="button" class="cart-action" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</button>
</div>
<p v-if="matchedSku && stock <= 0" class="stock-warning">{{ t("product.noStock") }}</p>
<p v-else-if="!matchedSku" class="stock-warning">{{ t("product.unavailable") }}</p>
</div>
</section>
<section class="below-grid">
<aside class="left-rail">
<div class="store-card mpanel">
<h2>{{ t("product.store") }}</h2>
<div class="store-heading">
<img :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
<strong>{{ pick(detail.store.name, locale) }}</strong>
</div>
<dl>
<div><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
<div><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
</dl>
<h3>{{ t("product.rates") }}</h3>
<div v-for="row in rateRows" :key="row.key" class="rate-row">
<span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" />
</div>
<div class="store-actions">
<NuxtLink class="store-button" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink>
<button type="button" class="store-button" disabled>{{ t("product.contact") }}</button>
</div>
</div>
<div class="sales-rank mpanel">
<h2>{{ t("product.salesRank") }}</h2>
<NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="rank-item">
<img :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" />
<span class="rank-name">{{ pick(item.name, locale) }}</span>
<span v-if="lowestSku(item)" class="rank-price"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span>
</NuxtLink>
</div>
</aside>
<section class="detail-panel mpanel">
<UiTabs v-model="activeTab" :tabs="tabs">
<template #default="{ active }">
<div v-if="active === 'detail'" class="detail-content">
<h2>{{ t("product.description") }}</h2>
<p>{{ pick(detail.product.description, locale) }}</p>
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
</div>
<div v-else-if="active === 'reviews'" class="reviews-content">
<div class="review-summary">
<strong>{{ t("product.reviewSummary", { rate: detail.commentStats.goodRate, n: detail.commentStats.all }) }}</strong>
<span>{{ t("product.reviewAll") }}</span>
</div>
<article v-for="comment in detail.comments" :key="comment.id" class="review">
<img :src="comment.avatar" :alt="comment.author" loading="lazy" />
<div class="review-body">
<header><strong>{{ comment.author }}</strong><UiRatingStars :value="comment.rating" :size="13" /><time>{{ comment.createdAt }}</time></header>
<p>{{ pick(comment.content, locale) }}</p>
<div v-if="comment.reply" class="reply"><strong>{{ t("product.reply") }}:</strong> {{ pick(comment.reply, locale) }}</div>
</div>
</article>
</div>
<div v-else class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p>{{ pick(detail.store.afterSale, locale) }}</p>
</div>
</template>
</UiTabs>
</section>
</section>
</main>
<UiEmptyState v-else :text="t('product.productNotFound')" />
</div>
</template>
<style scoped>
.product-page {
min-height: 700px;
padding-bottom: 60px;
}
.product-top {
display: grid;
grid-template-columns: 450px 1fr;
gap: 32px;
min-height: 500px;
background: #fff;
border: 1px solid var(--mall-line);
padding: 24px;
}
.gallery {
width: 400px;
}
.main-image {
display: flex;
width: 400px;
height: 400px;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
border: 1px solid var(--mall-line);
}
.zoom-image {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 220ms ease;
}
.main-image:hover .zoom-image {
transform: scale(1.07);
}
.thumbs {
display: flex;
gap: 12px;
margin-top: 14px;
}
.thumbs button {
width: 74px;
height: 74px;
padding: 4px;
border: 1px solid var(--mall-line);
background: #fff;
cursor: pointer;
}
.thumbs button.active {
border-color: var(--mall-red);
}
.thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
}
.summary {
min-width: 0;
}
.summary-heading {
display: flex;
justify-content: space-between;
gap: 12px;
}
h1 {
margin: 0;
color: var(--mall-ink);
font-size: 22px;
line-height: 1.35;
}
.subtitle {
margin: 8px 0 18px;
color: var(--mall-muted);
font-size: 13px;
}
.favorite {
align-self: flex-start;
border: 0;
background: transparent;
color: var(--mall-faint);
cursor: pointer;
font-size: 28px;
line-height: 1;
}
.favorite.active {
color: var(--mall-red);
}
.price-panel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 12px;
background: #fff5f5;
padding: 16px;
}
.price-label,
.market-label,
.label {
color: var(--mall-muted);
font-size: 13px;
}
.price {
color: var(--mall-red);
font-size: 28px;
}
.market-label s {
color: var(--mall-faint);
}
.summary-meta {
display: flex;
gap: 28px;
border-bottom: 1px solid var(--mall-line);
color: var(--mall-muted);
font-size: 12px;
padding: 13px 0;
}
.summary-meta .soldout,
.stock-warning {
color: var(--mall-red);
}
.coupon-row,
.attribute-row,
.quantity-row {
display: flex;
align-items: flex-start;
gap: 18px;
border-bottom: 1px solid var(--mall-line);
padding: 15px 0;
}
.coupon-row > .label,
.attribute-row > .label,
.quantity-row > .label {
width: 58px;
flex: 0 0 58px;
line-height: 30px;
}
.coupon-list,
.attribute-values {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.coupon {
display: inline-flex;
flex-direction: column;
gap: 3px;
border: 1px solid var(--mall-red);
color: var(--mall-red);
font-size: 12px;
padding: 5px 8px;
}
.coupon small {
color: var(--mall-muted);
font-size: 10px;
}
.attribute-values button {
min-width: 66px;
border: 1px solid var(--mall-line-dark);
background: #fff;
color: var(--mall-ink);
cursor: pointer;
font-size: 12px;
padding: 6px 12px;
}
.attribute-values button.active {
border-color: var(--mall-red);
color: var(--mall-red);
}
.actions {
display: flex;
gap: 12px;
margin-top: 22px;
}
.primary-action,
.cart-action {
min-width: 136px;
border: 1px solid var(--mall-red);
cursor: pointer;
font-size: 14px;
padding: 11px 22px;
}
.primary-action {
background: #fff0f0;
color: var(--mall-red);
}
.cart-action {
background: var(--mall-red);
color: #fff;
}
.primary-action:disabled,
.cart-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.stock-warning {
margin: 10px 0 0;
font-size: 12px;
}
.below-grid {
display: grid;
grid-template-columns: 260px 1fr;
gap: 20px;
margin-top: 20px;
}
.left-rail {
display: flex;
flex-direction: column;
gap: 20px;
}
.store-card,
.sales-rank,
.detail-panel {
background: #fff;
border: 1px solid var(--mall-line);
padding: 18px;
}
.store-card h2,
.sales-rank h2,
.detail-content h2,
.service-content h2 {
border-left: 3px solid var(--mall-red);
color: var(--mall-ink);
font-size: 16px;
margin: 0 0 16px;
padding-left: 8px;
}
.store-heading {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
}
.store-heading img {
width: 42px;
height: 42px;
object-fit: contain;
}
dl {
margin: 14px 0;
}
dl div {
display: flex;
gap: 6px;
font-size: 12px;
line-height: 1.6;
}
dt {
color: var(--mall-muted);
flex: 0 0 42px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.store-card h3 {
color: var(--mall-muted);
font-size: 12px;
font-weight: 400;
margin: 12px 0 8px;
}
.rate-row {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--mall-muted);
font-size: 12px;
line-height: 25px;
}
.store-actions {
display: flex;
gap: 6px;
margin-top: 14px;
}
.store-button {
flex: 1;
border: 1px solid var(--mall-line-dark);
background: #fff;
color: var(--mall-ink);
cursor: pointer;
font-size: 11px;
padding: 7px 4px;
text-align: center;
text-decoration: none;
}
.store-button:first-child {
border-color: var(--mall-red);
color: var(--mall-red);
}
.store-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.rank-item {
display: grid;
grid-template-columns: 52px 1fr;
gap: 5px 8px;
border-bottom: 1px solid var(--mall-line);
color: inherit;
padding: 10px 0;
text-decoration: none;
}
.rank-item:last-child {
border-bottom: 0;
}
.rank-item img {
grid-row: 1 / span 2;
width: 52px;
height: 52px;
object-fit: contain;
}
.rank-name {
align-self: end;
font-size: 11px;
line-height: 1.35;
max-height: 30px;
overflow: hidden;
}
.rank-price {
align-self: start;
color: var(--mall-red);
font-size: 12px;
}
.detail-panel {
min-height: 550px;
}
.detail-content p,
.service-content p {
color: var(--mall-muted);
font-size: 13px;
line-height: 1.8;
margin: 0 0 18px;
}
.detail-content > img {
display: block;
width: 100%;
max-height: 500px;
margin: 0 auto 14px;
object-fit: contain;
}
.review-summary {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--mall-line);
color: var(--mall-red);
font-size: 13px;
padding: 0 0 15px;
}
.review-summary span {
color: var(--mall-muted);
}
.review {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--mall-line);
padding: 18px 0;
}
.review > img {
width: 38px;
height: 38px;
border-radius: 50%;
}
.review-body {
min-width: 0;
flex: 1;
}
.review-body header {
display: flex;
align-items: center;
gap: 10px;
color: var(--mall-muted);
font-size: 12px;
}
.review-body header strong {
color: var(--mall-ink);
}
.review-body time {
margin-left: auto;
}
.review-body p {
color: var(--mall-ink);
font-size: 13px;
line-height: 1.6;
margin: 9px 0;
}
.reply {
background: #fafafa;
color: var(--mall-muted);
font-size: 12px;
padding: 8px 10px;
}
.reply strong {
color: var(--mall-red);
}
@media (max-width: 900px) {
.product-top,
.below-grid {
grid-template-columns: 1fr;
}
.gallery,
.main-image {
width: 100%;
max-width: 400px;
}
}
</style>