Files
hfb_sys/frontend/src/features/listings/views/HomeView.vue
T
yml2213 8e3283d951 style(home): 全面重构PC首页UI视觉层级与商品卡片布局
- 重构商品卡片为双栏分翼展台,消灭宽屏中间大面积空白
- 去除大粗冗余标题行,以核心资产微卡(哈夫币/保险/币比)作为视觉中心
- 过滤规格胶囊中的重复项(如AWM),与右侧仓库物资展台明确职责分工
- 优化核心资产文字与数字垂直居中对齐,统一沉稳灰阶底色与品牌点睛色
- 调整轮播图Banner高度与业务入口条呼吸感,提升首屏空间利用率与可读性
2026-09-06 18:57:13 +08:00

714 lines
20 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { ElMessage } from 'element-plus'
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'
const announcements = ref<string[]>(defaultHomeAnnouncements)
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecycleDialogVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackDialogVisible = ref(false)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortBy = ref('recommended')
const sortOptions = [
{ value: 'recommended', label: '综合推荐' },
{ value: 'coinDesc', label: '哈夫币' },
{ value: 'awmDesc', label: 'AWM数量' },
{ value: 'priceAsc', label: '价格最低' },
{ value: 'priceDesc', label: '价格最高' },
] as const
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,
} = 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
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 首页滚动位置持久化 ==========
// 详情页返回/浏览器后退时,首页列表异步加载导致页面高度不足,
// vue-router 的 savedPosition 恢复会被浏览器钳制到错误位置,
// 因此按「筛选签名」显式保存/恢复滚动位置,确保回到上次筛选的位置。
const pcHomeScrollStorageKey = 'hfb.home.scroll.desktop'
function savePcHomeScroll() {
try {
window.sessionStorage.setItem(
pcHomeScrollStorageKey,
JSON.stringify({ signature: desktopHomeStateSignature(), top: window.scrollY })
)
} catch {
// sessionStorage 不可用时忽略,仅损失滚动恢复
}
}
function restorePcHomeScroll() {
let savedTop = 0
let savedSignature = ''
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
let attempts = 0
const apply = () => {
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
if (savedTop <= maxTop) {
window.scrollTo({ top: savedTop })
return
}
// 首页无限滚动分页:已渲染高度不足时继续加载一页后重试,尽量恢复到位
if (attempts < 5 && hasMoreListings.value && !loadingMore.value) {
attempts += 1
void loadListingsPage(false).then(() => requestAnimationFrame(apply))
} else {
window.scrollTo({ top: maxTop })
}
}
requestAnimationFrame(apply)
}
onBeforeRouteLeave(() => {
savePcHomeScroll()
})
syncHomeQuery()
loadHome()
</script>
<template>
<section class="pc-home-redesign">
<HomeAnnouncement :announcements="announcements" />
<main class="home-content">
<div class="hero-section">
<HomeBanner :banners="banners" :stats="statCards" />
</div>
<div class="biz-entry-grid" aria-label="业务入口">
<RouterLink class="biz-entry" to="/">
<div class="biz-title-row">
<strong>租号大厅</strong>
<small>安全交接 · 随租随玩</small>
</div>
<van-icon name="arrow" :size="12" class="biz-arrow" />
</RouterLink>
<RouterLink class="biz-entry crash" to="/crash">
<div class="biz-title-row">
<strong>撞车 <em>NEW</em></strong>
<small>选购商品 · 进群交接</small>
</div>
<van-icon name="arrow" :size="12" 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="12" 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="12" class="biz-arrow" />
</button>
</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 sortOptions"
:key="item.value"
type="button"
class="sort-chip"
:class="{ active: sortBy === item.value }"
@click="sortBy = item.value"
>
{{ item.label }}
</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 {
width: 100%;
min-width: 0;
}
.biz-entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.biz-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 52px;
padding: 12px 18px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
text-decoration: none;
cursor: pointer;
font: inherit;
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:hover {
border-color: #ffbe98;
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.08);
transform: translateY(-2px);
}
.biz-title-row {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.biz-entry strong {
color: #0f172a;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
}
.biz-entry small {
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-arrow {
color: #94a3b8;
flex-shrink: 0;
transition: transform 0.2s ease, color 0.2s ease;
}
.biz-entry:hover .biz-arrow {
color: #ff6a00;
transform: 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: 960px) {
.biz-entry-grid {
grid-template-columns: repeat(2, 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 {
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);
}
.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;
}
</style>