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
+19 -130
View File
@@ -138,145 +138,34 @@ const sortOptions = computed(() => [
</script>
<template>
<div class="search-page section-bg">
<div class="min-h-screen bg-bg pb-12 font-sans text-text">
<UiBreadcrumb :items="breadcrumbItems" />
<main class="w1200">
<section class="filter mpanel" :aria-label="t('search.sort')">
<div class="filter-row">
<strong>{{ t("search.category") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
<button
v-for="category in topLevelCategories"
:key="category.id"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
<template v-if="selectedPath.length">
<button
v-for="category in selectedPath.slice(1)"
:key="`path-${category.id}`"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
</template>
<button
v-for="category in categoryChildren"
:key="`child-${category.id}`"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
<main class="mx-auto w-full max-w-mall px-4">
<VCard :padded="false" class="border border-border bg-surface px-5">
<div class="flex min-h-12 items-start gap-4 border-b border-border py-3 last:border-b-0">
<strong class="w-[90px] shrink-0 text-[13px] font-semibold leading-7 text-text">{{ t("search.category") }}</strong>
<div class="flex flex-wrap gap-x-2 gap-y-1">
<button type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="!state.category ? 'bg-primary text-white' : ''" @click="chooseCategory()">{{ t("search.all") }}</button>
<button v-for="category in topLevelCategories" :key="category.id" type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="category.id === state.category ? 'bg-primary text-white' : ''" @click="chooseCategory(category.id)">{{ pick(category.name, locale) }}</button>
<template v-if="selectedPath.length"><button v-for="category in selectedPath.slice(1)" :key="`path-${category.id}`" type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="category.id === state.category ? 'bg-primary text-white' : ''" @click="chooseCategory(category.id)">{{ pick(category.name, locale) }}</button></template>
<button v-for="category in categoryChildren" :key="`child-${category.id}`" type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="category.id === state.category ? 'bg-primary text-white' : ''" @click="chooseCategory(category.id)">{{ pick(category.name, locale) }}</button>
</div>
</div>
<div v-if="brands.length" class="filter-row">
<strong>{{ t("search.brand") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.brand }" @click="chooseBrand()">{{ t("search.all") }}</button>
<button
v-for="brand in brands"
:key="brand.id"
type="button"
:class="{ active: brand.id === state.brand }"
@click="chooseBrand(brand.id)"
>{{ pick(brand.name, locale) }}</button>
</div>
<div v-if="brands.length" class="flex min-h-12 items-start gap-4 border-b border-border py-3">
<strong class="w-[90px] shrink-0 text-[13px] font-semibold leading-7 text-text">{{ t("search.brand") }}</strong>
<div class="flex flex-wrap gap-x-2 gap-y-1"><button type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="!state.brand ? 'bg-primary text-white' : ''" @click="chooseBrand()">{{ t("search.all") }}</button><button v-for="brand in brands" :key="brand.id" type="button" class="rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="brand.id === state.brand ? 'bg-primary text-white' : ''" @click="chooseBrand(brand.id)">{{ pick(brand.name, locale) }}</button></div>
</div>
<div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong>
<div class="filter-options">
<button
v-for="option in sortOptions"
:key="option.key"
type="button"
:class="{ active: state.sort === option.key }"
:aria-label="option.key === state.sort ? (state.order === 'asc' ? t('search.ascending') : t('search.descending')) : undefined"
@click="chooseSort(option.key)"
>
{{ option.label }}
<span v-if="state.sort === option.key && option.key !== 'default'" aria-hidden="true">{{ state.order === "asc" ? "↑" : "↓" }}</span>
</button>
</div>
<div class="flex min-h-12 items-start gap-4 py-3">
<strong class="w-[90px] shrink-0 text-[13px] font-semibold leading-7 text-text">{{ t("search.sort") }}</strong>
<div class="flex flex-wrap gap-x-2 gap-y-1"><button v-for="option in sortOptions" :key="option.key" type="button" class="min-w-[60px] rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 text-muted hover:bg-primary hover:text-white" :class="state.sort === option.key ? 'bg-primary text-white' : ''" :aria-label="option.key === state.sort ? (state.order === 'asc' ? t('search.ascending') : t('search.descending')) : undefined" @click="chooseSort(option.key)">{{ option.label }} <span v-if="state.sort === option.key && option.key !== 'default'" aria-hidden="true">{{ state.order === "asc" ? "↑" : "↓" }}</span></button></div>
</div>
</section>
</VCard>
<div class="result-head">
<span>{{ t("search.resultCount", { n: result.total }) }}</span>
</div>
<div v-if="result.items.length" class="product-grid">
<UiProductCard v-for="product in result.items" :key="product.id" :product="product" />
</div>
<div class="px-0.5 pb-3 pt-[22px] text-[13px] text-muted">{{ t("search.resultCount", { n: result.total }) }}</div>
<div v-if="result.items.length" class="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]"><UiProductCard v-for="product in result.items" :key="product.id" :product="product" /></div>
<UiEmptyState v-else :text="t('search.empty')" />
<UiPagination :page="result.page" :total="result.total" :per-page="result.per_page" @change="(page) => replaceQuery({ page: String(page) })" />
</main>
</div>
</template>
<style scoped>
.search-page {
min-height: 650px;
padding-bottom: 50px;
}
.filter {
background: #fff;
border: 1px solid var(--mall-line);
padding: 2px 20px;
}
.filter-row {
display: flex;
min-height: 48px;
align-items: flex-start;
border-bottom: 1px solid var(--mall-line);
padding: 12px 0 8px;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-row strong {
width: 90px;
flex: 0 0 90px;
color: var(--mall-ink);
font-size: 13px;
line-height: 28px;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
}
.filter-options button {
border: 0;
background: transparent;
color: var(--mall-muted);
cursor: pointer;
font-size: 13px;
line-height: 28px;
padding: 0 10px;
}
.filter-options button:hover,
.filter-options button.active {
background: var(--mall-red);
color: #fff;
border-radius: var(--mall-radius);
}
.sort-row .filter-options button {
min-width: 60px;
}
.result-head {
color: var(--mall-muted);
font-size: 13px;
padding: 22px 2px 12px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 900px) {
.product-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>