feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,46 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Product } from '~/composables/useProductCenter';
|
||||
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
||||
|
||||
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>
|
||||
<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" loading="lazy" />
|
||||
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<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"><template v-if="price"><small>¥</small>{{ price }}<small>起</small></template><template v-else>暂无报价</template></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Product } from '~/composables/useProductCenter';
|
||||
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
||||
|
||||
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>
|
||||
<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" loading="lazy" />
|
||||
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<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"><template v-if="price"><small>¥</small>{{ price }}<small>起</small></template><template v-else>暂无报价</template></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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,88 +1,88 @@
|
||||
<template>
|
||||
<article class="skeleton-card">
|
||||
<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">
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
</div>
|
||||
<div class="skeleton-line price"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-card {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.skeleton-media,
|
||||
.skeleton-line,
|
||||
.skeleton-chip {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, #f7f7f7, transparent);
|
||||
animation: shimmer 1.25s infinite;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
width: 90%;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.skeleton-line.price {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.skeleton-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-chip {
|
||||
width: 46px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<article class="skeleton-card">
|
||||
<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">
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
<span class="skeleton-chip"></span>
|
||||
</div>
|
||||
<div class="skeleton-line price"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-card {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.skeleton-media,
|
||||
.skeleton-line,
|
||||
.skeleton-chip {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, #f7f7f7, transparent);
|
||||
animation: shimmer 1.25s infinite;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
width: 90%;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.skeleton-line.price {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.skeleton-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-chip {
|
||||
width: 46px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +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>
|
||||
<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,29 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Country } from '~/composables/useProductCenter';
|
||||
|
||||
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
|
||||
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
|
||||
|
||||
const configuredCountries = computed(() => props.countries);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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: 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>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Country } from '~/composables/useProductCenter';
|
||||
|
||||
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
|
||||
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
|
||||
|
||||
const configuredCountries = computed(() => props.countries);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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: 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,28 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watch } from 'vue';
|
||||
|
||||
const keyword = defineModel<string>('keyword', { default: '' });
|
||||
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>
|
||||
<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 { 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>
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watch } from 'vue';
|
||||
|
||||
const keyword = defineModel<string>('keyword', { default: '' });
|
||||
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>
|
||||
<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 { 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,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product } from '~/composables/useProductCenter';
|
||||
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
|
||||
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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(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>
|
||||
<script setup lang="ts">
|
||||
import type { Product } from '~/composables/useProductCenter';
|
||||
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
|
||||
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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(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,25 +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(() => 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">
|
||||
<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: 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>
|
||||
<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(() => 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">
|
||||
<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: 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,45 +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 = reactive<Record<string, boolean>>({});
|
||||
function selectRootCategory(category: Category): void {
|
||||
emit('update:activeCategoryId', category.id);
|
||||
if (category.children.length) expanded[category.id] = !expanded[category.id];
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
.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>
|
||||
<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 = reactive<Record<string, boolean>>({});
|
||||
function selectRootCategory(category: Category): void {
|
||||
emit('update:activeCategoryId', category.id);
|
||||
if (category.children.length) expanded[category.id] = !expanded[category.id];
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
.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,75 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||
import type { Tag, TagGroup } from '~/composables/useProductCenter';
|
||||
|
||||
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));
|
||||
|
||||
function selectTag(id: string): void {
|
||||
emit('toggle-tag', id);
|
||||
openGroup.value = null;
|
||||
}
|
||||
|
||||
function closeOnOutsideClick(event: MouseEvent): void {
|
||||
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||
import type { Tag, TagGroup } from '~/composables/useProductCenter';
|
||||
|
||||
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));
|
||||
|
||||
function selectTag(id: string): void {
|
||||
emit('toggle-tag', id);
|
||||
openGroup.value = null;
|
||||
}
|
||||
|
||||
function closeOnOutsideClick(event: MouseEvent): void {
|
||||
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user