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
+8
View File
@@ -474,6 +474,12 @@ const ProfilePage = lazy(() =>
const RealnamePage = lazy(() =>
import('@/features/realname/RealnamePage').then(module => ({ default: module.RealnamePage }))
)
const ProcessPage = lazy(() =>
import('@/features/help/ProcessPage').then(module => ({ default: module.ProcessPage }))
)
const RulesPage = lazy(() =>
import('@/features/help/RulesPage').then(module => ({ default: module.RulesPage }))
)
const AdminSectionPlaceholder = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminSectionPlaceholder }))
)
@@ -504,6 +510,8 @@ export function AppRouter() {
<Route path="messages/:id" element={<ChatPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="realname" element={<RealnamePage />} />
<Route path="process" element={<ProcessPage />} />
<Route path="rules" element={<RulesPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
<Route path="seller/listings/new" element={<SellerListingFormPage />} />
<Route path="seller/listings/:id" element={<SellerListingDetailPage />} />
@@ -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>
)
}
+17 -11
View File
@@ -1,5 +1,5 @@
import { Badge, Button, Dropdown, Layout, Space, Typography } from 'antd'
import { Link, Outlet, useNavigate } from 'react-router-dom'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { UserOutlined } from '@ant-design/icons'
import { BrandLogo } from '@/components/BrandLogo'
@@ -8,6 +8,8 @@ import { useSessionStore } from '@/stores/session'
const { Header, Content, Footer } = Layout
const navLinkClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'is-active' : undefined)
export function StorefrontLayout() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
@@ -23,19 +25,23 @@ export function StorefrontLayout() {
</Link>
<nav className="site-nav" aria-label="主导航">
<Link to="/">首页</Link>
<a href="/#listings">商品</a>
<Link to="/orders">我的订单</Link>
<Link to="/messages">
<NavLink to="/" end className={navLinkClass}>
首页
</NavLink>
<NavLink to="/orders" className={navLinkClass}>
我的订单
</NavLink>
<NavLink to="/messages" className={navLinkClass}>
<Badge count={unreadMessages} size="small" offset={[8, -2]}>
<span>消息</span>
</Badge>
</Link>
<Link to="/seller/listings">卖家中心</Link>
<Link to="/crash">摸红商城</Link>
<Link to="/announcements">公告</Link>
<a href="/#process">流程</a>
<a href="/#rules">规则</a>
</NavLink>
<NavLink to="/seller/listings" className={navLinkClass}>
卖家中心
</NavLink>
<NavLink to="/announcements" className={navLinkClass}>
公告
</NavLink>
</nav>
<Space size={12}>
+1
View File
@@ -60,4 +60,5 @@ export interface PublicListingPage {
total: number
page: number
page_size: number
zone_counts?: Record<string, number>
}
+14 -21
View File
@@ -90,32 +90,25 @@ a {
.site-nav a {
position: relative;
padding: 6px 0;
transition: color 160ms ease;
display: inline-flex;
align-items: center;
padding: 0 0 3px;
border-bottom: 2px solid transparent;
line-height: 1.2;
transition:
color 160ms ease,
border-color 160ms ease;
}
.site-nav a:hover {
color: var(--brand-ink);
color: var(--brand-seal);
border-bottom-color: rgba(166, 67, 50, 0.5);
}
.site-nav a::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
background: var(--brand-seal);
content: '';
opacity: 0;
transform: scaleX(0.6);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.site-nav a:hover::after {
opacity: 1;
transform: scaleX(1);
.site-nav a.is-active {
color: var(--brand-seal);
font-weight: 600;
border-bottom-color: var(--brand-seal);
}
.site-content {
+3
View File
@@ -1,9 +1,12 @@
/* 全局样式入口:按页面域拆分,导入顺序即级联顺序,请勿随意调整。 */
@import './base.css';
@import './storefront-home.css';
@import './storefront-portal.css';
@import './storefront-sections.css';
@import './storefront-listings.css';
@import './storefront-filters.css';
@import './storefront-content.css';
@import './storefront-help.css';
@import './wallet.css';
@import './auth.css';
@import './admin-misc.css';
+146
View File
@@ -260,3 +260,149 @@
line-height: 1.8;
}
/* 移动端首页:专区、筛选与排序 */
.mobile-zone-tabs {
display: flex;
gap: 8px;
margin: 10px 0 8px;
padding-bottom: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.mobile-zone-tabs::-webkit-scrollbar {
display: none;
}
.mobile-zone-tabs button {
flex: none;
padding: 6px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper-warm);
color: var(--ink-secondary);
font-size: 13px;
}
.mobile-zone-tabs button.is-active {
border-color: var(--brand-seal);
background: var(--brand-seal);
color: #fff;
}
.mobile-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.mobile-list-toolbar button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper-warm);
color: var(--brand-ink);
font-size: 13px;
}
.mobile-list-toolbar button:active {
border-color: var(--brand-seal);
}
.mobile-filter-sheet {
padding: 18px 16px 24px;
}
.mobile-filter-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px dashed var(--line);
}
.mobile-filter-sheet__head strong {
color: var(--brand-ink);
font-size: 16px;
}
.mobile-filter-sheet__head button {
border: 0;
background: transparent;
color: var(--brand-seal);
font-size: 13px;
}
.mobile-filter-sheet__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 20px;
}
.mobile-filter-group {
margin-bottom: 14px;
}
.mobile-filter-group__label {
display: block;
margin-bottom: 8px;
color: var(--ink-secondary);
font-size: 12px;
}
.mobile-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mobile-filter-chip {
padding: 5px 12px;
border: 1px solid transparent;
border-radius: 999px;
background: #f1f0e9;
color: var(--ink-secondary);
font-size: 12px;
}
.mobile-filter-chip.is-active {
border-color: var(--brand-ink);
background: var(--brand-ink);
color: #fff;
}
.mobile-sort-sheet {
padding: 18px 16px 24px;
}
.mobile-sort-sheet strong {
display: block;
margin-bottom: 10px;
color: var(--brand-ink);
font-size: 16px;
}
.mobile-sort-sheet button {
display: block;
width: 100%;
padding: 12px 4px;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--ink);
font-size: 14px;
text-align: left;
}
.mobile-sort-sheet button.is-active {
color: var(--brand-seal);
font-weight: 600;
}
+333
View File
@@ -0,0 +1,333 @@
/* 首页筛选、专区与紧凑列表 */
.section-block--listings {
padding-top: 44px;
}
.listing-filter {
padding: 16px 20px 18px;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper-warm);
}
.listing-filter__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px dashed var(--line);
}
.listing-filter__bar {
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--brand-seal);
}
.listing-filter__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 32px;
}
.listing-filter__grid--advanced {
margin-top: 6px;
padding-top: 12px;
border-top: 1px dashed var(--line);
}
.listing-filter__more {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 0;
border: 0;
background: transparent;
color: var(--brand-seal);
font-size: 12px;
cursor: pointer;
}
.listing-filter__more:hover {
color: var(--brand-ink);
}
.filter-group {
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 28px;
}
.filter-group--wide {
grid-column: 1 / -1;
}
.filter-group__label {
flex: none;
width: 62px;
padding-top: 4px;
color: var(--ink-secondary);
font-size: 12px;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 999px;
background: #f1f0e9;
color: var(--ink-secondary);
font-size: 12px;
line-height: 20px;
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease,
border-color 140ms ease;
}
.filter-chip:hover {
border-color: var(--sage);
color: var(--brand-ink);
}
.filter-chip--active {
border-color: var(--brand-ink);
background: var(--brand-ink);
color: #fff;
}
.zone-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0 14px;
}
.zone-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-secondary);
font-size: 13px;
cursor: pointer;
transition:
border-color 140ms ease,
color 140ms ease,
background 140ms ease;
}
.zone-tab:hover {
border-color: var(--sage);
color: var(--brand-ink);
}
.zone-tab span {
padding: 0 6px;
border-radius: 999px;
background: rgba(30, 77, 66, 0.08);
color: var(--brand-ink);
font-size: 11px;
line-height: 18px;
}
.zone-tab--active {
border-color: var(--brand-seal);
background: var(--brand-seal);
color: #fff;
}
.zone-tab--active span {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.listing-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.listing-row {
display: grid;
grid-template-columns: 136px minmax(0, 1fr) 176px;
gap: 18px;
align-items: center;
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper-warm);
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
.listing-row:hover {
border-color: var(--sage);
box-shadow: 0 12px 28px rgba(21, 58, 50, 0.08);
}
.listing-row-skeleton {
border-radius: 14px;
border-color: var(--line);
background: var(--paper-warm);
}
.listing-row__cover-link {
display: block;
}
.listing-row__cover {
width: 136px;
height: 90px;
overflow: hidden;
border-radius: 10px;
background: #eef1ea;
}
.listing-row__cover img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.listing-row__cover--empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.08em;
}
.listing-row__main {
min-width: 0;
}
.listing-row__head {
display: flex;
align-items: center;
gap: 8px;
}
.listing-row__head .ant-tag {
margin: 0;
border-radius: 4px;
font-size: 11px;
}
.listing-row__title-link {
color: inherit;
}
.listing-row__title-link:hover {
color: inherit;
}
.listing-row__title {
margin: 6px 0 8px !important;
color: var(--ink) !important;
font-size: 16px !important;
line-height: 1.45 !important;
}
.listing-row__specs {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
color: var(--ink-secondary);
font-size: 12px;
}
.listing-row__spec em {
margin-right: 4px;
color: var(--ink-muted);
font-style: normal;
}
.listing-row__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
text-align: right;
}
.listing-row__price-label {
color: var(--ink-muted);
font-size: 11px;
}
.listing-row__price {
color: var(--brand-seal);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.01em;
}
.listing-row__deposit {
margin-bottom: 6px;
color: var(--ink-muted);
font-size: 11px;
}
@media (max-width: 1100px) {
.listing-filter__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.listing-row {
grid-template-columns: 96px minmax(0, 1fr);
}
.listing-row__cover {
width: 96px;
height: 68px;
}
.listing-row__side {
grid-column: 1 / -1;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
}
.listing-row__price-label {
display: none;
}
}
/* 选号区:恢复整宽,快捷入口已移到上方门户行 */
.listings-layout {
display: block;
}
.listings-main {
min-width: 0;
}
.listing-grid .listing-card {
height: 100%;
}
+102
View File
@@ -0,0 +1,102 @@
/* 流程 / 规则独立页 + 首页底部入口带 */
.help-page {
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.help-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(135deg, rgba(30, 77, 66, 0.06), rgba(176, 138, 74, 0.08));
}
.help-hero__eyebrow {
display: inline-block;
color: var(--ink-muted);
font-size: 12px;
letter-spacing: 0.22em;
}
.help-hero h2 {
margin: 6px 0 4px;
}
.help-card {
margin-top: 16px;
border-radius: 16px;
border-color: var(--line);
background: var(--paper-warm);
}
.help-card .ant-collapse-header {
font-weight: 600;
color: var(--brand-ink) !important;
}
.help-card--note {
border-style: dashed;
}
.home-links {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
padding-top: 8px;
padding-bottom: 56px;
}
.home-link {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper-warm);
color: inherit;
transition:
border-color 160ms ease,
transform 160ms ease;
}
.home-link strong {
color: var(--brand-ink);
font-size: 15px;
}
.home-link span {
color: var(--ink-muted);
font-size: 12px;
}
.home-link .anticon {
position: absolute;
top: 50%;
right: 18px;
color: var(--ink-muted);
transform: translateY(-50%);
}
.home-link:hover {
border-color: var(--sage);
transform: translateY(-2px);
}
@media (max-width: 900px) {
.home-links {
grid-template-columns: minmax(0, 1fr);
}
.help-hero {
flex-direction: column;
padding: 22px;
}
}
+245 -29
View File
@@ -1,54 +1,104 @@
/* Home hero */
.home-hero {
background: var(--brand-ink);
position: relative;
overflow: hidden;
background:
radial-gradient(1100px 420px at 78% -10%, rgba(176, 138, 74, 0.2), transparent 62%),
radial-gradient(760px 420px at 6% 112%, rgba(166, 67, 50, 0.18), transparent 60%),
linear-gradient(160deg, #1b473d 0%, #16392f 58%, #122e27 100%);
color: var(--paper-warm);
}
/* 细网格底纹,让深色 hero 不显得是一整块死色。 */
.home-hero::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(246, 242, 232, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(246, 242, 232, 0.05) 1px, transparent 1px);
background-size: 96px 96px;
pointer-events: none;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 72%);
}
.home-hero__inner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: 48px;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: clamp(32px, 4vw, 72px);
align-items: center;
max-width: 1280px;
min-height: 520px;
margin: 0 auto;
padding: 76px 24px 68px;
padding: clamp(56px, 6vw, 92px) 24px clamp(52px, 5.6vw, 80px);
}
.home-hero__eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--brand-seal-line);
font-size: 12px;
letter-spacing: 0.18em;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.home-hero__eyebrow::before {
content: '';
width: 26px;
height: 1px;
background: currentColor;
opacity: 0.7;
}
.home-hero__title {
margin: 18px 0 20px !important;
color: #f0f4f2 !important;
font-size: clamp(42px, 5vw, 68px) !important;
line-height: 1.16 !important;
margin: 20px 0 22px !important;
color: #f4f7f5 !important;
font-size: clamp(40px, 4.6vw, 66px) !important;
line-height: 1.14 !important;
letter-spacing: 0.01em;
text-shadow: 0 2px 24px rgba(9, 28, 23, 0.35);
}
.home-hero__lead {
max-width: 560px;
margin-bottom: 30px !important;
color: #d8e2dd;
font-size: 16px;
line-height: 1.9;
max-width: 520px;
margin-bottom: 34px !important;
color: #cfdad4;
font-size: 15.5px;
line-height: 1.95;
}
.home-hero__search {
width: 420px;
width: 400px;
max-width: calc(100vw - 48px);
}
.home-hero__search .ant-input-affix-wrapper {
background: var(--paper-warm);
border-radius: 10px 0 0 10px;
}
.home-hero__search .ant-input-search-button {
border-radius: 0 10px 10px 0 !important;
}
.home-hero .ant-btn-default {
background: transparent;
border-color: rgba(246, 242, 232, 0.35);
color: var(--paper-warm);
}
.home-hero .ant-btn-default:hover {
border-color: var(--brand-seal-line) !important;
background: rgba(246, 242, 232, 0.08) !important;
color: #fff !important;
}
.home-hero__visual {
position: relative;
min-height: 300px;
min-height: 360px;
}
.hero-ticket {
@@ -56,12 +106,14 @@
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
width: clamp(232px, 21vw, 286px);
padding: 22px;
border: 1px solid rgba(30, 77, 66, 0.18);
border-radius: 6px;
border-radius: 12px;
background: var(--paper-warm);
box-shadow: 0 1px 2px rgba(21, 58, 50, 0.08), 0 22px 48px rgba(21, 58, 50, 0.2);
box-shadow:
0 1px 2px rgba(21, 58, 50, 0.08),
0 24px 52px rgba(9, 28, 23, 0.28);
color: var(--brand-ink);
}
@@ -73,9 +125,9 @@
.hero-ticket strong {
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 20px;
font-size: 19px;
font-weight: 600;
line-height: 1.4;
line-height: 1.45;
}
.hero-ticket small {
@@ -84,21 +136,23 @@
}
.hero-ticket--back {
top: 8px;
right: 0;
transform: rotate(7deg);
opacity: 0.7;
top: 4px;
right: 2%;
transform: rotate(6deg);
opacity: 0.68;
}
.hero-ticket--main {
top: 58px;
left: 8px;
top: 68px;
left: 0;
z-index: 2;
transform: rotate(-3deg);
}
.hero-ticket--front {
right: 10px;
bottom: 0;
right: 4%;
bottom: 4px;
z-index: 3;
transform: rotate(4deg);
}
@@ -109,9 +163,171 @@
width: 46px;
height: 46px;
border: 2px solid var(--brand-seal);
border-radius: 6px;
color: var(--brand-seal);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 13px;
letter-spacing: 0.1em;
}
@media (max-width: 1024px) {
.home-hero__inner {
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.home-hero__visual {
display: none;
}
}
/* 紧凑版首页头部:功能优先,不再放宣传卡片 */
.home-hero--compact .home-hero__inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: clamp(24px, 4vw, 64px);
min-height: 0;
padding: clamp(38px, 4.4vw, 60px) 24px clamp(34px, 4vw, 54px);
}
.home-hero--compact .home-hero__title {
margin: 14px 0 14px !important;
font-size: clamp(30px, 3.4vw, 46px) !important;
}
.home-hero--compact .home-hero__lead {
margin-bottom: 24px !important;
max-width: 520px;
}
.home-hero__stats {
display: grid;
grid-template-columns: repeat(3, minmax(96px, 1fr));
gap: 12px;
}
.home-hero__stats div {
min-width: 104px;
padding: 18px 20px;
border: 1px solid rgba(246, 242, 232, 0.18);
border-radius: 14px;
background: rgba(246, 242, 232, 0.06);
}
.home-hero__stats span {
display: block;
color: #b9c8c1;
font-size: 12px;
letter-spacing: 0.08em;
}
.home-hero__stats strong {
display: block;
margin-top: 6px;
color: #fff;
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 30px;
line-height: 1.1;
}
@media (max-width: 1024px) {
.home-hero--compact .home-hero__inner {
grid-template-columns: minmax(0, 1fr);
}
}
/* 市场工具条:品牌 + 搜索 + 实时数据,替代原大 hero 与信任条 */
.home-market-bar {
background: var(--paper-warm);
border-bottom: 1px solid var(--line);
}
.home-market-bar__inner {
display: flex;
align-items: center;
gap: 24px;
max-width: 1280px;
margin: 0 auto;
padding: 14px 24px;
}
.home-market-bar__copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 232px;
}
.home-market-bar__eyebrow {
color: var(--ink-muted);
font-size: 10.5px;
letter-spacing: 0.18em;
}
.home-market-bar__title {
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 22px;
letter-spacing: 0.05em;
line-height: 1.35;
}
.home-market-bar__trust {
color: var(--ink-muted);
font-size: 11px;
}
.home-market-bar__search {
flex: 1 1 auto;
max-width: 520px;
}
.home-market-bar__stats {
display: flex;
gap: 10px;
margin-left: auto;
}
.home-market-bar__stats div {
min-width: 74px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
text-align: center;
}
.home-market-bar__stats span {
display: block;
color: var(--ink-muted);
font-size: 11px;
}
.home-market-bar__stats strong {
display: block;
margin-top: 1px;
color: var(--brand-ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 18px;
line-height: 1.3;
}
@media (max-width: 900px) {
.home-market-bar__inner {
flex-wrap: wrap;
gap: 12px;
}
.home-market-bar__copy {
min-width: 0;
}
.home-market-bar__search {
order: 3;
flex-basis: 100%;
max-width: none;
}
.home-market-bar__stats {
margin-left: 0;
}
}
+10 -3
View File
@@ -3,19 +3,26 @@
height: 100%;
overflow: hidden;
border-color: var(--line);
border-radius: 14px;
background: var(--paper-warm);
transition:
border-color 160ms ease,
transform 160ms ease;
transform 160ms ease,
box-shadow 160ms ease;
}
.listing-card:hover {
border-color: var(--sage);
transform: translateY(-2px);
transform: translateY(-3px);
box-shadow: 0 16px 36px rgba(21, 58, 50, 0.1);
}
.listing-card .ant-card-body {
padding: 16px 18px 18px;
}
.listing-cover {
aspect-ratio: 4 / 3;
aspect-ratio: 16 / 10;
overflow: hidden;
background: #eef1ea;
}
+281
View File
@@ -0,0 +1,281 @@
/* 首页门户行:左快捷入口 + 中轮播 + 右账户面板 */
.home-portal {
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.home-portal__inner {
display: grid;
grid-template-columns: 208px minmax(0, 1fr) 268px;
gap: 16px;
align-items: stretch;
max-width: 1280px;
margin: 0 auto;
padding: 20px 24px;
}
.home-portal__menu {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper-warm);
}
.home-portal__menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background 140ms ease;
}
.home-portal__menu-item:hover {
background: rgba(30, 77, 66, 0.06);
}
.home-portal__menu-item strong {
display: block;
color: var(--brand-ink);
font-size: 13.5px;
}
.home-portal__menu-item span {
display: block;
margin-top: 2px;
color: var(--ink-muted);
font-size: 11px;
}
.home-portal__menu-item > .anticon {
color: var(--ink-muted);
font-size: 11px;
}
.home-portal__banner {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--brand-ink);
}
.home-portal__banner .slick-slider,
.home-portal__banner .slick-list,
.home-portal__banner .slick-track {
height: 100%;
}
.home-slide {
position: relative;
display: flex;
align-items: center;
height: 262px;
padding: 28px 34px;
color: #fff;
cursor: pointer;
}
.home-slide[data-tone='blue'] {
background:
radial-gradient(520px 260px at 88% 6%, rgba(176, 138, 74, 0.26), transparent 62%),
linear-gradient(135deg, #17453b, #20595a 58%, #1c4a52);
}
.home-slide[data-tone='green'] {
background:
radial-gradient(520px 260px at 86% 4%, rgba(111, 133, 124, 0.28), transparent 62%),
linear-gradient(135deg, #1b473d, #16392f 62%, #122e27);
}
.home-slide[data-tone='orange'] {
background:
radial-gradient(520px 260px at 88% 8%, rgba(246, 242, 232, 0.16), transparent 60%),
linear-gradient(135deg, #a64332, #8d4230 58%, #6f3527);
}
.home-slide img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.home-slide__body {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 72%;
}
.home-slide__badge {
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-size: 10px;
font-style: normal;
letter-spacing: 0.14em;
}
.home-slide__eyebrow {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
letter-spacing: 0.16em;
}
.home-slide strong {
color: #fff;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: clamp(20px, 2vw, 26px);
line-height: 1.4;
}
.home-slide__pill {
align-self: flex-start;
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 12px;
}
.home-portal__banner .slick-dots {
bottom: 12px;
}
.home-portal__banner .slick-dots li button {
background: rgba(255, 255, 255, 0.55);
}
.home-portal__account {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper-warm);
}
.home-account__head {
display: flex;
align-items: center;
gap: 10px;
}
.home-account__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--brand-ink);
color: #fff;
font-size: 16px;
}
.home-account__head strong {
display: block;
color: var(--brand-ink);
font-size: 14px;
}
.home-account__head span {
color: var(--ink-muted);
font-size: 12px;
}
.home-account__balance {
padding: 12px 14px;
border-radius: 12px;
background: rgba(30, 77, 66, 0.05);
}
.home-account__balance > span {
display: block;
color: var(--ink-muted);
font-size: 11px;
}
.home-account__balance strong {
display: block;
margin: 2px 0 6px;
color: var(--brand-ink);
font-size: 22px;
}
.home-account__balance a {
color: var(--brand-seal);
font-size: 12px;
}
.home-account__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.home-account__grid a {
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--ink-secondary);
font-size: 12px;
text-align: center;
}
.home-account__grid a:hover {
border-color: var(--sage);
color: var(--brand-ink);
}
.home-account__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: auto;
}
.home-account__title {
margin: 4px 0 0 !important;
color: var(--brand-ink) !important;
font-size: 17px !important;
}
.home-account__lead {
margin: 0 0 4px !important;
font-size: 12px;
}
@media (max-width: 1100px) {
.home-portal__inner {
grid-template-columns: minmax(0, 1fr);
}
.home-portal__menu {
flex-direction: row;
flex-wrap: wrap;
}
.home-portal__menu-item {
flex: 1 1 180px;
}
.home-slide {
height: 220px;
}
}
+11 -4
View File
@@ -16,14 +16,21 @@
.trust-item {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 12px;
color: var(--brand-ink);
}
.trust-item > .anticon {
margin-top: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(166, 67, 50, 0.08);
color: var(--brand-seal);
font-size: 18px;
font-size: 16px;
}
.trust-item strong,
@@ -39,7 +46,7 @@
margin-top: 4px;
color: var(--ink-muted);
font-size: 12px;
line-height: 1.5;
line-height: 1.55;
}
/* Sections */