From cfafc210140cb4f0166c42d9d02779066a063ddb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 18:27:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(storefront):=20=E9=A6=96=E9=A1=B5=E6=94=B9?= =?UTF-8?q?=E7=89=88=E4=B8=8E=E7=AD=9B=E9=80=89=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页收敛为市场工具条 + 门户行(快捷入口 / 轮播 / 账户面板) - 筛选按基础 10 项与高级筛选重排,接入皮肤组、段位、地区等参数 - 新增专区、排序、列表/宫格切换,并同步移动端筛选 - 精简导航并统一当前页下划线高亮 --- frontend/src/app/AppRouter.tsx | 8 + frontend/src/features/help/ProcessPage.tsx | 63 ++ frontend/src/features/help/RulesPage.tsx | 78 +++ frontend/src/features/home/HomePortalRow.tsx | 190 ++++++ frontend/src/features/home/api.ts | 82 +++ .../features/listings/ListingFilterPanel.tsx | 326 ++++++++++ .../src/features/listings/ListingListPage.tsx | 566 ++++++++++++------ frontend/src/features/listings/api.ts | 29 + .../src/features/mobile/MobileHomePage.tsx | 331 +++++++++- frontend/src/layouts/StorefrontLayout.tsx | 28 +- frontend/src/shared/api/types.ts | 1 + frontend/src/styles/base.css | 35 +- frontend/src/styles/global.css | 3 + frontend/src/styles/mobile-listing.css | 146 +++++ frontend/src/styles/storefront-filters.css | 333 +++++++++++ frontend/src/styles/storefront-help.css | 102 ++++ frontend/src/styles/storefront-home.css | 274 ++++++++- frontend/src/styles/storefront-listings.css | 13 +- frontend/src/styles/storefront-portal.css | 281 +++++++++ frontend/src/styles/storefront-sections.css | 15 +- 20 files changed, 2632 insertions(+), 272 deletions(-) create mode 100644 frontend/src/features/help/ProcessPage.tsx create mode 100644 frontend/src/features/help/RulesPage.tsx create mode 100644 frontend/src/features/home/HomePortalRow.tsx create mode 100644 frontend/src/features/home/api.ts create mode 100644 frontend/src/features/listings/ListingFilterPanel.tsx create mode 100644 frontend/src/styles/storefront-filters.css create mode 100644 frontend/src/styles/storefront-help.css create mode 100644 frontend/src/styles/storefront-portal.css 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 ( +
+
+
+ HOW IT WORKS + + 从挑选到结账,五步走完 + + + 每一步都有系统记录,出现分歧时可以按流程节点回溯。 + +
+ + + +
+ +
+ {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 ( +
+
+
+ RULES, IN PLAIN WORDS + + 规则说明 + + + 押金、租金、额外消耗与退款都会在订单里逐项列出,这里说明计算与处理口径。 + +
+ + + +
+ + + + + + + 一句话总结 + + 押金可退、租金一次性、消耗要留痕;结账备注不算钱,系统账单才算数。 + + +
+ ) +} 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: {entry.title}, + }) + 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: {item.title, + }) + } + } + + return ( +
+
+ + +
+ + {slides.map((item, index) => ( +
openSlide(item)} + > + {item.pc?.image_url || item.image_url ? ( + {item.pc?.alt + ) : ( +
+ {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 ( +
+
+ + + +
+ +
+ 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 ( +
+ {item.title} +
+ ) + } + 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 ( +
+ {item.title} +
+ ) + } + 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 ( -
- {item.title} -
- ) - } - 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" - /> - - -
- -
-
-
- {[ - ['实名审核', '发布与下单前完成身份核验'], - ['流程留痕', '交接、归还、结账全程有记录'], - ['账单透明', '租金、押金、消耗逐项列明'], - ['争议可申诉', '双方协商不成可进入仲裁'], - ].map(([title, desc], index) => ( -
- {index === 0 ? : index === 2 ? : null} -
- {title} - {desc} -
-
- ))} -
-
+ -
+
CURATED ACCOUNTS @@ -188,9 +353,62 @@ export function ListingListPage() { 按区服、段位和资产筛选,价格与押金直接写在柜签上。
- - 共 {data?.total ?? 0} 件 · 第 {page} 页 - + + changeView(value as 'list' | 'grid')} + options={[ + { label: , value: 'list' }, + { label: , value: 'grid' }, + ]} + /> + + 共 {data?.total ?? 0} 件 · 第 {page} 页 + +