Files
vmall/apps/mall/pages/search.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

172 lines
8.7 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Brand, Category, Paged, Product } from "@vmall/shared";
// Sorts the catalog model can answer: price and real sales. Comments still have
// no model, so that facet stays out.
type SortType = "default" | "price" | "sales";
type OrderType = "asc" | "desc";
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const queryValue = (value: unknown): string => {
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
return typeof value === "string" ? value : "";
};
const parseSort = (raw: string): SortType =>
raw === "price" || raw === "sales" ? raw : "default";
const state = computed(() => {
const sort = parseSort(queryValue(route.query.sort));
const order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
return {
q: queryValue(route.query.q),
category: queryValue(route.query.category),
brand: queryValue(route.query.brand),
sort,
order,
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
};
});
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: 20 };
const { data: result } = await useAsyncData(
"search-results",
() =>
$api.listProducts({
q: state.value.q || undefined,
category_id: state.value.category || undefined,
brand_id: state.value.brand || undefined,
sort: state.value.sort === "default" ? undefined : state.value.sort,
order: state.value.sort === "default" ? undefined : state.value.order,
page: state.value.page,
per_page: 20,
}),
{ watch: [state], default: () => emptyPage },
);
// Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const allCategories = computed(() => categories.value ?? []);
const byPosition = (a: Category, b: Category): number => a.position - b.position;
const topLevelCategories = computed(() =>
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
);
// Shared with the product page; the fixed-data adapter serves the same list.
const { data: brands } = await useAsyncData("brands", () => $api.listBrands(), {
default: () => [] as Brand[],
});
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
const next: Record<string, string> = {};
for (const [key, value] of Object.entries({
q: state.value.q,
category: state.value.category,
brand: state.value.brand,
sort: state.value.sort,
order: state.value.order,
page: String(state.value.page),
...patch,
})) {
if (value) next[key] = value;
}
if (next.sort === "default" || next.sort === undefined) {
delete next.sort;
delete next.order;
}
if (next.page === "1") delete next.page;
await router.replace({ query: next });
};
const chooseCategory = (id?: string): void => {
void replaceQuery({ category: id, page: "1" });
};
const chooseBrand = (id?: string): void => {
void replaceQuery({ brand: id, page: "1" });
};
const chooseSort = (sort: SortType): void => {
const order: OrderType = state.value.sort === sort && sort !== "default"
? state.value.order === "asc" ? "desc" : "asc"
: "asc";
void replaceQuery({ sort, order, page: "1" });
};
const categoryById = (id: string): Category | undefined =>
allCategories.value.find((category) => category.id === id);
const selectedPath = computed(() => {
const path: Category[] = [];
let current = state.value.category ? categoryById(state.value.category) : undefined;
while (current) {
path.unshift(current);
current = current.parent_id ? categoryById(current.parent_id) : undefined;
}
return path;
});
const categoryChildren = computed(() => {
const selected = selectedPath.value[selectedPath.value.length - 1];
return selected
? allCategories.value.filter((category) => category.parent_id === selected.id).sort(byPosition)
: [];
});
const breadcrumbItems = computed(() => [
{ label: t("search.home"), to: "/" },
{
label: state.value.q
? t("search.searchResults", { keyword: state.value.q })
: t("search.productList"),
},
]);
const sortOptions = computed(() => [
{ key: "default" as SortType, label: t("search.defaultSort") },
{ key: "price" as SortType, label: t("search.price") },
{ key: "sales" as SortType, label: t("search.sales") },
]);
</script>
<template>
<div class="min-h-screen bg-bg pb-12 font-sans text-text">
<UiBreadcrumb :items="breadcrumbItems" />
<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="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="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>
</VCard>
<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>