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:
+30
-208
@@ -68,225 +68,47 @@ const statusLinks = computed(() => [
|
||||
</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 v-if="stats"><PriceText :amount-minor="stats.balance_minor" :currency="stats.currency" /></strong>
|
||||
<strong v-else>{{ t("common.loading") }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>{{ t("user.statsPoints") }}</span>
|
||||
<strong>{{ stats ? stats.points : t("common.loading") }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>{{ t("user.statsFrozen") }}</span>
|
||||
<strong v-if="stats"><PriceText :amount-minor="stats.frozen_minor" :currency="stats.currency" /></strong>
|
||||
<strong v-else>{{ t("common.loading") }}</strong>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<VCard class="pb-0">
|
||||
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.dashboard") }}</h1>
|
||||
<div class="mb-5 grid gap-3 sm:grid-cols-3">
|
||||
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsBalance") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.balance_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
|
||||
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsPoints") }}</span><strong class="mt-2.5 block text-xl text-primary">{{ stats ? stats.points : t("common.loading") }}</strong></div>
|
||||
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsFrozen") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.frozen_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></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 class="grid grid-cols-3 border-t border-border sm:grid-cols-5">
|
||||
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="flex flex-col items-center gap-2 px-2 py-4 text-xs text-muted no-underline hover:text-primary sm:border-l sm:border-border first:sm:border-l-0"><span>{{ item.label }}</span><b class="text-lg font-semibold text-primary">{{ item.count }}</b></NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</VCard>
|
||||
|
||||
<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>
|
||||
<VCard>
|
||||
<h2 class="mb-4 flex items-center justify-between text-lg font-semibold text-text">{{ t("user.recentOrders") }} <NuxtLink class="text-sm font-normal text-primary no-underline" to="/user/orders">{{ t("user.viewAll") }} ›</NuxtLink></h2>
|
||||
<div v-if="loading" class="py-5 text-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 v-else class="space-y-3">
|
||||
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="border border-border">
|
||||
<header class="flex items-center justify-between gap-3 bg-bg px-3 py-2.5 text-xs text-muted"><span>{{ t("user.orderNo") }} {{ order.order_no }}</span><StatusBadge :status="order.status" kind="order" /></header>
|
||||
<div class="flex items-center gap-4 p-3 max-sm:flex-wrap">
|
||||
<div class="flex min-w-[140px] gap-1"><img v-for="item in order.items" :key="item.id" class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /></div>
|
||||
<div class="flex-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
|
||||
<div class="text-right text-xs"><span class="mb-1 block text-muted">{{ t("user.orderTotal") }}</span><PriceText class="font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></div>
|
||||
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</VCard>
|
||||
|
||||
<section class="mpanel">
|
||||
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
|
||||
<VCard>
|
||||
<h2 class="mb-4 text-lg font-semibold text-text">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</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.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
|
||||
<img :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
|
||||
<span>{{ pick(item.product.name, locale) }}</span>
|
||||
<PriceText v-if="item.product.price_minor !== null && item.product.currency" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
|
||||
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="block border border-border p-2.5 text-text no-underline hover:border-primary">
|
||||
<img class="mb-2 block h-[120px] w-full object-contain" :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
|
||||
<span class="block h-[34px] overflow-hidden text-xs leading-[17px]">{{ pick(item.product.name, locale) }}</span>
|
||||
<PriceText v-if="item.product.price_minor !== null && item.product.currency" class="mt-2 block text-sm font-semibold text-primary" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</VCard>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user