Files
vmall/apps/mall/pages/index.vue
T
Chengdong Zhang 0d0e10b97b 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
2026-09-22 18:22:50 +08:00

125 lines
5.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Category, HomeContent, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
interface HomeFloor {
categoryId: string;
name: Record<string, string>;
advImage: string | null;
advUrl: string;
products: Product[];
}
const emptyContent: HomeContent = { banners: [], promos: [], quick_links: [], floor_adverts: [] };
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
// Shared with the shell's category menu, so this does not refetch the tree.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
// Marketing content comes from the content API; an outage leaves these lists
// empty rather than breaking the page.
const { data: content } = await useAsyncData("home-content", () => $api.getHomeContent(), {
default: () => emptyContent,
});
const banners = computed(() =>
content.value.banners.map((banner) => ({ image: banner.image, url: banner.url })),
);
const quickLinks = computed(() => content.value.quick_links);
const promos = computed(() => content.value.promos);
const heroQuickLinks = computed(() => quickLinks.value.slice(0, 6));
const sidePromos = computed(() => promos.value.slice(0, 2));
const stripPromos = computed(() => (promos.value.length > 2 ? promos.value.slice(2, 5) : promos.value.slice(0, 3)));
// Floor structure comes from the category tree and floor products from the
// catalog API; advert art is an ordered pool assigned to floors in turn.
const { data: floors } = await useAsyncData("home-floors", async () => {
const roots: Category[] = categories.value ?? (await $api.listCategories());
const adverts = content.value.floor_adverts;
const tops = roots
.filter((category) => category.parent_id === null)
.sort((a, b) => a.position - b.position);
const built = await Promise.all(
tops.map(async (category, index) => {
const page = await $api.listProducts({ category_id: category.id, per_page: 8 });
const floor: HomeFloor = {
categoryId: category.id,
name: category.name,
advImage: adverts.length > 0 ? adverts[index % adverts.length].image : null,
advUrl: `/search?category=${category.id}`,
products: page.items,
};
return floor;
}),
);
return built.filter((floor) => floor.products.length > 0);
});
const visibleFloors = computed(() => floors.value ?? []);
</script>
<template>
<div>
<section class="mx-auto max-w-mall px-4">
<div class="grid h-hero grid-cols-[240px_minmax(0,1fr)_220px] bg-surface">
<ShellCategoryMenu pinned />
<div class="min-w-0">
<UiCarousel :images="banners" :height="450" />
</div>
<aside class="flex h-full flex-col border-l border-border bg-surface">
<ul class="grid list-none grid-cols-2 gap-px bg-border p-px">
<li v-for="q in heroQuickLinks" :key="q.id" class="bg-surface text-center">
<NuxtLink :to="q.url" class="block px-1 py-3 text-[12px] text-muted no-underline transition-colors hover:text-primary">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" class="mx-auto mb-1.5">
<path :d="q.glyph" />
</svg>
<span>{{ pick(q.label, locale) }}</span>
</NuxtLink>
</li>
</ul>
<div class="mt-2 flex-1 space-y-2 px-2 pb-2">
<NuxtLink
v-for="p in sidePromos"
:key="p.id"
:to="p.url"
class="block overflow-hidden"
>
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" class="block h-[106px] w-full object-cover" />
</NuxtLink>
</div>
</aside>
</div>
</section>
<section v-if="stripPromos.length > 0" class="mx-auto mb-5 mt-5 max-w-mall px-4">
<div class="grid grid-cols-3 gap-3">
<NuxtLink v-for="p in stripPromos" :key="p.id" :to="p.url" class="block overflow-hidden">
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" class="block h-40 w-full object-cover" />
</NuxtLink>
</div>
</section>
<div class="bg-bg py-8">
<section v-for="floor in visibleFloors" :key="floor.categoryId" class="mx-auto mb-[30px] max-w-mall px-4 last:mb-0">
<header class="flex items-end justify-between border-b-2 border-primary">
<h2 class="m-0 py-[10px] text-[18px] leading-none">{{ pick(floor.name, locale) }}</h2>
<NuxtLink
:to="`/search?category=${floor.categoryId}`"
class="pb-2 text-[12px] text-muted no-underline transition-colors hover:text-primary"
>{{ t("home.viewMore") }} </NuxtLink>
</header>
<div class="flex bg-surface">
<NuxtLink v-if="floor.advImage" :to="floor.advUrl" class="shrink-0 border-r border-border">
<img :src="floor.advImage" :alt="pick(floor.name, locale)" loading="lazy" class="block h-[614px] w-[234px] object-cover" />
</NuxtLink>
<div class="grid min-w-0 flex-1 grid-cols-4 gap-px bg-border p-px">
<UiProductCard v-for="p in floor.products" :key="p.id" :product="p" />
</div>
</div>
</section>
</div>
</div>
</template>