Files
hfb_sys/frontend/src/features/listings/views/HomeView.vue
T

1104 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePcHomeCacheStore } from '@/stores/pcHomeCache'
import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
normalizeHomeBanner,
type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { ElMessage } from 'element-plus'
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from '@/features/listings/api/listingOptions'
import { useHomeFilters } from '@/features/listings/composables/useHomeFilters'
import {
clearStoredHomeQuery,
desktopHomeFilterStorageKey,
desktopHomeFilterQueryKeys,
hasHomeQueryValues,
isSameQuery,
mergeHomeQuery,
readStoredHomeQuery,
joinQueryCSV,
readQueryCSV,
readQueryNumber,
readQueryString,
storeHomeQuery,
type HomeQueryValue,
} from '@/features/listings/composables/useHomeFilterQuery'
import { useListingQuery } from '@/features/listings/composables/useListingQuery'
import HomeAnnouncement from '../components/HomeAnnouncement.vue'
import HomeBanner from '../components/HomeBanner.vue'
import HomeFilters from '../components/HomeFilters.vue'
import HomeZones from '../components/HomeZones.vue'
import ListingCard from '../components/ListingCard.vue'
import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite'
const siteStore = useDistributionSiteStore()
const announcements = ref<string[]>(defaultHomeAnnouncements)
const displayAnnouncements = computed(() => {
if (siteStore.isDistribution) {
if (siteStore.announcements && siteStore.announcements.length > 0) {
return siteStore.announcements
}
return [
`【${siteStore.siteName}】精选三角洲账号:官方验号,安全秒交接,售后有保障!`,
...announcements.value,
]
}
return announcements.value
})
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const displayBanners = computed<HomeBannerSlide[]>(() => {
const list =
siteStore.isDistribution && siteStore.banners.length > 0
? mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
: banners.value
return list.filter(slide => slide.enabled !== false)
})
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecycleDialogVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackDialogVisible = ref(false)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
interface SortButtonItem {
key: string
label: string
hasDirection?: boolean
defaultSort?: string
altSort?: string
}
const sortBy = ref('recommended')
const sortButtons: SortButtonItem[] = [
{ key: 'recommended', label: '综合推荐' },
{
key: 'ratio',
label: '比例',
hasDirection: true,
defaultSort: 'ratioDesc',
altSort: 'ratioAsc',
},
{
key: 'coin',
label: '哈夫币',
hasDirection: true,
defaultSort: 'coinDesc',
altSort: 'coinAsc',
},
{
key: 'awm',
label: 'AWM数量',
hasDirection: true,
defaultSort: 'awmDesc',
altSort: 'awmAsc',
},
{
key: 'price',
label: '价格',
hasDirection: true,
defaultSort: 'priceAsc',
altSort: 'priceDesc',
},
]
function isSortActive(item: SortButtonItem): boolean {
if (!item.hasDirection) {
return sortBy.value === 'recommended' || !sortBy.value
}
return sortBy.value === item.defaultSort || sortBy.value === item.altSort
}
function getSortDirection(item: SortButtonItem): 'asc' | 'desc' | null {
if (!item.hasDirection) return null
if (sortBy.value === item.defaultSort) {
return item.defaultSort?.endsWith('Asc') ? 'asc' : 'desc'
}
if (sortBy.value === item.altSort) {
return item.altSort?.endsWith('Asc') ? 'asc' : 'desc'
}
return null
}
function handleSortClick(item: SortButtonItem) {
if (!item.hasDirection) {
sortBy.value = 'recommended'
return
}
if (sortBy.value === item.defaultSort) {
sortBy.value = item.altSort!
} else if (sortBy.value === item.altSort) {
sortBy.value = item.defaultSort!
} else {
sortBy.value = item.defaultSort!
}
}
const activeZone = ref('all')
const route = useRoute()
const router = useRouter()
const {
filters,
regionOptions,
loginMethodOptions,
skinFilterGroups,
advancedSkinFilterGroups,
advancedFiltersOpen,
advancedActiveCount,
resetFilters,
} = useHomeFilters(
publishOptions,
computed(() => listings.value)
)
applyRouteQueryToHomeState()
const {
loading,
loadingMore,
listings,
totalListings,
hasMoreListings,
loadListingsPage,
zoneCount,
restoreSnapshot,
getSnapshot,
} = useListingQuery(filters, sortBy, activeZone)
watch(
() => route.query,
() => {
applyRouteQueryToHomeState()
}
)
watch(
() => desktopHomeStateSignature(),
() => {
syncHomeQuery()
}
)
const statCards = computed(() => [
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{
label: '高哈夫币',
value: `${zoneCount('highCoin')}`,
hint: '100M 以上',
},
{
label: '账密登录',
value: `${zoneCount('password')}`,
hint: '交接更快',
},
])
const zoneOptions = computed(() => [
{
key: 'all',
label: '全部专区',
hint: '当前可租账号',
count: zoneCount('all'),
},
{
key: 'sale',
label: '特惠专区',
hint: '价格更划算',
count: zoneCount('sale'),
},
{
key: 'gift',
label: '赠送专区',
hint: '含赠送物品',
count: zoneCount('gift'),
},
{
key: 'night',
label: '夜间专区',
hint: '0-2点可响应上号',
count: zoneCount('night'),
},
{
key: 'password',
label: '账密专区',
hint: '交接更快',
count: zoneCount('password'),
},
{
key: 'highCoin',
label: '高币专区',
hint: '100M 以上',
count: zoneCount('highCoin'),
},
])
async function loadHome() {
loading.value = true
try {
const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()])
announcements.value = config.announcements
banners.value = config.banners
awRecycle.value = config.aw_recycle
cooperationFeedback.value = config.cooperation_feedback
publishOptions.value = config.publish_options
} catch {
announcements.value = defaultHomeAnnouncements
banners.value = defaultHomeBanners
awRecycle.value = { ...defaultAWRecycleConfig }
cooperationFeedback.value = { ...defaultCooperationFeedbackConfig }
publishOptions.value = emptyListingPublishOptions
} finally {
loading.value = false
saveToCache()
restorePcHomeScroll()
}
}
function openAWRecycle() {
if (!awRecycle.value.image_url) {
ElMessage.warning('回收说明图片尚未配置,请稍后再试')
return
}
awRecycleDialogVisible.value = true
}
function openCooperationFeedback() {
if (!cooperationFeedback.value.image_url) {
ElMessage.warning('合作与反馈图片尚未配置,请稍后再试')
return
}
cooperationFeedbackDialogVisible.value = true
}
function updateFilters(partial: Partial<typeof filters>) {
Object.assign(filters, partial)
}
function handleResetFilters() {
clearStoredHomeQuery(desktopHomeFilterStorageKey)
resetFilters()
activeZone.value = 'all'
sortBy.value = 'recommended'
syncHomeQuery()
}
function applyRouteQueryToHomeState() {
const storedQuery = readStoredHomeQuery(desktopHomeFilterStorageKey)
const routeHasManaged = hasHomeQueryValues(route.query, desktopHomeFilterQueryKeys)
// 路由有托管参数时以路由为准;否则回退 session。
// 注意:综合推荐时 sort 不在 URL,若路由仍有其它筛选参数,也不要用 storage 覆盖 sort。
const query = routeHasManaged ? route.query : storedQuery
filters.keyword = readQueryString(query, 'keyword')
filters.server = readQueryString(query, 'server')
filters.region = readQueryCSV(query, 'region')
filters.loginMethod = readQueryCSV(query, 'login_method')
filters.rank = readQueryCSV(query, 'rank')
filters.insurance = readQueryCSV(query, 'insurance')
filters.stamina = readQueryCSV(query, 'stamina')
filters.load = readQueryCSV(query, 'load')
// 兼容旧单值 skin_name / 新 CSV 多选
filters.skinNames = readQueryCSV(query, 'skin_name')
filters.minCoin = readQueryNumber(query, 'min_coin')
filters.maxCoin = readQueryNumber(query, 'max_coin')
filters.minRatio = readQueryNumber(query, 'min_ratio')
filters.maxRatio = readQueryNumber(query, 'max_ratio')
filters.minPrice = readQueryNumber(query, 'min_price')
filters.maxPrice = readQueryNumber(query, 'max_price')
filters.minDeposit = readQueryNumber(query, 'min_deposit')
filters.maxDeposit = readQueryNumber(query, 'max_deposit')
filters.minTotal = readQueryNumber(query, 'min_total')
filters.maxTotal = readQueryNumber(query, 'max_total')
filters.minFireLevel = readQueryNumber(query, 'min_fire_level')
filters.maxFireLevel = readQueryNumber(query, 'max_fire_level')
filters.minDailyLoss = readQueryNumber(query, 'min_daily_loss')
filters.maxDailyLoss = readQueryNumber(query, 'max_daily_loss')
filters.minAwmAmmo = readQueryNumber(query, 'resource_awmAmmo_min')
filters.maxAwmAmmo = readQueryNumber(query, 'resource_awmAmmo_max')
// 无 sort 参数即综合推荐(默认)
sortBy.value = readQueryString(query, 'sort') || 'recommended'
// zone 不从 query/storage 恢复,由用户实时点击决定,不参与筛选持久化
}
function desktopHomeStateSignature() {
return JSON.stringify(buildDesktopHomeQueryValues())
}
function syncHomeQuery() {
const query = mergeHomeQuery(
route.query,
desktopHomeFilterQueryKeys,
buildDesktopHomeQueryValues()
)
// 综合推荐不写 sort;若当前无任何筛选条件,必须清空 storage,
// 否则会回退读到旧的 sort=coinDesc 等,导致无法选回「综合推荐」。
if (hasHomeQueryValues(query, desktopHomeFilterQueryKeys)) {
storeHomeQuery(desktopHomeFilterStorageKey, query)
} else {
clearStoredHomeQuery(desktopHomeFilterStorageKey)
}
if (isSameQuery(route.query, query)) return
router.replace({ path: '/', query })
}
function buildDesktopHomeQueryValues(): Record<string, HomeQueryValue> {
return {
keyword: filters.keyword.trim() || undefined,
sort: sortBy.value === 'recommended' ? undefined : sortBy.value,
server: filters.server || undefined,
region: joinQueryCSV(filters.region),
login_method: joinQueryCSV(filters.loginMethod),
rank: joinQueryCSV(filters.rank),
insurance: joinQueryCSV(filters.insurance),
stamina: joinQueryCSV(filters.stamina),
load: joinQueryCSV(filters.load),
skin_name: joinQueryCSV(filters.skinNames),
min_coin: filters.minCoin,
max_coin: filters.maxCoin,
min_ratio: filters.minRatio,
max_ratio: filters.maxRatio,
min_price: filters.minPrice,
max_price: filters.maxPrice,
min_deposit: filters.minDeposit,
max_deposit: filters.maxDeposit,
min_total: filters.minTotal,
max_total: filters.maxTotal,
min_fire_level: filters.minFireLevel,
max_fire_level: filters.maxFireLevel,
min_daily_loss: filters.minDailyLoss,
max_daily_loss: filters.maxDailyLoss,
resource_awmAmmo_min: filters.minAwmAmmo,
resource_awmAmmo_max: filters.maxAwmAmmo,
}
}
// ========== PC 首页状态与滚动持久化 ==========
// 详情页返回/浏览器后退时,利用 Pinia 缓存同步恢复全部已加载列表与高度,
// 使首帧渲染即可完成完整布局,结合 instant 滚动定位彻底解决「从顶部闪跳」问题。
const pcHomeScrollStorageKey = 'hfb.home.scroll.desktop'
const pcHomeCache = usePcHomeCacheStore()
function restoreFromCache(): boolean {
const signature = desktopHomeStateSignature()
if (!pcHomeCache.matchList(signature)) return false
restoreSnapshot({
listings: pcHomeCache.listings,
totalListings: pcHomeCache.totalListings,
zoneCounts: pcHomeCache.zoneCounts,
currentPage: pcHomeCache.currentPage,
hasMoreListings: pcHomeCache.hasMoreListings,
})
if (pcHomeCache.homeConfigLoaded) {
announcements.value = pcHomeCache.announcements.slice()
banners.value = pcHomeCache.banners.map(slide => normalizeHomeBanner(slide))
awRecycle.value = { ...pcHomeCache.awRecycle }
cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback }
publishOptions.value = pcHomeCache.publishOptions
}
return true
}
function saveToCache() {
const snapshot = getSnapshot()
if (snapshot.listings.length === 0) return
pcHomeCache.saveList({
signature: desktopHomeStateSignature(),
listings: snapshot.listings,
totalListings: snapshot.totalListings,
zoneCounts: snapshot.zoneCounts,
currentPage: snapshot.currentPage,
hasMoreListings: snapshot.hasMoreListings,
})
pcHomeCache.saveHomeConfig({
announcements: announcements.value,
banners: banners.value,
awRecycle: awRecycle.value,
cooperationFeedback: cooperationFeedback.value,
publishOptions: publishOptions.value,
})
pcHomeCache.saveScrollTop(window.scrollY)
}
function savePcHomeScroll() {
saveToCache()
try {
window.sessionStorage.setItem(
pcHomeScrollStorageKey,
JSON.stringify({ signature: desktopHomeStateSignature(), top: window.scrollY })
)
} catch {
// sessionStorage 不可用时忽略
}
}
function restorePcHomeScroll() {
let savedTop = pcHomeCache.scrollTop
let savedSignature = pcHomeCache.listSignature
if (savedTop <= 0) {
try {
const raw = window.sessionStorage.getItem(pcHomeScrollStorageKey)
if (raw) {
const parsed = JSON.parse(raw) as { signature?: string; top?: number }
if (typeof parsed.signature === 'string' && typeof parsed.top === 'number') {
savedSignature = parsed.signature
savedTop = parsed.top
}
}
} catch {
return
}
}
if (savedTop <= 0 || savedSignature !== desktopHomeStateSignature()) return
const apply = () => {
window.scrollTo({ top: savedTop, behavior: 'instant' })
}
apply()
requestAnimationFrame(apply)
}
function handleScroll() {
if (window.scrollY > 0) {
pcHomeCache.saveScrollTop(window.scrollY)
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
if (restored) {
restorePcHomeScroll()
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
onBeforeRouteLeave(() => {
savePcHomeScroll()
})
syncHomeQuery()
const restored = restoreFromCache()
if (!restored) {
loadHome()
}
</script>
<template>
<section class="pc-home-redesign">
<HomeAnnouncement :announcements="displayAnnouncements" />
<!-- 弹窗公告:进入首页弹出,必须手动关闭,同一天同一条只弹一次 -->
<AnnouncementPopup />
<main class="home-content">
<!-- 商城专属标识 Banner -->
<div v-if="siteStore.isDistribution" class="dist-portal-banner">
<div class="dist-portal-content">
<div class="dist-portal-tag">
<span class="dist-pulse"></span>
{{ siteStore.storefrontText.hero_badge }}
</div>
<h2 class="dist-portal-title">{{ siteStore.coBrandName }}</h2>
<p class="dist-portal-desc">{{ siteStore.storefrontText.hero_desc }}</p>
</div>
<div class="dist-portal-badges">
<div class="dist-badge-item">
<span class="badge-icon">🛡️</span>
<div class="badge-text">
<strong>官方质检</strong>
<small>账号状态实时同步</small>
</div>
</div>
<div class="dist-badge-item">
<span class="badge-icon">⚡</span>
<div class="badge-text">
<strong>极速交付</strong>
<small>自动交接 快速上号</small>
</div>
</div>
<div class="dist-badge-item">
<span class="badge-icon">📑</span>
<div class="badge-text">
<strong>独立账单</strong>
<small>专属订单 隐私隔离</small>
</div>
</div>
</div>
</div>
<div class="hero-section">
<HomeBanner :banners="displayBanners" :stats="statCards" />
<!-- 业务入口:贴在轮播右侧竖排;原「租号大厅」只是跳回首页,已移除 -->
<div class="biz-entry-rail" aria-label="业务入口">
<RouterLink v-if="!siteStore.isDistribution" class="biz-entry crash" to="/crash">
<div class="biz-title-row">
<strong>撞车 <em>NEW</em></strong>
<small>选购商品 · 进群交接</small>
</div>
<van-icon name="arrow" :size="14" class="biz-arrow" />
</RouterLink>
<button
v-if="awRecycle.enabled"
type="button"
class="biz-entry aw-recycle"
@click="openAWRecycle"
>
<div class="biz-title-row">
<strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small>
</div>
<van-icon name="arrow" :size="14" class="biz-arrow" />
</button>
<button
v-if="cooperationFeedback.enabled"
type="button"
class="biz-entry cooperation-feedback"
@click="openCooperationFeedback"
>
<div class="biz-title-row">
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
</div>
<van-icon name="arrow" :size="14" class="biz-arrow" />
</button>
</div>
</div>
<el-dialog
v-model="awRecycleDialogVisible"
:title="awRecycle.title"
width="720px"
class="aw-recycle-dialog"
align-center
destroy-on-close
>
<AuthImage
v-if="awRecycle.image_url"
:source="awRecycle.image_url"
:alt="awRecycle.title"
fit="contain"
image-class="aw-recycle-image"
:preview-src-list="[awRecycle.image_url]"
/>
<el-empty v-else description="暂无展示图片" />
</el-dialog>
<el-dialog
v-model="cooperationFeedbackDialogVisible"
:title="cooperationFeedback.title"
width="720px"
class="aw-recycle-dialog"
align-center
destroy-on-close
>
<AuthImage
v-if="cooperationFeedback.image_url"
:source="cooperationFeedback.image_url"
:alt="cooperationFeedback.title"
fit="contain"
image-class="aw-recycle-image"
:preview-src-list="[cooperationFeedback.image_url]"
/>
<el-empty v-else description="暂无展示图片" />
</el-dialog>
<HomeFilters
:filters="filters"
:total-listings="totalListings"
:publish-options="publishOptions"
:region-options="regionOptions"
:login-method-options="loginMethodOptions"
:skin-filter-groups="skinFilterGroups"
:advanced-skin-filter-groups="advancedSkinFilterGroups"
:advanced-open="advancedFiltersOpen"
:advanced-active-count="advancedActiveCount"
@update:filters="updateFilters"
@update:advanced-open="advancedFiltersOpen = $event"
@reset="handleResetFilters"
/>
<!-- 专区与排序合并为同一行极简操作条 -->
<div class="list-control-bar">
<HomeZones
:zones="zoneOptions"
:active-zone="activeZone"
@update:active-zone="activeZone = $event"
/>
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
<button
v-for="item in sortButtons"
:key="item.key"
type="button"
class="sort-chip"
:class="{ active: isSortActive(item) }"
@click="handleSortClick(item)"
>
<span>{{ item.label }}</span>
<span v-if="item.hasDirection" class="sort-arrows" aria-hidden="true">
<i class="arrow-up" :class="{ active: getSortDirection(item) === 'asc' }" />
<i class="arrow-down" :class="{ active: getSortDirection(item) === 'desc' }" />
</span>
</button>
</div>
</div>
<div v-if="!loading && listings.length === 0" class="home-empty-state">
<strong>没有符合条件的账号</strong>
<span>可以放宽筛选条件或切回全部专区。</span>
<button type="button" @click="handleResetFilters">重置条件</button>
</div>
<div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
</div>
<div v-if="!loading && listings.length" class="infinite-load-state">
<span v-if="loadingMore">正在加载更多账号...</span>
<span v-else-if="!hasMoreListings">已经到底了</span>
</div>
</main>
</section>
</template>
<style scoped>
.pc-home-redesign {
display: grid;
gap: 12px;
width: 100%;
max-width: 1720px;
min-width: 0;
margin: 0 auto;
overflow-x: clip;
}
.home-content {
display: grid;
gap: 12px;
min-width: 0;
}
.hero-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 14px;
align-items: stretch;
width: 100%;
min-width: 0;
}
.biz-entry-rail {
display: grid;
grid-auto-rows: minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.biz-entry {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-height: 84px;
padding: 16px 46px 16px 20px;
border-radius: 14px;
border: 1px solid #e2e8f0;
background: #ffffff;
text-decoration: none;
text-align: left;
cursor: pointer;
font: inherit;
overflow: hidden;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.biz-entry::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #ff8a3d 0%, #ff6a00 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
.biz-entry:hover {
border-color: #ffbe98;
box-shadow: 0 8px 20px rgba(255, 106, 0, 0.1);
transform: translateY(-2px);
}
.biz-entry:hover::before {
opacity: 1;
}
.biz-title-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
min-width: 0;
}
.biz-entry strong {
color: #0f172a;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
}
.biz-entry small {
color: #64748b;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-arrow {
position: absolute;
top: 50%;
right: 18px;
color: #94a3b8;
transform: translateY(-50%);
transition:
transform 0.2s ease,
color 0.2s ease;
}
.biz-entry:hover .biz-arrow {
color: #ff6a00;
transform: translateY(-50%) translateX(2px);
}
.biz-entry.crash strong em {
margin-left: 6px;
padding: 2px 6px;
border-radius: 999px;
background: #ff6a00;
color: #fff;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.biz-entry.aw-recycle {
border-color: #e0e7ff;
background: linear-gradient(135deg, #ffffff 0%, #f6f8fe 100%);
}
.biz-entry.cooperation-feedback {
border-color: #ccfbf1;
background: linear-gradient(135deg, #ffffff 0%, #f0fdf9 100%);
}
:deep(.aw-recycle-image) {
display: block;
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 10px;
background: #f8fafc;
}
@media (max-width: 1440px) {
.hero-section {
grid-template-columns: minmax(0, 1fr) 280px;
gap: 12px;
}
.biz-entry-rail {
gap: 12px;
}
}
@media (max-width: 1024px) {
.hero-section {
grid-template-columns: minmax(0, 1fr);
}
.biz-entry-rail {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: auto;
gap: 10px;
}
.biz-entry {
min-height: 76px;
padding: 14px 40px 14px 16px;
}
}
@media (max-width: 680px) {
.biz-entry-rail {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.infinite-load-state {
padding: 12px 0 24px;
color: #94a3b8;
font-size: 13px;
font-weight: 600;
text-align: center;
}
/* 专区与排序单行交互条 */
.list-control-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 2px 0;
}
.sort-bar {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 3px;
border-radius: 10px;
background: #f1f5f9;
}
.sort-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease;
}
.sort-chip:hover {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
.sort-chip.active {
background: #ffffff;
color: #ff6a00;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.sort-arrows {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
margin-left: 4px;
}
.sort-arrows .arrow-up,
.sort-arrows .arrow-down {
display: block;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
transition: border-color 0.15s ease;
}
.sort-arrows .arrow-up {
border-bottom: 3.5px solid #cbd5e1;
}
.sort-arrows .arrow-up.active {
border-bottom-color: #ff6a00;
}
.sort-arrows .arrow-down {
border-top: 3.5px solid #cbd5e1;
}
.sort-arrows .arrow-down.active {
border-top-color: #ff6a00;
}
.sort-chip:hover .sort-arrows .arrow-up:not(.active),
.sort-chip:hover .sort-arrows .arrow-down:not(.active) {
border-color: #94a3b8 transparent;
}
.enhanced-desktop-list {
display: grid;
gap: 16px;
min-width: 0;
}
.home-empty-state {
display: grid;
place-items: center;
gap: 8px;
min-height: 148px;
padding: 28px;
border: 1px dashed #d8e1ee;
border-radius: 16px;
background: #ffffff;
color: #7b8798;
text-align: center;
}
.home-empty-state strong {
color: #17233d;
font-size: 18px;
}
.home-empty-state span {
font-size: 13px;
}
.home-empty-state button {
height: 34px;
margin-top: 4px;
padding: 0 16px;
border: 1px solid #ff6a00;
border-radius: 8px;
background: #fff7ed;
color: #ff6a00;
font-weight: 800;
cursor: pointer;
}
.dist-portal-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 24px 32px;
border-radius: 20px;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
color: #ffffff;
}
.dist-portal-content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 600px;
}
.dist-portal-tag {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 3px 10px;
border-radius: 999px;
background: rgba(20, 119, 255, 0.2);
border: 1px solid rgba(20, 119, 255, 0.4);
color: #60a5fa;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
}
.dist-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: #38bdf8;
box-shadow: 0 0 8px #38bdf8;
}
.dist-portal-title {
margin: 0;
font-size: 24px;
font-weight: 800;
color: #f8fafc;
letter-spacing: -0.5px;
}
.dist-portal-desc {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #94a3b8;
}
.dist-portal-badges {
display: flex;
align-items: center;
gap: 16px;
}
.dist-badge-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
}
.badge-icon {
font-size: 22px;
}
.badge-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.badge-text strong {
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
}
.badge-text small {
font-size: 11px;
color: #64748b;
}
@media (max-width: 960px) {
.dist-portal-banner {
flex-direction: column;
align-items: flex-start;
}
.dist-portal-badges {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
</style>