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:
@@ -0,0 +1,701 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user