feat(storefront): 首页改版与筛选体系

- 首页收敛为市场工具条 + 门户行(快捷入口 / 轮播 / 账户面板)
- 筛选按基础 10 项与高级筛选重排,接入皮肤组、段位、地区等参数
- 新增专区、排序、列表/宫格切换,并同步移动端筛选
- 精简导航并统一当前页下划线高亮
This commit is contained in:
yml2213
2026-10-31 11:52:11 +08:00
parent 82abc05b21
commit cfafc21014
20 changed files with 2632 additions and 272 deletions
@@ -0,0 +1,63 @@
import { Button, Card, Collapse, Typography } from 'antd'
import { Link } from 'react-router-dom'
const steps = [
['01', '挑选账号', '看区服、段位、资产、押金与折算比,确认商品编号后再下单。'],
['02', '下单支付', '核对租金与押金,支付后订单进入待交接,双方开始留痕。'],
['03', '完成交接', '号主提交交接信息,租客核对无误后确认,租期正式开始。'],
['04', '归还账号', '租期结束前提交归还,号主确认后进入结账环节。'],
['05', '核对账单', '系统账单逐项列出租金、押金与消耗,双方确认或由客服仲裁。'],
]
const details = [
{
key: 'handoff',
label: '交接阶段要注意什么',
children: '号主提交的账号信息只在订单内可见,租客确认前请先核对区服、段位与资产是否与商品一致。发现不符可发起申诉,不要先确认。',
},
{
key: 'return',
label: '归还与次日租期',
children: '归还由租客发起、号主确认。超时未确认会自动进入下一阶段,具体时长以订单页显示为准。',
},
{
key: 'checkout',
label: '结账与额外消耗',
children: '结账页会列出系统结算信息,包括租金、押金、额外消耗与退款。结账备注只是双方留言,不参与系统结算。',
},
]
export function ProcessPage() {
return (
<div className="help-page">
<header className="help-hero">
<div>
<span className="help-hero__eyebrow">HOW IT WORKS</span>
<Typography.Title level={2} className="brand-title">
从挑选到结账,五步走完
</Typography.Title>
<Typography.Paragraph type="secondary">
每一步都有系统记录,出现分歧时可以按流程节点回溯。
</Typography.Paragraph>
</div>
<Link to="/">
<Button type="primary">去看柜选</Button>
</Link>
</header>
<div className="process-grid">
{steps.map(([no, title, desc]) => (
<div className="process-step" key={no}>
<span className="process-step__no">{no}</span>
<strong>{title}</strong>
<p>{desc}</p>
</div>
))}
</div>
<Card className="help-card" title="流程细节">
<Collapse ghost items={details} />
</Card>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Button, Card, Collapse, Typography } from 'antd'
import { Link } from 'react-router-dom'
const rules = [
{
key: 'deposit',
label: '押金怎么算、什么时候退',
children:
'押金按下单时商品页标注的金额收取,与租金分开记账。订单正常结账后押金原路退回;存在额外消耗或违约时,按系统账单逐项扣除后退还剩余部分。',
},
{
key: 'rent',
label: '租金与额外消耗',
children:
'租金在支付时一次性收取。租期内的额外消耗(如游戏内资源、道具)由双方在交接与归还时确认,结账时计入系统账单,不使用口头约定。',
},
{
key: 'checkout',
label: '结账备注与系统结算的区别',
children:
'结账备注是双方留言,方便沟通,不参与金额计算。真正参与结算的是系统结算信息,由订单流程数据自动生成。',
},
{
key: 'refund',
label: '退款与争议仲裁',
children:
'双方对账单无异议时确认结账即可完成。协商不成可发起申诉,由客服根据交接、归还与聊天记录仲裁,仲裁结果会写入订单。',
},
{
key: 'account',
label: '账号安全与责任边界',
children:
'请勿在平台外转移账号或私下交易。交接后请及时修改必要的登录信息,租期结束按约定归还;因私自改密、转借造成的损失需自行承担。',
},
{
key: 'publish',
label: '号主发布与下架',
children:
'发布需要完成实名认证并通过审核。账号进入租赁后会自动下架,结账完成后按规则恢复;描述与截图需与实际一致,虚假描述会被下架处理。',
},
]
export function RulesPage() {
return (
<div className="help-page">
<header className="help-hero">
<div>
<span className="help-hero__eyebrow">RULES, IN PLAIN WORDS</span>
<Typography.Title level={2} className="brand-title">
规则说明
</Typography.Title>
<Typography.Paragraph type="secondary">
押金、租金、额外消耗与退款都会在订单里逐项列出,这里说明计算与处理口径。
</Typography.Paragraph>
</div>
<Link to="/">
<Button type="primary">去看柜选</Button>
</Link>
</header>
<Card className="help-card">
<Collapse
items={rules}
defaultActiveKey={['deposit']}
expandIconPosition="end"
bordered={false}
/>
</Card>
<Card className="help-card help-card--note">
<Typography.Text strong>一句话总结</Typography.Text>
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
押金可退、租金一次性、消耗要留痕;结账备注不算钱,系统账单才算数。
</Typography.Paragraph>
</Card>
</div>
)
}
@@ -0,0 +1,190 @@
import { useQuery } from '@tanstack/react-query'
import { App, Button, Carousel, Typography } from 'antd'
import { ArrowRightOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import {
fallbackBanners,
useHomeConfig,
type HomeBannerItem,
type HomeEntryConfig,
} from '@/features/home/api'
import { fetchWalletBalance } from '@/features/wallet/api'
import { useSessionStore } from '@/stores/session'
import { formatMoney } from '@/shared/utils/format'
export function HomePortalRow() {
const navigate = useNavigate()
const { modal, message } = App.useApp()
const user = useSessionStore(state => state.user)
const token = useSessionStore(state => state.token)
const home = useHomeConfig()
const wallet = useQuery({
queryKey: ['wallet-balance', 'home'],
queryFn: fetchWalletBalance,
enabled: Boolean(token),
})
const banners = (home.data?.banners ?? []).filter(item => item.enabled !== false)
const slides = banners.length > 0 ? banners : fallbackBanners
const aw = home.data?.aw_recycle
const co = home.data?.cooperation_feedback
const entriesEnabled = home.data?.home_entries_enabled ?? true
const openEntry = (entry: HomeEntryConfig) => {
if (entry.image_url) {
modal.info({
title: entry.title,
width: 520,
content: <img src={entry.image_url} alt={entry.title} style={{ width: '100%' }} />,
})
return
}
message.info(entry.subtitle || '暂无更多说明')
}
const openSlide = (item: HomeBannerItem) => {
const action = item.pc?.action
if (!action || action.type === 'none' || !action.target) return
if (action.type === 'route') {
navigate(action.target)
return
}
if (action.type === 'link') {
window.open(action.target, action.open_new_tab ? '_blank' : '_self')
return
}
if (action.type === 'preview') {
modal.info({
title: item.title || item.eyebrow || '轮播图',
width: 720,
content: <img src={action.target} alt={item.title || ''} style={{ width: '100%' }} />,
})
}
}
return (
<section className="home-portal">
<div className="home-portal__inner">
<nav className="home-portal__menu" aria-label="快捷入口">
<Link to="/crash" className="home-portal__menu-item">
<div>
<strong>撞车</strong>
<span>撞车装备与现货</span>
</div>
<ArrowRightOutlined />
</Link>
{entriesEnabled && aw?.enabled ? (
<button type="button" className="home-portal__menu-item" onClick={() => openEntry(aw)}>
<div>
<strong>{aw.title}</strong>
<span>{aw.subtitle}</span>
</div>
<ArrowRightOutlined />
</button>
) : null}
{entriesEnabled && co?.enabled ? (
<button type="button" className="home-portal__menu-item" onClick={() => openEntry(co)}>
<div>
<strong>{co.title}</strong>
<span>{co.subtitle}</span>
</div>
<ArrowRightOutlined />
</button>
) : null}
<Link to="/announcements" className="home-portal__menu-item">
<div>
<strong>公告中心</strong>
<span>平台规则与活动</span>
</div>
<ArrowRightOutlined />
</Link>
<Link to="/messages" className="home-portal__menu-item">
<div>
<strong>联系客服</strong>
<span>问题留痕,处理更快</span>
</div>
<ArrowRightOutlined />
</Link>
</nav>
<div className="home-portal__banner">
<Carousel autoplay autoplaySpeed={5200} dots>
{slides.map((item, index) => (
<div
className="home-slide"
key={`${item.title ?? 'slide'}-${index}`}
data-tone={item.tone || 'green'}
onClick={() => openSlide(item)}
>
{item.pc?.image_url || item.image_url ? (
<img
src={item.pc?.image_url || item.image_url}
alt={item.pc?.alt || item.title || ''}
/>
) : (
<div className="home-slide__body">
{item.badge ? <em className="home-slide__badge">{item.badge}</em> : null}
{item.eyebrow ? <span className="home-slide__eyebrow">{item.eyebrow}</span> : null}
<strong>{item.title}</strong>
{item.pill ? <span className="home-slide__pill">{item.pill}</span> : null}
</div>
)}
</div>
))}
</Carousel>
</div>
<aside className="home-portal__account">
{user ? (
<>
<div className="home-account__head">
<span className="home-account__avatar">
{(user.nickname || user.phone || '客').slice(0, 1)}
</span>
<div>
<strong>欢迎回来</strong>
<span>{user.nickname || user.phone}</span>
</div>
</div>
<div className="home-account__balance">
<span>可用余额</span>
<strong>{wallet.data ? formatMoney(wallet.data.available_balance_cent) : '—'}</strong>
<Link to="/wallet">
查看钱包 <ArrowRightOutlined />
</Link>
</div>
<div className="home-account__grid">
<Link to="/orders">我的订单</Link>
<Link to="/listings/collections">我的足迹</Link>
<Link to="/profile">个人中心</Link>
<Link to="/seller/listings">卖家中心</Link>
</div>
<div className="home-account__actions">
<Button type="primary" onClick={() => document.querySelector('#listings')?.scrollIntoView()}>
我要租号
</Button>
<Button onClick={() => navigate('/seller/listings/new')}>我要出租</Button>
</div>
</>
) : (
<>
<Typography.Title level={4} className="home-account__title">
欢迎来到解忧商行
</Typography.Title>
<Typography.Paragraph type="secondary" className="home-account__lead">
登录后可查看订单、钱包与足迹,未登录也能先逛柜选。
</Typography.Paragraph>
<Button type="primary" block onClick={() => navigate('/login')}>
登录 / 注册
</Button>
<Button block onClick={() => navigate('/listings/collections')}>
我的足迹
</Button>
</>
)}
</aside>
</div>
</section>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
export interface HomeEntryConfig {
title: string
subtitle: string
image_url: string
enabled: boolean
}
export interface HomeBannerAction {
type: string
target: string
open_new_tab?: boolean
}
export interface HomeBannerVisual {
image_url?: string
alt?: string
action?: HomeBannerAction
}
export interface HomeBannerItem {
eyebrow?: string
title?: string
badge?: string
pill?: string
tone?: string
enabled?: boolean
image_url?: string
pc?: HomeBannerVisual
mobile?: HomeBannerVisual
}
export interface HomeConfig {
announcements: string[]
banners: HomeBannerItem[]
publish_options: Record<string, unknown>
aw_recycle: HomeEntryConfig
cooperation_feedback: HomeEntryConfig
home_entries_enabled: boolean
}
export const fallbackBanners: HomeBannerItem[] = [
{
eyebrow: '三角洲行动 · 账号租赁',
title: '严选账号,逐项核验后再上柜',
badge: 'HOT',
pill: '支持扫码号与账密号',
tone: 'blue',
},
{
eyebrow: '平台担保交易',
title: '交接全程留痕,拒绝私下转账',
badge: 'SAFE',
pill: '实名审核 · 争议可申诉',
tone: 'green',
},
{
eyebrow: '按需筛选',
title: '区服、段位、哈夫币、皮肤一次筛完',
badge: 'FAST',
pill: '列表与宫格两种浏览方式',
tone: 'orange',
},
]
export function fetchHomeConfig() {
return unwrapData<HomeConfig>(
apiClient.get<ApiResponse<HomeConfig>>('/mobile-home-config')
)
}
export function useHomeConfig() {
return useQuery({
queryKey: ['mobile-home-config'],
queryFn: fetchHomeConfig,
staleTime: 300_000,
})
}
@@ -0,0 +1,326 @@
import { useState } from 'react'
import { Button, Space, Typography } from 'antd'
import { DownOutlined, ReloadOutlined, UpOutlined } from '@ant-design/icons'
import type { PublishOptionGroup } from '@/features/seller/options'
export interface RangeOption {
key: string
label: string
min?: number
max?: number
}
export interface ListingFilterState {
coinKey?: string
priceKey?: string
ratioKey?: string
depositKey?: string
totalKey?: string
fireLevelKey?: string
dailyLossKey?: string
secretKdKey?: string
awmKey?: string
insurance: string[]
stamina: string[]
load: string[]
loginMethod: string[]
ranks: string[]
regions: string[]
skinNames: string[]
}
export const emptyListingFilters: ListingFilterState = {
insurance: [],
stamina: [],
load: [],
loginMethod: [],
ranks: [],
regions: [],
skinNames: [],
}
export const coinRanges: RangeOption[] = [
{ key: 'c10', label: '10-50', min: 10, max: 50 },
{ key: 'c50', label: '50-100', min: 50, max: 100 },
{ key: 'c100', label: '100-150', min: 100, max: 150 },
{ key: 'c150', label: '150-200', min: 150, max: 200 },
{ key: 'c200', label: '200-250', min: 200, max: 250 },
{ key: 'c250', label: '250-300', min: 250, max: 300 },
{ key: 'c300', label: '300以上', min: 300 },
]
export const priceRanges: RangeOption[] = [
{ key: 'p50', label: '50-100', min: 50, max: 100 },
{ key: 'p100', label: '100-150', min: 100, max: 150 },
{ key: 'p150', label: '150-200', min: 150, max: 200 },
{ key: 'p200', label: '200以上', min: 200 },
]
export const ratioRanges: RangeOption[] = [
{ key: 'r30', label: '30-35', min: 30, max: 35 },
{ key: 'r35', label: '35-40', min: 35, max: 40 },
{ key: 'r40', label: '40-45', min: 40, max: 45 },
{ key: 'r45', label: '45以上', min: 45 },
]
export const awmRanges: RangeOption[] = [
{ key: 'a20', label: '20-50', min: 20, max: 50 },
{ key: 'a50', label: '50-100', min: 50, max: 100 },
{ key: 'a100', label: '100-200', min: 100, max: 200 },
{ key: 'a200', label: '200以上', min: 200 },
]
export const depositRanges: RangeOption[] = [
{ key: 'd50', label: '50-100', min: 50, max: 100 },
{ key: 'd100', label: '100-150', min: 100, max: 150 },
{ key: 'd150', label: '150-200', min: 150, max: 200 },
{ key: 'd200', label: '200-400', min: 200, max: 400 },
{ key: 'd400', label: '400-600', min: 400, max: 600 },
{ key: 'd600', label: '600以上', min: 600 },
]
export const totalRanges: RangeOption[] = [
{ key: 't0', label: '0-500', max: 500 },
{ key: 't500', label: '500-1000', min: 500, max: 1000 },
{ key: 't1000', label: '1000-2000', min: 1000, max: 2000 },
{ key: 't2000', label: '2000-5000', min: 2000, max: 5000 },
{ key: 't5000', label: '5000以上', min: 5000 },
]
export const fireLevelRanges: RangeOption[] = [
{ key: 'f38', label: '38-50', min: 38, max: 50 },
{ key: 'f50', label: '50-60', min: 50, max: 60 },
]
export const dailyLossRanges: RangeOption[] = [
{ key: 'l10', label: '≤10', max: 10 },
{ key: 'l20', label: '10-20', min: 10, max: 20 },
{ key: 'l30', label: '20-30', min: 20, max: 30 },
{ key: 'l40', label: '30-40', min: 30, max: 40 },
{ key: 'l50', label: '40以上', min: 40 },
]
export const secretKdRanges: RangeOption[] = [
{ key: 'k1', label: '1-2', min: 1, max: 2 },
{ key: 'k2', label: '2-3', min: 2, max: 3 },
{ key: 'k3', label: '3-5', min: 3, max: 5 },
{ key: 'k5', label: '5以上', min: 5 },
]
const insuranceOptions = ['2*1', '2*2', '2*3', '3*3']
const levelOptions = ['4级', '5级', '6级', '7级']
const loginOptions = ['扫码', '账号密码']
const fallbackRanks = ['青铜', '白银', '黄金', '铂金', '钻石', '黑鹰', '三角洲巅峰']
const fallbackRegions = [
'北京', '天津', '河北', '山西', '内蒙古', '辽宁', '吉林', '黑龙江', '上海', '江苏', '浙江',
'安徽', '福建', '江西', '山东', '河南', '湖北', '湖南', '广东', '广西', '海南', '重庆',
'四川', '贵州', '云南', '陕西', '甘肃', '青海', '宁夏', '新疆', '西藏', '香港', '澳门', '台湾',
]
interface Props {
value: ListingFilterState
total: number
skinGroups: PublishOptionGroup[]
rankOptions?: string[]
regionOptions?: string[]
onChange: (next: ListingFilterState) => void
onReset: () => void
}
function ChipGroup({
label,
options,
selected,
onToggle,
wide,
}: {
label: string
options: string[]
selected: string[]
onToggle: (option: string) => void
wide?: boolean
}) {
return (
<div className={`filter-group${wide ? ' filter-group--wide' : ''}`}>
<span className="filter-group__label">{label}</span>
<div className="filter-chips">
{options.map(option => (
<button
key={option}
type="button"
className={`filter-chip${selected.includes(option) ? ' filter-chip--active' : ''}`}
onClick={() => onToggle(option)}
>
{option}
</button>
))}
</div>
</div>
)
}
function RangeGroup({
label,
options,
activeKey,
onSelect,
wide,
}: {
label: string
options: RangeOption[]
activeKey?: string
onSelect: (key: string) => void
wide?: boolean
}) {
return (
<div className={`filter-group${wide ? ' filter-group--wide' : ''}`}>
<span className="filter-group__label">{label}</span>
<div className="filter-chips">
{options.map(option => (
<button
key={option.key}
type="button"
className={`filter-chip${activeKey === option.key ? ' filter-chip--active' : ''}`}
onClick={() => onSelect(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
export function ListingFilterPanel({
value,
total,
skinGroups,
rankOptions,
regionOptions,
onChange,
onReset,
}: Props) {
const [advancedOpen, setAdvancedOpen] = useState(false)
const quickKeys = ['melee', 'operatorRed']
const quickGroups = skinGroups.filter(group => quickKeys.includes(group.key))
const advancedSkinGroups = skinGroups.filter(group => !quickKeys.includes(group.key))
const ranks = (rankOptions ?? []).length > 0 ? rankOptions ?? [] : fallbackRanks
const regions = (regionOptions ?? []).length > 0 ? regionOptions ?? [] : fallbackRegions
const toggle = (
key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'ranks' | 'regions' | 'skinNames',
option: string
) => {
const current = value[key]
onChange({
...value,
[key]: current.includes(option)
? current.filter(item => item !== option)
: [...current, option],
})
}
const toggleRange = (
key: 'coinKey' | 'priceKey' | 'ratioKey' | 'depositKey' | 'totalKey' | 'fireLevelKey' | 'dailyLossKey' | 'secretKdKey' | 'awmKey',
nextKey: string
) => {
onChange({ ...value, [key]: value[key] === nextKey ? undefined : nextKey })
}
const activeCount =
value.insurance.length +
value.stamina.length +
value.load.length +
value.loginMethod.length +
value.ranks.length +
value.regions.length +
value.skinNames.length +
[
value.coinKey,
value.priceKey,
value.ratioKey,
value.depositKey,
value.totalKey,
value.fireLevelKey,
value.dailyLossKey,
value.secretKdKey,
value.awmKey,
].filter(Boolean).length
return (
<section className="listing-filter">
<div className="listing-filter__head">
<Space size={8} align="center">
<span className="listing-filter__bar" aria-hidden="true" />
<Typography.Text strong>筛选条件</Typography.Text>
<Typography.Text type="secondary">{total} 个结果</Typography.Text>
{activeCount > 0 ? <Typography.Text type="secondary">已选 {activeCount} 项</Typography.Text> : null}
</Space>
<Button size="small" icon={<ReloadOutlined />} onClick={onReset}>
重置全部条件
</Button>
</div>
<div className="listing-filter__grid">
<RangeGroup label="哈夫币" options={coinRanges} activeKey={value.coinKey} onSelect={key => toggleRange('coinKey', key)} />
<RangeGroup label="租金" options={priceRanges} activeKey={value.priceKey} onSelect={key => toggleRange('priceKey', key)} />
<RangeGroup label="比例" options={ratioRanges} activeKey={value.ratioKey} onSelect={key => toggleRange('ratioKey', key)} />
<ChipGroup label="保险" options={insuranceOptions} selected={value.insurance} onToggle={option => toggle('insurance', option)} />
<RangeGroup label="AWM子弹" options={awmRanges} activeKey={value.awmKey} onSelect={key => toggleRange('awmKey', key)} />
<ChipGroup label="体力" options={levelOptions} selected={value.stamina} onToggle={option => toggle('stamina', option)} />
<ChipGroup label="登录方式" options={loginOptions} selected={value.loginMethod} onToggle={option => toggle('loginMethod', option)} />
<ChipGroup label="负重" options={levelOptions} selected={value.load} onToggle={option => toggle('load', option)} />
{quickGroups.map(group => (
<ChipGroup
key={group.key}
label={group.title}
options={group.options}
selected={value.skinNames}
onToggle={option => toggle('skinNames', option)}
wide
/>
))}
</div>
<button
type="button"
className="listing-filter__more"
onClick={() => setAdvancedOpen(open => !open)}
>
{advancedOpen ? (
<>
收起高级筛选 <UpOutlined />
</>
) : (
<>
展开高级筛选(押金、段位、地区、枪皮等) <DownOutlined />
</>
)}
</button>
{advancedOpen ? (
<div className="listing-filter__grid listing-filter__grid--advanced">
<RangeGroup label="押金" options={depositRanges} activeKey={value.depositKey} onSelect={key => toggleRange('depositKey', key)} />
<RangeGroup label="合计金额" options={totalRanges} activeKey={value.totalKey} onSelect={key => toggleRange('totalKey', key)} />
<RangeGroup label="等级" options={fireLevelRanges} activeKey={value.fireLevelKey} onSelect={key => toggleRange('fireLevelKey', key)} />
<RangeGroup label="日消耗" options={dailyLossRanges} activeKey={value.dailyLossKey} onSelect={key => toggleRange('dailyLossKey', key)} />
<RangeGroup label="绝密KD" options={secretKdRanges} activeKey={value.secretKdKey} onSelect={key => toggleRange('secretKdKey', key)} />
<ChipGroup label="段位" options={ranks} selected={value.ranks} onToggle={option => toggle('ranks', option)} />
<ChipGroup label="IP地区" options={regions} selected={value.regions} onToggle={option => toggle('regions', option)} wide />
{advancedSkinGroups.map(group => (
<ChipGroup
key={group.key}
label={group.title}
options={group.options}
selected={value.skinNames}
onToggle={option => toggle('skinNames', option)}
wide
/>
))}
</div>
) : null}
</section>
)
}
+385 -181
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
@@ -8,33 +9,176 @@ import {
Input,
Pagination,
Row,
Segmented,
Select,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import {
AppstoreOutlined,
ArrowRightOutlined,
SafetyCertificateOutlined,
BarsOutlined,
SearchOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { BrandLogo } from '@/components/BrandLogo'
import { useListings } from '@/features/listings/api'
import type { Listing } from '@/shared/api/types'
import { Link } from 'react-router-dom'
const PAGE_SIZE = 12
import { BrandLogo } from '@/components/BrandLogo'
import { HomePortalRow } from '@/features/home/HomePortalRow'
import { useListings } from '@/features/listings/api'
import {
awmRanges,
coinRanges,
dailyLossRanges,
depositRanges,
emptyListingFilters,
fireLevelRanges,
ListingFilterPanel,
priceRanges,
ratioRanges,
secretKdRanges,
totalRanges,
type ListingFilterState,
} from '@/features/listings/ListingFilterPanel'
import { fetchListingPublishOptions } from '@/features/seller/options'
import type { Listing } from '@/shared/api/types'
const PAGE_SIZE = 20
const zoneTabs = [
{ key: 'all', label: '全部专区' },
{ key: 'sale', label: '特惠专区' },
{ key: 'gift', label: '赠送专区' },
{ key: 'night', label: '夜间专区' },
{ key: 'password', label: '账密专区' },
{ key: 'highCoin', label: '高币专区' },
]
const sortOptions = [
{ value: '', label: '综合推荐' },
{ value: 'published', label: '最新发布' },
{ value: 'ratio', label: '比例优先' },
{ value: 'coinDesc', label: '哈夫币从高到低' },
{ value: 'priceAsc', label: '价格从低到高' },
{ value: 'priceDesc', label: '价格从高到低' },
]
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
return `¥${(cents / 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
}
function ListingCard({ item }: { item: Listing }) {
function assetText(item: Listing, key: string) {
const value = item.asset_summary?.[key]
if (value == null || value === '') return ''
if (typeof value === 'object') return ''
return String(value)
}
function ratioText(item: Listing) {
const priceYuan = item.price_cent / 100
if (priceYuan <= 0 || !item.haf_coin_amount) return ''
return `${((item.haf_coin_amount / priceYuan) * 100).toFixed(0)}w`
}
function ListingCover({ item }: { item: Listing }) {
if (item.cover_url) {
return (
<div className="listing-row__cover">
<img src={item.cover_url} alt={item.title} loading="lazy" />
</div>
)
}
return (
<div className="listing-row__cover listing-row__cover--empty">
<BrandLogo height={26} />
<span>解忧柜选</span>
</div>
)
}
function ListingRow({ item }: { item: Listing }) {
const specs = [
{ label: '哈夫币', value: `${item.haf_coin_amount.toLocaleString()}M` },
{ label: '保险', value: assetText(item, 'season_insurance') },
{ label: '体力', value: assetText(item, 'stamina_level') },
{ label: '负重', value: assetText(item, 'load_level') },
{ label: '段位', value: item.rank_level },
{ label: '登录', value: item.login_platform },
{ label: '区服', value: item.server_region },
{ label: '折算比', value: ratioText(item) ? `1元≈${ratioText(item)}` : '' },
].filter(spec => spec.value)
return (
<article className="listing-row">
<Link to={`/listings/${item.id}`} className="listing-row__cover-link">
<ListingCover item={item} />
</Link>
<div className="listing-row__main">
<div className="listing-row__head">
<Typography.Text className="listing-card__no">{item.listing_no}</Typography.Text>
<Tag color={item.in_transaction ? 'default' : 'green'} bordered={false}>
{item.in_transaction ? '租赁中' : '可租'}
</Tag>
{item.is_accelerated_sale ? (
<Tag color="orange" bordered={false}>
特惠
</Tag>
) : null}
</div>
<Link to={`/listings/${item.id}`} className="listing-row__title-link">
<Typography.Title level={4} className="listing-row__title" ellipsis={{ rows: 1 }}>
{item.title}
</Typography.Title>
</Link>
<div className="listing-row__specs">
{specs.map(spec => (
<span className="listing-row__spec" key={spec.label}>
<em>{spec.label}</em>
{spec.value}
</span>
))}
</div>
</div>
<div className="listing-row__side">
<span className="listing-row__price-label">总租金</span>
<strong className="listing-row__price">{money(item.price_cent)}</strong>
<span className="listing-row__deposit">押金 {money(item.deposit_amount_cent)}</span>
<Link to={`/listings/${item.id}`}>
<Button type="primary">立即租用</Button>
</Link>
</div>
</article>
)
}
function ListingGridCover({ item }: { item: Listing }) {
if (item.cover_url) {
return (
<div className="listing-cover">
<img src={item.cover_url} alt={item.title} loading="lazy" />
</div>
)
}
return (
<div className="listing-cover listing-cover--empty">
<BrandLogo height={30} />
<span>解忧柜选</span>
</div>
)
}
function ListingGridCard({ item }: { item: Listing }) {
return (
<Link to={`/listings/${item.id}`} className="listing-card-link">
<Card className="listing-card" hoverable cover={<ListingCover item={item} />}>
<Card className="listing-card" hoverable cover={<ListingGridCover item={item} />}>
<div className="listing-card__head">
<Typography.Text className="listing-card__no">{item.listing_no}</Typography.Text>
<Tag color={item.in_transaction ? 'default' : 'green'} bordered={false}>
@@ -65,26 +209,10 @@ function ListingCard({ item }: { item: Listing }) {
)
}
function ListingCover({ item }: { item: Listing }) {
if (item.cover_url) {
return (
<div className="listing-cover">
<img src={item.cover_url} alt={item.title} loading="lazy" />
</div>
)
}
return (
<div className="listing-cover listing-cover--empty">
<BrandLogo height={30} />
<span>解忧柜选</span>
</div>
)
}
function ListingSkeleton() {
return (
<Card className="listing-card">
<Skeleton active paragraph={{ rows: 4 }} />
<Card className="listing-row-skeleton">
<Skeleton active paragraph={{ rows: 3 }} />
</Card>
)
}
@@ -92,92 +220,129 @@ function ListingSkeleton() {
export function ListingListPage() {
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const { data, isPending, isError, refetch } = useListings({
page,
page_size: PAGE_SIZE,
keyword,
const [zone, setZone] = useState('all')
const [sort, setSort] = useState('')
const [filters, setFilters] = useState<ListingFilterState>(emptyListingFilters)
const [viewMode, setViewMode] = useState<'list' | 'grid'>(() => {
if (typeof localStorage === 'undefined') return 'list'
return localStorage.getItem('jieyou:listings:view') === 'grid' ? 'grid' : 'list'
})
const changeView = (mode: 'list' | 'grid') => {
setViewMode(mode)
localStorage.setItem('jieyou:listings:view', mode)
}
const publishOptions = useQuery({
queryKey: ['listing-publish-options'],
queryFn: fetchListingPublishOptions,
staleTime: 300_000,
})
const coin = coinRanges.find(range => range.key === filters.coinKey)
const price = priceRanges.find(range => range.key === filters.priceKey)
const ratio = ratioRanges.find(range => range.key === filters.ratioKey)
const awm = awmRanges.find(range => range.key === filters.awmKey)
const deposit = depositRanges.find(range => range.key === filters.depositKey)
const total = totalRanges.find(range => range.key === filters.totalKey)
const fireLevel = fireLevelRanges.find(range => range.key === filters.fireLevelKey)
const dailyLoss = dailyLossRanges.find(range => range.key === filters.dailyLossKey)
const secretKd = secretKdRanges.find(range => range.key === filters.secretKdKey)
const query = useMemo(
() => ({
page,
page_size: PAGE_SIZE,
keyword,
zone: zone === 'all' ? '' : zone,
sort,
min_coin: coin?.min,
max_coin: coin?.max,
min_price: price?.min,
max_price: price?.max,
min_ratio: ratio?.min,
max_ratio: ratio?.max,
min_deposit: deposit?.min,
max_deposit: deposit?.max,
min_total: total?.min,
max_total: total?.max,
min_fire_level: fireLevel?.min,
max_fire_level: fireLevel?.max,
min_daily_loss: dailyLoss?.min,
max_daily_loss: dailyLoss?.max,
min_secret_kd: secretKd?.min,
max_secret_kd: secretKd?.max,
resource_awmAmmo_min: awm?.min,
resource_awmAmmo_max: awm?.max,
insurance: filters.insurance.join(','),
stamina: filters.stamina.join(','),
load: filters.load.join(','),
login_method: filters.loginMethod.join(','),
rank: filters.ranks.join(','),
region: filters.regions.join(','),
skin_name: filters.skinNames.join(','),
}),
[page, keyword, zone, sort, coin, price, ratio, awm, deposit, total, fireLevel, dailyLoss, secretKd, filters]
)
const { data, isPending, isError, refetch } = useListings(query)
const submitSearch = (value: string) => {
setKeyword(value.trim())
setPage(1)
}
const updateFilters = (next: ListingFilterState) => {
setFilters(next)
setPage(1)
}
const currentItems = data?.items ?? []
const rentableCount = currentItems.filter(item => !item.in_transaction).length
const saleCount = data?.zone_counts?.sale ?? 0
return (
<>
<section className="home-hero">
<div className="home-hero__inner">
<div className="home-hero__copy">
<Typography.Text className="home-hero__eyebrow">
解忧商行 · 账号租赁
</Typography.Text>
<Typography.Title className="brand-title home-hero__title">
租得明白,
<br />
还得安心。
</Typography.Title>
<Typography.Paragraph className="home-hero__lead">
实名审核、流程留痕、账单逐项列明。先看清规则,再决定要不要租。
</Typography.Paragraph>
<Space size={12} wrap>
<Input.Search
size="large"
allowClear
enterButton={
<Button type="primary" icon={<SearchOutlined />}>
找账号
</Button>
}
placeholder="搜索区服、段位、装备或商品号"
onSearch={submitSearch}
className="home-hero__search"
/>
<Button size="large" onClick={() => document.querySelector('#listings')?.scrollIntoView()}>
浏览柜选 <ArrowRightOutlined />
<section className="home-market-bar">
<div className="home-market-bar__inner">
<div className="home-market-bar__copy">
<span className="home-market-bar__eyebrow">JIEYOU TRADING HOUSE · 账号租赁</span>
<strong className="home-market-bar__title">解忧柜选</strong>
<span className="home-market-bar__trust">
实名审核 · 流程留痕 · 账单透明 · 争议可申诉
</span>
</div>
<Input.Search
allowClear
enterButton={
<Button type="primary" icon={<SearchOutlined />}>
找账号
</Button>
</Space>
</div>
<div className="home-hero__visual" aria-hidden="true">
<div className="hero-ticket hero-ticket--back">
<span>交接留痕</span>
<strong>每一步都有记录</strong>
}
placeholder="搜索区服、段位、装备或商品号"
onSearch={submitSearch}
className="home-market-bar__search"
/>
<div className="home-market-bar__stats">
<div>
<span>在售</span>
<strong>{data?.total ?? '—'}</strong>
</div>
<div className="hero-ticket hero-ticket--main">
<div className="hero-ticket__seal">解忧</div>
<span>本周柜选</span>
<strong>严选账号 · 逐项核验</strong>
<small>费用透明 / 押金可退 / 争议可申诉</small>
<div>
<span>本页可租</span>
<strong>{rentableCount}</strong>
</div>
<div className="hero-ticket hero-ticket--front">
<span>账单</span>
<strong>租金 + 押金 + 消耗</strong>
<small>确认后再支付</small>
<div>
<span>特惠</span>
<strong>{saleCount}</strong>
</div>
</div>
</div>
</section>
<section className="trust-strip">
<div className="trust-strip__inner">
{[
['实名审核', '发布与下单前完成身份核验'],
['流程留痕', '交接、归还、结账全程有记录'],
['账单透明', '租金、押金、消耗逐项列明'],
['争议可申诉', '双方协商不成可进入仲裁'],
].map(([title, desc], index) => (
<div className="trust-item" key={title}>
{index === 0 ? <SafetyCertificateOutlined /> : index === 2 ? <WalletOutlined /> : null}
<div>
<strong>{title}</strong>
<span>{desc}</span>
</div>
</div>
))}
</div>
</section>
<HomePortalRow />
<section className="section-block" id="listings">
<section className="section-block section-block--listings" id="listings">
<div className="section-heading">
<div>
<Typography.Text className="section-eyebrow">CURATED ACCOUNTS</Typography.Text>
@@ -188,9 +353,62 @@ export function ListingListPage() {
按区服、段位和资产筛选,价格与押金直接写在柜签上。
</Typography.Text>
</div>
<Typography.Text type="secondary">
共 {data?.total ?? 0} 件 · 第 {page} 页
</Typography.Text>
<Space size={12} wrap>
<Segmented
size="small"
value={viewMode}
onChange={value => changeView(value as 'list' | 'grid')}
options={[
{ label: <BarsOutlined />, value: 'list' },
{ label: <AppstoreOutlined />, value: 'grid' },
]}
/>
<Typography.Text type="secondary">
共 {data?.total ?? 0} 件 · 第 {page} 页
</Typography.Text>
<Select
size="small"
value={sort}
options={sortOptions}
style={{ width: 168 }}
onChange={value => {
setSort(value)
setPage(1)
}}
/>
</Space>
</div>
<div className="listings-layout">
<div className="listings-main">
<ListingFilterPanel
value={filters}
total={data?.total ?? 0}
skinGroups={publishOptions.data?.skin_groups ?? []}
rankOptions={publishOptions.data?.rank_options}
regionOptions={publishOptions.data?.region_options}
onChange={updateFilters}
onReset={() => updateFilters(emptyListingFilters)}
/>
<div className="zone-tabs">
{zoneTabs.map(tab => {
const count = data?.zone_counts?.[tab.key]
return (
<button
key={tab.key}
type="button"
className={`zone-tab${zone === tab.key ? ' zone-tab--active' : ''}`}
onClick={() => {
setZone(tab.key)
setPage(1)
}}
>
{tab.label}
{typeof count === 'number' ? <span>{count}</span> : null}
</button>
)
})}
</div>
{isError ? (
@@ -207,93 +425,79 @@ export function ListingListPage() {
/>
) : null}
<Row gutter={[16, 16]}>
{isPending
? Array.from({ length: 6 }).map((_, index) => (
<Col xs={24} sm={12} lg={8} key={index}>
<ListingSkeleton />
</Col>
))
: data?.items.map(item => (
<Col xs={24} sm={12} lg={8} key={item.id}>
<ListingCard item={item} />
</Col>
))}
</Row>
{viewMode === 'grid' ? (
<Row gutter={[16, 16]} className="listing-grid">
{isPending
? Array.from({ length: 6 }).map((_, index) => (
<Col xs={24} sm={12} lg={8} key={index}>
<Card className="listing-card">
<Skeleton active paragraph={{ rows: 4 }} />
</Card>
</Col>
))
: data?.items.map(item => (
<Col xs={24} sm={12} lg={8} key={item.id}>
<ListingGridCard item={item} />
</Col>
))}
</Row>
) : (
<div className="listing-rows">
{isPending
? Array.from({ length: 4 }).map((_, index) => <ListingSkeleton key={index} />)
: data?.items.map(item => <ListingRow key={item.id} item={item} />)}
</div>
)}
{!isPending && !isError && data?.items.length === 0 ? (
<div className="empty-panel">
<Empty
description={
<span>
这里还没有符合条件的商品。
<br />
换个关键词,或者稍后再来看看。
</span>
}
>
<Button type="primary" onClick={() => submitSearch('')}>
查看全部
</Button>
</Empty>
</div>
) : null}
{!isPending && !isError && data?.items.length === 0 ? (
<div className="empty-panel">
<Empty
description={
<span>
这里还没有符合条件的商品。
<br />
换个关键词,或者放宽筛选条件。
</span>
}
>
<Button type="primary" onClick={() => submitSearch('')}>
查看全部
</Button>
</Empty>
</div>
) : null}
{data && data.total > PAGE_SIZE ? (
<div className="section-pagination">
<Pagination
current={page}
pageSize={PAGE_SIZE}
total={data.total}
showSizeChanger={false}
onChange={setPage}
/>
{data && data.total > PAGE_SIZE ? (
<div className="section-pagination">
<Pagination
current={page}
pageSize={PAGE_SIZE}
total={data.total}
showSizeChanger={false}
onChange={setPage}
/>
</div>
) : null}
</div>
) : null}
</section>
<section className="section-block section-block--paper" id="process">
<div className="section-heading">
<div>
<Typography.Text className="section-eyebrow">HOW IT WORKS</Typography.Text>
<Typography.Title level={2} className="brand-title">
从挑选到结账,五步走完
</Typography.Title>
</div>
</div>
<div className="process-grid">
{[
['01', '挑选账号', '看区服、段位、资产和押金,确认商品编号。'],
['02', '下单支付', '核对租金与押金,支付后进入待交接。'],
['03', '完成交接', '号主提交交接信息,租客确认后开始租期。'],
['04', '归还账号', '租客提交归还,号主确认后进入结账。'],
['05', '核对账单', '双方确认或客服仲裁,完成后结算。'],
].map(([no, title, desc]) => (
<div className="process-step" key={no}>
<span className="process-step__no">{no}</span>
<strong>{title}</strong>
<p>{desc}</p>
</div>
))}
</div>
</section>
<section className="section-block" id="rules">
<div className="rules-panel">
<div>
<Typography.Text className="section-eyebrow">RULES, IN PLAIN WORDS</Typography.Text>
<Typography.Title level={3} className="brand-title">
先把规则说明白
</Typography.Title>
<Typography.Paragraph type="secondary">
押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注只是双方留言,
不参与系统结算;真正参与结算的是系统账单。
</Typography.Paragraph>
</div>
<Button size="large" onClick={() => document.querySelector('#listings')?.scrollIntoView()}>
去看柜选
</Button>
</div>
<section className="section-block home-links">
<Link to="/process" className="home-link">
<strong>交易流程</strong>
<span>从下单到结账的五步走法</span>
<ArrowRightOutlined />
</Link>
<Link to="/rules" className="home-link">
<strong>规则说明</strong>
<span>押金、租金、消耗与退款口径</span>
<ArrowRightOutlined />
</Link>
<Link to="/messages" className="home-link">
<strong>联系客服</strong>
<span>有问题先找客服留痕</span>
<ArrowRightOutlined />
</Link>
</section>
</>
)
+29
View File
@@ -7,6 +7,34 @@ export interface ListingQuery {
page?: number
page_size?: number
keyword?: string
zone?: string
sort?: string
login_method?: string
rank?: string
region?: string
insurance?: string
stamina?: string
load?: string
skin_group?: string
skin_name?: string
min_coin?: number
max_coin?: number
min_price?: number
max_price?: number
min_deposit?: number
max_deposit?: number
min_ratio?: number
max_ratio?: number
min_total?: number
max_total?: number
min_fire_level?: number
max_fire_level?: number
min_daily_loss?: number
max_daily_loss?: number
min_secret_kd?: number
max_secret_kd?: number
resource_awmAmmo_min?: number
resource_awmAmmo_max?: number
}
export function fetchListings(query: ListingQuery) {
@@ -21,6 +49,7 @@ export function fetchListings(query: ListingQuery) {
total: data.total ?? 0,
page: data.page ?? query.page ?? 1,
page_size: data.page_size ?? query.page_size ?? 12,
zone_counts: data.zone_counts ?? {},
}))
}
+308 -23
View File
@@ -1,24 +1,89 @@
import { useState } from 'react'
import { Button, Empty, ErrorBlock, PullToRefresh, SearchBar, Skeleton, Tag } from 'antd-mobile'
import { RightOutline, SearchOutline } from 'antd-mobile-icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Empty, ErrorBlock, Popup, PullToRefresh, SearchBar, Skeleton, Tag } from 'antd-mobile'
import { DownOutline, RightOutline, SearchOutline } from 'antd-mobile-icons'
import { Link } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListings } from '@/features/listings/api'
import {
coinRanges,
emptyListingFilters,
priceRanges,
ratioRanges,
type ListingFilterState,
} from '@/features/listings/ListingFilterPanel'
import { fetchListingPublishOptions } from '@/features/seller/options'
import type { Listing } from '@/shared/api/types'
const quickFilters = [
{ label: '全部', keyword: '' },
{ label: '国服', keyword: '国服' },
{ label: '高资产', keyword: '高资产' },
{ label: '可租', keyword: '' },
const zoneTabs = [
{ key: 'all', label: '全部' },
{ key: 'sale', label: '特惠' },
{ key: 'gift', label: '赠送' },
{ key: 'night', label: '夜间' },
{ key: 'password', label: '账密' },
{ key: 'highCoin', label: '高币' },
]
const sortOptions = [
{ value: '', label: '综合推荐' },
{ value: 'published', label: '最新发布' },
{ value: 'ratio', label: '比例优先' },
{ value: 'coinDesc', label: '哈夫币从高到低' },
{ value: 'priceAsc', label: '价格从低到高' },
]
const insuranceOptions = ['2*1', '2*2', '2*3', '3*3']
const levelOptions = ['4级', '5级', '6级', '7级']
const loginOptions = ['扫码', '账号密码']
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function assetText(item: Listing, key: string) {
const value = item.asset_summary?.[key]
if (value == null || value === '' || typeof value === 'object') return ''
return String(value)
}
function MobileChipGroup({
label,
options,
selected,
onToggle,
}: {
label: string
options: string[]
selected: string[]
onToggle: (option: string) => void
}) {
return (
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">{label}</span>
<div className="mobile-filter-chips">
{options.map(option => (
<button
key={option}
type="button"
className={`mobile-filter-chip${selected.includes(option) ? ' is-active' : ''}`}
onClick={() => onToggle(option)}
>
{option}
</button>
))}
</div>
</div>
)
}
function MobileListingCard({ item }: { item: Listing }) {
const specs = [
assetText(item, 'season_insurance') ? `保险 ${assetText(item, 'season_insurance')}` : '',
assetText(item, 'stamina_level') ? `体力 ${assetText(item, 'stamina_level')}` : '',
assetText(item, 'load_level') ? `负重 ${assetText(item, 'load_level')}` : '',
].filter(Boolean)
return (
<Link to={`/m/listings/${item.id}`} className="mobile-listing-card">
<div className="mobile-listing-card__cover">
@@ -40,6 +105,9 @@ function MobileListingCard({ item }: { item: Listing }) {
<span>{item.server_region || '区服待定'}</span>
<span>{item.rank_level || '段位待定'}</span>
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</span>
{specs.map(spec => (
<span key={spec}>{spec}</span>
))}
</div>
<div className="mobile-listing-card__bottom">
<div>
@@ -58,17 +126,79 @@ function MobileListingCard({ item }: { item: Listing }) {
export function MobileHomePage() {
const [keyword, setKeyword] = useState('')
const [searchValue, setSearchValue] = useState('')
const [zone, setZone] = useState('all')
const [sort, setSort] = useState('')
const [filters, setFilters] = useState<ListingFilterState>(emptyListingFilters)
const [draft, setDraft] = useState<ListingFilterState>(emptyListingFilters)
const [filterOpen, setFilterOpen] = useState(false)
const [sortOpen, setSortOpen] = useState(false)
const publishOptions = useQuery({
queryKey: ['listing-publish-options'],
queryFn: fetchListingPublishOptions,
staleTime: 300_000,
})
const quickSkinGroups = (publishOptions.data?.skin_groups ?? []).filter(group =>
['melee', 'operatorRed'].includes(group.key)
)
const coin = coinRanges.find(range => range.key === filters.coinKey)
const price = priceRanges.find(range => range.key === filters.priceKey)
const ratio = ratioRanges.find(range => range.key === filters.ratioKey)
const { data, isPending, isError, refetch } = useListings({
page: 1,
page_size: 20,
keyword,
zone: zone === 'all' ? '' : zone,
sort,
min_coin: coin?.min,
max_coin: coin?.max,
min_price: price?.min,
max_price: price?.max,
min_ratio: ratio?.min,
max_ratio: ratio?.max,
insurance: filters.insurance.join(','),
stamina: filters.stamina.join(','),
load: filters.load.join(','),
login_method: filters.loginMethod.join(','),
skin_name: filters.skinNames.join(','),
})
const applyFilter = (value: string) => {
setKeyword(value)
setSearchValue(value)
const applyFilter = () => {
setFilters(draft)
setFilterOpen(false)
}
const resetDraft = () => setDraft(emptyListingFilters)
const toggleDraft = (
key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'skinNames',
option: string
) => {
const current = draft[key]
setDraft({
...draft,
[key]: current.includes(option)
? current.filter(item => item !== option)
: [...current, option],
})
}
const toggleRange = (key: 'coinKey' | 'priceKey' | 'ratioKey', nextKey: string) => {
setDraft({ ...draft, [key]: draft[key] === nextKey ? undefined : nextKey })
}
const activeCount =
filters.insurance.length +
filters.stamina.length +
filters.load.length +
filters.loginMethod.length +
filters.skinNames.length +
[filters.coinKey, filters.priceKey, filters.ratioKey].filter(Boolean).length
const sortLabel = sortOptions.find(option => option.value === sort)?.label ?? '综合推荐'
return (
<div className="mobile-home">
<section className="mobile-hero">
@@ -87,18 +217,6 @@ export function MobileHomePage() {
placeholder="搜索区服、段位、装备或商品号"
searchIcon={<SearchOutline />}
/>
<div className="mobile-chips">
{quickFilters.map(item => (
<button
type="button"
key={item.label}
className={keyword === item.keyword && item.label !== '全部' ? 'is-active' : ''}
onClick={() => applyFilter(item.keyword)}
>
{item.label}
</button>
))}
</div>
</section>
<section className="mobile-trust">
@@ -116,6 +234,34 @@ export function MobileHomePage() {
<span className="mobile-count">共 {data?.total ?? 0} 件</span>
</div>
<div className="mobile-zone-tabs">
{zoneTabs.map(tab => (
<button
key={tab.key}
type="button"
className={zone === tab.key ? 'is-active' : ''}
onClick={() => setZone(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="mobile-list-toolbar">
<button
type="button"
onClick={() => {
setDraft(filters)
setFilterOpen(true)
}}
>
筛选{activeCount > 0 ? ` (${activeCount})` : ''}
</button>
<button type="button" onClick={() => setSortOpen(true)}>
{sortLabel} <DownOutline />
</button>
</div>
{isError ? (
<div className="mobile-error">
<ErrorBlock
@@ -148,7 +294,17 @@ export function MobileHomePage() {
) : (
<div className="mobile-empty">
<Empty description="还没有符合条件的商品" />
<Button size="small" color="primary" onClick={() => applyFilter('')}>
<Button
size="small"
color="primary"
onClick={() => {
setKeyword('')
setSearchValue('')
setFilters(emptyListingFilters)
setZone('all')
setSort('')
}}
>
查看全部
</Button>
</div>
@@ -161,6 +317,135 @@ export function MobileHomePage() {
<h2>先把规则说明白</h2>
<p>押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。</p>
</section>
<Popup
visible={filterOpen}
onMaskClick={() => setFilterOpen(false)}
bodyStyle={{ height: '72vh', overflowY: 'auto', borderTopLeftRadius: 16, borderTopRightRadius: 16 }}
>
<div className="mobile-filter-sheet">
<div className="mobile-filter-sheet__head">
<strong>筛选条件</strong>
<button type="button" onClick={resetDraft}>
重置
</button>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">哈夫币</span>
<div className="mobile-filter-chips">
{coinRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.coinKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('coinKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">租金</span>
<div className="mobile-filter-chips">
{priceRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.priceKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('priceKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">比例</span>
<div className="mobile-filter-chips">
{ratioRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.ratioKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('ratioKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<MobileChipGroup
label="保险"
options={insuranceOptions}
selected={draft.insurance}
onToggle={option => toggleDraft('insurance', option)}
/>
<MobileChipGroup
label="体力"
options={levelOptions}
selected={draft.stamina}
onToggle={option => toggleDraft('stamina', option)}
/>
<MobileChipGroup
label="负重"
options={levelOptions}
selected={draft.load}
onToggle={option => toggleDraft('load', option)}
/>
<MobileChipGroup
label="登录"
options={loginOptions}
selected={draft.loginMethod}
onToggle={option => toggleDraft('loginMethod', option)}
/>
{quickSkinGroups.map(group => (
<MobileChipGroup
key={group.key}
label={group.title}
options={group.options}
selected={draft.skinNames}
onToggle={option => toggleDraft('skinNames', option)}
/>
))}
<div className="mobile-filter-sheet__actions">
<Button block onClick={() => setFilterOpen(false)}>
取消
</Button>
<Button block color="primary" onClick={applyFilter}>
确定
</Button>
</div>
</div>
</Popup>
<Popup
visible={sortOpen}
onMaskClick={() => setSortOpen(false)}
bodyStyle={{ borderTopLeftRadius: 16, borderTopRightRadius: 16 }}
>
<div className="mobile-sort-sheet">
<strong>排序方式</strong>
{sortOptions.map(option => (
<button
key={option.value}
type="button"
className={sort === option.value ? 'is-active' : ''}
onClick={() => {
setSort(option.value)
setSortOpen(false)
}}
>
{option.label}
</button>
))}
</div>
</Popup>
</div>
)
}