feat(ui): adopt Tailwind v4 design system and archive change

- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin
- Add @vmall/shared/theme.css tokens with html[data-accent] presets
- Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage,
  VAccentSwatch, useAccent) as a Nuxt module
- Convert all three apps to kit + utilities; delete ui.css/mall.css and
  every <style scoped>; consoles get accent presets, mall locked to red
- Fix VCard boolean prop default (padding) and PDP/store stale
  useAsyncData keys on param navigation
- Archive adopt-tailwind-design-system; new frontend-ui capability spec
This commit is contained in:
Chengdong Zhang
2026-09-22 18:22:50 +08:00
parent 4d2ee3b0bf
commit 0d0e10b97b
101 changed files with 2833 additions and 7207 deletions
+27 -566
View File
@@ -24,7 +24,10 @@ const routeId = computed(() => {
// product that was just fetched, and chaining two useAsyncData calls left the
// second handler running before the first had data.
const { data: pageData } = await useAsyncData(
"product-detail",
// Keyed by route param: Nuxt remounts this page on param change, and a
// static key would serve the previous product's cached entry without
// re-running the handler.
`product-detail-${routeId.value}`,
async () => {
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
@@ -290,583 +293,41 @@ const detailImages = computed(() => product.value?.images ?? []);
</script>
<template>
<div class="product-page section-bg">
<div class="min-h-screen bg-bg pb-16 font-sans text-text">
<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>
<main v-if="detail" class="mx-auto w-full max-w-mall px-4">
<section class="grid overflow-hidden rounded-lg border border-border bg-surface shadow-sm lg:grid-cols-2">
<div class="p-5">
<div class="flex aspect-square items-center justify-center overflow-hidden bg-bg"><img class="h-full w-full object-contain transition-transform hover:scale-105" :src="currentImage" :alt="pick(detail.product.name, locale)" /></div>
<div class="mt-3 flex gap-2" role="list"><button v-for="(image, index) in detail.product.images.slice(0, 3)" :key="`${image}-${index}`" type="button" class="h-16 w-16 overflow-hidden rounded-sm border border-border bg-surface p-0 hover:border-primary" :class="galleryIndex === index ? 'border-2 border-primary' : ''" :aria-label="`${t('product.detail')} ${index + 1}`" @click="galleryIndex = index"><img class="h-full w-full object-contain" :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 }"
:disabled="favoriteLoading || favoriteBusy"
:aria-label="favorite ? t('product.unfavorite') : t('product.favorite')"
@click="toggleFavorite"
>{{ favorite ? "♥" : "♡" }}</button>
</div>
<p v-if="favoriteError" class="favorite-error">{{ favoriteError }}</p>
<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: detail.product.sold_count }) }}</span>
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
<div class="border-l border-border p-5 max-lg:border-l-0 max-lg:border-t">
<div class="flex items-start justify-between gap-3"><div><h1 class="m-0 text-xl font-bold text-text">{{ pick(detail.product.name, locale) }}</h1><p class="mt-2 text-sm leading-relaxed text-muted">{{ pick(detail.product.description, locale) }}</p></div><VBtn size="sm" type="button" :disabled="favoriteLoading || favoriteBusy" :aria-label="favorite ? t('product.unfavorite') : t('product.favorite')" @click="toggleFavorite">{{ favorite ? "" : "" }}</VBtn></div>
<p v-if="favoriteError" class="my-2 text-sm text-danger">{{ favoriteError }}</p>
<div class="my-4 flex flex-wrap items-baseline gap-2 bg-primary-soft px-4 py-3"><span class="text-xs text-muted">{{ t("product.currentPrice") }}</span><strong v-if="matchedSku" class="text-2xl font-semibold text-primary"><PriceText :amount-minor="matchedSku.price_minor" :currency="matchedSku.currency" /></strong><strong v-else class="text-2xl font-semibold text-primary">{{ t("product.unavailable") }}</strong><span v-if="matchedSku" class="text-xs text-muted">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span></div>
<div class="mb-4 flex gap-5 text-xs text-muted"><span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span><span :class="stock <= 0 ? 'text-danger' : ''">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span></div>
<div v-if="detail.coupons.length > 0" 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.ends_at.slice(0, 10) }) }}</small>
<button
type="button"
class="coupon-claim"
:disabled="claimingId === coupon.id || claimedIds.has(coupon.id)"
@click="claim(coupon)"
>
{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}
</button>
</span>
<span v-if="claimError" class="coupon-error">{{ claimError }}</span>
</div>
</div>
<div v-if="detail.coupons.length > 0" class="mb-4 flex gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.coupons") }}</span><div class="flex flex-wrap gap-2"><span v-for="coupon in detail.coupons" :key="coupon.id" class="rounded-sm border border-primary/30 bg-primary-soft px-2 py-1 text-xs text-primary">{{ pick(coupon.title, locale) }} <small class="block text-[11px] text-muted">{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small><VBtn class="mt-1" size="sm" type="button" :disabled="claimingId === coupon.id || claimedIds.has(coupon.id)" @click="claim(coupon)">{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}</VBtn></span><span v-if="claimError" class="text-sm text-danger">{{ claimError }}</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 v-for="group in attributeGroups" :key="group.key" class="mb-3 flex gap-3"><span class="w-16 shrink-0 pt-1 text-sm text-muted">{{ group.key }}</span><div class="flex flex-wrap gap-2"><button v-for="value in group.values" :key="value" type="button" class="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text hover:border-primary hover:text-primary" :class="selectedAttributes[group.key] === value ? 'border-primary bg-primary-soft text-primary' : ''" @click="selectAttribute(group.key, value)">{{ value }}</button></div></div>
<div class="mb-4 flex items-center gap-3"><span class="w-16 shrink-0 text-sm text-muted">{{ t("product.quantity") }}</span><UiQtyStepper v-model="quantity" :max="Math.max(1, stock)" /></div>
<div class="flex flex-wrap gap-2"><VBtn variant="primary" type="button" :disabled="!matchedSku || stock <= 0" @click="buyNow">{{ t("product.buyNow") }}</VBtn><VBtn type="button" :disabled="!matchedSku || stock <= 0" @click="addCart">{{ cartSuccess ? t("product.addedCart") : t("product.addCart") }}</VBtn></div>
<p v-if="matchedSku && stock <= 0" class="my-2 text-sm text-danger">{{ t("product.noStock") }}</p><p v-else-if="!matchedSku" class="my-2 text-sm text-danger">{{ t("product.unavailable") }}</p>
</div>
</section>
<section class="below-grid">
<aside class="left-rail">
<div v-if="detail.store" class="store-card mpanel">
<h2>{{ t("product.store") }}</h2>
<div class="store-heading">
<img v-if="detail.store.logo" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
<strong>{{ pick(detail.store.name, locale) }}</strong>
</div>
<dl>
<div v-if="detail.store.company"><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
<div v-if="detail.store.region"><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
</dl>
<template v-if="rateRows.length">
<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>
</template>
<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>
<section class="mt-5 grid gap-5 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside class="space-y-5">
<VCard v-if="detail.store">
<h2 class="mb-3 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.store") }}</h2><div class="flex items-center gap-2.5 border-b border-border pb-3"><img v-if="detail.store.logo" class="h-12 w-12 border border-border object-contain" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" /><strong class="text-sm">{{ pick(detail.store.name, locale) }}</strong></div><dl class="my-3 text-xs text-muted"><div v-if="detail.store.company" class="my-2 flex justify-between gap-2"><dt>{{ t("product.company") }}</dt><dd class="m-0 text-right">{{ detail.store.company }}</dd></div><div v-if="detail.store.region" class="my-2 flex justify-between gap-2"><dt>{{ t("product.region") }}</dt><dd class="m-0 text-right">{{ detail.store.region }}</dd></div></dl><template v-if="rateRows.length"><h3 class="mb-2 text-xs font-medium">{{ t("product.rates") }}</h3><div v-for="row in rateRows" :key="row.key" class="flex items-center justify-between py-1 text-xs text-muted"><span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" /></div></template><div class="mt-3 grid gap-2"><NuxtLink class="rounded-md border border-primary bg-primary px-3 py-2 text-center text-sm font-medium text-white no-underline hover:bg-primary-hover" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink><VBtn type="button" disabled>{{ t("product.contact") }}</VBtn></div>
</VCard>
<VCard><h2 class="mb-2 border-l-[3px] border-primary pl-2 text-sm font-medium">{{ t("product.salesRank") }}</h2><NuxtLink v-for="item in detail.salesRank" :key="item.id" :to="`/goods/${item.slug}`" class="grid grid-cols-[48px_minmax(0,1fr)] gap-2 border-t border-border py-2 text-xs text-text no-underline"><img class="h-12 w-12 object-contain" :src="item.images[0]" :alt="pick(item.name, locale)" loading="lazy" /><span class="min-w-0 self-center truncate">{{ pick(item.name, locale) }}<span v-if="lowestSku(item)" class="mt-1 block text-right text-primary"><PriceText :amount-minor="lowestSku(item)!.price_minor" :currency="lowestSku(item)!.currency" /></span></span></NuxtLink></VCard>
</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 class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
</div>
</template>
</UiTabs>
</section>
<VCard :padded="false" class="overflow-hidden"><UiTabs v-model="activeTab" :tabs="tabs"><template #default="{ active }"><div v-if="active === 'detail'" class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.description") }}</h2><p class="text-sm leading-relaxed text-text">{{ pick(detail.product.description, locale) }}</p><img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" class="w-full object-contain" :src="image" :alt="t('product.detail')" loading="lazy" /></div><div v-else class="space-y-3 p-5"><h2 class="text-lg font-semibold">{{ t("product.tabsAfterSale") }}</h2><p v-if="detail.store?.after_sale" class="text-sm leading-relaxed text-text">{{ pick(detail.store.after_sale, locale) }}</p></div></template></UiTabs></VCard>
</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,
.favorite-error {
color: var(--mall-red);
}
.favorite-error {
margin: 6px 0 0;
font-size: 12px;
}
.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;
}
.coupon-claim {
margin-top: 3px;
padding: 2px 8px;
border: 1px solid var(--mall-red);
background: transparent;
color: var(--mall-red);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.coupon-claim:disabled {
border-color: var(--mall-line-dark);
color: var(--mall-faint);
cursor: default;
}
.coupon-error {
align-self: center;
color: #b42318;
font-size: 11px;
}
.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>