diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx
index 89b605b..688b950 100644
--- a/frontend/src/app/AppRouter.tsx
+++ b/frontend/src/app/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/features/help/ProcessPage.tsx b/frontend/src/features/help/ProcessPage.tsx
new file mode 100644
index 0000000..12eddb9
--- /dev/null
+++ b/frontend/src/features/help/ProcessPage.tsx
@@ -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 (
+
+
+
+
+ {steps.map(([no, title, desc]) => (
+
+
{no}
+
{title}
+
{desc}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/help/RulesPage.tsx b/frontend/src/features/help/RulesPage.tsx
new file mode 100644
index 0000000..0dc4576
--- /dev/null
+++ b/frontend/src/features/help/RulesPage.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ 一句话总结
+
+ 押金可退、租金一次性、消耗要留痕;结账备注不算钱,系统账单才算数。
+
+
+
+ )
+}
diff --git a/frontend/src/features/home/HomePortalRow.tsx b/frontend/src/features/home/HomePortalRow.tsx
new file mode 100644
index 0000000..52db6c1
--- /dev/null
+++ b/frontend/src/features/home/HomePortalRow.tsx
@@ -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:
,
+ })
+ 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:
,
+ })
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {slides.map((item, index) => (
+ openSlide(item)}
+ >
+ {item.pc?.image_url || item.image_url ? (
+

+ ) : (
+
+ {item.badge ? {item.badge} : null}
+ {item.eyebrow ? {item.eyebrow} : null}
+ {item.title}
+ {item.pill ? {item.pill} : null}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/home/api.ts b/frontend/src/features/home/api.ts
new file mode 100644
index 0000000..64c285d
--- /dev/null
+++ b/frontend/src/features/home/api.ts
@@ -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
+ 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(
+ apiClient.get>('/mobile-home-config')
+ )
+}
+
+export function useHomeConfig() {
+ return useQuery({
+ queryKey: ['mobile-home-config'],
+ queryFn: fetchHomeConfig,
+ staleTime: 300_000,
+ })
+}
diff --git a/frontend/src/features/listings/ListingFilterPanel.tsx b/frontend/src/features/listings/ListingFilterPanel.tsx
new file mode 100644
index 0000000..8c05803
--- /dev/null
+++ b/frontend/src/features/listings/ListingFilterPanel.tsx
@@ -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 (
+
+
{label}
+
+ {options.map(option => (
+
+ ))}
+
+
+ )
+}
+
+function RangeGroup({
+ label,
+ options,
+ activeKey,
+ onSelect,
+ wide,
+}: {
+ label: string
+ options: RangeOption[]
+ activeKey?: string
+ onSelect: (key: string) => void
+ wide?: boolean
+}) {
+ return (
+
+
{label}
+
+ {options.map(option => (
+
+ ))}
+
+
+ )
+}
+
+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 (
+
+
+
+
+ 筛选条件
+ {total} 个结果
+ {activeCount > 0 ? 已选 {activeCount} 项 : null}
+
+ } onClick={onReset}>
+ 重置全部条件
+
+
+
+
+ toggleRange('coinKey', key)} />
+ toggleRange('priceKey', key)} />
+ toggleRange('ratioKey', key)} />
+ toggle('insurance', option)} />
+ toggleRange('awmKey', key)} />
+ toggle('stamina', option)} />
+ toggle('loginMethod', option)} />
+ toggle('load', option)} />
+ {quickGroups.map(group => (
+ toggle('skinNames', option)}
+ wide
+ />
+ ))}
+
+
+
+
+ {advancedOpen ? (
+
+ toggleRange('depositKey', key)} />
+ toggleRange('totalKey', key)} />
+ toggleRange('fireLevelKey', key)} />
+ toggleRange('dailyLossKey', key)} />
+ toggleRange('secretKdKey', key)} />
+ toggle('ranks', option)} />
+ toggle('regions', option)} wide />
+ {advancedSkinGroups.map(group => (
+ toggle('skinNames', option)}
+ wide
+ />
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/features/listings/ListingListPage.tsx b/frontend/src/features/listings/ListingListPage.tsx
index 4f1e286..9ad0d82 100644
--- a/frontend/src/features/listings/ListingListPage.tsx
+++ b/frontend/src/features/listings/ListingListPage.tsx
@@ -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 (
+
+

+
+ )
+ }
+ return (
+
+
+ 解忧柜选
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+
+ {item.listing_no}
+
+ {item.in_transaction ? '租赁中' : '可租'}
+
+ {item.is_accelerated_sale ? (
+
+ 特惠
+
+ ) : null}
+
+
+
+
+ {item.title}
+
+
+
+
+ {specs.map(spec => (
+
+ {spec.label}
+ {spec.value}
+
+ ))}
+
+
+
+
+ 总租金
+ {money(item.price_cent)}
+ 押金 {money(item.deposit_amount_cent)}
+
+
+
+
+
+ )
+}
+
+function ListingGridCover({ item }: { item: Listing }) {
+ if (item.cover_url) {
+ return (
+
+

+
+ )
+ }
+ return (
+
+
+ 解忧柜选
+
+ )
+}
+
+function ListingGridCard({ item }: { item: Listing }) {
return (
- }>
+ }>
{item.listing_no}
@@ -65,26 +209,10 @@ function ListingCard({ item }: { item: Listing }) {
)
}
-function ListingCover({ item }: { item: Listing }) {
- if (item.cover_url) {
- return (
-
-

-
- )
- }
- return (
-
-
- 解忧柜选
-
- )
-}
-
function ListingSkeleton() {
return (
-
-
+
+
)
}
@@ -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(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 (
<>
-
-
-
-
- 解忧商行 · 账号租赁
-
-
- 租得明白,
-
- 还得安心。
-
-
- 实名审核、流程留痕、账单逐项列明。先看清规则,再决定要不要租。
-
-
- }>
- 找账号
-
- }
- placeholder="搜索区服、段位、装备或商品号"
- onSearch={submitSearch}
- className="home-hero__search"
- />
-