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
+37
View File
@@ -1,5 +1,42 @@
@import "tailwindcss";
@font-face {
font-family: 'PingFang SC';
src: url('/fonts/pingfang-sc-thin.ttf') format('truetype');
font-display: swap;
font-style: normal;
font-weight: 100;
}
@font-face {
font-family: 'PingFang SC';
src: url('/fonts/pingfang-sc-regular.ttf') format('truetype');
font-display: swap;
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: 'PingFang SC';
src: url('/fonts/pingfang-sc-medium.ttf') format('truetype');
font-display: swap;
font-style: normal;
font-weight: 500;
}
@font-face {
font-family: 'PingFang SC';
src: url('/fonts/pingfang-sc-semibold.ttf') format('truetype');
font-display: swap;
font-style: normal;
font-weight: 600 900;
}
html,
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
@theme {
--color-inkreach-homepage-1: #f3f4f5;
--color-inkreach-homepage-2: #f8f9fb;
+1 -8
View File
@@ -3,7 +3,7 @@
<div class="max-w-7xl mx-auto">
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
<div class="md:col-span-2 lg:col-span-1">
<div class="text-2xl font-bold mb-4">Inkreach</div>
<img src="/logo-unified.svg" alt="Inkreach" class="mb-4 h-[56px] w-[97px] object-contain brightness-0 invert" />
<p class="text-gray-400 text-sm">全球POD定制平台, 服务中国卖家出海</p>
</div>
<div class="grid grid-cols-3 gap-4 md:col-span-2 lg:col-span-3">
@@ -45,10 +45,3 @@
</footer>
</template>
<style scoped>
@media (min-width: 901px) {
.product-footer {
margin-left: 240px;
}
}
</style>
+32 -23
View File
@@ -2,7 +2,7 @@
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
const PORTAL_URL = 'https://inkpod.vip/portal/search';
const LOGIN_URL = 'https://inkpod.vip/user/login';
const SHOW_EXTENDED_NAV = false;
const mobileMenuOpen = ref(false);
const recommendMenuOpen = ref(false);
@@ -60,6 +60,10 @@ function closeMobileMenu(): void {
mobileSolutionOpen.value = false;
}
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
function toggleMobileRecommend(): void {
mobileRecommendOpen.value = !mobileRecommendOpen.value;
mobileSolutionOpen.value = false;
@@ -80,11 +84,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
<template>
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
<div>
<img src="/logo-橙@2x.png" alt="Inkreach" class="h-12 lg:h-14" />
</div>
<NuxtLink to="/" aria-label="返回首页">
<img src="/logo-unified.svg" alt="Inkreach" class="h-12 w-[83px] lg:h-[56px] lg:w-[97px] object-contain" />
</NuxtLink>
<nav class="hidden lg:flex gap-6 text-xl flex-1 ml-8 items-center h-20">
<nav class="desktop-nav hidden lg:flex gap-6 flex-1 ml-8 items-center h-20">
<NuxtLink to="/product-center"
class="transition-colors"
:class="isProductCenter
@@ -92,13 +96,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="transition-colors"
:class="isProductCenter
? 'text-gray-400'
: 'hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
class="text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2 transition-colors"
>在线设计</a>
<div
v-if="SHOW_EXTENDED_NAV"
class="relative h-full flex items-center"
@mouseenter="openRecommendMenu"
@mouseleave="closeRecommendMenu"
@@ -107,7 +109,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
class="transition-colors flex items-center gap-1"
:class="[
recommendMenuOpen ? 'text-primary' : '',
isProductCenter && !recommendMenuOpen ? 'text-gray-400' : '',
!recommendMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
@@ -122,6 +124,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
</div>
<div
v-if="SHOW_EXTENDED_NAV"
class="relative h-full flex items-center"
@mouseenter="openSolutionMenu"
@mouseleave="closeSolutionMenu"
@@ -130,7 +133,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
class="transition-colors flex items-center gap-1"
:class="[
solutionMenuOpen ? 'text-primary' : '',
isProductCenter && !solutionMenuOpen ? 'text-gray-400' : '',
!solutionMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
@@ -144,13 +147,13 @@ const isProductCenter = computed(() => route.path === '/product-center');
</div>
</nav>
<div class="hidden lg:flex gap-6 text-xl items-center">
<a :href="LOGIN_URL" target="_blank"
<div class="desktop-nav hidden lg:flex gap-6 items-center">
<button type="button" @click="goToLogin"
class="hover:text-primary hover:underline underline-offset-12 decoration-primary decoration-2 transition-colors"
>登录</a>
<a :href="LOGIN_URL" target="_blank"
>登录</button>
<button type="button" @click="goToLogin"
class="text-white rounded-lg px-6 py-1.5 self-center hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary"
>立即开始</a>
>立即开始</button>
</div>
<button
@@ -178,7 +181,9 @@ const isProductCenter = computed(() => route.path === '/product-center');
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
>
<div class="flex justify-between items-center p-6 pb-4">
<img src="/logo-橙.png" alt="Inkreach" class="h-8" />
<NuxtLink to="/" aria-label="返回首页" @click="closeMobileMenu">
<img src="/logo-unified.svg" alt="Inkreach" class="h-[56px] w-[97px] object-contain" />
</NuxtLink>
<button @click="closeMobileMenu" class="p-2" aria-label="Close menu">
<i class="fa-solid fa-xmark text-2xl"></i>
</button>
@@ -194,7 +199,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
class="py-3 hover:text-primary transition-colors"
>在线设计</a>
<div>
<!-- Temporarily hidden until recommendation content is ready. -->
<div v-if="SHOW_EXTENDED_NAV">
<button
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
@click="toggleMobileRecommend"
@@ -244,7 +250,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
</Transition>
</div>
<div>
<!-- Temporarily hidden until solution content is ready. -->
<div v-if="SHOW_EXTENDED_NAV">
<button
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
@click="toggleMobileSolution"
@@ -291,12 +298,12 @@ const isProductCenter = computed(() => route.path === '/product-center');
</nav>
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
<a :href="LOGIN_URL" target="_blank"
<button type="button" @click="goToLogin"
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
>登录</a>
<a :href="LOGIN_URL" target="_blank"
>登录</button>
<button type="button" @click="goToLogin"
class="text-white rounded-lg px-6 py-3 text-center hover:shadow-lg transition-all duration-300 pulse-animation bg-primary"
>立即开始</a>
>立即开始</button>
</div>
</div>
</Transition>
@@ -304,6 +311,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
</template>
<style scoped>
.desktop-nav { font-size: 18px; }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
@@ -1,5 +1,5 @@
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-cover bg-center bg-no-repeat" style="background-image: url('/公司简介背景.png')">
<section class="company-section py-10 lg:py-16 px-4 lg:px-6 bg-cover bg-center bg-no-repeat" style="background-image: url('/公司简介背景.png')">
<div class="max-w-7xl mx-auto">
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">公司简介</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 mb-8 lg:mb-12">
@@ -48,3 +48,7 @@ const capabilities = [
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .company-section { height: 600px; } }
</style>
+13 -1
View File
@@ -1,9 +1,17 @@
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-primary">
<section class="cta-section py-10 lg:py-16 px-4 lg:px-6 bg-primary">
<div class="max-w-4xl mx-auto text-center">
<h2 class="text-[clamp(1.5rem,4vw,3rem)] font-bold text-white mb-3 lg:mb-4">开始你的POD生意, 现在就行动</h2>
<p class="text-white text-base lg:text-xl mb-6 lg:mb-8">加入成千上万的成功卖家, 开始你的定制商品生意</p>
<button
type="button"
@click="goToLogin"
class="bg-white text-primary px-8 lg:px-10 py-3 lg:py-4 rounded-lg text-base lg:text-lg font-bold hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation"
>
立即免费使用
@@ -12,3 +20,7 @@
</div>
</section>
</template>
<style scoped>
@media (min-width: 1024px) { .cta-section { height: 338px; padding-top: 72px; } }
</style>
+61 -20
View File
@@ -1,28 +1,27 @@
<script setup>
const customerCases = ref([
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
{ id: 6, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: '/case-temu-washed.png' },
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: '/case-tiktok-kit.png' },
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: '/case-shein-print.png' },
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '天然彩棉 亲肤百搭', description: '达人联名系列,持续热卖无库存压力', salesGrowth: '210%', monthlySales: '38W美元', image: '/case-temu-cotton.png' },
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '日系简约 清凉无袖', description: '音乐节应援款,透气轻便人气高', salesGrowth: '240%', monthlySales: '32W美元', image: '/case-tiktok-sleeveless.png' },
]);
</script>
<template>
<section class="py-10 lg:py-16 bg-gray-50 overflow-hidden">
<section class="cases-section py-10 lg:py-16 bg-gray-50 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 lg:px-6">
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">用户案例</h2>
</div>
<div class="hidden lg:block overflow-hidden hover:[&>.marquee-track]:[animation-play-state:paused]">
<div class="marquee-track flex gap-8 w-max animate-marquee">
<div class="hidden lg:block overflow-hidden" aria-label="用户案例自动轮播">
<div class="case-track flex gap-8 w-max">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'a-' + idx"
class="flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
class="case-card relative flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
>
<div class="relative h-48">
<div class="case-media relative h-48">
<span
:class="['absolute top-4 left-4', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-3 py-1 rounded-full text-sm font-medium']"
>
@@ -30,28 +29,30 @@ const customerCases = ref([
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</div>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<div>
<div class="case-details p-6">
<div class="case-metrics flex justify-between items-center mb-4">
<div class="growth-metric">
<p class="text-gray-500 text-sm">销量增长</p>
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
</div>
<div class="text-right">
<div class="sales-metric text-right">
<p class="text-gray-500 text-sm">月销售额</p>
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.monthlySales }}</p>
</div>
</div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-2">{{ testCase.title }}</h3>
<p class="text-gray-600 text-sm">{{ testCase.description }}</p>
<div class="case-copy">
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-2">{{ testCase.title }}</h3>
<p class="text-gray-600 text-sm">{{ testCase.description }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="lg:hidden overflow-x-auto pb-4 px-4">
<div class="flex gap-4 w-max">
<div class="lg:hidden overflow-hidden pb-4 px-4" aria-label="用户案例自动轮播">
<div class="case-track-mobile flex gap-4 w-max">
<div
v-for="(testCase, idx) in customerCases"
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'m-' + idx"
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
>
@@ -82,3 +83,43 @@ const customerCases = ref([
</div>
</section>
</template>
<style scoped>
.case-track { animation: case-marquee 30s linear infinite; }
.case-track-mobile { animation: case-marquee-mobile 30s linear infinite; }
.case-track:hover,
.case-track:focus-within,
.case-track-mobile:hover,
.case-track-mobile:focus-within { animation-play-state: paused; }
@keyframes case-marquee {
to { transform: translateX(calc(-50% - 16px)); }
}
@keyframes case-marquee-mobile {
to { transform: translateX(calc(-50% - 8px)); }
}
@media (prefers-reduced-motion: reduce) {
.case-track,
.case-track-mobile { animation: none; }
}
@media (min-width: 1024px) {
.cases-section { height: 928px; padding-top: 54px; }
.case-track { padding-left: 122px; }
.case-card { width: 464px; height: 500px; }
.case-media { height: 360px; background: #f8f9fb; }
.case-media img { object-fit: contain; }
.case-details { position: absolute; inset: 0; padding: 0; pointer-events: none; }
.case-metrics { display: block; margin: 0; }
.case-metrics > div { position: absolute; right: 16px; width: 240px; text-align: right; }
.growth-metric { top: 148px; }
.sales-metric { top: 253px; }
.case-metrics p:first-child { margin-bottom: 2px; font-size: 16px; }
.case-metrics p:last-child { font-size: 48px; line-height: 1.1; white-space: nowrap; }
.case-copy { position: absolute; top: 392px; left: 0; width: 100%; padding: 0 24px; text-align: center; }
.case-copy h3 { margin-bottom: 12px; font-size: 24px; }
.case-copy p { font-size: 16px; white-space: nowrap; }
}
</style>
+31 -31
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
const activeFeature = ref(0);
const FEATURE_INTERVAL_MS = 2000;
const features = [
{ title: '设计工具', desc: '轻松设计,快速定制', image: '/设计工具.png' },
@@ -12,52 +15,34 @@ const selectFeature = (idx: number) => {
activeFeature.value = idx;
};
let timer: ReturnType<typeof setInterval> | null = null;
let featureTimer: ReturnType<typeof setInterval> | null = null;
const startAutoPlay = () => {
stopAutoPlay();
timer = setInterval(() => {
onMounted(() => {
featureTimer = setInterval(() => {
activeFeature.value = (activeFeature.value + 1) % features.length;
}, 3000);
};
}, FEATURE_INTERVAL_MS);
});
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const pauseAutoPlay = () => {
stopAutoPlay();
};
const resumeAutoPlay = () => {
startAutoPlay();
};
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
onBeforeUnmount(() => {
if (featureTimer) clearInterval(featureTimer);
});
</script>
<template>
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-2xl md:text-4xl lg:text-5xl text-center">用强大功能开启盈利快车道</div>
<section class="feature-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">用强大功能开启盈利快车道</div>
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
<div class="flex flex-row lg:flex-col justify-start lg:justify-between gap-3 lg:gap-0 overflow-x-auto lg:overflow-visible"
@mouseenter="pauseAutoPlay"
@mouseleave="resumeAutoPlay"
>
<div class="feature-list flex flex-row gap-3 overflow-x-auto lg:overflow-visible">
<div
v-for="(feat, idx) in features"
:key="idx"
@click="selectFeature(idx)"
@mouseenter="activeFeature = idx"
class="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-5 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-38 rounded-lg cursor-pointer transition-colors duration-300"
class="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-4 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-auto rounded-lg cursor-pointer transition-colors duration-300"
:class="activeFeature === idx ? 'bg-primary text-white' : 'bg-inkreach-homepage-2'"
>
<p class="text-xl lg:text-4xl">{{ feat.title }}</p>
<p class="feature-item-title text-xl lg:text-3xl">{{ feat.title }}</p>
<p class="text-sm lg:text-base indent-1">{{ feat.desc }}</p>
</div>
</div>
@@ -68,6 +53,7 @@ onUnmounted(stopAutoPlay);
:key="'feat-img-' + idx"
:src="feat.image"
:alt="feat.title"
:data-testid="`feature-image-${idx}`"
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
:class="activeFeature === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
/>
@@ -75,3 +61,17 @@ onUnmounted(stopAutoPlay);
</div>
</section>
</template>
<style scoped>
@media (min-width: 1024px) {
.feature-section { height: 754px; padding-top: 54px; }
.feature-section > div:nth-child(2) { height: 550px; }
.feature-list {
display: grid;
grid-template-rows: repeat(4, minmax(0, 1fr));
width: 400px;
height: 100%;
gap: 12px;
}
}
</style>
+48 -11
View File
@@ -1,5 +1,11 @@
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<section class="w-full bg-inkreach-homepage-1 flex flex-col items-center gap-6 lg:gap-10 p-6 lg:p-10 relative overflow-hidden">
<section class="hero w-full bg-inkreach-homepage-1 flex flex-col items-center gap-6 lg:gap-10 p-6 lg:p-10 relative overflow-hidden">
<img
src="/水洗T恤.png"
alt=""
@@ -11,21 +17,25 @@
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
/>
<div class="flex flex-col lg:flex-row gap-2 lg:gap-4 text-3xl md:text-5xl lg:text-7xl font-extrabold text-center mt-4 lg:mt-0">
<div>一站定制</div>
<div>通全球</div>
<div class="hero-copy flex flex-col items-center lg:items-start gap-3 mt-4 lg:mt-0">
<div class="hero-title text-3xl md:text-5xl font-extrabold text-center lg:text-left">
一站定制 <span class="hero-gradient-text">通全球</span>
</div>
<p class="hero-subtitle font-thin text-xl md:text-2xl text-center lg:text-left">从定制设计至生产发货如此简单</p>
</div>
<p class="text-xl md:text-2xl lg:text-4xl text-center">从定制设计至生产发货如此简单</p>
<div class="flex gap-3 text-base lg:text-xl">
<div>0库存</div>
<div>0关税</div>
<div>1件起订</div>
<div class="selling-points flex items-center gap-3 text-base lg:text-xl">
<span>0库存</span>
<span class="selling-point-separator" aria-hidden="true">|</span>
<span>0关税</span>
<span class="selling-point-separator" aria-hidden="true">|</span>
<span>1件起订</span>
</div>
<div class="relative">
<button
type="button"
@click="goToLogin"
class="text-lg lg:text-xl text-white px-10 lg:px-14 py-3 lg:py-4 rounded-lg hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary relative z-10"
>
免费试用
@@ -37,6 +47,33 @@
/>
</div>
<img src="/主视觉.png" class="w-full max-w-5xl" />
<img
src="/主视觉.png"
alt="InkReach 全球 POD 定制商品"
width="800"
height="400"
class="hero-visual"
/>
</section>
</template>
<style scoped>
@media (min-width: 1024px) {
.hero { height: auto; min-height: 0; margin-top: 80px; padding-top: 52px; padding-bottom: 32px; gap: 18px; }
.hero-copy { align-items: flex-start; }
.hero-title { font-size: 80px; line-height: 1.25; }
.hero-subtitle { font-size: 48px; line-height: 1.4; }
.hero-visual { flex: 0 0 auto; width: min(100%, 800px); height: auto; object-fit: contain; }
}
.hero-visual { width: min(100%, 800px); height: auto; object-fit: contain; }
.hero-gradient-text {
display: inline-block;
background: linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%);
background-clip: text;
color: transparent;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.selling-point-separator { color: #9ca3af; }
</style>
+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>
+41 -36
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
const activeStep = ref(0);
const STEP_INTERVAL_MS = 2000;
const steps = [
{ num: '1', title: '选择产品', desc: '海量精选产品,满足多元定制需求', image: '/01步骤-选择产品.png' },
@@ -10,63 +13,50 @@ const steps = [
const selectStep = (idx: number) => {
activeStep.value = idx;
startStepTimer();
};
let timer: ReturnType<typeof setInterval> | null = null;
let stepTimer: ReturnType<typeof setInterval> | null = null;
const startAutoPlay = () => {
stopAutoPlay();
timer = setInterval(() => {
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
function startStepTimer(): void {
if (stepTimer) clearInterval(stepTimer);
stepTimer = setInterval(() => {
activeStep.value = (activeStep.value + 1) % steps.length;
}, 3000);
};
}, STEP_INTERVAL_MS);
}
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
onMounted(startStepTimer);
const pauseAutoPlay = () => {
stopAutoPlay();
};
const resumeAutoPlay = () => {
startAutoPlay();
};
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
onBeforeUnmount(() => {
if (stepTimer) clearInterval(stepTimer);
});
</script>
<template>
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-2xl md:text-4xl lg:text-6xl text-center">4 步开始你的按需定制生意</div>
<section class="step-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-[48px] font-extrabold md:text-4xl lg:text-6xl text-center">4步开始你的按需定制生意</div>
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
<div class="flex flex-row lg:flex-col gap-3 lg:gap-10 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center"
@mouseenter="pauseAutoPlay"
@mouseleave="resumeAutoPlay"
>
<div class="flex flex-row lg:flex-col gap-3 lg:gap-8 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex-shrink-0 flex flex-col gap-2 lg:gap-5 cursor-pointer group min-w-[140px] lg:min-w-0"
@click="selectStep(idx)"
@mouseenter="activeStep = idx"
@mouseenter="selectStep(idx)"
>
<p class="text-xl lg:text-3xl font-bold">{{ step.num }} {{ step.title }}</p>
<p class="step-title text-xl lg:text-4xl">{{ step.num }} {{ step.title }}</p>
<p class="hidden lg:block indent-10 text-gray-500 text-xl">{{ step.desc }}</p>
<div class="h-0.5 w-full rounded-full bg-gray-200 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="activeStep === idx ? 'w-1/2 bg-primary' : 'w-0 bg-primary'"
></div>
<div v-if="activeStep === idx" class="step-progress h-full rounded-full bg-primary"></div>
</div>
</div>
<div class="hidden lg:flex items-center pt-6">
<button class="text-xl lg:text-2xl text-white bg-primary rounded-lg px-10 lg:px-14 py-3 lg:py-4 hover:opacity-90 transition-opacity">
<button type="button" class="text-xl lg:text-2xl text-white bg-primary rounded-lg px-10 lg:px-14 py-3 lg:py-4 hover:opacity-90 transition-opacity" @click="goToLogin">
立即开始
</button>
</div>
@@ -78,6 +68,7 @@ onUnmounted(stopAutoPlay);
:key="'img-' + idx"
:src="step.image"
:alt="step.title"
:data-testid="`step-image-${idx}`"
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
:class="activeStep === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
/>
@@ -85,9 +76,23 @@ onUnmounted(stopAutoPlay);
</div>
<div class="flex lg:hidden">
<button class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity">
<button type="button" class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity" @click="goToLogin">
立即开始
</button>
</div>
</section>
</template>
<style scoped>
.step-progress { width: 0; animation: fill-step-progress 2s linear forwards; }
@keyframes fill-step-progress {
to { width: 100%; }
}
@media (min-width: 1024px) {
.step-section { height: 935px; padding-top: 54px; }
.step-section > div:first-child { font-size: 48px; }
.step-section > div:nth-child(2) { height: 708px; }
}
</style>
+11 -4
View File
@@ -1,6 +1,6 @@
<template>
<section class="w-full bg-inkreach-homepage-2 flex flex-col justify-center items-center gap-6 lg:gap-10 p-6 lg:p-10 py-12 lg:py-0 lg:h-138">
<div class="flex gap-2 text-2xl md:text-4xl lg:text-5xl text-center">全球5000+卖家信赖我们的平台</div>
<section class="trust-section w-full bg-inkreach-homepage-2 flex flex-col justify-center items-center gap-6 lg:gap-10 p-6 lg:p-10 py-12 lg:py-0 lg:h-138">
<div class="flex gap-2 text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">全球5000+卖家信赖我们的平台</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-14 w-full max-w-5xl">
<div
@@ -10,11 +10,11 @@
>
<div class="bg-inkreach-homepage-3">
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
<div>{{ item.value }}</div>
<div class="trust-stat-value text-primary font-black">{{ item.value }}</div>
<div>{{ item.label }}</div>
</div>
</div>
<div class="relative -top-6 lg:-top-10">
<div class="trust-illustration relative -top-6 lg:-top-10">
<img :src="item.icon" :alt="item.label" />
</div>
</div>
@@ -30,3 +30,10 @@ const stats = [
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) {
.trust-section { height: 600px; }
.trust-illustration { margin-bottom: -40px; }
}
</style>
+5 -1
View File
@@ -1,5 +1,5 @@
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
<section class="why-section py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
<div class="max-w-7xl mx-auto">
<h2 class="text-2xl md:text-4xl lg:text-5xl font-bold text-center mb-8 lg:mb-10">
为什么选择<span
@@ -30,3 +30,7 @@ const reasons = [
{ title: '全球配送', icon: '/全球配送图标.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .why-section { height: 430px; } }
</style>
@@ -3,146 +3,44 @@ import { computed } from 'vue';
import type { Product } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
const props = defineProps<{
product: Product;
showCountry?: boolean;
}>();
const sortedTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 4));
function formatPrice(price: string | null): string {
if (!price) return '—';
const num = Number(price);
if (!Number.isFinite(num)) return '—';
return `¥${num.toFixed(2)}`;
}
const props = defineProps<{ product: Product; showCountry?: boolean }>();
const visibleTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 3));
const price = computed(() => {
const value = Number(props.product.price);
return Number.isFinite(value) ? value.toFixed(2) : null;
});
</script>
<template>
<article class="product-card">
<a class="product-card" :href="`https://inkpod.vip/portal/detail/${product.id}`">
<div class="product-media">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="product-img"
loading="lazy"
/>
<div v-else class="placeholder">
<i class="fa-solid fa-image"></i>
</div>
<img v-if="product.image" :src="product.image" :alt="product.name" loading="lazy" />
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
</div>
<div class="product-info">
<h3 class="product-title" :title="product.name">{{ product.name }}</h3>
<div v-if="sortedTags.length > 0 || showCountry" class="chips">
<span
v-for="tag in sortedTags"
:key="tag.id"
class="chip"
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
>
{{ tag.name }}
</span>
<span v-if="showCountry" class="chip country-chip">
<img
v-if="product.country.icon"
:src="product.country.icon"
:alt="product.country.name"
class="country-flag"
/>
{{ product.country.name }}
</span>
<h3 :title="product.name">{{ product.name }}</h3>
<div class="chips">
<span v-for="tag in visibleTags" :key="tag.id" class="chip" :style="{ color: tag.fontColor || tag.color || '#21834b', backgroundColor: `${tag.color || '#eaf8ef'}30` }">{{ tag.name }}</span>
<span v-if="showCountry" class="chip country">{{ product.country.name }}</span>
</div>
<div class="price">{{ formatPrice(product.price) }}</div>
<div class="price"><template v-if="price"><small></small>{{ price }}<small></small></template><template v-else>暂无报价</template></div>
</div>
</article>
</a>
</template>
<style scoped>
.product-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
border: 1px solid #eee;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.product-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.06);
}
.product-media {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1 / 1;
background: linear-gradient(180deg, #f8f8f8, #f0f0f0);
overflow: hidden;
}
.product-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
color: #ccc;
font-size: 32px;
}
.product-info {
padding: 14px;
}
.product-title {
margin: 0 0 12px;
font-size: 19px;
line-height: 1.4;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #111;
}
.chips {
display: flex;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 5px;
color: #ff6a00;
background: #fff2e8;
font-size: 14px;
white-space: nowrap;
}
.country-chip {
color: #555;
background: #f3f4f6;
font-size: 14px;
}
.country-flag {
width: 16px;
height: 12px;
border-radius: 2px;
object-fit: cover;
margin-right: 4px;
}
.price {
color: #222;
font-size: 20px;
font-weight: 700;
.product-card { display: block; width: 100%; height: 382px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: inherit; text-decoration: none; transition: box-shadow .2s, transform .2s; }.product-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.08); }
.product-media { display: grid; place-items: center; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 8px; background: #f2f2f2; color: #bbb; font-size: 32px; }
.product-media img { width: 100%; height: 100%; object-fit: cover; }
.product-info { padding: 7px 12px 9px; }
.product-info h3 { margin: 0 0 5px; overflow: hidden; color: #000; font-size: 18px; font-weight: 500; line-height: 25px; text-overflow: ellipsis; white-space: nowrap; }
.chips { display: flex; gap: 6px; height: 21px; margin-bottom: 5px; overflow: hidden; }
.chip { display: inline-flex; align-items: center; flex: 0 0 auto; height: 21px; padding: 0 6px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
.chip.country { color: #535353; background: #ececef; }
.price { margin-top: 5px; color: #000; font-size: 20px; font-weight: 700; line-height: 28px; }
.price small { font-size: 16px; font-weight: 500; }
@media (max-width: 760px) {
.product-card { height: auto; min-height: 0; max-width: none; overflow: visible; }
.product-info h3 { font-size: 15px; }
}
</style>
@@ -1,6 +1,6 @@
<template>
<article class="skeleton-card">
<div class="skeleton-media"></div>
<div class="skeleton-media"><i class="fa-solid fa-image" aria-hidden="true"></i></div>
<div class="skeleton-info">
<div class="skeleton-line title"></div>
<div class="skeleton-chips">
@@ -31,10 +31,14 @@
}
.skeleton-media {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1 / 1;
}
.skeleton-media i { position: relative; z-index: 1; color: #c2c2c2; font-size: 44px; }
.skeleton-media::after,
.skeleton-line::after,
.skeleton-chip::after {
@@ -0,0 +1,19 @@
<script setup lang="ts">
const portalUrl = 'https://inkpod.vip/portal/search';
const loginUrl = 'https://inkpod.vip/user/login';
</script>
<template>
<header class="header">
<NuxtLink to="/" class="logo"><img src="/logo-unified.svg" alt="InkReach" /></NuxtLink>
<nav><NuxtLink to="/product-center" class="active">产品中心</NuxtLink><a :href="portalUrl" target="_blank">在线设计</a><a href="#">选品推荐</a><a href="#">解决方案</a></nav>
<div class="actions"><a :href="loginUrl" target="_blank"><i class="fa-solid fa-desktop"></i>工作台</a><a :href="loginUrl" target="_blank"><i class="fa-solid fa-cart-shopping"></i>购物车</a><a class="phone" href="tel:18588980344"><i class="fa-solid fa-circle-user"></i>18588980344<i class="fa-solid fa-chevron-down"></i></a></div>
</header>
</template>
<style scoped>
.header { position: fixed; z-index: 60; top: 0; left: 0; right: 0; display: flex; align-items: center; height: 80px; padding: 0 70px; border-bottom: 1px solid #e5e7eb; background: #fff; }
.logo { width: 97px; height: 56px; display: grid; place-items: center; }.logo img { width: 97px; height: 56px; object-fit: contain; }
nav { display: flex; gap: 32px; margin-left: 44px; } nav a { color: rgba(0,0,0,.5); font-size: 18px; text-decoration: none; white-space: nowrap; } nav .active { color: #000; }
.actions { display: flex; align-items: center; gap: 24px; margin-left: auto; }.actions a { display: flex; align-items: center; gap: 8px; color: rgba(0,0,0,.62); font-size: 16px; text-decoration: none; white-space: nowrap; }.actions i { font-size: 18px; }.actions .phone { color: #222; font-size: 18px; }.phone .fa-chevron-down { margin-left: 4px; font-size: 10px; }
@media (max-width: 1000px) { .header { padding: 0 20px; }.header nav { display: none; }.actions a:not(.phone) { display: none; } }
@media (max-width: 520px) { .actions .phone { font-size: 0; }.actions .phone i { font-size: 20px; } }
</style>
@@ -1,89 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Country } from '~/composables/useProductCenter';
defineProps<{
countries: Country[];
activeCountryId: string | null;
}>();
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
const emit = defineEmits<{
'update:activeCountryId': [id: string | null];
}>();
function selectCountry(id: string | null): void {
emit('update:activeCountryId', id);
}
const configuredCountries = computed(() => props.countries);
</script>
<template>
<div class="country-bar">
<button
type="button"
class="filter-pill"
:class="{ active: activeCountryId === null }"
@click="selectCountry(null)"
>
全部
</button>
<button
v-for="country in countries"
:key="country.id"
type="button"
class="filter-pill"
:class="{ active: activeCountryId === country.id }"
@click="selectCountry(country.id)"
>
<img
v-if="country.icon"
:src="country.icon"
:alt="country.name"
class="flag"
/>
{{ country.name }}
</button>
<div class="country-bar" aria-label="国家筛选">
<button type="button" class="filter-pill" :class="{ active: activeCountryId === null }" @click="emit('update:activeCountryId', null)">全部</button>
<template v-for="country in configuredCountries" :key="country.id">
<button type="button" class="filter-pill" :class="{ active: activeCountryId === country.id }" @click="emit('update:activeCountryId', country.id)">
<img v-if="country.icon" :src="country.icon" :alt="country.name" class="flag" />
{{ country.name }}
</button>
</template>
</div>
</template>
<style scoped>
.country-bar {
display: flex;
align-items: center;
gap: 10px;
overflow-x: auto;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid #eee;
background: #fff;
font-size: 16px;
color: #555;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
flex-shrink: 0;
}
.filter-pill:hover {
border-color: #ddd;
color: #222;
}
.filter-pill.active {
color: #ff6a00;
border-color: #ff6a00;
background: #fff2e8;
}
.flag {
width: 18px;
height: 12px;
border-radius: 2px;
object-fit: cover;
}
.country-bar { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; max-width: 100%; flex-wrap: wrap; }
.filter-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 38px; padding: 0 16px; border: 0; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; white-space: nowrap; cursor: pointer; }
.filter-pill.active { color: #ff6800; background: rgba(255, 104, 0, .06); }
.flag { width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
@media (max-width: 760px) { .country-bar { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } }
</style>
@@ -1,108 +1,28 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
const keyword = defineModel<string>('keyword', { default: '' });
const emit = defineEmits<{
search: [];
}>();
const localKeyword = ref(keyword.value);
watch(keyword, (v) => {
localKeyword.value = v;
});
function onSearch(): void {
keyword.value = localKeyword.value.trim();
emit('search');
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
onSearch();
}
}
function clearKeyword(): void {
localKeyword.value = '';
keyword.value = '';
emit('search');
}
const emit = defineEmits<{ search: [] }>();
const localKeyword = shallowRef(keyword.value);
watch(keyword, (value) => { localKeyword.value = value; });
function submit(): void { keyword.value = localKeyword.value.trim(); emit('search'); }
function clear(): void { localKeyword.value = ''; keyword.value = ''; emit('search'); }
</script>
<template>
<div class="search-box">
<input
v-model="localKeyword"
type="text"
placeholder="搜索商品"
@keydown="onKeydown"
/>
<button v-if="localKeyword" type="button" class="clear-btn" @click="clearKeyword">
<i class="fa-solid fa-times"></i>
</button>
<button type="button" class="search-btn" @click="onSearch">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
<form class="search-box" role="search" @submit.prevent="submit">
<i class="fa-solid fa-magnifying-glass search-icon" aria-hidden="true"></i>
<input v-model="localKeyword" aria-label="搜索商品" placeholder="搜索商品" />
<button v-if="localKeyword" class="clear-search" type="button" aria-label="清除搜索" @click="clear"><i class="fa-solid fa-xmark"></i></button>
<button class="submit-search" type="submit">搜索</button>
</form>
</template>
<style scoped>
.search-box {
display: flex;
align-items: stretch;
height: 38px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.search-box:focus-within {
border-color: #ff6a00;
}
.search-box input {
width: 200px;
padding: 0 12px;
font-size: 14px;
border: 0;
outline: none;
background: transparent;
color: #222;
}
.search-box input::placeholder {
color: #aaa;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
border: 0;
background: transparent;
color: #999;
cursor: pointer;
}
.clear-btn:hover {
color: #555;
}
.search-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
border: 0;
background: #ff6a00;
color: #fff;
cursor: pointer;
transition: opacity 0.2s ease;
}
.search-btn:hover {
opacity: 0.9;
}
.search-box { position: relative; display: flex; align-items: center; width: 220px; height: 38px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; overflow: hidden; }
.search-icon { position: absolute; left: 12px; color: #666; font-size: 14px; }
.search-box input { min-width: 0; flex: 1; height: 100%; padding: 0 2px 0 34px; border: 0; outline: 0; background: transparent; font-size: 16px; }
.search-box button { flex: 0 0 auto; height: 34px; padding: 0; border: 0; background: transparent; cursor: pointer; }
.search-box .clear-search { width: 28px; color: #777; font-size: 14px; }.search-box .submit-search { width: 50px; color: #ff6800; font-size: 16px; }
@media (max-width: 760px) { .search-box { width: 100%; } }
</style>
@@ -1,127 +1,24 @@
<script setup lang="ts">
import type { Product } from '~/composables/useProductCenter';
const props = defineProps<{
products: Product[];
loading: boolean;
showCountry?: boolean;
}>();
const skeletonCount = 12;
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
</script>
<template>
<div>
<div v-if="loading" class="product-grid">
<ProductCardSkeleton
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
/>
</div>
<div v-else-if="products.length === 0" class="empty-state">
<div class="empty-art">
<div class="empty-box"></div>
<div class="empty-lid"></div>
<div class="empty-spark spark-1"></div>
<div class="empty-spark spark-2"></div>
</div>
<h2>暂无匹配商品</h2>
<p>试试清空筛选条件或切换其他分类看看</p>
</div>
<div v-else class="product-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
:show-country="showCountry"
/>
</div>
<div v-if="loading" class="product-grid"><ProductCardSkeleton v-for="index in 8" :key="index" /></div>
<div v-else-if="!products.length" class="empty">
<img src="/product-empty.svg" alt="" />
<h2>未找到相关商品</h2>
<p>当前筛选条件下暂无匹配产品可尝试调整关键词或减少筛选条件</p>
<div class="empty-actions"><button type="button" class="view-all" @click="emit('view-all')">查看全部商品</button><button type="button" class="clear" @click="emit('clear-filters')">清空筛选</button></div>
</div>
<div v-else class="product-grid"><ProductCard v-for="product in products" :key="product.id" :product="product" :show-country="showCountry" /></div>
</template>
<style scoped>
.product-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 28px;
}
.empty-state {
display: grid;
place-items: center;
min-height: 480px;
padding: 80px 0;
text-align: center;
}
.empty-art {
position: relative;
width: 220px;
height: 176px;
margin: 0 auto 28px;
}
.empty-box {
position: absolute;
left: 35px;
top: 42px;
width: 150px;
height: 94px;
border-radius: 18px;
background: #f2f2f2;
transform: perspective(400px) rotateX(58deg) rotateZ(-8deg);
}
.empty-lid {
position: absolute;
left: 48px;
top: 20px;
width: 124px;
height: 64px;
border-radius: 16px;
background: #e8e8e8;
transform: rotate(-8deg);
}
.empty-spark {
position: absolute;
border-radius: 999px;
background: #ff6a00;
}
.spark-1 {
left: 21px;
top: 52px;
width: 12px;
height: 12px;
}
.spark-2 {
right: 26px;
top: 18px;
width: 8px;
height: 8px;
}
.empty-state h2 {
margin: 0 0 10px;
font-size: 24px;
font-weight: 600;
color: #111;
}
.empty-state p {
margin: 0;
color: #777;
font-size: 16px;
}
@media (max-width: 900px) {
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
}
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px 24px; }
.empty { display: flex; flex-direction: column; align-items: center; min-height: 600px; padding-top: 86px; color: rgba(0,0,0,.6); text-align: center; }.empty img { width: 260px; height: 260px; object-fit: contain; }.empty h2 { margin: 12px 0 10px; color: #000; font-size: 22px; font-weight: 600; }.empty p { margin: 0; font-size: 14px; }.empty-actions { display: flex; gap: 24px; margin-top: 34px; }.empty-actions button { height: 38px; padding: 0 16px; border: 0; border-radius: 60px; font-size: 16px; cursor: pointer; }.empty-actions .view-all { color: #ff6800; background: rgba(255,104,0,.06); }.empty-actions .clear { min-width: 128px; color: #fff; background: #ff6800; }
@media (max-width: 1180px) { .product-grid { gap: 16px; } }
@media (max-width: 760px) { .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 430px) { .product-grid { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -1,249 +1,25 @@
<script setup lang="ts">
const props = defineProps<{
total: number;
page: number;
pageSize: number;
}>();
const emit = defineEmits<{
'update:page': [page: number];
'update:pageSize': [size: number];
}>();
const totalPages = computed(() => {
if (props.total <= 0) return 1;
return Math.max(1, Math.ceil(props.total / props.pageSize));
});
const pageNumbers = computed<(number | string)[]>(() => {
const total = totalPages.value;
const current = props.page;
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | string)[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push('...');
pages.push(total);
return pages;
});
const canPrev = computed(() => props.page > 1);
const canNext = computed(() => props.page < totalPages.value);
const gotoInput = ref('');
function goPrev(): void {
if (canPrev.value) emit('update:page', props.page - 1);
}
function goNext(): void {
if (canNext.value) emit('update:page', props.page + 1);
}
function goToPage(page: number): void {
if (page < 1 || page > totalPages.value || page === props.page) return;
emit('update:page', page);
}
function onPageSizeChange(event: Event): void {
const target = event.target as HTMLSelectElement;
const size = Number(target.value);
if (!Number.isFinite(size) || size <= 0) return;
emit('update:pageSize', size);
}
function onGoto(): void {
const value = Number(gotoInput.value);
if (!Number.isFinite(value)) return;
const clamped = Math.min(totalPages.value, Math.max(1, Math.floor(value)));
gotoInput.value = '';
goToPage(clamped);
}
function onGotoKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
onGoto();
}
}
const props = defineProps<{ total: number; page: number; pageSize: number }>();
const emit = defineEmits<{ 'update:page': [page: number]; 'update:pageSize': [size: number] }>();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const pages = computed<(number | string)[]>(() => totalPages.value <= 7 ? Array.from({ length: totalPages.value }, (_, i) => i + 1) : [1, 2, 3, 4, 5, '•••', totalPages.value]);
const goto = shallowRef('');
function go(page: number): void { if (page >= 1 && page <= totalPages.value && page !== props.page) emit('update:page', page); }
function jump(): void { go(Math.floor(Number(goto.value))); goto.value = ''; }
</script>
<template>
<div class="pagination">
<div class="total-text">
总计 <span>{{ total }}</span> 个产品
</div>
<div class="page-buttons">
<button
type="button"
class="nav-btn"
:disabled="!canPrev"
@click="goPrev"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button
v-for="(item, idx) in pageNumbers"
:key="`page-${idx}-${item}`"
type="button"
class="page-btn"
:class="{
current: item === page,
dots: item === '...',
}"
:disabled="item === '...'"
@click="typeof item === 'number' && goToPage(item)"
>
{{ item }}
</button>
<button
type="button"
class="nav-btn"
:disabled="!canNext"
@click="goNext"
>
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
<div class="right-controls">
<select :value="pageSize" class="size-select" @change="onPageSizeChange">
<option :value="12">12 / </option>
<option :value="20">20 / </option>
<option :value="40">40 / </option>
</select>
<span class="goto-label">导航至</span>
<input
v-model="gotoInput"
type="number"
min="1"
:max="totalPages"
class="goto-input"
@keydown="onGotoKeydown"
/>
<button type="button" class="goto-btn" @click="onGoto">Go</button>
</div>
<strong>总计{{ total }}个产品</strong>
<button type="button" :disabled="page <= 1" aria-label="上一页" @click="go(page - 1)"><i class="fa-solid fa-chevron-left"></i></button>
<template v-for="item in pages" :key="item"><span v-if="typeof item === 'string'" class="dots">{{ item }}</span><button v-else type="button" :class="{ current: item === page }" @click="go(item)">{{ item }}</button></template>
<button type="button" :disabled="page >= totalPages" aria-label="下一页" @click="go(page + 1)"><i class="fa-solid fa-chevron-right"></i></button>
<select :value="pageSize" aria-label="每页数量" @change="emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"><option :value="10">10 / 每页</option><option :value="20">20 / 每页</option><option :value="40">40 / 每页</option></select>
<label>导航至<input v-model="goto" type="number" min="1" :max="totalPages" @keydown.enter.prevent="jump" /></label>
</div>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
font-size: 14px;
color: #777;
}
.total-text span {
font-weight: 600;
color: #222;
}
.page-buttons {
display: flex;
align-items: center;
gap: 4px;
}
.nav-btn,
.page-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid #eee;
background: #fff;
color: #555;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.nav-btn:hover:not(:disabled),
.page-btn:hover:not(.dots):not(.current) {
border-color: #ff6a00;
color: #ff6a00;
}
.nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-btn.current {
background: #ff6a00;
border-color: #ff6a00;
color: #fff;
}
.page-btn.dots {
border-color: transparent;
cursor: default;
}
.right-controls {
display: flex;
align-items: center;
gap: 8px;
}
.size-select {
height: 32px;
padding: 0 8px;
border: 1px solid #eee;
border-radius: 6px;
background: #fff;
font-size: 14px;
color: #555;
outline: none;
}
.goto-label {
color: #999;
}
.goto-input {
width: 48px;
height: 32px;
padding: 0 8px;
border: 1px solid #eee;
border-radius: 6px;
background: #fff;
font-size: 14px;
color: #222;
outline: none;
text-align: center;
}
.goto-input:focus {
border-color: #ff6a00;
}
.goto-btn {
height: 32px;
padding: 0 12px;
border: 1px solid #ff6a00;
border-radius: 6px;
background: transparent;
color: #ff6a00;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.goto-btn:hover {
background: #ff6a00;
color: #fff;
}
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px 0; color: rgba(0,0,0,.85); font-size: 14px; }.pagination strong { margin-right: 2px; font-size: 13px; }
button { display: grid; place-items: center; min-width: 35px; height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; }button.current { color: #ff6800; border-color: #ff6800; }button:disabled { opacity: .4; }.dots { width: 35px; text-align: center; color: rgba(0,0,0,.3); letter-spacing: 2px; }
select,input { height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; }select { width: 112px; padding: 0 12px; }label { display: flex; align-items: center; gap: 8px; }input { width: 55px; padding: 0 8px; }
@media (max-width: 760px) { .pagination { flex-wrap: wrap; }.pagination strong { width: 100%; text-align: center; } }
</style>
@@ -1,257 +1,45 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import type { Category } from '~/composables/useProductCenter';
const props = defineProps<{
categories: Category[];
activeCategoryId: string | null;
}>();
const emit = defineEmits<{
'update:activeCategoryId': [id: string | null];
}>();
const expanded = ref<Record<string, boolean>>({});
function toggleExpand(id: string): void {
expanded.value[id] = !expanded.value[id];
const props = defineProps<{ categories: Category[]; activeCategoryId: string | null }>();
const emit = defineEmits<{ 'update:activeCategoryId': [id: string | null] }>();
const expanded = reactive<Record<string, boolean>>({});
function selectRootCategory(category: Category): void {
emit('update:activeCategoryId', category.id);
if (category.children.length) expanded[category.id] = !expanded[category.id];
}
function isExpanded(id: string): boolean {
return Boolean(expanded.value[id]);
}
function selectCategory(id: string | null): void {
emit('update:activeCategoryId', id);
}
function shouldAutoExpand(id: string): boolean {
if (props.activeCategoryId === null) return false;
if (props.activeCategoryId === id) return true;
const cat = props.categories.find((c) => c.id === id);
if (!cat) return false;
return cat.children.some((c) => c.id === props.activeCategoryId);
}
watch(
() => props.categories,
(cats) => {
for (const c of cats) {
if (shouldAutoExpand(c.id)) {
expanded.value[c.id] = true;
}
}
},
{ immediate: true },
);
watch(() => [props.categories, props.activeCategoryId] as const, () => {
for (const category of props.categories) {
if (category.id === props.activeCategoryId || category.children.some((item) => item.id === props.activeCategoryId)) expanded[category.id] = true;
}
}, { immediate: true });
</script>
<template>
<aside class="product-sidebar">
<div class="category-list">
<button
type="button"
class="cat-row"
:class="{ active: activeCategoryId === null }"
@click="selectCategory(null)"
>
<span class="cat-icon"></span>
<span class="cat-name">全部商品</span>
<nav class="sidebar" aria-label="商品分类">
<button class="root-row" :class="{ active: activeCategoryId === null }" type="button" @click="emit('update:activeCategoryId', null)"><i class="fa-solid fa-border-all"></i><span>全部商品</span></button>
<template v-for="category in categories" :key="category.id">
<button class="root-row" :class="{ active: activeCategoryId === category.id }" type="button" @click="selectRootCategory(category)">
<img v-if="category.icon" :src="category.icon" alt="" /><i v-else class="fa-regular fa-folder"></i><span>{{ category.name }}</span><i v-if="category.children.length" class="fa-solid fa-chevron-down arrow" :class="{ open: expanded[category.id] }"></i>
</button>
<template v-for="cat in categories" :key="cat.id">
<button
type="button"
class="cat-row"
:class="{ active: activeCategoryId === cat.id }"
@click="cat.children.length > 0 ? toggleExpand(cat.id) : selectCategory(cat.id)"
>
<span class="cat-icon"></span>
<span class="cat-name">{{ cat.name }}</span>
<span v-if="cat.children.length > 0" class="chevron" :class="{ expanded: isExpanded(cat.id) }"></span>
</button>
<Transition name="subtree">
<div v-if="cat.children.length > 0 && isExpanded(cat.id)" class="subtree">
<button
v-for="child in cat.children"
:key="child.id"
type="button"
class="sub-row"
:class="{ active: activeCategoryId === child.id }"
@click="selectCategory(child.id)"
>
{{ child.name }}
</button>
</div>
</Transition>
</template>
<div v-if="categories.length === 0" class="empty-cat">暂无分类</div>
</div>
</aside>
<div v-if="category.children.length && expanded[category.id]" class="children">
<button v-for="child in category.children" :key="child.id" type="button" :class="{ active: activeCategoryId === child.id }" @click="emit('update:activeCategoryId', child.id)">{{ child.name }}</button>
</div>
</template>
<p v-if="!categories.length" class="empty">暂无分类</p>
</nav>
</template>
<style scoped>
.product-sidebar {
position: relative;
padding-top: 26px;
background: #ffffff;
min-height: 100%;
}
.category-list {
width: 216px;
margin: 0 auto;
}
.cat-row {
display: flex;
align-items: center;
height: 45px;
margin-bottom: 12px;
padding: 0 10px;
border-radius: 8px;
font-size: 16px;
color: #222;
transition: background 0.2s ease, color 0.2s ease;
border: 0;
background: transparent;
cursor: pointer;
width: 100%;
}
.cat-row:hover {
background: #f5f5f5;
}
.cat-row.active {
color: #ff6a00;
background: #fff2e8;
}
.cat-icon {
position: relative;
width: 24px;
height: 24px;
margin-right: 8px;
color: currentColor;
flex-shrink: 0;
}
.cat-icon::before {
content: '';
position: absolute;
inset: 4px 5px;
border: 2px solid currentColor;
border-radius: 7px 7px 4px 4px;
}
.cat-icon::after {
content: '';
position: absolute;
left: 8px;
top: 2px;
width: 8px;
height: 7px;
border: 2px solid currentColor;
border-bottom: 0;
border-radius: 7px 7px 0 0;
}
.cat-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
width: 8px;
height: 8px;
border-right: 1.8px solid currentColor;
border-bottom: 1.8px solid currentColor;
transform: rotate(45deg) translateY(-2px);
transition: transform 0.2s ease;
}
.chevron.expanded {
transform: rotate(225deg) translateY(-2px);
}
.subtree {
position: relative;
margin: -2px 0 12px;
padding: 0 0 0 42px;
}
.subtree::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 0;
width: 1px;
background: #ddd;
}
.sub-row {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 45px;
margin-bottom: 10px;
padding-left: 10px;
border-radius: 8px;
color: #555;
font-size: 15px;
border: 0;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.sub-row::before {
content: '';
position: absolute;
left: -16px;
top: 50%;
width: 6px;
height: 1px;
background: #ddd;
}
.sub-row:hover {
background: #f5f5f5;
}
.sub-row.active {
color: #ff6a00;
background: #fff2e8;
}
.empty-cat {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
.subtree-enter-active,
.subtree-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.subtree-enter-from,
.subtree-leave-to {
opacity: 0;
max-height: 0;
}
.subtree-enter-to,
.subtree-leave-from {
max-height: 600px;
}
.sidebar { width: 216px; padding-top: 12px; }
.root-row { display: flex; align-items: center; width: 216px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; font-size: 18px; cursor: pointer; }
.root-row:hover, .root-row.active, .children button.active { color: #ff6800; background: #fff3eb; }.root-row img { filter: brightness(0); }.root-row.active img { filter: invert(48%) sepia(99%) saturate(3770%) hue-rotate(3deg) brightness(104%) contrast(104%); }
.root-row img, .root-row > i:first-child { width: 24px; height: 24px; margin-right: 8px; object-fit: contain; text-align: center; line-height: 24px; }
.root-row span { flex: 1; text-align: left; }
.arrow { font-size: 12px; transition: transform .2s; }.arrow.open { transform: rotate(180deg); }
.children { position: relative; margin-left: 10px; padding-left: 20px; border-left: 1px solid #e5e7eb; }
.children button { position: relative; display: block; width: 174px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; text-align: left; font-size: 18px; cursor: pointer; }
.children button::before { content: ''; position: absolute; top: 22px; left: -14px; width: 6px; border-top: 1px solid #e5e7eb; }
.empty { color: #777; text-align: center; }
</style>
@@ -1,191 +1,75 @@
<script setup lang="ts">
import type { Tag } from '~/composables/useProductCenter';
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
import type { Tag, TagGroup } from '~/composables/useProductCenter';
const props = defineProps<{
tags: Tag[];
selectedTagIds: string[];
}>();
const props = defineProps<{ tags: Tag[]; tagGroups: TagGroup[]; selectedTagIds: string[] }>();
const emit = defineEmits<{ 'toggle-tag': [id: string] }>();
const openGroup = shallowRef<string | null>(null);
const rootElement = shallowRef<HTMLElement | null>(null);
const groupedTags = computed(() => {
return props.tagGroups
.filter((group) => group.name.includes('物流') || group.name.includes('工艺'))
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((group) => ({
id: group.id,
label: group.name.includes('物流') ? '物流' : '工艺',
tags: props.tags.filter((tag) => tag.group?.id === group.id),
}))
.filter((group) => group.tags.length > 0);
});
const selectedTagByGroup = computed(() => {
const selectedIds = new Set(props.selectedTagIds);
return new Map(
props.tags
.filter((tag) => selectedIds.has(tag.id) && tag.group)
.map((tag) => [tag.group!.id, tag]),
);
});
const selectedGroups = computed(() => groupedTags.value
.map((group) => ({ ...group, selectedTag: selectedTagByGroup.value.get(group.id) }))
.filter((group) => group.selectedTag));
const emit = defineEmits<{
'toggle-tag': [id: string];
}>();
const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
function toggleDropdown(): void {
dropdownOpen.value = !dropdownOpen.value;
}
function toggleTag(id: string): void {
function selectTag(id: string): void {
emit('toggle-tag', id);
openGroup.value = null;
}
function isSelected(id: string): boolean {
return props.selectedTagIds.includes(id);
function closeOnOutsideClick(event: MouseEvent): void {
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
}
function onClickOutside(event: MouseEvent): void {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
dropdownOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutside);
});
const label = computed(() => {
if (props.selectedTagIds.length === 0) return '标签';
if (props.selectedTagIds.length === 1) {
return props.tags.find((t) => t.id === props.selectedTagIds[0])?.name ?? '标签';
}
return `标签 (${props.selectedTagIds.length})`;
});
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
</script>
<template>
<div ref="dropdownRef" class="filter-pill-wrapper">
<button
type="button"
class="filter-pill"
:class="{ active: selectedTagIds.length > 0 }"
@click="toggleDropdown"
>
{{ label }}
<span class="chevron" :class="{ expanded: dropdownOpen }"></span>
</button>
<Transition name="dropdown">
<div v-if="dropdownOpen" class="dropdown-menu">
<button
v-for="tag in tags"
:key="tag.id"
type="button"
class="dropdown-item"
@click="toggleTag(tag.id)"
>
<span class="tag-label">{{ tag.name }}</span>
<span class="check-box" :class="{ checked: isSelected(tag.id) }">
<i v-if="isSelected(tag.id)" class="fa-solid fa-check text-[10px]"></i>
</span>
<div ref="rootElement" class="tag-filters">
<div class="filter-controls">
<div v-for="group in groupedTags" :key="group.id" class="filter-group">
<div class="dropdown">
<button type="button" class="filter-button" :class="{ open: openGroup === group.id }" @click="openGroup = openGroup === group.id ? null : group.id">{{ group.label }}<i class="fa-solid fa-chevron-down"></i></button>
<div v-if="openGroup === group.id" class="menu">
<button v-for="tag in group.tags" :key="tag.id" type="button" :class="{ selected: selectedTagIds.includes(tag.id) }" @click="selectTag(tag.id)"><span>{{ tag.name }}</span><span class="checkbox"><i v-if="selectedTagIds.includes(tag.id)" class="fa-solid fa-check"></i></span></button>
</div>
</div>
</div>
</div>
<div v-if="selectedGroups.length" class="selected-tags" :class="{ 'craft-only': selectedGroups.length === 1 && selectedGroups[0]?.label === '工艺' }">
<div v-for="group in selectedGroups" :key="group.id" class="selected-tag">
<span>{{ group.selectedTag?.name }}</span>
<button type="button" class="selected-tag-remove" :aria-label="`取消${group.selectedTag?.name}筛选`" @click="emit('toggle-tag', group.selectedTag!.id)">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Transition>
</div>
</div>
</template>
<style scoped>
.filter-pill-wrapper {
position: relative;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid #eee;
background: #fff;
font-size: 15px;
color: #222;
cursor: pointer;
transition: border-color 0.2s ease;
}
.filter-pill:hover {
border-color: #ddd;
}
.filter-pill.active {
color: #ff6a00;
border-color: #ff6a00;
}
.chevron {
width: 8px;
height: 8px;
border-right: 1.8px solid currentColor;
border-bottom: 1.8px solid currentColor;
transform: rotate(45deg) translateY(-2px);
transition: transform 0.2s ease;
}
.chevron.expanded {
transform: rotate(225deg) translateY(-2px);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.08);
padding: 6px 0;
z-index: 20;
}
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 16px;
font-size: 14px;
color: #555;
border: 0;
background: transparent;
cursor: pointer;
transition: background 0.15s ease;
}
.dropdown-item:hover {
background: #f7f7f7;
}
.tag-label {
flex: 1;
text-align: left;
}
.check-box {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1.5px solid #ccc;
border-radius: 4px;
color: #fff;
flex-shrink: 0;
transition: all 0.15s ease;
}
.check-box.checked {
background: #ff6a00;
border-color: #ff6a00;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
overflow: hidden;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.tag-filters { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }.filter-controls, .selected-tags { display: flex; align-items: flex-start; gap: 12px; }.filter-group { display: flex; align-items: flex-start; }.dropdown { position: relative; }
.filter-button { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; color: #000; font-size: 16px; cursor: pointer; }.filter-button i { color: #777; font-size: 11px; transition: transform .2s; }.filter-button.open i { transform: rotate(180deg); }
.menu { position: absolute; z-index: 40; top: 50px; left: -2px; width: 141px; padding: 6px; border-radius: 6px; background: #fff; box-shadow: 0 1px 5px rgba(0,0,0,.25); }
.menu button { display: flex; align-items: center; justify-content: space-between; width: 129px; height: 36px; padding: 6px; border: 0; background: #fff; color: #000; text-align: left; font-size: 16px; cursor: pointer; }.menu button:hover { background: #fafafa; }
.checkbox { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid #c7c7c7; border-radius: 4px; color: #fff; font-size: 11px; }.menu button.selected .checkbox { border-color: #ff6800; background: #ff6800; }
.selected-tag { display: flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 16px; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; line-height: normal; white-space: nowrap; }.selected-tag-remove { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; background: transparent; color: #000; font-size: 16px; cursor: pointer; }
</style>
@@ -44,10 +44,10 @@ const COUNTRY_NAME_MAP: Record<string, string> = {
'墨西哥工厂本地直发': '墨西哥',
'巴西本地工厂直发': '巴西',
'中东本地工厂直发': '中东',
'欧洲波兰工厂直发': '欧洲波兰',
'欧洲西班牙工厂直发': '欧洲西班牙',
'欧洲德国工厂本地直发': '欧洲德国',
'欧洲意大利工厂直发': '欧洲意大利',
'欧洲波兰工厂直发': '波兰',
'欧洲西班牙工厂直发': '西班牙',
'欧洲德国工厂本地直发': '德国',
'欧洲意大利工厂直发': '意大利',
'英国本地直发': '英国',
'加拿大本地工厂直发': '加拿大',
'澳大利亚本地工厂直发': '澳大利亚',
@@ -56,6 +56,64 @@ export interface ProductQuery {
pageSize: number;
}
export function resolveBackendAssetUrl(value: string | null, backendUrl: string): string | null {
if (!value || /^(?:https?:|data:|blob:)/i.test(value)) return value;
return new URL(value, `${backendUrl.replace(/\/$/, '')}/`).toString();
}
export function resolveCountryIdFromQuery(
value: string | string[] | null | undefined,
countries: Country[],
): string | undefined {
const countryId = Array.isArray(value) ? value[0] : value;
return countryId && countries.some((country) => country.id === countryId) ? countryId : undefined;
}
export function resolveCategoryIdFromQuery(
value: string | string[] | null | undefined,
categories: Category[],
): string | undefined {
const categoryId = Array.isArray(value) ? value[0] : value;
const containsId = (nodes: Category[]): boolean => nodes.some(
(category) => category.id === categoryId || containsId(category.children),
);
return categoryId && containsId(categories) ? categoryId : undefined;
}
export function findCategoryIdByName(name: string, categories: Category[]): string | undefined {
for (const category of categories) {
if (category.name === name) return category.id;
const childId = findCategoryIdByName(name, category.children);
if (childId) return childId;
}
return undefined;
}
export function findCategoryIdForPodName(name: string, categories: Category[]): string | undefined {
const rootName = name.includes('男士')
? '男士服装'
: name.includes('女士')
? '女士服装'
: /童装|儿童/.test(name)
? '儿童服装'
: undefined;
const root = rootName ? categories.find((category) => category.name === rootName) : undefined;
if (!root) return findCategoryIdByName(name, categories);
const childName = name.includes('T恤')
? 'T恤'
: name.includes('裤')
? '裤装'
: name.includes('卫衣')
? '卫衣'
: name.includes('裙')
? '裙装'
: name.includes('内衣')
? '内衣'
: undefined;
return root.children.find((category) => category.name === childName)?.id ?? root.id;
}
interface BackendCategoryNode {
id: string;
categoryName: string;
@@ -111,21 +169,21 @@ interface BackendEnvelope<T> {
success: boolean;
}
function mapCategory(node: BackendCategoryNode): Category {
function mapCategory(node: BackendCategoryNode, backendUrl: string): Category {
return {
id: node.id,
name: node.categoryName,
icon: node.categoryIcon,
icon: resolveBackendAssetUrl(node.categoryIcon, backendUrl),
parentId: node.parentCategoryId,
children: node.children.map(mapCategory),
children: node.children.map((child) => mapCategory(child, backendUrl)),
};
}
function mapCountry(c: BackendCountry): Country {
function mapCountry(c: BackendCountry, backendUrl: string): Country {
return {
id: c.id,
name: c.countryName,
icon: c.countryIcon,
icon: resolveBackendAssetUrl(c.countryIcon, backendUrl),
};
}
@@ -160,18 +218,41 @@ export function sortTagsByGroup(tags: Tag[]): Tag[] {
});
}
function mapProduct(p: BackendProduct): Product {
export function selectExclusiveTagIds(selectedIds: string[], tags: Tag[], tagId: string): string[] {
const selectedTag = tags.find((tag) => tag.id === tagId);
if (!selectedTag) return selectedIds;
if (selectedIds.includes(tagId)) return selectedIds.filter((id) => id !== tagId);
const selectedGroupId = selectedTag.group?.id;
const otherGroups = selectedIds.filter((id) => {
const tag = tags.find((item) => item.id === id);
return !selectedGroupId || tag?.group?.id !== selectedGroupId;
});
return [...otherGroups, tagId];
}
export function resolveDefaultProductFilters(
_categories: Category[],
_tags: Tag[],
): Pick<ProductQuery, 'categoryId' | 'tagIds'> {
return {
categoryId: undefined,
tagIds: [],
};
}
function mapProduct(p: BackendProduct, backendUrl: string): Product {
return {
id: p.id,
name: p.goodName,
priority: p.goodPriority,
image: p.image,
image: resolveBackendAssetUrl(p.image, backendUrl),
price: p.price,
country: mapCountry(p.country),
country: mapCountry(p.country, backendUrl),
category: {
id: p.category.id,
name: p.category.categoryName,
icon: p.category.categoryIcon,
icon: resolveBackendAssetUrl(p.category.categoryIcon, backendUrl),
parentId: null,
children: [],
},
@@ -181,6 +262,7 @@ function mapProduct(p: BackendProduct): Product {
}
export function useProductCenter() {
const backendUrl = useRuntimeConfig().public.backendUrl;
const categories = useState<Category[]>('pc-categories', () => []);
const countries = useState<Country[]>('pc-countries', () => []);
const tags = useState<Tag[]>('pc-tags', () => []);
@@ -216,15 +298,15 @@ export function useProductCenter() {
$fetch<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
$fetch<BackendEnvelope<BackendCountry[]>>('/api/backend/countries'),
]);
categories.value = cats.data.map(mapCategory);
countries.value = cos.data.map(mapCountry);
categories.value = cats.data.map((category) => mapCategory(category, backendUrl));
countries.value = cos.data.map((country) => mapCountry(country, backendUrl));
}
} catch (error) {
console.error('Failed to load filters:', error);
}
}
async function fetchProducts(): Promise<void> {
async function fetchProducts(options: { ignoreTags?: boolean } = {}): Promise<void> {
loading.value = true;
try {
const params: Record<string, string | number> = {
@@ -233,14 +315,16 @@ export function useProductCenter() {
};
if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId;
if (query.tagIds.length > 0) params.tagIds = query.tagIds.join(',');
if (!options.ignoreTags && query.tagIds.length > 0) {
params.tagIds = query.tagIds.join(',');
}
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
'/api/backend/goods',
{ params },
);
products.value = response.data.items.map(mapProduct);
products.value = response.data.items.map((product) => mapProduct(product, backendUrl));
total.value = response.data.total;
} catch (error) {
console.error('Failed to fetch products:', error);
@@ -262,12 +346,7 @@ export function useProductCenter() {
}
function setTag(id: string): void {
const idx = query.tagIds.indexOf(id);
if (idx >= 0) {
query.tagIds.splice(idx, 1);
} else {
query.tagIds.push(id);
}
query.tagIds = selectExclusiveTagIds(query.tagIds, tags.value, id);
query.page = 1;
}
+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>
+7
View File
@@ -7,6 +7,7 @@
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"test": "vitest",
"postinstall": "nuxt prepare"
},
"dependencies": {
@@ -18,5 +19,11 @@
"tailwindcss": "^4.3.0",
"vue": "^3.5.34",
"vue-router": "^5.0.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"@vue/test-utils": "^2.4.11",
"happy-dom": "^20.10.6",
"vitest": "^4.1.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 140 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="logo-&#230;&#169;&#153;">
<g id="Rectangle 1">
</g>
<path id="Vector" d="M27.84 27.4H30.36C30.8241 27.4 31.2 27.7655 31.2 28.2168V33.7579C31.2 34.8852 30.2592 35.8 29.1 35.8C27.9408 35.8 27 34.8852 27 33.7579V28.2168C27 27.7655 27.3759 27.4 27.84 27.4Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_2" d="M44.36 19.8H41.84C41.3761 19.8 41 20.1581 41 20.5998V40.0002C41 40.4419 41.3761 40.8 41.84 40.8H44.36C44.8239 40.8 45.2 40.4419 45.2 40.0002V20.5998C45.2 20.1581 44.8239 19.8 44.36 19.8Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_3" d="M85.6307 36.981C76.5464 45.1314 66.2036 48.3663 62.528 44.2067C61.0848 42.5736 60.8852 40.0431 61.7237 37.078C59.1392 41.8692 58.5846 46.1915 60.6997 48.5856C64.3753 52.7452 74.7182 49.5103 83.8024 41.3599C89.3193 36.41 93.1023 30.7851 94.4 26.2C92.4945 29.733 89.4858 33.5217 85.6307 36.981Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_4" d="M24.5645 22.4H22.2355C21.7741 22.4 21.4 22.7554 21.4 23.1937V25.4063C21.4 25.8446 21.7741 26.2 22.2355 26.2H24.5645C25.0259 26.2 25.4 25.8446 25.4 25.4063V23.1937C25.4 22.7554 25.0259 22.4 24.5645 22.4Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_5" d="M24.5645 27.4H22.2355C21.7741 27.4 21.4 27.7598 21.4 28.2037V39.7963C21.4 40.2402 21.7741 40.6 22.2355 40.6H24.5645C25.0259 40.6 25.4 40.2402 25.4 39.7963V28.2037C25.4 27.7598 25.0259 27.4 24.5645 27.4Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_6" d="M38.5726 40.8H36.0906C35.6335 40.8 35.2632 40.4367 35.2632 39.9881V33.5336C35.2632 32.4375 34.3966 31.5028 33.2796 31.4612C32.1058 31.4176 31.1368 32.3421 31.1368 33.4838V39.9587C31.1368 40.4072 30.7665 40.7706 30.3094 40.7706H27.8274C27.3702 40.7706 27 40.4072 27 39.9587V33.5762C27 30.2373 29.7013 27.4515 33.1038 27.4007C36.5663 27.349 39.4 30.0982 39.4 33.4838V39.9881C39.4 40.4367 39.0298 40.8 38.5726 40.8Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_7" d="M107.16 19.8H109.44C109.86 19.8 110.2 20.1548 110.2 20.5929V32.6177C110.2 33.7119 109.349 34.6 108.3 34.6C107.251 34.6 106.4 33.7119 106.4 32.6177V20.5929C106.4 20.1548 106.74 19.8 107.16 19.8Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_8" d="M117.599 40.8H115.197C114.755 40.8 114.397 40.4367 114.397 39.9881V33.5336C114.397 32.4375 113.558 31.5028 112.477 31.4612C111.341 31.4176 110.403 32.3421 110.403 33.4838V39.9587C110.403 40.4072 110.045 40.7706 109.603 40.7706H107.201C106.758 40.7706 106.4 40.4072 106.4 39.9587V33.5762C106.4 30.2373 109.014 27.4515 112.307 27.4007C115.658 27.349 118.4 30.0982 118.4 33.4838V39.9881C118.4 40.4367 118.042 40.8 117.599 40.8Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_9" d="M55.88 27.4H58.8368C59.147 27.4 59.4 27.6322 59.4 27.9192V39.9887C59.4 40.4369 59.0062 40.8 58.52 40.8H55.88C55.3938 40.8 55 40.4369 55 39.9887V28.2123C55 27.7641 55.3938 27.401 55.88 27.401V27.4Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_10" d="M41.2145 32.2104C41.0773 32.0585 41 31.8604 41 31.6541V28.2211C41 27.7674 41.364 27.4 41.8133 27.4H45.0667V29.6908C45.0667 29.9124 45.1551 30.1259 45.3127 30.2809C47.0522 31.9795 49.1333 30.752 49.1333 29.0421V28.2211C49.1333 27.7674 49.4973 27.4 49.9467 27.4H52.3867C52.836 27.4 53.2 27.7674 53.2 28.2211V29.0421C53.2 34.3441 46.5276 38.1025 41.2145 32.2104Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_11" d="M52.3867 40.8H49.9467C49.4973 40.8 49.1333 40.442 49.1333 40V37.2489C49.1333 36.1659 48.2783 35.2418 47.1783 35.2008C46.0213 35.1578 45.0667 36.0709 45.0667 37.1999V40C45.0667 40.442 44.7027 40.8 44.2533 40.8H41.8133C41.364 40.8 41 40.442 41 40V37.2909C41 33.9978 43.6586 31.2507 47.0054 31.2007C50.4123 31.1497 53.2 33.8608 53.2 37.1999V40C53.2 40.442 52.836 40.8 52.3867 40.8Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_12" d="M59.0789 35.2457V33.4974C59.0789 32.3532 60.0341 31.4266 61.1912 31.4703C61.1912 31.4703 61.1912 31.4703 61.1922 31.4703C61.2717 31.4734 61.3471 31.4398 61.392 31.3747C62.1026 30.3505 62.8896 29.3274 63.7459 28.3133C63.8428 28.1984 63.8051 28.0235 63.6685 27.9604C62.859 27.5871 61.9537 27.3837 61.0005 27.401C57.6537 27.461 55 30.2488 55 33.5879V39.9863C55 40.4359 55.365 40.8 55.8156 40.8H57.2387C57.4803 39.0862 58.0951 37.2199 59.0778 35.2447L59.0789 35.2457Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_13" d="M68.1372 36.5053L72.7809 34.6331L76.1462 33.2752C76.3562 33.1901 76.4555 32.956 76.3685 32.7518C76.015 31.9253 75.5643 30.7925 75.0838 30.1811C73.832 28.456 71.7862 27.3973 69.5673 27.4003H69.5694C65.4348 27.3642 62.1864 31.1257 62.8984 35.0974C63.0797 36.1791 63.5478 37.2118 64.2455 38.0704H64.2444C66.7369 41.1735 71.6705 41.4386 74.8738 38.7719C75.0449 38.6298 75.0633 38.3766 74.9179 38.2095L73.0237 36.035C72.8824 35.8729 72.6375 35.8499 72.4634 35.977C71.0589 37.0007 69.6104 37.2378 68.1383 36.5064L68.1372 36.5053ZM66.7123 33.5544C66.8772 31.3959 69.678 30.2392 71.3508 31.6861C70.1605 32.1664 67.9006 33.0781 66.7123 33.5574V33.5544Z" fill="var(--fill-0, #FF6800)"/>
<g id="Group">
<path id="Vector_14" d="M100.211 27.5073C97.183 27.5073 94.5329 29.5412 93.7485 32.4659C92.9652 35.3906 94.2431 38.4774 96.8658 39.9915C99.2149 41.3474 102.117 41.1386 104.236 39.5415C104.633 39.2426 104.679 38.6659 104.329 38.3153L102.853 36.8398C102.585 36.5722 102.166 36.5216 101.849 36.7273C101.028 37.2583 99.9861 37.3657 99.0578 36.9816C97.9319 36.5155 97.1982 35.4169 97.1982 34.1978C97.1982 32.9787 97.9319 31.8801 99.0578 31.414C99.985 31.0299 101.028 31.1373 101.849 31.6683C102.166 31.8741 102.585 31.8234 102.853 31.5558L104.33 30.0793C104.679 29.7297 104.635 29.1531 104.24 28.8551C103.088 27.9826 101.673 27.5042 100.211 27.5083" fill="var(--fill-0, #FF6800)"/>
</g>
<g id="Group_2">
<path id="Vector_15" d="M90.7529 29.4166V27.9268C90.7529 27.7029 90.5715 27.5215 90.3476 27.5215H87.3073C87.0834 27.5215 86.902 27.7029 86.902 27.9268V28.1184C85.9869 27.6887 84.9603 27.4597 83.8759 27.4891C80.3432 27.5863 77.4651 30.4715 77.3759 34.0043C77.3131 36.5195 78.6396 38.7298 80.6411 39.9236C81.6687 39.1412 82.6862 38.3001 83.6824 37.4053C83.8273 37.2745 83.9712 37.1438 84.1141 37.0131C84.0968 37.0131 84.0796 37.0141 84.0624 37.0141C82.3903 37.0141 81.0516 35.5608 81.2421 33.8512C81.387 32.549 82.4359 31.5011 83.7381 31.3552C85.4477 31.1647 86.9009 32.5034 86.9009 34.1755C86.9009 34.1968 86.8999 34.2181 86.8989 34.2384C88.3603 32.6584 89.6564 31.035 90.7519 29.4145L90.7529 29.4166Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_16" d="M86.902 40.1303V40.7677H90.3475C90.5715 40.7677 90.7529 40.5863 90.7529 40.3624V35.9115C89.4264 37.4265 88.4758 38.7176 86.902 40.1303Z" fill="var(--fill-0, #FF6800)"/>
</g>
<path id="Vector_17" d="M70.4667 22.021C79.2819 13.4468 89.9062 10.072 94.1967 14.4849C96.0664 16.4085 96.4238 19.4945 95.5021 23.0742C97.6625 18.2454 97.8502 13.9487 95.5505 11.5837C91.4458 7.36174 80.9729 10.891 72.1567 19.4652C69.2664 22.2765 66.9132 25.2817 65.2 28.2C66.6557 26.1026 68.4224 24.0093 70.4667 22.021Z" fill="var(--fill-0, #FF6800)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_0_30)">
<g id="Group">
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
</g>
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
<g id="Group_2">
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
</g>
</g>
<defs>
<clipPath id="clip0_0_30">
<rect width="97" height="56" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#231;&#169;&#186;&#231;&#138;&#182;&#230;&#128;&#129;">
<path id="Vector" d="M63.2429 46.2863C66.6064 46.0297 72.4699 46.2271 76.0334 46.229L100.679 46.2312L179.31 46.1991C181.817 46.1973 183.448 46.1838 185.784 47.1709C190.411 49.1269 193.222 53.3572 193.263 58.3786C193.286 61.0849 193.291 63.8983 193.287 66.66L193.275 84.8904L193.272 151.471C194.464 152.047 195.627 152.679 196.756 153.367C204.403 158.078 209.871 165.63 211.962 174.366C214.268 183.999 212.345 192.628 207.287 200.961C210.206 203.976 211.385 202.484 212.055 203.141C216.671 207.656 226.381 215.924 229.346 221.07C230.887 223.745 224.655 233.138 218.501 228.727C213.169 223.519 207.679 217.986 202.47 212.633C202.048 212.2 202.301 210.899 202.406 210.361L200.552 208.472C196.181 211.91 191.993 214.121 186.529 215.388C183.078 216.202 179.518 216.441 175.99 216.097C174.378 215.925 172.149 215.514 170.586 215.504C163.979 215.454 157.359 215.474 150.752 215.474L92.6955 215.467L74.3909 215.476C69.3064 215.485 62.2801 216.256 57.9473 213.511C55.2466 211.775 53.346 209.037 52.6642 205.9C52.3085 204.204 52.3902 200.545 52.3926 198.674L52.4063 188.395L52.4051 149.978L52.4108 87.7877L52.3985 68.4416C52.3949 64.9306 52.2549 61.2389 52.4661 57.7453C52.8458 51.4634 57.3365 47.379 63.2429 46.2863Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_2" d="M64.5305 48.4078C64.8713 48.3956 65.2121 48.3881 65.5532 48.3852L146.291 48.3909L170.31 48.3911C174.273 48.3847 178.998 48.1609 182.783 48.5504C188.007 49.0879 191.688 54.3209 191.425 59.3651C191.299 61.8093 191.45 64.5657 191.446 67.0171L191.422 85.4932L191.45 150.657C183.885 147.769 175.545 147.638 167.892 150.286C159.376 153.252 152.395 159.492 148.501 167.62C144.544 175.624 143.98 184.883 146.941 193.306C150.539 203.019 156.28 208.936 165.559 213.409C157.099 213.288 148.455 213.38 139.983 213.38H93.5428L73.7357 213.39C70.3212 213.392 66.5217 213.525 63.1807 213.229C58.9313 212.753 55.0321 208.865 54.6873 204.599C54.4426 201.571 54.5356 198.449 54.5399 195.44L54.5622 180.744L54.5506 134.099L54.5328 83.1416L54.5346 67.1703C54.5356 64.2388 54.4587 61.2967 54.6135 58.3698C54.9168 52.6375 58.9566 48.9309 64.5305 48.4078Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_3" d="M108.323 66.088C109.2 66.1253 112.237 68.6559 113.395 69.2556C118.817 72.0631 127.634 72.4062 132.96 69.1682C134.008 68.5317 136.41 66.3098 137.445 66.1875C138.226 66.0953 154.944 72.0837 156.729 72.9098C158.048 73.5363 159.282 74.3287 160.4 75.268C161.117 75.8686 162.005 76.7251 162.581 77.4663C167.102 83.2899 171.639 89.1149 176.067 95.0066C176.449 95.5136 176.658 96.0473 176.716 96.6721C176.015 97.8918 160.854 112.526 159.309 113.418C157.83 113.35 155.165 109.939 153.959 108.699L153.969 137.873C153.969 139.875 154.23 156.182 153.563 156.997C152.026 157.757 145.456 157.455 143.306 157.441L111.317 157.453C108.471 157.457 93.4802 157.789 92.2281 156.954C91.6966 154.954 91.8954 144.37 91.8949 141.668L91.8893 108.707C90.7898 109.919 88.1342 113.242 86.6755 113.439C86.146 113.164 85.4861 112.732 85.0318 112.278C79.8859 107.133 74.2377 102.296 69.3625 96.9225C68.6522 96.1395 71.5067 92.7719 72.0739 92.0767C76.4274 86.7415 80.3461 80.7282 85.007 75.6891C87.4031 73.0985 92.4277 71.6931 95.6614 70.5499L108.323 66.088Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_4" d="M103.686 70.028C105.455 71.0903 106.199 81.5705 118.223 84.7356C123.21 86.0417 128.515 85.2924 132.942 82.6569C137.655 79.8712 140.847 75.2394 142.194 69.988C146.898 71.7448 152.06 73.2293 156.596 75.275C159.724 76.6866 163.257 81.8113 165.357 84.5115L174.55 96.4075C169.363 101.281 164.229 106.209 159.15 111.192L154.017 105.677C154.021 103.665 154.49 99.2772 153.317 97.9335C151.373 97.7715 151.941 102.724 151.942 103.889L151.944 114.167L151.949 149.525L151.947 155.355L94.0001 155.348L94.0017 113.551L93.9882 104.19C94.0018 102.581 94.5554 99.0958 93.2053 98.1509C92.5371 98.164 92.8475 98.0818 92.2514 98.4482C91.5906 99.3537 91.8704 104.238 91.8796 105.732C90.1911 107.518 88.5202 109.322 86.8672 111.141C81.8995 106.705 76.3593 101.085 71.4164 96.407C74.6542 92.38 77.6961 88.1589 80.9067 84.1102C83.2681 81.1323 85.6515 77.2422 89.0593 75.4033C90.9333 74.392 93.5202 73.6256 95.5548 72.9173L103.686 70.028Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_5" d="M138.063 68.688L140.316 69.561C140.078 70.5564 139.739 71.4129 139.382 72.3704C139.173 72.8481 138.945 73.3176 138.699 73.7779C136.404 78.1041 132.872 81.2699 128.104 82.7033C123.781 83.9973 119.116 83.5295 115.143 81.4036C110.33 78.8124 107.617 74.5953 106.083 69.5107L108.076 68.7486C108.288 68.9403 108.293 68.8966 108.385 69.1466C110.273 74.236 113.178 78.3408 118.627 80.09C122.374 81.3083 126.457 80.9811 129.958 79.1824C134.481 76.8666 136.597 73.3024 138.063 68.688Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_6" d="M111.283 70.5909C112.865 71.2139 113.914 71.9903 115.866 72.5357C123.012 74.5316 128.717 73.8549 135.116 70.4213C134.418 71.7459 134.042 72.3751 133.182 73.5956C131.995 75.0701 130.84 76.1541 129.185 77.0734C126.228 78.7484 122.726 79.1104 119.5 78.0748C115.525 76.8214 113.167 74.2609 111.283 70.5909Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_7" d="M69.4044 175.416C82.7754 175.147 96.8527 175.386 110.282 175.382L121.036 175.366C122.609 175.361 125.782 175.167 127.184 175.535C129.172 176.055 129.03 178.402 127.134 179.478C114.278 179.787 100.401 179.5 87.4608 179.503L75.5429 179.514C73.7291 179.521 69.2091 180.083 68.2271 178.426C67.9432 177.947 67.8733 177.364 68.0355 176.828C68.2662 176.062 68.7797 175.777 69.4044 175.416Z" fill="var(--fill-0, #A7A6A6)"/>
<path id="Vector_8" d="M69.9164 190.173C73.1296 190.101 94.8028 189.781 96.3471 190.37C96.7895 190.539 97.1371 190.955 97.2934 191.36C97.5027 191.895 97.4427 192.487 97.1298 192.978C96.7369 193.588 96.2231 193.695 95.533 193.824C92.2294 193.842 70.4603 194.125 69.1663 193.634C68.6536 193.438 68.2136 193.028 68.0472 192.536C67.8632 191.991 67.9373 191.406 68.2898 190.924C68.6885 190.38 69.2619 190.268 69.9164 190.173Z" fill="var(--fill-0, #CDCDCC)"/>
<path id="Vector_9" d="M116.679 190.097C119.774 190.038 122.926 189.974 126.017 190.073C128.647 190.158 129.716 192.494 127.146 193.782C125.127 193.896 123.049 193.873 120.98 193.884C119.331 193.892 114.826 194.357 115.206 191.753C115.338 190.846 115.89 190.51 116.679 190.097Z" fill="var(--fill-0, #CDCDCC)"/>
<path id="Vector_10" d="M105.377 190.068C106.045 190.034 107.048 189.932 107.645 190.207C109.758 191.184 109.563 193.314 107.268 193.887C106.685 193.909 105.516 193.998 105 193.757C102.842 192.756 103.003 190.671 105.377 190.068Z" fill="var(--fill-0, #CDCDCC)"/>
<path id="Vector_11" d="M177.721 150.185C195.307 149.423 210.177 163.071 210.921 180.658C211.664 198.245 198.002 213.101 180.413 213.827C162.851 214.552 148.022 200.913 147.279 183.351C146.535 165.79 160.16 150.946 177.721 150.185Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_12" d="M175.656 155.116C190.633 153.332 204.216 164.032 205.991 179.009C207.764 193.988 197.057 207.565 182.079 209.331C167.113 211.095 153.548 200.397 151.775 185.432C150.004 170.467 160.693 156.895 175.656 155.116Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_13" d="M178.473 156.663C192.471 156.317 204.098 167.385 204.442 181.383C204.786 195.381 193.714 207.007 179.716 207.347C165.722 207.689 154.099 196.621 153.757 182.626C153.414 168.632 164.478 157.008 178.473 156.663Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_14" d="M210.712 204.755C211.917 205.272 223.837 217.464 225.997 219.42C226.887 220.227 227.497 221.307 227.408 222.602C227.288 224.367 225.554 225.677 224.321 226.817C223.332 227.733 222.377 227.801 221.097 227.671C218.26 226.587 210.594 217.338 207.683 215.121C206.92 214.541 204.725 212.226 204.016 211.453C205.997 209.328 208.62 206.817 210.712 204.755Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_15" d="M206.194 202.588C206.664 202.922 207.863 203.999 208.35 204.416C207.004 205.782 205.298 207.317 203.878 208.655C203.419 208.39 202.296 207.342 201.85 206.947C203.495 205.552 204.78 204.158 206.194 202.588Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_16" d="M229.865 109.855L230.759 109.915C231.667 110.412 232.805 114.541 233.561 116.107C235.599 117.353 237.194 117.668 239.285 118.652C239.374 118.787 239.463 118.923 239.55 119.058L239.454 119.475C238.45 120.656 235.431 121.414 233.522 122.554C232.371 124.598 231.827 127.785 231.031 128.921L230.147 128.828C229.302 128.322 228.126 124.078 227.441 122.566C227.386 122.535 227.333 122.503 227.278 122.472L227.006 122.321C225.023 121.203 223.021 120.794 221.566 119.949C221.288 119.499 221.327 119.732 221.414 119.232C222.279 118.085 225.514 117.173 227.439 115.996C228.653 113.846 228.919 111.575 229.865 109.855Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_17" d="M230.427 113.755C230.613 114.021 231.924 116.873 232.254 117.511C233.616 118.379 234.637 118.859 236.083 119.548C235.554 119.743 235.032 119.954 234.519 120.182C231.761 121.387 231.491 122.797 230.444 125.455C229.855 123.919 229.362 122.74 228.538 121.326C227.418 120.679 225.992 120.044 224.816 119.5C228.832 117.705 228.76 117.68 230.427 113.755Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_18" d="M20.6652 165.343C23.5077 165.088 26.0328 167.077 26.3225 169.799C26.612 172.52 24.5576 174.958 21.7202 175.257C18.8489 175.562 16.2699 173.565 15.9769 170.811C15.6839 168.057 17.7894 165.601 20.6652 165.343Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_19" d="M20.6174 167.094C22.4647 166.825 24.207 167.977 24.5552 169.699C24.9034 171.422 23.7319 173.092 21.9075 173.476C20.6723 173.735 19.3844 173.343 18.5478 172.452C17.7114 171.562 17.4592 170.314 17.8901 169.198C18.321 168.082 19.3665 167.276 20.6174 167.094Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_20" d="M201.304 24.0879C203.918 23.7729 206.281 25.7314 206.584 28.4636C206.887 31.1958 205.016 33.6681 202.404 33.9872C199.788 34.3067 197.42 32.3475 197.115 29.6124C196.812 26.8774 198.689 24.4032 201.304 24.0879Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_21" d="M201.403 25.8238C203.036 25.5636 204.563 26.7496 204.839 28.493C205.117 30.2362 204.042 31.8967 202.421 32.2297C201.343 32.4515 200.236 32.0307 199.529 31.1297C198.82 30.2286 198.623 28.9883 199.014 27.8874C199.403 26.7864 200.316 25.9969 201.403 25.8238Z" fill="var(--fill-0, #FDFDFD)"/>
<path id="Vector_22" d="M98.0306 226.519C99.3123 226.472 103.947 226.43 104.924 226.495C108.398 226.727 129.764 226.058 131.137 226.732C131.274 227.263 131.236 227.093 131 227.665C125.384 227.824 119.182 227.583 113.506 227.687C108.748 227.773 103.724 227.64 98.941 227.68C98.5193 227.684 98.2831 227.626 97.9692 227.411C97.7848 226.895 97.8204 227.087 98.0306 226.519Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_23" d="M128.792 233.909C131.259 233.784 151.55 233.609 152.38 234.243C152.529 234.762 152.417 234.933 152.251 235.445C151.388 235.461 148.861 235.505 148.166 235.447C145.409 235.219 129.783 235.923 128.758 235.141C128.531 234.492 128.603 234.691 128.792 233.909Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_24" d="M30.465 32.7214C32.4441 32.7595 37.1616 36.7524 39.1174 37.7854C39.9501 38.2253 40.9096 38.9279 41.083 39.8707C40.9491 40.4143 40.7181 40.5581 40.3093 40.9547C38.5618 40.9622 32.9576 36.4666 31.1908 35.5331C29.7722 34.7837 29.3191 34.0644 30.465 32.7214Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_25" d="M241.059 176.708C242.189 176.392 243.402 176.71 244.23 177.539C245.06 178.369 245.378 179.581 245.064 180.71C244.749 181.841 243.85 182.714 242.71 182.993C240.986 183.418 239.242 182.38 238.791 180.664C238.339 178.947 239.351 177.185 241.059 176.708Z" fill="var(--fill-0, #FF6800)"/>
<path id="Vector_26" d="M218.987 71.4006C220.697 70.9402 222.46 71.9324 222.953 73.6322C223.447 75.3321 222.49 77.1143 220.8 77.6409C219.677 77.9906 218.452 77.7065 217.598 76.898C216.745 76.0893 216.394 74.8824 216.682 73.7422C216.97 72.602 217.852 71.7064 218.987 71.4006Z" fill="var(--fill-0, #CDCDCC)"/>
<path id="Vector_27" d="M17.1511 98.7579C18.7443 98.1847 20.4689 99.1093 20.9959 100.819C21.523 102.529 20.6513 104.372 19.0528 104.928C17.4655 105.48 15.76 104.555 15.2368 102.857C14.7135 101.159 15.569 99.3271 17.1511 98.7579Z" fill="var(--fill-0, #CDCDCC)"/>
<path id="Vector_28" d="M44.5972 23.6214C44.8409 23.6813 45.5729 23.8691 45.665 24.0086C46.5563 25.3585 48.7479 32.0605 48.883 33.3871C48.7668 33.9451 48.4731 34.085 48.0107 34.4547C47.7398 34.4113 47.0273 34.297 46.9205 34.1274C45.9953 32.6597 43.8683 26.1212 43.683 24.7795C43.8179 24.1771 44.1088 24.0304 44.5972 23.6214Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_29" d="M27.8099 47.1507C29.9031 47.1135 35.9869 46.6632 37.3838 47.6964C37.8665 48.6459 37.5752 49.0571 36.72 49.5386C34.5615 49.5373 28.4815 49.9566 27.0212 48.92C26.7101 48.3604 26.7686 48.652 26.8389 48.0143C27.3284 47.3103 27.0699 47.4971 27.8099 47.1507Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_30" d="M72.0079 226.529C73.4588 226.536 77.7491 226.1 78.6744 226.933C78.8427 227.603 78.8206 227.253 78.5715 228.024C77.0957 228.062 73.0695 228.46 71.9958 227.648C71.7705 227.049 71.8307 227.258 72.0079 226.529Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_31" d="M85.4352 226.54C86.1422 226.534 86.849 226.513 87.5552 226.48C88.5936 226.44 90.5096 226.237 91.2597 226.926C91.4087 227.579 91.3735 227.356 91.1482 228.07C89.699 228.096 86.4819 228.394 85.4766 227.692C85.1958 227.065 85.2552 227.319 85.4352 226.54Z" fill="var(--fill-0, #3E3D41)"/>
<path id="Vector_32" d="M159.123 233.956C160.06 233.902 160.999 233.851 161.937 233.809C162.81 233.773 163.831 233.701 164.491 234.434C164.643 235.241 164.609 234.96 164.372 235.844C163.062 235.867 159.982 236.277 159.077 235.358C158.873 234.597 158.933 234.846 159.123 233.956Z" fill="var(--fill-0, #3E3D41)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+4 -1
View File
@@ -1,5 +1,8 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const query = getQuery(event);
const requestUrl = event.node.req.url ?? '/';
const query = Object.fromEntries(
new URL(requestUrl, 'http://localhost').searchParams.entries(),
);
return $fetch(`${config.public.backendUrl}/public/goods`, { query });
});
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import FeatureCards from '../app/components/FeatureCards.vue';
import StepProcess from '../app/components/StepProcess.vue';
const readComponent = (name: string) =>
readFileSync(resolve(process.cwd(), `app/components/${name}.vue`), 'utf8');
describe('homepage navigation and carousel behavior', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('uses current-page login navigation for every homepage CTA', () => {
const components = ['AppHeader', 'HeroBanner', 'StepProcess', 'CtaBanner'];
for (const component of components) {
const source = readComponent(component);
expect(source).not.toMatch(/target="_blank"[^>]*>\s*(|||使)/u);
expect(source).toContain("window.location.href = 'https://inkpod.vip/user/login'");
}
});
it('advances the four-step content automatically', async () => {
vi.useFakeTimers();
vi.stubGlobal('ref', ref);
const wrapper = mount(StepProcess);
expect(wrapper.get('[data-testid="step-image-0"]').classes()).toContain('opacity-100');
await vi.advanceTimersByTimeAsync(2000);
expect(wrapper.get('[data-testid="step-image-1"]').classes()).toContain('opacity-100');
wrapper.unmount();
});
it('fills the active step progress line before advancing', () => {
const source = readComponent('StepProcess');
expect(source).toContain('step-progress');
expect(source).toContain('animation: fill-step-progress 2s linear forwards');
expect(source).toContain('@keyframes fill-step-progress');
expect(source).toContain('to { width: 100%; }');
expect(source).toContain('class="step-title text-xl lg:text-4xl"');
expect(source).not.toContain('step-title text-xl font-bold');
});
it('advances the feature content every two seconds', async () => {
vi.useFakeTimers();
vi.stubGlobal('ref', ref);
const wrapper = mount(FeatureCards);
expect(wrapper.get('[data-testid="feature-image-0"]').classes()).toContain('opacity-100');
await vi.advanceTimersByTimeAsync(2000);
expect(wrapper.get('[data-testid="feature-image-1"]').classes()).toContain('opacity-100');
wrapper.unmount();
});
it('keeps feature items aligned with the image and uses restrained titles', () => {
const source = readComponent('FeatureCards');
expect(source).toContain('feature-list');
expect(source).toContain('feature-item-title text-xl lg:text-3xl');
expect(source).toContain('grid-template-rows: repeat(4, minmax(0, 1fr))');
});
it('runs the customer-case track as an automatic marquee', () => {
const source = readComponent('CustomerCases');
expect(source).toContain('animation: case-marquee 30s');
expect(source).toContain('@media (prefers-reduced-motion: reduce)');
});
it('keeps the hero visual as a capped image instead of a background', () => {
const source = readComponent('HeroBanner');
expect(source).toMatch(/<img[^>]+src="\/\.png"/u);
expect(source).not.toMatch(/background(?:-image)?:\s*[^;]*\.png/u);
expect(source).toContain('hero-visual');
});
it('styles the hero emphasis and separates the three selling points', () => {
const source = readComponent('HeroBanner');
expect(source).toContain('hero-copy');
expect(source).toContain('hero-gradient-text');
expect(source).toContain('linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%)');
expect(source).toContain('.hero-copy { align-items: flex-start; }');
expect(source).toContain('hero-subtitle font-thin');
expect(source).toContain('selling-point-separator');
expect(source).toContain('aria-hidden="true">|</span>');
});
it('loads the supplied PingFang SC font weights globally', () => {
const css = readFileSync(resolve(process.cwd(), 'app/assets/css/tailwind.css'), 'utf8');
expect(css).toContain("font-family: 'PingFang SC'");
expect(css).toContain("url('/fonts/pingfang-sc-regular.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-thin.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-medium.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-semibold.ttf')");
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-regular.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-thin.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-medium.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-semibold.ttf'))).toBe(true);
});
it('uses the theme color for trust statistics', () => {
const source = readComponent('TrustSection');
expect(source).toContain('trust-stat-value text-primary');
expect(source).toContain('trust-illustration');
expect(source).toContain('margin-bottom: -40px');
});
it('temporarily hides recommendation and solution navigation', () => {
const source = readComponent('AppHeader');
expect(source).toContain('v-if="SHOW_EXTENDED_NAV"');
});
it('uses a compact desktop navigation font size', () => {
const source = readComponent('AppHeader');
expect(source).toContain('desktop-nav');
expect(source).toContain('.desktop-nav { font-size: 18px; }');
});
it('aligns country flags and keeps the more-products action at the bottom', () => {
const source = readComponent('PodProducts');
expect(source).toContain('country-button');
expect(source).toContain('country-flag');
expect(source).toContain('more-products');
expect(source).toContain('more-products-link text-primary');
expect(source).not.toContain('fa-arrow-right');
expect(source).toContain('margin-top: auto');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductCard from '../app/components/product/ProductCard.vue';
import type { Product } from '../app/composables/useProductCenter';
describe('ProductCard', () => {
it('links the entire product card to its Inkpod detail page', () => {
const product: Product = {
id: '12345', name: '测试商品', priority: 0, image: null, price: '28',
country: { id: '1', name: '美国', icon: null },
category: { id: '2', name: 'T恤', icon: null, parentId: null, children: [] },
tag: null, tags: [],
};
const wrapper = mount(ProductCard, { props: { product } });
const link = wrapper.get('a.product-card');
expect(link.attributes('href')).toBe('https://inkpod.vip/portal/detail/12345');
expect(link.attributes('target')).toBeUndefined();
});
});
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('product center layout', () => {
it('removes the sidebar left edge and extends filter dividers to the viewport right', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/pages/product-center.vue'),
'utf8',
);
expect(source).not.toMatch(/\.sidebar-shell\s*\{[^}]*border-left:/u);
expect(source).toContain('.countries::after, .filter-tools::after');
expect(source).toContain('right: calc(-1 * var(--page-gutter))');
expect(source).toContain('left: 0');
expect(source).toContain('height: 1px');
expect(source).toContain('background: #e5e7eb');
});
it('adds breathing room between root category rows', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/components/product/ProductSidebar.vue'),
'utf8',
);
expect(source).toMatch(/\.root-row\s*\{[^}]*margin-bottom:\s*10px/u);
});
});
@@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductFilterBar from '../app/components/product/ProductFilterBar.vue';
describe('ProductFilterBar', () => {
it('keeps search visible while showing a separate clear button for text', async () => {
const wrapper = mount(ProductFilterBar, {
props: { keyword: '' },
});
await wrapper.get('input').setValue('卫衣');
expect(wrapper.get('button[type="submit"]').text()).toBe('搜索');
expect(wrapper.get('.clear-search').attributes('aria-label')).toBe('清除搜索');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductSidebar from '../app/components/product/ProductSidebar.vue';
import type { Category } from '../app/composables/useProductCenter';
const categories: Category[] = [{
id: 'men',
name: '男士服装',
icon: null,
parentId: null,
children: [{ id: 'men-tee', name: 'T恤', icon: null, parentId: 'men', children: [] }],
}];
describe('ProductSidebar', () => {
it('selects and expands a parent category when it is clicked', async () => {
const wrapper = mount(ProductSidebar, {
props: { categories, activeCategoryId: null },
});
await wrapper.findAll('.root-row')[1]?.trigger('click');
expect(wrapper.emitted('update:activeCategoryId')).toEqual([['men']]);
expect(wrapper.text()).toContain('T恤');
});
});
+100
View File
@@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductTagFilter from '../app/components/product/ProductTagFilter.vue';
import type { Tag, TagGroup } from '../app/composables/useProductCenter';
const tagGroups: TagGroup[] = [
{ id: 'shipping', name: '物流渠道', icon: null, color: null, sortOrder: 1 },
{ id: 'position', name: '印刷位置', icon: null, color: null, sortOrder: 2 },
{ id: 'craft', name: '印刷工艺', icon: null, color: null, sortOrder: 3 },
];
const tags: Tag[] = [
{
id: 'free-shipping',
name: '包邮',
color: null,
fontColor: null,
group: { id: 'shipping', name: '物流渠道', sortOrder: 1 },
},
{
id: 'heat-transfer',
name: '烫画',
color: null,
fontColor: null,
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
},
];
describe('ProductTagFilter', () => {
it('renders selected logistics and craft tags and removes them through the existing event', async () => {
const wrapper = mount(ProductTagFilter, {
props: {
tags,
tagGroups,
selectedTagIds: ['free-shipping', 'heat-transfer'],
},
});
const selectedTags = wrapper.findAll('.selected-tag');
expect(selectedTags.map((tag) => tag.text())).toEqual(['包邮', '烫画']);
await selectedTags[0]?.find('.selected-tag-remove').trigger('click');
expect(wrapper.emitted('toggle-tag')).toEqual([['free-shipping']]);
});
it('places a craft-only selection at the left edge of the selected tag row', () => {
const wrapper = mount(ProductTagFilter, {
props: { tags, tagGroups, selectedTagIds: ['heat-transfer'] },
});
const selectedRow = wrapper.get('.selected-tags');
expect(selectedRow.findAll('.selected-tag')).toHaveLength(1);
expect(selectedRow.get('.selected-tag').text()).toBe('烫画');
expect(selectedRow.classes()).toContain('craft-only');
});
it('closes the dropdown after selection and when clicking outside', async () => {
const wrapper = mount(ProductTagFilter, {
attachTo: document.body,
props: { tags, tagGroups, selectedTagIds: [] },
});
await wrapper.findAll('.filter-button')[0]?.trigger('click');
expect(wrapper.find('.menu').exists()).toBe(true);
await wrapper.get('.menu button').trigger('click');
expect(wrapper.find('.menu').exists()).toBe(false);
await wrapper.findAll('.filter-button')[0]?.trigger('click');
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await wrapper.vm.$nextTick();
expect(wrapper.find('.menu').exists()).toBe(false);
wrapper.unmount();
});
it('uses backend group membership and excludes the print-position group', async () => {
const craftTags: Tag[] = ['烫画', '直喷', '不打印'].map((name, index) => ({
id: `craft-${index}`,
name,
color: null,
fontColor: null,
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
}));
const positionTag: Tag = {
id: 'single-side', name: '单面印', color: null, fontColor: null,
group: { id: 'position', name: '印刷位置', sortOrder: 2 },
};
const wrapper = mount(ProductTagFilter, {
props: { tags: [tags[0]!, ...craftTags, positionTag], tagGroups, selectedTagIds: [] },
});
expect(wrapper.findAll('.filter-button').map((item) => item.text())).toEqual(['物流', '工艺']);
await wrapper.findAll('.filter-button')[1]?.trigger('click');
expect(wrapper.findAll('.menu button').map((item) => item.text())).toEqual([
'烫画',
'直喷',
'不打印',
]);
});
});
+5
View File
@@ -0,0 +1,5 @@
import { config } from '@vue/test-utils';
config.global.stubs = {
NuxtLink: { template: '<a><slot /></a>' },
};
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import type { Category, Tag } from '../app/composables/useProductCenter';
import { findCategoryIdByName, findCategoryIdForPodName, resolveBackendAssetUrl, resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters, selectExclusiveTagIds } from '../app/composables/useProductCenter';
describe('resolveCountryIdFromQuery', () => {
const countries = [{ id: '45', name: '美国', icon: null }];
it('accepts only a configured backend country id', () => {
expect(resolveCountryIdFromQuery('45', countries)).toBe('45');
expect(resolveCountryIdFromQuery('999', countries)).toBeUndefined();
expect(resolveCountryIdFromQuery(['45'], countries)).toBe('45');
});
});
describe('product-center category routing', () => {
it('finds and validates nested backend category ids', () => {
expect(findCategoryIdByName('T恤', categories)).toBe('tee');
expect(resolveCategoryIdFromQuery('tee', categories)).toBe('tee');
expect(resolveCategoryIdFromQuery('unknown', categories)).toBeUndefined();
});
it('maps compound homepage POD names into the backend category tree', () => {
const backendCategories: Category[] = [{
id: 'women', name: '女士服装', icon: null, parentId: null,
children: [{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] }],
}];
expect(findCategoryIdForPodName('女士T恤', backendCategories)).toBe('tee');
expect(findCategoryIdForPodName('女士背心', backendCategories)).toBe('women');
});
});
describe('resolveBackendAssetUrl', () => {
it('resolves backend-relative uploaded and static assets', () => {
expect(resolveBackendAssetUrl('/assets/product-center/countries/us.png', 'http://localhost:3001'))
.toBe('http://localhost:3001/assets/product-center/countries/us.png');
});
it('preserves absolute, data, and empty asset values', () => {
expect(resolveBackendAssetUrl('https://cdn.example/icon.svg', 'http://localhost:3001')).toBe('https://cdn.example/icon.svg');
expect(resolveBackendAssetUrl('data:image/svg+xml;base64,AA==', 'http://localhost:3001')).toBe('data:image/svg+xml;base64,AA==');
expect(resolveBackendAssetUrl(null, 'http://localhost:3001')).toBeNull();
});
});
const categories: Category[] = [
{
id: 'women',
name: '女式服装',
icon: null,
parentId: null,
children: [
{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] },
],
},
];
const productionCategories: Category[] = [
{
...categories[0],
name: '女士服装',
},
];
const tags: Tag[] = [
{ id: 'shipping', name: '包邮', color: null, fontColor: null, group: null },
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: null },
];
describe('resolveDefaultProductFilters', () => {
it('defaults to all products with no tag filters', () => {
expect(resolveDefaultProductFilters(categories, tags)).toEqual({
categoryId: undefined,
tagIds: [],
});
});
it('omits filters that are unavailable from the backend', () => {
expect(resolveDefaultProductFilters([], [])).toEqual({
categoryId: undefined,
tagIds: [],
});
});
it('does not infer a category from production category wording', () => {
expect(resolveDefaultProductFilters(productionCategories, tags).categoryId).toBeUndefined();
});
});
describe('selectExclusiveTagIds', () => {
const groupedTags: Tag[] = [
{ id: 'free-shipping', name: '包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
{ id: 'paid-shipping', name: '不包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: { id: 'craft', name: '工艺', sortOrder: 2 } },
];
it('replaces the selected tag within the same group', () => {
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'paid-shipping')).toEqual(['transfer', 'paid-shipping']);
});
it('keeps selections from other groups and toggles the active item off', () => {
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'free-shipping')).toEqual(['transfer']);
});
});
+18
View File
@@ -0,0 +1,18 @@
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'~': fileURLToPath(new URL('./app', import.meta.url)),
'@': fileURLToPath(new URL('./app', import.meta.url)),
},
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
});