588 lines
15 KiB
Vue
588 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
import {
|
|
defaultAWRecycleConfig,
|
|
defaultHomeAnnouncements,
|
|
defaultHomeBanners,
|
|
fetchMobileHomeConfig,
|
|
type AWRecycleConfig,
|
|
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 HomeStats from '../components/HomeStats.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 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-8点可上号',
|
|
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
|
|
publishOptions.value = config.publish_options
|
|
} catch {
|
|
announcements.value = defaultHomeAnnouncements
|
|
banners.value = defaultHomeBanners
|
|
awRecycle.value = { ...defaultAWRecycleConfig }
|
|
publishOptions.value = emptyListingPublishOptions
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openAWRecycle() {
|
|
if (!awRecycle.value.image_url) {
|
|
ElMessage.warning('回收说明图片尚未配置,请稍后再试')
|
|
return
|
|
}
|
|
awRecycleDialogVisible.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')
|
|
// 无 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,
|
|
}
|
|
}
|
|
|
|
syncHomeQuery()
|
|
loadHome()
|
|
</script>
|
|
|
|
<template>
|
|
<section class="pc-home-redesign">
|
|
<HomeAnnouncement :announcements="announcements" />
|
|
|
|
<main class="home-content">
|
|
<div class="hero-section">
|
|
<HomeBanner :banners="banners" />
|
|
<HomeStats :stats="statCards" />
|
|
</div>
|
|
|
|
<div class="biz-entry-grid" aria-label="业务入口">
|
|
<RouterLink class="biz-entry" to="/">
|
|
<strong>租号大厅</strong>
|
|
<small>高哈夫币 · 安全交接 · 随租随玩</small>
|
|
</RouterLink>
|
|
<RouterLink class="biz-entry crash" to="/crash">
|
|
<strong>撞车 <em>NEW</em></strong>
|
|
<small>选购商品 · 支付后进群联系客服</small>
|
|
</RouterLink>
|
|
<button
|
|
v-if="awRecycle.enabled"
|
|
type="button"
|
|
class="biz-entry aw-recycle"
|
|
@click="openAWRecycle"
|
|
>
|
|
<strong>{{ awRecycle.title }}</strong>
|
|
<small>{{ awRecycle.subtitle }}</small>
|
|
</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>
|
|
|
|
<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-head">
|
|
<div class="zone-head">
|
|
<p class="eyebrow">Account Zone</p>
|
|
<h2>账号专区</h2>
|
|
</div>
|
|
<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>
|
|
|
|
<HomeZones
|
|
:zones="zoneOptions"
|
|
:active-zone="activeZone"
|
|
@update:active-zone="activeZone = $event"
|
|
/>
|
|
|
|
<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: 20px;
|
|
width: 100%;
|
|
max-width: 1720px;
|
|
min-width: 0;
|
|
margin: 0 auto;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.home-content {
|
|
display: grid;
|
|
gap: 20px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.biz-entry-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.biz-entry {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 16px 18px;
|
|
border-radius: 14px;
|
|
border: 1px solid #eef1f5;
|
|
background: #fff;
|
|
text-decoration: none;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
transition:
|
|
border-color 0.18s ease,
|
|
box-shadow 0.18s ease;
|
|
}
|
|
|
|
.biz-entry:hover {
|
|
border-color: #ffd4b0;
|
|
box-shadow: 0 6px 16px rgba(255, 106, 0, 0.08);
|
|
}
|
|
|
|
.biz-entry strong {
|
|
color: #17233d;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.biz-entry small {
|
|
color: #7b8798;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.biz-entry.crash strong em {
|
|
margin-left: 6px;
|
|
padding: 1px 6px;
|
|
border-radius: 999px;
|
|
background: #ff6a00;
|
|
color: #fff;
|
|
font-size: 11px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.biz-entry.aw-recycle {
|
|
border-color: #e8edff;
|
|
background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 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: 1fr;
|
|
}
|
|
}
|
|
|
|
.infinite-load-state {
|
|
padding: 6px 0 18px;
|
|
color: #94a3b8;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
}
|
|
|
|
.hero-section {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 320px;
|
|
gap: 20px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.list-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.zone-head {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.eyebrow {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: #ff6a00;
|
|
}
|
|
|
|
.zone-head h2 {
|
|
margin: 0;
|
|
font-size: 28px;
|
|
font-weight: 900;
|
|
color: #17233d;
|
|
}
|
|
|
|
.sort-bar {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 4px;
|
|
border-radius: 12px;
|
|
background: #f3f5f9;
|
|
}
|
|
|
|
.sort-chip {
|
|
min-height: 32px;
|
|
padding: 0 14px;
|
|
border: none;
|
|
border-radius: 9px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition:
|
|
background 0.15s ease,
|
|
color 0.15s ease,
|
|
box-shadow 0.15s ease;
|
|
}
|
|
|
|
.sort-chip:hover {
|
|
color: #334155;
|
|
background: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
.sort-chip.active {
|
|
background: #ffffff;
|
|
color: #ff6a00;
|
|
box-shadow: 0 1px 4px rgba(23, 35, 61, 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>
|