- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted cart/orders/invoices), runtime switch via mockApi flag (default on) - Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs, stores, brands, banners, floors, seckill/collective/integral, comments, coupons, addresses, seeded orders/shipments/invoices - B2B2C mall shell: top bar, logo/search/cart header, dark nav + category mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme - UI primitives replacing element-plus: carousel, pagination, breadcrumb, qty stepper, rating stars, modal, tabs, step bar, product card - Pages: home floors, search (filters/sort/paging), goods detail (SKU picker, store rail, review tabs), cart -> checkout -> pay -> success, auth pages, user center (dashboard/orders/addresses/favorites/coupons/ invoices), stores, seckill, collective, integral - i18n split into per-domain locale modules (en+zh) - OpenSpec change mall-pc-storefront-replica archived; all specs green
267 lines
8.1 KiB
Vue
267 lines
8.1 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import type { Category } from "@vmall/shared";
|
|
import {
|
|
MOCK_BRANDS,
|
|
MOCK_CATEGORIES,
|
|
childCategories,
|
|
searchMockProducts,
|
|
topCategories,
|
|
} from "~/mock/data";
|
|
|
|
type SortType = "default" | "price" | "sales" | "comments";
|
|
type OrderType = "asc" | "desc";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { locale, t } = useI18n();
|
|
|
|
const queryValue = (value: unknown): string => {
|
|
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
|
|
return typeof value === "string" ? value : "";
|
|
};
|
|
|
|
const state = computed(() => {
|
|
const sortValue = queryValue(route.query.sort);
|
|
const orderValue = queryValue(route.query.order);
|
|
const sort: SortType = ["default", "price", "sales", "comments"].includes(sortValue)
|
|
? (sortValue as SortType)
|
|
: "default";
|
|
const order: OrderType = orderValue === "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 result = computed(() =>
|
|
searchMockProducts({
|
|
q: state.value.q,
|
|
categoryId: state.value.category || undefined,
|
|
brandId: state.value.brand || undefined,
|
|
sort: state.value.sort,
|
|
order: state.value.order,
|
|
page: state.value.page,
|
|
perPage: 20,
|
|
}),
|
|
);
|
|
|
|
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") {
|
|
delete next.sort;
|
|
if (next.order === "desc") 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 => MOCK_CATEGORIES.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 ? childCategories(selected.id) : [];
|
|
});
|
|
|
|
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") },
|
|
{ key: "comments" as SortType, label: t("search.comments") },
|
|
]);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="search-page section-bg">
|
|
<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 topCategories()"
|
|
: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>
|
|
</div>
|
|
</div>
|
|
<div 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 MOCK_BRANDS"
|
|
:key="brand.id"
|
|
type="button"
|
|
:class="{ active: brand.id === state.brand }"
|
|
@click="chooseBrand(brand.id)"
|
|
>{{ brand.name }}</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>
|
|
</section>
|
|
|
|
<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>
|
|
<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>
|