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
+59 -39
View File
@@ -1,69 +1,78 @@
<script setup lang="ts">
const {
countryGroups,
activeCountryIdx,
loading,
loadingProducts,
countryPage,
visibleCountries,
canPrevCountry,
canNextCountry,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
const selectedCountryId = shallowRef<string>();
onMounted(() => {
fetchCategories();
});
async function initialize(): Promise<void> {
await Promise.all([fetchCategories(), loadFilters()]);
const firstCountry = configuredCountries.value[0];
if (firstCountry) switchCountry(firstCountry);
}
function switchCountry(country: { id: string; name: string }): void {
selectedCountryId.value = country.id;
const index = countryGroups.value.findIndex(
(group) => group.name === country.name || group.name.includes(country.name),
);
if (index >= 0) selectCountry(index);
}
function categoryLink(categoryName: string) {
const categoryId = findCategoryIdForPodName(categoryName, configuredCategories.value);
return {
path: '/product-center',
query: {
countryId: selectedCountryId.value,
...(categoryId ? { categoryId } : {}),
},
};
}
onMounted(() => void initialize());
</script>
<template>
<section class="w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
<div class="w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
<div class="text-2xl md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
<section class="pod-section w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
<div class="pod-content w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
<div class="text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
<div v-if="loading" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
</div>
<template v-else>
<div class="flex items-center justify-center gap-4 lg:gap-10 overflow-x-auto pb-2">
<div class="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
<button
v-if="canPrevCountry"
@click="countryPage--"
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
>
<i class="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
v-for="country in visibleCountries"
v-for="country in configuredCountries"
:key="country.id"
@click="selectCountry(countryGroups.indexOf(country))"
class="px-4 lg:px-6 py-2 rounded-lg text-base lg:text-xl transition-colors whitespace-nowrap"
:class="activeCountryIdx === countryGroups.indexOf(country)
? 'bg-primary text-white'
: 'bg-white text-gray-500 hover:text-gray-700'"
type="button"
class="country-button px-4 py-2 rounded-lg transition-colors whitespace-nowrap"
:class="selectedCountryId === country.id ? 'bg-primary text-white' : 'bg-white text-gray-600 hover:text-primary'"
@click="switchCountry(country)"
>
{{ country.name }}
</button>
<button
v-if="canNextCountry"
@click="countryPage++"
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
>
<i class="fa-solid fa-chevron-right text-xs"></i>
<span class="country-flag">
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
</span>
<span>{{ country.name }}</span>
</button>
</div>
<div class="flex justify-center items-center min-h-[200px]">
<div class="product-stage flex justify-center items-center min-h-[200px]">
<div v-if="loadingProducts" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
<div
<NuxtLink
v-for="product in currentProducts"
:key="product.id"
:to="categoryLink(product.name)"
class="bg-white rounded-lg overflow-hidden"
>
<div class="h-[14.25rem] flex items-center justify-center">
@@ -81,20 +90,31 @@ onMounted(() => {
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</div>
</NuxtLink>
</div>
</div>
<div class="w-full flex justify-center py-6 lg:py-10">
<div class="more-products w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="inline-flex items-center gap-2 px-6 lg:px-8 py-2.5 lg:py-3 rounded-lg border-2 border-primary text-primary text-base lg:text-lg font-medium bg-white hover:bg-primary hover:text-white hover:shadow-lg transition-all duration-300 transform hover:-translate-y-0.5"
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
>
更多产品
<i class="fa-solid fa-arrow-right text-xs"></i>
</NuxtLink>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.country-button { display: grid; grid-template-columns: 22px auto; align-items: center; justify-content: center; column-gap: 8px; min-height: 40px; }
.country-flag { display: grid; place-items: center; width: 22px; height: 16px; }
.country-flag img { display: block; width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
.more-products { margin-top: auto; }
@media (min-width: 1024px) {
.pod-section { height: 1007px; padding-top: 54px; }
.pod-content { height: 100%; }
.product-stage { flex: 1; min-height: 0; align-items: flex-start; }
}
</style>