- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
69 lines
5.5 KiB
Vue
69 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
import { resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters } from '~/composables/useProductCenter';
|
|
|
|
useSeoMeta({ title: '产品中心 | InkReach', description: 'InkReach 产品中心,支持全球 POD 商品分类、筛选、搜索与分页浏览。' });
|
|
|
|
const { categories, countries, tags, tagGroups, products, total, loading, query, loadFilters, fetchProducts, setCategory, setCountry, setTag, clearTags, setKeyword, setPage, setPageSize } = useProductCenter();
|
|
const keyword = shallowRef('');
|
|
const mobileSidebarOpen = shallowRef(false);
|
|
const route = useRoute();
|
|
|
|
await loadFilters();
|
|
const defaults = resolveDefaultProductFilters(categories.value, tags.value);
|
|
query.categoryId = resolveCategoryIdFromQuery(route.query.categoryId, categories.value) ?? defaults.categoryId;
|
|
query.tagIds = defaults.tagIds;
|
|
query.countryId = resolveCountryIdFromQuery(route.query.countryId, countries.value);
|
|
query.pageSize = 20;
|
|
await fetchProducts();
|
|
|
|
watch(() => [query.countryId, query.categoryId, [...query.tagIds], query.keyword, query.page, query.pageSize], () => fetchProducts());
|
|
|
|
function chooseCategory(id: string | null): void { setCategory(id); mobileSidebarOpen.value = false; }
|
|
function search(): void { setKeyword(keyword.value); }
|
|
function clearAll(): void { setCategory(null); setCountry(null); clearTags(); keyword.value = ''; setKeyword(''); }
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page">
|
|
<AppHeader />
|
|
<button class="mobile-filter-trigger" type="button" @click="mobileSidebarOpen = true"><i class="fa-solid fa-sliders"></i>商品分类</button>
|
|
<aside class="sidebar-shell" :class="{ open: mobileSidebarOpen }">
|
|
<button class="drawer-close" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"><i class="fa-solid fa-xmark"></i></button>
|
|
<ProductSidebar :categories="categories" :active-category-id="query.categoryId ?? null" @update:active-category-id="chooseCategory" />
|
|
<!-- <img class="promo" src="/product-promo-banner.png" alt="印美达全球 POD 供应链" /> -->
|
|
</aside>
|
|
<button v-if="mobileSidebarOpen" class="backdrop" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"></button>
|
|
|
|
<main class="main">
|
|
<section class="filters">
|
|
<div class="countries"><ProductCountryFilter :countries="countries" :active-country-id="query.countryId ?? null" @update:active-country-id="setCountry" /></div>
|
|
<div class="filter-tools">
|
|
<ProductTagFilter :tags="tags" :tag-groups="tagGroups" :selected-tag-ids="query.tagIds" @toggle-tag="setTag" />
|
|
<ProductFilterBar v-model:keyword="keyword" @search="search" />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="catalog"><ProductGrid :products="products" :loading="loading" :show-country="!query.countryId" @clear-filters="clearAll" @view-all="clearAll" /></section>
|
|
<ProductPagination v-if="!loading && total" :total="total" :page="query.page" :page-size="query.pageSize" @update:page="setPage" @update:page-size="setPageSize" />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page { --page-gutter: clamp(24px, 3vw, 56px); min-height: 100vh; padding-top: 80px; background: #fff; color: #000; }
|
|
.sidebar-shell { position: fixed; z-index: 30; top: 80px; bottom: 0; left: var(--page-gutter); width: 240px; padding: 0 12px; border-right: 1px solid #e5e7eb; background: #fff; overflow-y: auto; scrollbar-width: none; }
|
|
.sidebar-shell::-webkit-scrollbar { display: none; }
|
|
/* Banner is temporarily hidden because expanded category trees can overlap it. */
|
|
.promo { position: absolute; top: 752px; left: 12px; width: 216px; height: 60px; border-radius: 28px; object-fit: cover; }
|
|
.main { min-width: 0; margin-right: var(--page-gutter); margin-left: calc(240px + var(--page-gutter)); }.filters { position: sticky; z-index: 20; top: 80px; min-width: 0; background: #fff; }.countries { position: relative; min-width: 0; max-width: 100%; padding: 12px; overflow: visible; }.filter-tools { position: relative; display: flex; justify-content: space-between; min-height: 60px; padding: 12px; }
|
|
.countries::after, .filter-tools::after { position: absolute; right: calc(-1 * var(--page-gutter)); bottom: 0; left: 0; height: 1px; background: #e5e7eb; content: ''; pointer-events: none; }
|
|
.catalog { padding: 24px; }.drawer-close,.mobile-filter-trigger,.backdrop { display: none; }
|
|
@media (min-width: 1440px) { .catalog { padding-left: 24px; }.countries { padding-right: 12px; } }
|
|
@media (max-width: 1000px) {
|
|
.mobile-filter-trigger { position: fixed; z-index: 25; top: 92px; left: 16px; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; }
|
|
.sidebar-shell { z-index: 80; top: 0; left: 0; width: 240px; transform: translateX(-100%); transition: transform .2s; }.sidebar-shell.open { transform: translateX(0); }.drawer-close { display: grid; place-items: center; width: 36px; height: 36px; margin: 10px 0 0 auto; border: 0; background: transparent; font-size: 20px; }.backdrop { position: fixed; z-index: 70; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); }
|
|
.main { margin-right: 0; margin-left: 0; }.filters { position: relative; top: 0; }.countries { padding-top: 62px; }.filter-tools { gap: 12px; flex-wrap: wrap; }.countries::after, .filter-tools::after { right: 0; }
|
|
}
|
|
@media (max-width: 760px) { .countries { min-height: auto; }.catalog { padding: 16px; } }
|
|
</style>
|