From adc5a905da252868554bf86f4d5aab4955bc32d4 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 12 Nov 2026 22:48:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20=E9=87=8D=E6=9E=84=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E9=A6=96=E9=A1=B5=E4=B8=8E=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 + frontend/src/app/MobileRouteGate.tsx | 33 + frontend/src/features/listings/api.ts | 16 +- .../src/features/mobile/MobileHomePage.tsx | 653 ++++++++--- .../src/features/mobile/MobileProfilePage.tsx | 243 +++- frontend/src/layouts/MobileLayout.tsx | 101 +- frontend/src/shared/api/types.ts | 10 + frontend/src/shared/utils/mobileHost.test.ts | 23 + frontend/src/shared/utils/mobileHost.ts | 51 + frontend/src/styles/global.css | 1 + frontend/src/styles/mobile-refresh.css | 1001 +++++++++++++++++ 11 files changed, 1902 insertions(+), 232 deletions(-) create mode 100644 frontend/src/app/MobileRouteGate.tsx create mode 100644 frontend/src/shared/utils/mobileHost.test.ts create mode 100644 frontend/src/shared/utils/mobileHost.ts create mode 100644 frontend/src/styles/mobile-refresh.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3adb74d..7db61c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import zhCN from 'antd/locale/zh_CN' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' +import { MobileRouteGate } from '@/app/MobileRouteGate' import { AppRouter } from '@/app/AppRouter' import { ChatEventsBridge } from '@/features/messages/ChatEventsBridge' import { useSessionStore } from '@/stores/session' @@ -41,6 +42,7 @@ export default function App() { + diff --git a/frontend/src/app/MobileRouteGate.tsx b/frontend/src/app/MobileRouteGate.tsx new file mode 100644 index 0000000..8d69150 --- /dev/null +++ b/frontend/src/app/MobileRouteGate.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect } from 'react' +import { useLocation } from 'react-router-dom' + +import { + getMobilePath, + isMobileDevice, + prefersDesktopSite, + setDesktopPreference, +} from '@/shared/utils/mobileHost' + +export function MobileRouteGate() { + const location = useLocation() + + useLayoutEffect(() => { + const params = new URLSearchParams(location.search) + if (params.get('desktop') === '1') { + setDesktopPreference(true) + params.delete('desktop') + const search = params.toString() + window.location.replace(`${location.pathname}${search ? `?${search}` : ''}${location.hash}`) + return + } + + if (prefersDesktopSite() || !isMobileDevice()) return + + const mobilePath = getMobilePath(location.pathname) + if (mobilePath === location.pathname) return + + window.location.replace(`${mobilePath}${location.search}${location.hash}`) + }, [location.hash, location.pathname, location.search]) + + return null +} diff --git a/frontend/src/features/listings/api.ts b/frontend/src/features/listings/api.ts index ad80dc7..3673164 100644 --- a/frontend/src/features/listings/api.ts +++ b/frontend/src/features/listings/api.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { apiClient, unwrapData } from '@/shared/api/client' import type { ApiResponse, Listing, PublicListingPage } from '@/shared/api/types' @@ -60,6 +60,20 @@ export function useListings(query: ListingQuery) { }) } +export function useInfiniteListings(query: ListingQuery) { + return useInfiniteQuery({ + queryKey: ['listings-infinite', query], + initialPageParam: 1, + queryFn: ({ pageParam }) => + fetchListings({ + ...query, + page: pageParam, + }), + getNextPageParam: lastPage => + lastPage.page * lastPage.page_size < lastPage.total ? lastPage.page + 1 : undefined, + }) +} + export function fetchListingDetail(id: number | string) { return unwrapData(apiClient.get>(`/listings/${id}`)) } diff --git a/frontend/src/features/mobile/MobileHomePage.tsx b/frontend/src/features/mobile/MobileHomePage.tsx index 9aa30a0..bb5f80d 100644 --- a/frontend/src/features/mobile/MobileHomePage.tsx +++ b/frontend/src/features/mobile/MobileHomePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { Button, @@ -8,33 +8,75 @@ import { PullToRefresh, SearchBar, Skeleton, + Swiper, Tag, } from 'antd-mobile' -import { DownOutline, RightOutline, SearchOutline } from 'antd-mobile-icons' -import { Link } from 'react-router-dom' +import { + AddCircleOutline, + BillOutline, + CheckShieldOutline, + DownOutline, + FlagOutline, + GiftOutline, + PhoneFill, + RightOutline, + SearchOutline, + ShopbagOutline, + SmileOutline, +} from 'antd-mobile-icons' +import { Link, useNavigate } from 'react-router-dom' import { BrandLogo } from '@/components/BrandLogo' -import { useListings } from '@/features/listings/api' +import { + fallbackBanners, + useHomeConfig, + type HomeBannerItem, + type HomeEntryConfig, +} from '@/features/home/api' +import { useInfiniteListings } from '@/features/listings/api' import { banRecordText, faceOwnerText, gameNameChangeText, + listingRatioText, listingRentalDays, + listingResources, onlineTimeText, + resourceBadgeText, } from '@/features/listings/labels' import { + awmRanges, coinRanges, + dailyLossRanges, + depositRanges, emptyListingFilters, + fireLevelRanges, priceRanges, ratioRanges, + secretKdRanges, + totalRanges, type ListingFilterState, } from '@/features/listings/ListingFilterPanel' import { fetchListingPublishOptions } from '@/features/seller/options' import { formatCoinM } from '@/shared/utils/format' import type { Listing } from '@/shared/api/types' +const sortOptions = [ + { value: '', label: '综合推荐' }, + { value: 'published', label: '最新发布' }, + { value: 'ratioDesc', label: '比例最高' }, + { value: 'ratioAsc', label: '比例最低' }, + { value: 'priceAsc', label: '价格最低' }, + { value: 'priceDesc', label: '价格最高' }, + { value: 'coinDesc', label: '哈夫币最多' }, + { value: 'coinAsc', label: '哈夫币最少' }, + { value: 'awmDesc', label: 'AWM最多' }, + { value: 'awmAsc', label: 'AWM最少' }, +] + const zoneTabs = [ { key: 'all', label: '全部' }, + { key: 'premium', label: '极品' }, { key: 'sale', label: '特惠' }, { key: 'gift', label: '赠送' }, { key: 'night', label: '夜间' }, @@ -42,18 +84,24 @@ const zoneTabs = [ { 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 = ['扫码', '账号密码'] +type MultiFilterKey = + 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'ranks' | 'regions' | 'skinNames' + +type RangeFilterKey = + | 'coinKey' + | 'priceKey' + | 'ratioKey' + | 'awmKey' + | 'depositKey' + | 'totalKey' + | 'fireLevelKey' + | 'dailyLossKey' + | 'secretKdKey' + function money(cents: number) { return `¥${(cents / 100).toFixed(2)}` } @@ -75,6 +123,7 @@ function MobileChipGroup({ selected: string[] onToggle: (option: string) => void }) { + if (options.length === 0) return null return (
{label} @@ -94,32 +143,88 @@ function MobileChipGroup({ ) } +function MobileRangeGroup({ + label, + options, + activeKey, + onSelect, +}: { + label: string + options: { key: string; label: string }[] + activeKey?: string + onSelect: (key: string) => void +}) { + return ( +
+ {label} +
+ {options.map(option => ( + + ))} +
+
+ ) +} + function MobileListingCard({ item }: { item: Listing }) { + const [coverFailed, setCoverFailed] = useState(false) const online = onlineTimeText(item.asset_summary) const rentalDays = listingRentalDays(item) const faceOwner = faceOwnerText(item.asset_summary) const banRecord = banRecordText(item.asset_summary) const gameNameChange = gameNameChangeText(item.asset_summary) + const ratio = listingRatioText(item) + const resources = listingResources(item.asset_summary).slice(0, 2) 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')}` : '', - online ? `在线 ${online}` : '', - rentalDays ? `预计租期 ${rentalDays} 天` : '', - faceOwner ? `人脸 ${faceOwner}` : '', - banRecord ? `封禁 ${banRecord}` : '', - gameNameChange ? `改名 ${gameNameChange}` : '', - ].filter(Boolean) + { label: '保险', value: assetText(item, 'season_insurance') }, + { label: '体力', value: assetText(item, 'stamina_level') }, + { label: '负重', value: assetText(item, 'load_level') }, + { label: '区服', value: item.server_region }, + { label: '租期', value: rentalDays ? `${rentalDays}天` : '' }, + { label: '在线', value: online }, + { label: '人脸', value: faceOwner }, + { label: '封禁', value: banRecord }, + { label: '改名', value: gameNameChange }, + ] + .filter(spec => spec.value) + .slice(0, 5) return (
- {item.cover_url ? ( - {item.title} + {item.cover_url && !coverFailed ? ( + {item.title} setCoverFailed(true)} + /> ) : ( - +
+ + 实拍图整理中 +
)} + {item.is_premium || + item.owner_is_quality_owner || + item.compensation_plan === 'insurance' ? ( +
+ {item.owner_is_quality_owner ? 优质号主 : null} + {item.is_premium ? 极品 : null} + {item.compensation_plan === 'insurance' ? ( + 保险 + ) : null} +
+ ) : null}
+
{item.listing_no} @@ -127,22 +232,35 @@ function MobileListingCard({ item }: { item: Listing }) { {item.in_transaction ? '租赁中' : '可租'}
+

{item.title}

+
+ {formatCoinM(item.haf_coin_amount)} + {ratio ? 1:{ratio} : null} +
- {item.server_region || '区服待定'} - {item.rank_level || '段位待定'} - 哈夫币 {formatCoinM(item.haf_coin_amount)} {specs.map(spec => ( - {spec} + + {spec.label} + {spec.value} + + ))} + {resources.map(resource => ( + + {resource.label} + {resource.quantity} + {resourceBadgeText(resource, 'compact')} + ))}
+
{money(item.price_cent)} 押金 {money(item.deposit_amount_cent)}
- - 查看 + + 立即租
@@ -150,7 +268,44 @@ function MobileListingCard({ item }: { item: Listing }) { ) } +function QuickService({ + to, + icon, + label, + hint, + onClick, +}: { + to?: string + icon: ReactNode + label: string + hint: string + onClick?: () => void +}) { + const content = ( + <> + {icon} + {label} + {hint} + + ) + + if (to) { + return ( + + {content} + + ) + } + + return ( + + ) +} + export function MobileHomePage() { + const navigate = useNavigate() const [keyword, setKeyword] = useState('') const [searchValue, setSearchValue] = useState('') const [zone, setZone] = useState('all') @@ -159,50 +314,99 @@ export function MobileHomePage() { const [draft, setDraft] = useState(emptyListingFilters) const [filterOpen, setFilterOpen] = useState(false) const [sortOpen, setSortOpen] = useState(false) + const [entryPreview, setEntryPreview] = useState(null) + const homeConfig = useHomeConfig() 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 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 { 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 listingQuery = useMemo( + () => ({ + page_size: 10, + 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(','), + }), + [ + awm, + coin, + dailyLoss, + deposit, + filters, + fireLevel, + keyword, + price, + ratio, + secretKd, + sort, + total, + zone, + ] + ) + const listings = useInfiniteListings(listingQuery) + + const items = listings.data?.pages.flatMap(page => page.items) ?? [] + const totalListings = listings.data?.pages[0]?.total ?? 0 + const zoneCounts = listings.data?.pages[0]?.zone_counts ?? {} + const quickSkinGroups = (publishOptions.data?.skin_groups ?? []).filter(group => + ['melee', 'operatorRed'].includes(group.key) + ) + const rankOptions = publishOptions.data?.rank_options ?? [] + const regionOptions = publishOptions.data?.region_options ?? [] const applyFilter = () => { setFilters(draft) setFilterOpen(false) } - const resetDraft = () => setDraft(emptyListingFilters) + const resetFilters = () => { + setDraft(emptyListingFilters) + setFilters(emptyListingFilters) + setKeyword('') + setSearchValue('') + setZone('all') + setSort('') + } - const toggleDraft = ( - key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'skinNames', - option: string - ) => { + const toggleDraft = (key: MultiFilterKey, option: string) => { const current = draft[key] setDraft({ ...draft, @@ -212,7 +416,7 @@ export function MobileHomePage() { }) } - const toggleRange = (key: 'coinKey' | 'priceKey' | 'ratioKey', nextKey: string) => { + const toggleRange = (key: RangeFilterKey, nextKey: string) => { setDraft({ ...draft, [key]: draft[key] === nextKey ? undefined : nextKey }) } @@ -221,44 +425,141 @@ export function MobileHomePage() { filters.stamina.length + filters.load.length + filters.loginMethod.length + + filters.ranks.length + + filters.regions.length + filters.skinNames.length + - [filters.coinKey, filters.priceKey, filters.ratioKey].filter(Boolean).length + [ + filters.coinKey, + filters.priceKey, + filters.ratioKey, + filters.awmKey, + filters.depositKey, + filters.totalKey, + filters.fireLevelKey, + filters.dailyLossKey, + filters.secretKdKey, + ].filter(Boolean).length - const sortLabel = sortOptions.find(option => option.value === sort)?.label ?? '综合推荐' + const sortLabel = sortOptions.find(option => option.value === sort)?.label ?? '综合排序' + const banners = (homeConfig.data?.banners ?? fallbackBanners) + .filter(banner => banner.enabled !== false) + .slice(0, 4) + + const runBannerAction = (banner: HomeBannerItem) => { + const action = banner.mobile?.action + if (!action || action.type === 'none' || !action.target) return + if (action.type === 'route') { + navigate(action.target) + return + } + window.open(action.target, action.open_new_tab ? '_blank' : '_self') + } + + const submitSearch = (value: string) => { + setKeyword(value.trim()) + void listings.refetch() + } + + const scrollToListings = () => { + document.getElementById('listings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } return (
-
-
JIEYOU TRADING HOUSE
-

- 租得明白, -
- 还得安心。 -

-

实名审核 · 流程留痕 · 账单逐项列明

+
+
+
+ 解忧柜选 + 今天想租什么账号? +
+ 规则 +
setKeyword(value.trim())} - placeholder="搜索区服、段位、装备或商品号" + onSearch={submitSearch} + placeholder="搜编号 / 区服 / 段位 / 资产" searchIcon={} />
- 实名审核 - 交接留痕 - 争议可申诉 + + + 实名审核 + + + + 找回包赔 + + + + 客服护航 + + + + 账单透明 +
-
-
+
+ + {banners.map((banner, index) => { + const imageURL = banner.mobile?.image_url || banner.image_url + return ( + + + + ) + })} + +
+ +
+ } + label="我要租号" + hint="海量账号" + onClick={scrollToListings} + /> + } + label="我要出号" + hint="闲置账号出租" + /> + } label="撞车商城" hint="装备与现货" /> + } + label={homeConfig.data?.aw_recycle.title || 'AW回收'} + hint="查看回收说明" + onClick={() => setEntryPreview(homeConfig.data?.aw_recycle ?? null)} + /> + } label="联系客服" hint="问题留痕" /> +
+ +
+
- CURATED ACCOUNTS -

本周柜选

+ 放心购 + 严选账号
- 共 {data?.total ?? 0} 件 + 共 {totalListings} 个可租账号
@@ -270,11 +571,15 @@ export function MobileHomePage() { onClick={() => setZone(tab.key)} > {tab.label} + {typeof zoneCounts[tab.key] === 'number' ? {zoneCounts[tab.key]} : null} ))}
+ -
- {isError ? ( + {listings.isError ? (
-
) : null} - refetch().then(() => undefined)}> - {isPending ? ( + listings.refetch().then(() => undefined)}> + {listings.isPending ? (
{Array.from({ length: 3 }).map((_, index) => (
- +
))}
- ) : data?.items.length ? ( -
- {data.items.map(item => ( - - ))} -
+ ) : items.length ? ( + <> +
+ {items.map(item => ( + + ))} +
+
+ {listings.hasNextPage ? ( + + ) : ( + 已经看到全部账号 + )} +
+ ) : (
-
)}
-
+
RULES, IN PLAIN WORDS

先把规则说明白

-

押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。

+

押金、租金、额外消耗和退款规则都会在订单里逐项列出。

setFilterOpen(false)} bodyStyle={{ - height: '72vh', + height: '82vh', overflowY: 'auto', borderTopLeftRadius: 16, borderTopRightRadius: 16, @@ -358,58 +666,65 @@ export function MobileHomePage() {
筛选条件 -
-
- 哈夫币 -
- {coinRanges.map(option => ( - - ))} -
-
- -
- 租金 -
- {priceRanges.map(option => ( - - ))} -
-
- -
- 比例 -
- {ratioRanges.map(option => ( - - ))} -
-
+ toggleRange('coinKey', key)} + /> + toggleRange('priceKey', key)} + /> + toggleRange('ratioKey', key)} + /> + toggleRange('awmKey', key)} + /> + toggleRange('depositKey', key)} + /> + toggleRange('totalKey', key)} + /> + toggleRange('fireLevelKey', key)} + /> + toggleRange('dailyLossKey', key)} + /> + toggleRange('secretKdKey', key)} + /> toggleDraft('loginMethod', option)} /> + toggleDraft('ranks', option)} + /> + toggleDraft('regions', option)} + /> {quickSkinGroups.map(group => ( + + setEntryPreview(null)} + bodyStyle={{ borderTopLeftRadius: 16, borderTopRightRadius: 16 }} + > + {entryPreview ? ( +
+ {entryPreview.title} + {entryPreview.subtitle} + {entryPreview.image_url ? ( + {entryPreview.title} + ) : ( +

暂无更多说明

+ )} +
+ ) : null} +
) } diff --git a/frontend/src/features/mobile/MobileProfilePage.tsx b/frontend/src/features/mobile/MobileProfilePage.tsx index 08f8dcc..9f53ea7 100644 --- a/frontend/src/features/mobile/MobileProfilePage.tsx +++ b/frontend/src/features/mobile/MobileProfilePage.tsx @@ -1,7 +1,29 @@ -import { Button, List, Tag } from 'antd-mobile' -import { useNavigate } from 'react-router-dom' +import type { ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Button, Dialog, Tag } from 'antd-mobile' +import { + AddCircleOutline, + BillOutline, + CheckCircleOutline, + ClockCircleOutline, + GiftOutline, + GlobalOutline, + MessageOutline, + PayCircleOutline, + RightOutline, + SetOutline, + ShopbagOutline, + StarOutline, + TruckOutline, + UserCircleOutline, + UserContactOutline, + VideoOutline, +} from 'antd-mobile-icons' +import { Link, useNavigate } from 'react-router-dom' import { BrandLogo } from '@/components/BrandLogo' +import { fetchWalletBalance } from '@/features/wallet/api' +import { formatMoney } from '@/shared/utils/format' import { useSessionStore } from '@/stores/session' const realnameLabel: Record = { @@ -10,61 +32,200 @@ const realnameLabel: Record = { rejected: '未通过', } +interface ServiceItem { + label: string + icon: ReactNode + to: string +} + +const buyerServices: ServiceItem[] = [ + { label: '全部订单', icon: , to: '/m/orders' }, + { label: '待支付', icon: , to: '/m/orders?tab=pending_payment' }, + { label: '待交接', icon: , to: '/m/orders?tab=pending_handoff' }, + { label: '使用中', icon: , to: '/m/orders?tab=renting' }, + { label: '已完成', icon: , to: '/m/orders?tab=completed' }, + { label: '我的足迹', icon: , to: '/m/listings/collections' }, +] + +const sellerServices: ServiceItem[] = [ + { label: '发布商品', icon: , to: '/m/seller/listings/new' }, + { label: '我的商品', icon: , to: '/m/seller/listings' }, + { label: '提现', icon: , to: '/m/wallet/withdrawal' }, + { label: '撞车商城', icon: , to: '/m/crash' }, +] + +const publicServices: ServiceItem[] = [ + { label: '消息中心', icon: , to: '/m/messages' }, + { label: '邀请好友', icon: , to: '/m/invitations' }, + { label: '公告中心', icon: , to: '/m/announcements' }, + { label: '实名认证', icon: , to: '/m/realname' }, +] + +function ServiceGrid({ items }: { items: ServiceItem[] }) { + return ( +
+ {items.map(item => ( + + {item.icon} + {item.label} + + ))} +
+ ) +} + export function MobileProfilePage() { const navigate = useNavigate() const user = useSessionStore(state => state.user) + const token = useSessionStore(state => state.token) const logout = useSessionStore(state => state.logout) + const wallet = useQuery({ + queryKey: ['wallet-balance', 'mobile-profile'], + queryFn: fetchWalletBalance, + enabled: Boolean(token), + }) const realnameStatus = user?.realname_status || 'unverified' - const realnameTone = - realnameStatus === 'verified' ? 'success' : realnameStatus === 'rejected' ? 'danger' : 'default' + const qualityOwner = user?.quality_owner_status === 'active' + + const handleLogout = async () => { + const confirmed = await Dialog.confirm({ + content: '确定退出当前账号吗?', + confirmText: '退出', + cancelText: '取消', + }) + if (!confirmed) return + await logout() + navigate('/m') + } return (
-
- -
-

{user ? user.nickname || '我的商行' : '还没登录'}

-

{user ? user.phone : '登录后查看订单、钱包与实名状态'}

+
+
+
+ {user ? user.nickname?.slice(0, 1) || user.phone.slice(-1) : } +
+
+
+

{user ? user.nickname || `用户${user.phone.slice(-6)}` : '立即登录'}

+ {user ? ( + + {realnameLabel[realnameStatus] || '未实名'} + + ) : null} + {qualityOwner ? 优质号主 : null} +
+

+ {user + ? `ID: ${user.id} · ${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` + : '登录后查看订单、余额与账号收益'} +

+
+ {user ? ( + + ) : null}
-
- - 账号状态 - navigate(user ? '/m/realname' : '/m/login?redirect=/m/realname')} - extra={{realnameLabel[realnameStatus] ?? '未实名'}} - > - 实名认证 - - navigate('/m/orders')}>我的订单 - navigate('/m/messages')}>消息中心 - - navigate(user ? '/m/seller/listings' : '/m/login?redirect=/m/seller/listings') - } - > - 卖家中心 - - - -
{user ? ( - +
+
+ 账户余额 + + {wallet.data ? formatMoney(wallet.data.available_balance_cent) : '加载中'} + +
+
+ 可用积分 + {user.points_available ?? 0} +
+ +
) : ( )} -
+
+ +
+ + + + +
+ 我要租号 + 海量账号任你挑选 +
+ + + + + + +
+ 我要出号 + 闲置账号快速出租 +
+ + +
+ +
+
+
+ 买家中心 + 我的租用 +
+ + 全部订单 + +
+ +
+ +
+
+
+ 卖家中心 + 账号出租 +
+ + 商品管理 + +
+ +
+ +
+
+
+ 公共服务 + 账户与服务 +
+
+ +
+ + {user ? ( +
+ +
+ {user.nickname || '解忧商行用户'} + 成长等级:{user.renter_growth_level_name || '普通用户'} +
+ +
+ ) : null}
) } diff --git a/frontend/src/layouts/MobileLayout.tsx b/frontend/src/layouts/MobileLayout.tsx index e67e039..3e34685 100644 --- a/frontend/src/layouts/MobileLayout.tsx +++ b/frontend/src/layouts/MobileLayout.tsx @@ -1,8 +1,13 @@ import 'antd-mobile/es/global' -import { TabBar } from 'antd-mobile' -import { AppOutline, MessageOutline, UnorderedListOutline, UserOutline } from 'antd-mobile-icons' -import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom' +import { + AddOutline, + AppOutline, + MessageOutline, + UnorderedListOutline, + UserOutline, +} from 'antd-mobile-icons' +import { Link, Outlet, useLocation } from 'react-router-dom' import { BrandLogo } from '@/components/BrandLogo' import { useChatUnreadCount } from '@/features/messages/api' @@ -11,63 +16,87 @@ import { useSessionStore } from '@/stores/session' const tabs = [ { key: '/m', title: '首页', icon: }, { key: '/m/orders', title: '订单', icon: }, + { key: '/m/seller/listings/new', title: '发布', icon: , primary: true }, { key: '/m/messages', title: '消息', icon: }, { key: '/m/profile', title: '我的', icon: }, ] function activeTab(pathname: string) { - if (pathname.startsWith('/m/orders') || pathname.startsWith('/m/chats')) return '/m/orders' - if (pathname.startsWith('/m/messages')) return '/m/messages' + if (pathname.startsWith('/m/orders') || pathname.startsWith('/m/crash/orders')) { + return '/m/orders' + } + if (pathname.startsWith('/m/messages') || pathname.startsWith('/m/chats')) { + return '/m/messages' + } if (pathname.startsWith('/m/profile')) return '/m/profile' + if (pathname.startsWith('/m/seller/listings/new')) return '/m/seller/listings/new' return '/m' } export function MobileLayout() { const location = useLocation() - const navigate = useNavigate() const user = useSessionStore(state => state.user) const chatUnread = useChatUnreadCount(Boolean(user)) - const unreadBadge = chatUnread.data && chatUnread.data > 0 ? chatUnread.data : undefined - const hideTabbar = + const unreadCount = chatUnread.data ?? 0 + const active = activeTab(location.pathname) + const hideChrome = /^\/m\/listings\/[^/]+$/.test(location.pathname) || /^\/m\/orders\/[^/]+$/.test(location.pathname) || - /^\/m\/messages\/[^/]+$/.test(location.pathname) + /^\/m\/messages\/[^/]+$/.test(location.pathname) || + /^\/m\/crash\/[^/]+$/.test(location.pathname) || + location.pathname === '/m/login' || + location.pathname === '/m/register' || + location.pathname === '/m/add-home-guide' return ( -
-
- - - -
- {user ? ( - - {user.nickname || '我的'} +
+ {hideChrome ? null : ( +
+ + + +
+ + + {unreadCount > 0 ? ( + {unreadCount > 99 ? '99+' : unreadCount} + ) : null} - ) : ( - - 登录 + + {user ? user.nickname || '我的' : '登录'} - )} -
-
+
+
+ )}
- + {hideChrome ? null : ( + + )}
) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 8a0fa61..2948c1e 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -13,6 +13,12 @@ export interface AuthUser { risk_status?: string credit_score?: number status?: string + renter_growth_level?: string + renter_growth_level_name?: string + renter_growth_points?: number + points_available?: number + quality_owner_status?: string + deposit_free_quota_cent?: number } export interface TokenPair { @@ -46,6 +52,10 @@ export interface Listing { price_cent: number deposit_amount_cent: number is_accelerated_sale?: boolean + is_premium?: boolean + owner_is_quality_owner?: boolean + compensation_plan?: string + insurance_fee_bps?: number in_transaction: boolean status: string review_status: string diff --git a/frontend/src/shared/utils/mobileHost.test.ts b/frontend/src/shared/utils/mobileHost.test.ts new file mode 100644 index 0000000..b5299e1 --- /dev/null +++ b/frontend/src/shared/utils/mobileHost.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { getMobilePath } from './mobileHost' + +describe('getMobilePath', () => { + it('maps storefront routes to the dedicated mobile tree', () => { + expect(getMobilePath('/')).toBe('/m') + expect(getMobilePath('/listings/12')).toBe('/m/listings/12') + expect(getMobilePath('/orders/9')).toBe('/m/orders/9') + expect(getMobilePath('/messages/3')).toBe('/m/messages/3') + expect(getMobilePath('/seller/listings/new')).toBe('/m/seller/listings/new') + expect(getMobilePath('/i/ABC123')).toBe('/m/i/ABC123') + }) + + it('keeps mobile and admin routes unchanged', () => { + expect(getMobilePath('/m/profile')).toBe('/m/profile') + expect(getMobilePath('/console-jy8k3m/users')).toBe('/console-jy8k3m/users') + }) + + it('does not invent mobile pages for unsupported static pages', () => { + expect(getMobilePath('/rules')).toBe('/rules') + }) +}) diff --git a/frontend/src/shared/utils/mobileHost.ts b/frontend/src/shared/utils/mobileHost.ts new file mode 100644 index 0000000..27f7f94 --- /dev/null +++ b/frontend/src/shared/utils/mobileHost.ts @@ -0,0 +1,51 @@ +import { getAdminBasePath } from '@/shared/api/adminClient' + +export const DESKTOP_PREFERENCE_KEY = 'jieyou:prefer-desktop' + +const mobileUserAgentPattern = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i + +export function isMobileDevice() { + if (typeof window === 'undefined') return false + return mobileUserAgentPattern.test(window.navigator.userAgent) || window.innerWidth < 768 +} + +export function prefersDesktopSite() { + if (typeof window === 'undefined') return false + return window.sessionStorage.getItem(DESKTOP_PREFERENCE_KEY) === '1' +} + +export function setDesktopPreference(enabled: boolean) { + if (typeof window === 'undefined') return + if (enabled) { + window.sessionStorage.setItem(DESKTOP_PREFERENCE_KEY, '1') + return + } + window.sessionStorage.removeItem(DESKTOP_PREFERENCE_KEY) +} + +export function getMobilePath(path: string): string { + if (path === '/m' || path.startsWith('/m/')) return path + + const adminBase = getAdminBasePath() + if (path === adminBase || path.startsWith(`${adminBase}/`)) return path + + if (path === '/') return '/m' + if (path === '/login') return '/m/login' + if (path === '/register') return '/m/register' + if (path === '/realname') return '/m/realname' + if (path === '/notifications') return '/m/notifications' + if (path === '/invitations') return '/m/invitations' + if (path === '/messages' || path.startsWith('/messages/')) return `/m${path}` + if (path === '/announcements' || path.startsWith('/announcements/')) return `/m${path}` + if (path === '/profile' || path === '/wallet') return '/m/profile' + if (path === '/wallet/withdrawal') return '/m/wallet/withdrawal' + + if (path === '/listings' || path.startsWith('/listings/')) return `/m${path}` + if (path === '/orders' || path.startsWith('/orders/')) return `/m${path}` + if (path === '/crash' || path.startsWith('/crash/')) return `/m${path}` + if (path.startsWith('/seller/listings')) return `/m${path}` + if (path.startsWith('/i/')) return `/m${path}` + + return path +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 299bd58..2a54fe2 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -29,3 +29,4 @@ @import './seller.css'; @import './mobile-seller.css'; @import './components.css'; +@import './mobile-refresh.css'; diff --git a/frontend/src/styles/mobile-refresh.css b/frontend/src/styles/mobile-refresh.css new file mode 100644 index 0000000..f85f143 --- /dev/null +++ b/frontend/src/styles/mobile-refresh.css @@ -0,0 +1,1001 @@ +/* Mobile app shell */ +.mobile-shell { + width: 100%; + max-width: 480px; + min-height: 100dvh; + margin: 0 auto; + padding-bottom: calc(76px + env(safe-area-inset-bottom)); + background: #f3f5f2; + box-shadow: 0 0 0 1px rgba(28, 37, 33, 0.04); +} + +.mobile-shell--detail { + padding-bottom: 0; +} + +.mobile-header { + height: 56px; + padding: 0 14px; + border-bottom: 1px solid #e3e7e1; + background: rgba(255, 255, 255, 0.96); +} + +.mobile-header__right { + gap: 8px; +} + +.mobile-header__icon { + position: relative; + display: inline-grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 50%; + color: #31433c; + font-size: 20px; +} + +.mobile-header__badge { + position: absolute; + top: -2px; + right: -4px; + min-width: 17px; + height: 17px; + padding: 0 4px; + border: 2px solid #fff; + border-radius: 999px; + background: #d24b3c; + color: #fff; + font-size: 9px; + font-style: normal; + font-weight: 700; + line-height: 13px; + text-align: center; +} + +.mobile-header__user { + max-width: 104px; + overflow: hidden; + padding: 7px 12px; + border: 1px solid #d7ddd7; + border-radius: 999px; + color: #23372f; + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-content { + min-height: calc(100dvh - 56px); +} + +.mobile-tabbar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 40; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; + max-width: 480px; + height: calc(66px + env(safe-area-inset-bottom)); + margin: 0 auto; + padding: 6px 8px env(safe-area-inset-bottom); + border-top: 1px solid #e1e5df; + border-radius: 18px 18px 0 0; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 -8px 24px rgba(25, 45, 37, 0.08); + backdrop-filter: blur(16px); +} + +.mobile-tab { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + color: #8a958f; + text-decoration: none; +} + +.mobile-tab__icon { + position: relative; + display: grid; + width: 28px; + height: 28px; + place-items: center; + font-size: 21px; +} + +.mobile-tab__title { + font-size: 10px; + font-weight: 600; +} + +.mobile-tab.is-active { + color: #1e4d42; +} + +.mobile-tab.is-primary .mobile-tab__icon { + width: 46px; + height: 38px; + margin-top: -12px; + border: 4px solid #fff; + border-radius: 50%; + background: #1e4d42; + color: #fff; + box-shadow: 0 7px 16px rgba(30, 77, 66, 0.24); + font-size: 23px; +} + +.mobile-tab.is-primary .mobile-tab__title { + color: #1e4d42; +} + +.mobile-tab__badge { + position: absolute; + margin: -18px 0 0 18px; + padding: 1px 4px; + border-radius: 999px; + background: #d24b3c; + color: #fff; + font-size: 9px; + font-style: normal; +} + +/* Home */ +.mobile-home { + padding-bottom: 18px; +} + +.mobile-home-search { + padding: 14px 14px 12px; + background: #fff; +} + +.mobile-home-search__heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 11px; +} + +.mobile-home-search__heading span, +.mobile-market__head span, +.mobile-profile-section__head span { + display: block; + color: #8b6e50; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.mobile-home-search__heading strong { + display: block; + margin-top: 2px; + color: #1e4d42; + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 22px; +} + +.mobile-home-search__heading > a { + color: #7c8983; + font-size: 12px; + text-decoration: none; +} + +.mobile-search { + --background: #f3f5f2; + --border-radius: 10px; + --height: 42px; + --padding-left: 12px; + color: #1c2521; +} + +.mobile-trust { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding: 11px 8px; + border-bottom: 1px solid #e2e6e0; + background: #fff; +} + +.mobile-trust span { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 3px; + border-right: 0; + color: #52625b; + font-size: 11px; +} + +.mobile-trust svg { + flex: 0 0 auto; + color: #1e4d42; + font-size: 16px; +} + +.mobile-trust em { + overflow: hidden; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-banner { + margin: 12px 14px 0; + overflow: hidden; + border-radius: 12px; + background: #1e4d42; +} + +.mobile-banner .adm-swiper { + border-radius: 12px; +} + +.mobile-banner__item { + position: relative; + display: flex; + width: 100%; + min-height: 154px; + align-items: flex-end; + padding: 0; + overflow: hidden; + border: 0; + background: #1e4d42; + color: #fff; + text-align: left; +} + +.mobile-banner__item.tone-orange { + background: #9f4938; +} + +.mobile-banner__item.tone-blue { + background: #295b61; +} + +.mobile-banner__item img { + width: 100%; + height: 100%; + min-height: 154px; + object-fit: cover; +} + +.mobile-banner__copy { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 22px; +} + +.mobile-banner__copy em { + align-self: flex-start; + padding: 2px 8px; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 9px; + font-style: normal; + letter-spacing: 0.08em; +} + +.mobile-banner__copy strong { + max-width: 82%; + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 22px; + line-height: 1.35; +} + +.mobile-banner__copy small { + color: rgba(255, 255, 255, 0.74); + font-size: 11px; +} + +.mobile-quick-services { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 2px; + margin: 12px 14px 0; + padding: 14px 4px 12px; + border: 1px solid #e2e6e0; + border-radius: 12px; + background: #fff; +} + +.mobile-quick-service { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-decoration: none; +} + +.mobile-quick-service__icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 12px; + background: #eef3ee; + color: #1e4d42; + font-size: 21px; +} + +.mobile-quick-service:nth-child(2) .mobile-quick-service__icon { + background: #f5eee4; + color: #9a6b35; +} + +.mobile-quick-service:nth-child(3) .mobile-quick-service__icon { + background: #f4ece9; + color: #a94d39; +} + +.mobile-quick-service:nth-child(4) .mobile-quick-service__icon { + background: #edf0f4; + color: #526b7e; +} + +.mobile-quick-service strong { + max-width: 100%; + overflow: hidden; + color: #26352f; + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-quick-service small { + display: none; +} + +.mobile-market { + margin-top: 12px; + padding: 16px 14px 4px; + border-top: 1px solid #e2e6e0; + background: #fff; +} + +.mobile-market__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-bottom: 11px; +} + +.mobile-market__head strong { + display: block; + margin-top: 1px; + color: #1e4d42; + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 23px; +} + +.mobile-market__head small { + padding-bottom: 3px; + color: #8b958f; + font-size: 11px; +} + +.mobile-zone-tabs { + gap: 7px; + padding-bottom: 4px; +} + +.mobile-zone-tabs button { + min-width: 64px; + padding: 8px 12px; + border: 1px solid #dfe4de; + border-radius: 999px; + background: #fff; + color: #52625b; + font-size: 12px; + font-weight: 600; +} + +.mobile-zone-tabs button em { + margin-left: 4px; + color: #9aa39e; + font-size: 9px; + font-style: normal; +} + +.mobile-zone-tabs button.is-active { + border-color: #1e4d42; + background: #1e4d42; + color: #fff; +} + +.mobile-zone-tabs button.is-active em { + color: rgba(255, 255, 255, 0.72); +} + +.mobile-list-toolbar { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 8px 0 12px; + border: 0; + background: transparent; +} + +.mobile-list-toolbar button { + justify-content: center; + min-height: 38px; + border: 1px solid #e0e5df; + border-radius: 8px; + background: #f8faf8; + color: #394a43; + font-size: 12px; +} + +.mobile-listing-list { + gap: 10px; +} + +.mobile-listing-card { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 11px; + padding: 10px; + border: 1px solid #e1e5df; + border-radius: 12px; + background: #fff; + color: inherit; + text-decoration: none; +} + +.mobile-listing-card__cover { + position: relative; + width: 112px; + height: 120px; + border-radius: 9px; +} + +.mobile-listing-card__cover > img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} + +.mobile-listing-card__fallback { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: inherit; + background: linear-gradient(145deg, rgba(30, 77, 66, 0.1), rgba(159, 73, 56, 0.08)), #f3f5f1; +} + +.mobile-listing-card__fallback img { + width: auto; + height: 36px; + object-fit: contain; +} + +.mobile-listing-card__fallback span { + color: #849088; + font-size: 9px; +} + +.mobile-listing-card__badges { + position: absolute; + right: 5px; + bottom: 5px; + left: 5px; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.mobile-listing-card__badges span { + padding: 2px 5px; + border-radius: 4px; + background: rgba(30, 77, 66, 0.9); + color: #fff; + font-size: 9px; +} + +.mobile-listing-card__badges span.is-premium { + background: rgba(159, 73, 56, 0.92); +} + +.mobile-listing-card__badges span.is-insurance { + background: rgba(52, 101, 82, 0.92); +} + +.mobile-listing-card__body { + gap: 0; +} + +.mobile-listing-card__top { + align-items: center; +} + +.mobile-listing-card__no { + font-size: 10px; +} + +.mobile-listing-card__body h3 { + margin: 7px 0 8px; + color: #1c2521; + font-family: 'Noto Sans SC', 'PingFang SC', sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 1.4; + -webkit-line-clamp: 2; +} + +.mobile-listing-card__core { + display: flex; + align-items: baseline; + gap: 7px; + margin-bottom: 6px; +} + +.mobile-listing-card__core strong { + color: #1e4d42; + font-size: 15px; +} + +.mobile-listing-card__core span { + color: #9b7853; + font-size: 11px; +} + +.mobile-listing-card__tags { + display: flex; + max-height: 44px; + flex-wrap: wrap; + gap: 4px; + overflow: hidden; +} + +.mobile-listing-card__tags span { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 5px; + border-radius: 4px; + background: #f1f4f0; + color: #53645c; + font-size: 10px; +} + +.mobile-listing-card__tags em { + color: #94a098; + font-style: normal; +} + +.mobile-listing-card__tags i { + color: #a94d39; + font-style: normal; +} + +.mobile-listing-card__bottom { + margin-top: auto; + padding-top: 7px; + border-top: 1px dashed #e4e8e2; +} + +.mobile-listing-card__bottom strong { + color: #a64332; + font-size: 20px; +} + +.mobile-listing-card__bottom small { + margin-top: 0; + color: #8a958f; + font-size: 10px; +} + +.mobile-listing-card__cta { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 7px 10px; + border-radius: 999px; + background: #1e4d42; + color: #fff; + font-size: 11px; + font-weight: 700; +} + +.mobile-list-more { + padding: 14px 0 8px; + color: #8b958f; + font-size: 11px; + text-align: center; +} + +.mobile-skeleton-card { + border-radius: 12px; +} + +.mobile-skeleton-cover { + width: 112px; + height: 120px; +} + +.mobile-rules { + margin: 12px 14px; + border-radius: 10px; +} + +.mobile-filter-sheet { + min-height: 100%; + padding-bottom: calc(14px + env(safe-area-inset-bottom)); +} + +.mobile-filter-chip { + min-height: 34px; + padding: 7px 11px; + font-size: 12px; +} + +.mobile-sort-sheet { + padding-bottom: calc(10px + env(safe-area-inset-bottom)); +} + +.mobile-sort-sheet button { + min-height: 42px; +} + +.mobile-entry-preview { + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 18px calc(22px + env(safe-area-inset-bottom)); +} + +.mobile-entry-preview > strong { + color: #1e4d42; + font-size: 20px; +} + +.mobile-entry-preview > small { + color: #77837d; + font-size: 12px; +} + +.mobile-entry-preview img { + width: 100%; + max-height: 62vh; + object-fit: contain; + border-radius: 10px; +} + +/* Profile */ +.mobile-profile { + padding: 12px 12px 24px; +} + +.mobile-profile-hero { + padding: 18px; + border-radius: 14px; + background: #203f36; + color: #fff; +} + +.mobile-profile-hero.is-guest { + background: #fff; + color: #1c2521; +} + +.mobile-profile-hero__top { + display: flex; + align-items: center; + gap: 12px; +} + +.mobile-profile-avatar { + display: grid; + flex: 0 0 58px; + width: 58px; + height: 58px; + place-items: center; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.28); + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 24px; +} + +.mobile-profile-hero.is-guest .mobile-profile-avatar { + border-color: #e1e6e0; + background: #f1f4f0; + color: #718079; +} + +.mobile-profile-identity { + flex: 1; + min-width: 0; +} + +.mobile-profile-identity > div { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; +} + +.mobile-profile-identity h1 { + max-width: 100%; + margin: 0; + overflow: hidden; + color: inherit; + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-profile-identity p { + margin: 5px 0 0; + color: rgba(255, 255, 255, 0.64); + font-size: 11px; +} + +.mobile-profile-hero.is-guest .mobile-profile-identity p { + color: #7f8c86; +} + +.mobile-profile-settings { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 50%; + background: transparent; + color: #fff; + font-size: 19px; +} + +.mobile-profile-assets { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 12px; + align-items: end; + margin-top: 18px; + padding: 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); +} + +.mobile-profile-assets span { + display: block; + margin-bottom: 3px; + color: rgba(255, 255, 255, 0.64); + font-size: 10px; +} + +.mobile-profile-assets strong { + display: block; + color: #fff; + font-size: 22px; + line-height: 1.2; +} + +.mobile-profile-assets button { + padding: 7px 14px; + border: 0; + border-radius: 999px; + background: #e3c58d; + color: #26352f; + font-size: 12px; + font-weight: 700; +} + +.mobile-primary-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.mobile-primary-action { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; + padding: 14px 12px; + border-radius: 12px; + color: #fff; + text-decoration: none; +} + +.mobile-primary-action.is-rent { + background: #a64332; +} + +.mobile-primary-action.is-sell { + background: #b97835; +} + +.mobile-primary-action > span { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 50%; + background: rgba(255, 255, 255, 0.16); + font-size: 20px; +} + +.mobile-primary-action > div { + flex: 1; + min-width: 0; +} + +.mobile-primary-action strong, +.mobile-primary-action small { + display: block; +} + +.mobile-primary-action strong { + font-size: 14px; +} + +.mobile-primary-action small { + margin-top: 2px; + overflow: hidden; + color: rgba(255, 255, 255, 0.72); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-primary-action > svg { + flex: 0 0 auto; + font-size: 12px; +} + +.mobile-profile-section { + margin-top: 12px; + padding: 15px 14px; + border: 1px solid #e1e5df; + border-radius: 12px; + background: #fff; +} + +.mobile-profile-section__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; +} + +.mobile-profile-section__head strong { + display: block; + margin-top: 1px; + color: #1c2521; + font-size: 17px; +} + +.mobile-profile-section__head a { + display: inline-flex; + align-items: center; + color: #87938d; + font-size: 10px; + text-decoration: none; +} + +.mobile-service-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 15px 6px; +} + +.mobile-service-item { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; +} + +.mobile-service-item > span { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: 12px; + background: #f2f5f1; + color: #344b41; + font-size: 21px; +} + +.mobile-service-item strong { + max-width: 100%; + overflow: hidden; + color: #34443d; + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-profile-account { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #e1e5df; + border-radius: 12px; + background: #fff; +} + +.mobile-profile-account > div { + flex: 1; + min-width: 0; +} + +.mobile-profile-account strong, +.mobile-profile-account span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-profile-account strong { + color: #26352f; + font-size: 13px; +} + +.mobile-profile-account span { + margin-top: 2px; + color: #8b958f; + font-size: 10px; +} + +@media (max-width: 374px) { + .mobile-quick-service__icon { + width: 34px; + height: 34px; + font-size: 18px; + } + + .mobile-quick-service strong { + font-size: 10px; + } + + .mobile-listing-card { + grid-template-columns: 100px minmax(0, 1fr); + } + + .mobile-listing-card__cover { + width: 100px; + height: 112px; + } + + .mobile-service-grid { + gap: 14px 4px; + } +}