feat(product-center): implement Figma-designed UI, upload module, and website tests

- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases)
- Add API upload module (multer) with static serving for uploads/public assets
- Add OriginGood.delisted flag and SDS request retry logic
- Add admin ImageUpload component and goods import/upload flows
- Add vitest suite for website components and composables (32 tests)
- Add skills, docs, plans and PRODUCT.md
This commit is contained in:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
+47 -366
View File
@@ -1,387 +1,68 @@
<script setup lang="ts">
import type { Tag, Country } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
import { resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters } from '~/composables/useProductCenter';
useSeoMeta({
title: '产品中心 | InkReach',
description:
'InkReach 产品中心:按品类和国家筛选全球 POD 定制商品,支持关键词搜索、分页浏览与多维筛选。',
});
useSeoMeta({ title: '产品中心 | InkReach', description: 'InkReach 产品中心,支持全球 POD 商品分类、筛选、搜索与分页浏览。' });
const stickyTopRef = ref<HTMLElement | null>(null);
const stickySpacerHeight = ref(152);
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
if (!stickyTopRef.value) return;
const updateHeight = () => {
if (stickyTopRef.value) {
stickySpacerHeight.value = stickyTopRef.value.offsetHeight;
}
};
updateHeight();
resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(stickyTopRef.value);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
const {
categories,
countries,
tags,
products,
total,
loading,
query,
loadFilters,
fetchProducts,
setCategory,
setCountry,
setTag,
clearTags,
setKeyword,
setPage,
setPageSize,
} = useProductCenter();
const searchKeyword = ref('');
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();
},
);
watch(() => [query.countryId, query.categoryId, [...query.tagIds], query.keyword, query.page, query.pageSize], () => fetchProducts());
function onSidebarSelect(id: string | null): void {
setCategory(id);
}
function onCountrySelect(id: string | null): void {
setCountry(id);
}
function onToggleTag(id: string): void {
setTag(id);
}
function onSearch(): void {
setKeyword(searchKeyword.value);
}
function onPageChange(page: number): void {
setPage(page);
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function onPageSizeChange(size: number): void {
setPageSize(size);
}
function clearAllFilters(): void {
setCategory(null);
setCountry(null);
clearTags();
searchKeyword.value = '';
setKeyword('');
}
const activeTags = computed(() => {
const list = query.tagIds
.map((id) => tags.value.find((t: Tag) => t.id === id))
.filter(Boolean) as Tag[];
return sortTagsByGroup(list);
});
const activeCountryName = computed(() => {
if (!query.countryId) return null;
return countries.value.find((c: Country) => c.id === query.countryId)?.name ?? null;
});
const showCountry = computed(() => !query.countryId);
const hasActiveFilters = computed(() => {
return query.tagIds.length > 0 || Boolean(query.countryId || query.keyword);
});
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="product-page">
<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="content">
<aside class="sidebar-area">
<ProductSidebar
:categories="categories"
:active-category-id="query.categoryId ?? null"
@update:active-category-id="onSidebarSelect"
/>
</aside>
<section class="main-area">
<div ref="stickyTopRef" class="sticky-top">
<div class="country-section">
<ProductCountryFilter
:countries="countries"
:active-country-id="query.countryId ?? null"
@update:active-country-id="onCountrySelect"
/>
</div>
<div class="filter-section">
<div class="filter-left">
<ProductTagFilter
:tags="tags"
:selected-tag-ids="query.tagIds"
@toggle-tag="onToggleTag"
/>
</div>
<div class="selected-tags">
<span
v-for="tag in activeTags"
:key="'tag-' + tag.id"
class="sel-tag"
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
>
{{ tag.name }}
<button type="button" class="tag-close" @click="onToggleTag(tag.id)"></button>
</span>
<span v-if="query.keyword" class="sel-tag">
"{{ query.keyword }}"
<button type="button" class="tag-close" @click="searchKeyword = ''; onSearch()"></button>
</span>
<span v-if="activeCountryName" class="sel-tag">
{{ activeCountryName }}
<button type="button" class="tag-close" @click="onCountrySelect(null)"></button>
</span>
</div>
<div class="filter-right">
<ProductFilterBar
v-model:keyword="searchKeyword"
@search="onSearch"
/>
</div>
</div>
<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>
<div class="sticky-spacer" :style="{ height: stickySpacerHeight + 'px' }"></div>
<ProductGrid :products="products" :loading="loading" :show-country="showCountry" />
<div v-if="!loading && products.length === 0 && hasActiveFilters" class="clear-all-area">
<button type="button" class="clear-all-btn" @click="clearAllFilters">
清空筛选
</button>
</div>
<ProductPagination
v-if="!loading && total > 0"
:total="total"
:page="query.page"
:page-size="query.pageSize"
@update:page="onPageChange"
@update:page-size="onPageSizeChange"
/>
</section>
</main>
<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>
.product-page {
width: 100%;
min-height: 100vh;
background: #fff;
padding-top: 80px;
}
.content {
display: flex;
}
.sidebar-area {
position: fixed;
left: 0;
top: 80px;
bottom: 0;
width: 240px;
border-right: 1px solid #eee;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sidebar-area::-webkit-scrollbar {
display: none;
}
.main-area {
min-width: 0;
background: #fff;
width: 100%;
margin-left: 240px;
padding: 0 32px 24px;
}
.sticky-spacer {
height: 152px;
}
.sticky-top {
position: fixed;
left: 240px;
right: 0;
top: 80px;
z-index: 10;
background: #fff;
padding: 20px 32px 0;
}
.country-section {
padding: 0 0 14px;
border-bottom: 1px solid #eee;
}
.filter-section {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
}
.filter-left {
flex-shrink: 0;
}
.selected-tags {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.sel-tag {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid;
font-size: 13px;
white-space: nowrap;
}
.tag-close {
position: relative;
width: 14px;
height: 14px;
border: 0;
background: transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.tag-close:hover {
opacity: 1;
}
.tag-close::before,
.tag-close::after {
content: '';
position: absolute;
left: 6px;
top: 1px;
width: 1.5px;
height: 12px;
background: currentColor;
}
.tag-close::before {
transform: rotate(45deg);
}
.tag-close::after {
transform: rotate(-45deg);
}
.filter-right {
flex-shrink: 0;
}
.clear-all-area {
display: flex;
justify-content: center;
padding: 40px 0;
}
.clear-all-btn {
height: 44px;
padding: 0 26px;
border: 0;
border-radius: 10px;
background: #ff6a00;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s ease;
}
.clear-all-btn:hover {
opacity: 0.9;
}
@media (max-width: 900px) {
.sidebar-area {
position: relative;
width: 100%;
top: 0;
border-right: 0;
border-bottom: 1px solid #eee;
overflow-x: auto;
}
.main-area {
margin-left: 0;
}
.sticky-top {
position: relative;
left: 0;
top: 0;
}
.sticky-spacer {
display: none;
}
.filter-section {
flex-wrap: wrap;
}
.filter-right {
width: 100%;
}
.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>