Files
vmall/apps/mall/pages/index.vue
T
Chengdong ZhangandClaude Sonnet 5 13cd598a45 fix(mall): prevent floor grid columns from overflowing container
`min-width: 0` on the flex item and `minmax(0, 1fr)` columns stop the
grid tracks from growing past their content and overflowing the
floor-grid's flex container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:08:35 +08:00

207 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);
// 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>
<div class="w1200 hero">
<ShellCategoryMenu pinned />
<div class="hero-slider">
<UiCarousel :images="banners" :height="450" />
</div>
</div>
<div class="w1200 strip">
<ul class="quick">
<li v-for="q in quickLinks" :key="q.id">
<NuxtLink :to="q.url">
<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor"><path :d="q.glyph" /></svg>
<span>{{ pick(q.label, locale) }}</span>
</NuxtLink>
</li>
</ul>
<div class="promos">
<NuxtLink v-for="p in promos" :key="p.id" :to="p.url">
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" />
</NuxtLink>
</div>
</div>
<div class="section-bg">
<section v-for="floor in visibleFloors" :key="floor.categoryId" class="w1200 floor">
<header class="floor-head">
<h2>{{ pick(floor.name, locale) }}</h2>
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink>
</header>
<div class="floor-body">
<NuxtLink v-if="floor.advImage" :to="floor.advUrl" class="floor-adv">
<img :src="floor.advImage" :alt="pick(floor.name, locale)" loading="lazy" />
</NuxtLink>
<div class="floor-grid">
<UiProductCard v-for="p in floor.products" :key="p.id" :product="p" />
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.hero {
display: flex;
height: 450px;
margin-top: 0;
background: #fff;
}
.hero-slider {
flex: 1;
min-width: 0;
}
.strip {
display: flex;
gap: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
.quick {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--mall-line);
border: 1px solid var(--mall-line);
list-style: none;
margin: 0;
padding: 0;
width: 240px;
}
.quick li {
background: #fff;
text-align: center;
}
.quick a {
display: block;
padding: 14px 0;
color: var(--mall-muted);
text-decoration: none;
font-size: 12px;
}
.quick a:hover {
color: var(--mall-red);
}
.quick svg {
display: block;
margin: 0 auto 6px;
}
.promos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.promos img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.floor {
margin-bottom: 30px;
}
.floor-head {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 2px solid var(--mall-red);
margin-bottom: 0;
}
.floor-head h2 {
font-size: 18px;
margin: 0;
padding: 10px 0;
}
.more {
font-size: 12px;
color: var(--mall-muted);
text-decoration: none;
}
.more:hover {
color: var(--mall-red);
}
.floor-body {
display: flex;
gap: 0;
background: #fff;
}
.floor-adv img {
width: 234px;
height: 614px;
display: block;
object-fit: cover;
}
.floor-grid {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
background: var(--mall-line);
}
.floor-grid :deep(.card) {
border: 0;
}
</style>