feat(storefront): 首页改版与筛选体系
- 首页收敛为市场工具条 + 门户行(快捷入口 / 轮播 / 账户面板) - 筛选按基础 10 项与高级筛选重排,接入皮肤组、段位、地区等参数 - 新增专区、排序、列表/宫格切换,并同步移动端筛选 - 精简导航并统一当前页下划线高亮
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 ?? {},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user