fix(mobile): 重构商品详情资产展示与验图

This commit is contained in:
yml
2026-11-13 13:36:13 +08:00
parent 5d7fedd915
commit b54802ada9
4 changed files with 1132 additions and 118 deletions
@@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest'
import {
faceOwnerText,
listingAssetRows,
listingGalleryItems,
listingPriceDetail,
listingRatioText,
listingResources,
listingSkinGroups,
resourceBadgeText,
resourceChargeText,
} from '@/features/listings/labels'
@@ -168,3 +171,96 @@ describe('faceOwnerText', () => {
expect(faceOwnerText({ face_owner: '需配合人脸' })).toBe('需配合人脸')
})
})
describe('listingAssetRows', () => {
it('把内部资产字段转换成买家可读文案,并排除截图和计价数据', () => {
const rows = listingAssetRows({
fire_level: 60,
stamina_level: '7级',
load_level: '4级',
secret_kd: '2.5',
daily_loss_m: 30,
common_regions: ['内蒙古', '北京'],
all_hero: '是',
online_time: { start: '09:00', end: '23:00' },
face_owner: '否',
screenshot_groups: { coin: ['/api/listings/2/screenshots/0'] },
publish_ratio: 34,
resources: [{ label: '6级弹', quantity: 10 }],
})
const values = Object.fromEntries(rows.map(row => [row.label, row.value]))
expect(values).toMatchObject({
烽火等级: '60 级',
体力等级: '7级',
负重等级: '4级',
'绝密 KD': '2.5',
每日损耗: '30M / 天',
常用地区: '内蒙古、北京',
全英雄: '是',
在线时段: '09:00 - 23:00',
人脸情况: '非本人',
})
expect(rows.some(row => row.label === 'screenshot_groups')).toBe(false)
expect(rows.some(row => row.label === 'publish_ratio')).toBe(false)
expect(rows.some(row => row.label === 'resources')).toBe(false)
})
it('缺少资产摘要时返回空数组', () => {
expect(listingAssetRows(null)).toEqual([])
expect(listingAssetRows({})).toEqual([])
})
})
describe('listingGalleryItems', () => {
it('优先按业务分组生成有标签的验号截图', () => {
expect(
listingGalleryItems({
cover_url: '/api/listings/2/cover',
screenshot_urls: ['/api/listings/2/screenshots/0'],
asset_summary: {
screenshot_groups: {
coin: ['/api/listings/2/screenshots/0', '/api/listings/2/screenshots/1'],
gameId: ['/api/listings/2/screenshots/2'],
},
},
})
).toEqual([
{ url: '/api/listings/2/screenshots/0', label: '纯币截图 1' },
{ url: '/api/listings/2/screenshots/1', label: '纯币截图 2' },
{ url: '/api/listings/2/screenshots/2', label: '游戏 ID 截图' },
])
})
it('没有分组时使用截图数组,最后才回退到商品封面', () => {
expect(
listingGalleryItems({
cover_url: '/api/listings/2/cover',
screenshot_urls: ['/api/listings/2/screenshots/0'],
})
).toEqual([{ url: '/api/listings/2/screenshots/0', label: '纯币截图' }])
expect(
listingGalleryItems({
cover_url: '/api/listings/2/cover',
screenshot_urls: [],
})
).toEqual([{ url: '/api/listings/2/cover', label: '商品封面' }])
})
})
describe('listingSkinGroups', () => {
it('过滤空分组并保留皮肤品级', () => {
expect(
listingSkinGroups({
skin_groups: {
melee: ['影锋', '北极星'],
operatorRed: ['天际线'],
weapon: [],
},
})
).toEqual([
{ key: 'melee', label: '近战皮肤', tone: 'melee', names: ['影锋', '北极星'] },
{ key: 'operatorRed', label: '干员红皮', tone: 'operatorRed', names: ['天际线'] },
])
})
})
+155
View File
@@ -60,6 +60,161 @@ export function assetFieldLabel(key: string) {
return assetFieldLabels[key] ?? key
}
export type ListingAssetRow = {
label: string
value: string
}
export type ListingGalleryItem = {
url: string
label: string
}
export type ListingSkinTone = 'melee' | 'operatorRed' | 'operatorGold' | 'weapon' | 'other'
export type ListingSkinGroup = {
key: string
label: string
tone: ListingSkinTone
names: string[]
}
const skinGroupMeta: Record<string, { label: string; tone: ListingSkinTone }> = {
melee: { label: '近战皮肤', tone: 'melee' },
operatorRed: { label: '干员红皮', tone: 'operatorRed' },
operatorGold: { label: '干员金皮', tone: 'operatorGold' },
operator: { label: '干员皮肤', tone: 'other' },
weapon: { label: '武器皮肤', tone: 'weapon' },
other: { label: '其他皮肤', tone: 'other' },
imported: { label: '其他皮肤', tone: 'other' },
}
const screenshotGroupMeta = [
{ key: 'coin', label: '纯币截图' },
{ key: 'tencentSecurity', label: '腾讯安全截图' },
{ key: 'skin', label: '皮肤截图' },
{ key: 'gameId', label: '游戏 ID 截图' },
{ key: 'totalAsset', label: '总资产截图' },
]
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function plainText(value: unknown) {
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? '是' : '否'
return ''
}
function yesNoText(value: unknown) {
if (value === true) return '是'
if (value === false) return '否'
return plainText(value)
}
function stringList(value: unknown) {
if (!Array.isArray(value)) return []
return value
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}
/**
* 买家侧资产档案只展示用户能理解的字段,避免把内部截图地址和计价字段直接暴露出来。
*/
export function listingAssetRows(summary?: Record<string, unknown> | null): ListingAssetRow[] {
if (!summary) return []
const fireLevel = Number(summary.fire_level ?? 0)
const secretKd = Number(summary.secret_kd ?? 0)
const dailyLoss = Number(summary.daily_loss_m ?? 0)
const regions = stringList(summary.common_regions)
const rows: ListingAssetRow[] = [
{
label: '烽火等级',
value:
Number.isFinite(fireLevel) && fireLevel > 0
? `${fireLevel} 级`
: plainText(summary.fire_level),
},
{ label: '体力等级', value: plainText(summary.stamina_level) },
{ label: '负重等级', value: plainText(summary.load_level) },
{ label: '赛季保险', value: plainText(summary.season_insurance) },
{
label: '绝密 KD',
value: Number.isFinite(secretKd) && secretKd > 0 ? String(secretKd) : '',
},
{
label: '每日损耗',
value: Number.isFinite(dailyLoss) && dailyLoss > 0 ? `${dailyLoss}M / 天` : '',
},
{ label: '全英雄', value: yesNoText(summary.all_hero ?? summary.all_heroes) },
{ label: '解锁德穆兰', value: yesNoText(summary.unlock_demulan) },
{ label: '在线时段', value: onlineTimeText(summary) },
{ label: '常用地区', value: regions.join('、') },
{ label: '凌晨响应', value: yesNoText(summary.early_morning_response) },
{ label: '人脸情况', value: faceOwnerText(summary) },
{ label: '封禁记录', value: banRecordText(summary) },
{ label: '可改游戏名', value: gameNameChangeText(summary) },
{ label: '下赛季通行证', value: yesNoText(summary.next_season_pass) },
{ label: '号主备注', value: plainText(summary.remark) },
]
return rows.filter(row => row.value)
}
export function listingSkinGroups(summary?: Record<string, unknown> | null): ListingSkinGroup[] {
const groups = asRecord(summary?.skin_groups)
return Object.entries(groups)
.map(([key, value]) => {
const meta = skinGroupMeta[key] ?? { label: key, tone: 'other' as const }
return {
key,
label: meta.label,
tone: meta.tone,
names: stringList(value),
}
})
.filter(group => group.names.length > 0)
}
export function listingGalleryItems(item: {
cover_url?: string
screenshot_urls?: string[]
asset_summary?: Record<string, unknown>
}): ListingGalleryItem[] {
const seen = new Set<string>()
const rows: ListingGalleryItem[] = []
const add = (url: unknown, label: string) => {
if (typeof url !== 'string') return
const value = url.trim()
if (!value || seen.has(value)) return
seen.add(value)
rows.push({ url: value, label })
}
const groups = asRecord(item.asset_summary?.screenshot_groups)
for (const slot of screenshotGroupMeta) {
const urls = stringList(groups[slot.key])
urls.forEach((url, index) => {
add(url, urls.length > 1 ? `${slot.label} ${index + 1}` : slot.label)
})
}
if (rows.length === 0) {
const fallbackLabels = ['纯币截图', '游戏 ID 截图', '总资产截图', '腾讯安全截图', '皮肤截图']
;(item.screenshot_urls ?? []).forEach((url, index) => {
add(url, fallbackLabels[index] ?? `账号截图 ${index + 1}`)
})
}
if (rows.length === 0 && item.cover_url) add(item.cover_url, '商品封面')
return rows
}
function formatOnlineTime(value: unknown) {
if (!value || typeof value !== 'object') return ''
const item = value as { start?: unknown; end?: unknown }
@@ -1,19 +1,37 @@
import { useState } from 'react'
import { Button, Checkbox, Dialog, ErrorBlock, Popup, Skeleton, Tag, Toast } from 'antd-mobile'
import { LeftOutline, PhoneFill } from 'antd-mobile-icons'
import {
Button,
Checkbox,
Dialog,
ErrorBlock,
ImageViewer,
Popup,
Skeleton,
Tag,
Toast,
} from 'antd-mobile'
import {
CheckShieldOutline,
LeftOutline,
PhoneFill,
SmileOutline,
UserContactOutline,
} from 'antd-mobile-icons'
import { useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListingDetail } from '@/features/listings/api'
import {
banRecordText,
faceOwnerText,
gameNameChangeText,
listingAssetRows,
listingGalleryItems,
listingPriceDetail,
listingRatioText,
listingRentalDays,
listingResources,
listingSkinGroups,
resourceBadgeText,
type ListingGalleryItem,
} from '@/features/listings/labels'
import { ensureSupportConversation } from '@/features/messages/api'
import { createOrder, useOrderAgreements, usePostRentalNotice } from '@/features/orders/api'
@@ -24,18 +42,111 @@ function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function assetValue(value: unknown) {
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>
if (typeof record.start === 'string' && typeof record.end === 'string') {
return `${record.start} – ${record.end}`
}
return Object.entries(record)
.map(([key, item]) => `${key}:${String(item)}`)
.join(',')
function summaryText(value: unknown) {
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? '是' : '否'
return ''
}
function MobileListingGallery({ items }: { items: ListingGalleryItem[] }) {
const [activeIndex, setActiveIndex] = useState(0)
const [failedURLs, setFailedURLs] = useState<string[]>([])
const [viewerOpen, setViewerOpen] = useState(false)
const [viewerIndex, setViewerIndex] = useState(0)
const safeIndex = Math.min(activeIndex, Math.max(items.length - 1, 0))
const active = items[safeIndex]
const availableItems = items.filter(item => !failedURLs.includes(item.url))
const markFailed = (url: string) => {
setFailedURLs(current => (current.includes(url) ? current : [...current, url]))
}
if (Array.isArray(value)) return value.map(String).join('、')
return value == null || value === '' ? '未填写' : String(value)
const openViewer = () => {
if (!active || failedURLs.includes(active.url) || availableItems.length === 0) return
setViewerIndex(
Math.max(
availableItems.findIndex(item => item.url === active.url),
0
)
)
setViewerOpen(true)
}
return (
<section className="mobile-detail__panel mobile-detail__gallery-card">
<div className="mobile-detail__section-head">
<div>
<span>验号图片展台</span>
<h2>真实游戏截图</h2>
</div>
<small>共 {items.length} 张</small>
</div>
<button
type="button"
className="mobile-detail__stage"
onClick={openViewer}
aria-label={active ? `查看${active.label}` : '暂无验号截图'}
>
{active && !failedURLs.includes(active.url) ? (
<img
src={active.url}
alt={active.label}
loading="eager"
onError={() => markFailed(active.url)}
/>
) : (
<span className="mobile-detail__image-fallback">
<BrandLogo height={40} variant="seal" />
<em>{active ? '截图暂不可访问' : '暂无验号截图'}</em>
</span>
)}
{active ? <span className="mobile-detail__stage-label">{active.label}</span> : null}
{active && !failedURLs.includes(active.url) && items.length > 0 ? (
<span className="mobile-detail__stage-hint">点击放大</span>
) : null}
</button>
{items.length > 1 ? (
<div className="mobile-detail__thumb-strip">
{items.map((item, index) => (
<button
type="button"
key={`${item.url}-${index}`}
className={`mobile-detail__thumb${index === safeIndex ? ' is-active' : ''}`}
onClick={() => setActiveIndex(index)}
>
{failedURLs.includes(item.url) ? (
<span className="mobile-detail__thumb-fallback">
<BrandLogo height={20} variant="seal" />
</span>
) : (
<img
src={item.url}
alt={item.label}
loading="lazy"
onError={() => markFailed(item.url)}
/>
)}
<em>{item.label}</em>
</button>
))}
</div>
) : null}
<ImageViewer.Multi
key={`${viewerOpen}-${viewerIndex}`}
visible={viewerOpen}
images={availableItems.map(item => item.url)}
defaultIndex={viewerIndex}
onClose={() => setViewerOpen(false)}
renderFooter={(_image, index) => (
<span className="mobile-detail__viewer-label">{availableItems[index]?.label}</span>
)}
/>
</section>
)
}
export function MobileListingDetailPage() {
@@ -51,6 +162,16 @@ export function MobileListingDetailPage() {
const [submitting, setSubmitting] = useState(false)
const [openingSupport, setOpeningSupport] = useState(false)
const copyListingNo = async () => {
if (!data) return
try {
await navigator.clipboard.writeText(data.listing_no)
Toast.show({ content: '商品编号已复制' })
} catch {
Toast.show({ content: '复制失败,请手动记录编号' })
}
}
const openSupport = async () => {
if (!token) {
navigate(`/m/login?redirect=/m/listings/${id}`)
@@ -148,55 +269,84 @@ export function MobileListingDetailPage() {
)
}
const onlineTime =
data.asset_summary && typeof data.asset_summary === 'object'
? (data.asset_summary.online_time as unknown)
: undefined
const assetEntries = Object.entries(data.asset_summary || {}).filter(
([key]) =>
key !== 'online_time' &&
key !== 'face_owner' &&
key !== 'ban_record' &&
key !== 'can_change_game_name' &&
key !== 'modifiable_name' &&
key !== 'pure_coin_price_cent' &&
key !== 'extra_item_price_cent' &&
key !== 'publish_ratio' &&
key !== 'resources'
)
const summary = data.asset_summary ?? {}
const rentalDays = listingRentalDays(data)
const faceOwner = faceOwnerText(data.asset_summary)
const banRecord = banRecordText(data.asset_summary)
const gameNameChange = gameNameChangeText(data.asset_summary)
const faceOwner = faceOwnerText(summary)
const ratio = listingRatioText(data)
const resources = listingResources(data.asset_summary)
const galleryItems = listingGalleryItems(data)
const assetRows = listingAssetRows(summary)
const skinGroups = listingSkinGroups(summary)
const skinCount = skinGroups.reduce((total, group) => total + group.names.length, 0)
const priceDetail = listingPriceDetail(data, { compact: true })
const insuranceSlot = summaryText(summary.season_insurance)
const dailyLoss = Number(summary.daily_loss_m ?? 0)
const fireLevel = Number(summary.fire_level ?? 0)
const coreTiles = [
{ label: '哈夫币', value: formatCoinM(data.haf_coin_amount), tone: 'coin' },
{ label: '安全箱', value: insuranceSlot || '—', tone: insuranceSlot === '3*3' ? 'coin' : '' },
{ label: '折算比', value: ratio ? `1元 ≈ ${ratio}` : '—', tone: '' },
{ label: '预计可租', value: rentalDays ? `${rentalDays} 天` : '—', tone: 'success' },
{
label: '每日损耗',
value: Number.isFinite(dailyLoss) && dailyLoss > 0 ? `${dailyLoss}M / 天` : '—',
tone: 'warm',
},
{ label: '人脸情况', value: faceOwner || '—', tone: faceOwner === '非本人' ? 'danger' : '' },
]
const isOwner = user?.id === data.owner_id
return (
<div className="mobile-detail">
<button type="button" className="mobile-detail__back" onClick={() => navigate(-1)}>
<LeftOutline /> 返回
</button>
<header className="mobile-detail__nav">
<button type="button" className="mobile-detail__nav-back" onClick={() => navigate(-1)}>
<LeftOutline />
<span>返回</span>
</button>
<strong>账号详情</strong>
<span className="mobile-detail__nav-spacer" />
</header>
<div className="mobile-detail__cover">
{data.cover_url ? (
<img src={data.cover_url} alt={data.title} />
) : (
<BrandLogo height={52} variant="seal" />
)}
<div className="mobile-detail__fraud-tip">
<CheckShieldOutline />
<span>平台官方担保交易,请按平台交接流程验号,勿脱离平台私下转账。</span>
</div>
<section className="mobile-detail__head">
<div className="mobile-detail__no">{data.listing_no}</div>
<h1>{data.title}</h1>
<div className="mobile-detail__tags">
<section className="mobile-detail__hero-card">
<div className="mobile-detail__hero-meta">
<button
type="button"
className="mobile-detail__code"
onClick={() => void copyListingNo()}
>
<span>{data.listing_no}</span>
<em>复制</em>
</button>
<Tag color={data.in_transaction ? 'default' : 'success'} fill="outline">
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
</div>
<h1>{data.title}</h1>
<div className="mobile-detail__tags">
<Tag fill="outline">{data.server_region || '区服待定'}</Tag>
<Tag fill="outline">{data.login_platform || '登录方式待定'}</Tag>
<Tag fill="outline">{data.rank_level || '段位待定'}</Tag>
{fireLevel > 0 ? <Tag fill="outline">烽火 {fireLevel} 级</Tag> : null}
{data.is_premium ? <Tag color="warning">极品专区</Tag> : null}
{data.owner_is_quality_owner ? <Tag color="success">优质号主</Tag> : null}
{data.compensation_plan === 'insurance' ? <Tag color="primary">无忧保险</Tag> : null}
</div>
<div className="mobile-detail__core-grid">
{coreTiles.map(item => (
<div
key={item.label}
className={`mobile-detail__core-tile${item.tone ? ` is-${item.tone}` : ''}`}
>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
</section>
@@ -218,83 +368,135 @@ export function MobileListingDetailPage() {
</div>
</section>
<section className="mobile-detail__section">
<h2>账号说明</h2>
<p>{data.description || '号主暂未填写补充说明。'}</p>
</section>
<MobileListingGallery key={data.id} items={galleryItems} />
<section className="mobile-detail__section">
<h2>资产摘要</h2>
<dl className="mobile-detail__assets">
{ratio ? (
<div>
<dt>折算比</dt>
<dd>1 元 ≈ {ratio}</dd>
</div>
) : null}
{assetEntries.length ? (
assetEntries.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{assetValue(value)}</dd>
</div>
))
) : (
<div>
<dt>资产</dt>
<dd>未填写</dd>
</div>
)}
{faceOwner ? (
<div>
<dt>人脸情况</dt>
<dd>{faceOwner}</dd>
</div>
) : null}
{banRecord ? (
<div>
<dt>封禁记录</dt>
<dd>{banRecord}</dd>
</div>
) : null}
{gameNameChange ? (
<div>
<dt>可改游戏名</dt>
<dd>{gameNameChange}</dd>
</div>
) : null}
{rentalDays ? (
<div>
<dt>预计租期</dt>
<dd>{rentalDays} 天</dd>
</div>
) : null}
<section className="mobile-detail__panel">
<div className="mobile-detail__section-head">
<div>
<dt>在线时间</dt>
<dd>{assetValue(onlineTime)}</dd>
<span>账号核心档案</span>
<h2>资产与游戏信息</h2>
</div>
</dl>
</section>
{resources.length ? (
<section className="mobile-detail__section">
<h2>仓库物资</h2>
<dl className="mobile-detail__assets">
{resources.map(row => (
<div key={row.label}>
<dt>{row.label}</dt>
<dd>
{row.quantity} 个
<span className={`mobile-detail__resource-mode is-${row.mode}`}>
{resourceBadgeText(row)}
</span>
</dd>
<small>{assetRows.length} 项信息</small>
</div>
{assetRows.length ? (
<div className="mobile-detail__spec-grid">
{assetRows.map(row => (
<div className="mobile-detail__spec-cell" key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
</dl>
</div>
) : (
<p className="mobile-detail__empty">号主暂未填写更多资产信息。</p>
)}
</section>
<section className="mobile-detail__panel">
<div className="mobile-detail__section-head">
<div>
<span>号主留言与说明</span>
<h2>交接前请先阅读</h2>
</div>
</div>
<div className="mobile-detail__note">
{data.description || '号主暂未填写特殊交接要求,可正常直接下单租用。'}
</div>
</section>
{skinGroups.length ? (
<section className="mobile-detail__panel">
<div className="mobile-detail__section-head">
<div>
<span>精选皮肤展台</span>
<h2>账号皮肤收藏</h2>
</div>
<small>共 {skinCount} 款</small>
</div>
<div className="mobile-detail__skin-groups">
{skinGroups.map(group => (
<div className="mobile-detail__skin-group" key={group.key}>
<div>
<strong>{group.label}</strong>
<span>{group.names.length} 款</span>
</div>
<div className="mobile-detail__skin-pills">
{group.names.map(name => (
<span className={`is-${group.tone}`} key={name}>
{name}
</span>
))}
</div>
</div>
))}
</div>
</section>
) : null}
{resources.length ? (
<section className="mobile-detail__panel">
<div className="mobile-detail__section-head">
<div>
<span>仓库物资与消耗品</span>
<h2>随号附带资源</h2>
</div>
<small>{resources.length} 种</small>
</div>
<div className="mobile-detail__resource-grid">
{resources.map(row => (
<div className="mobile-detail__resource-tile" key={row.label}>
<div>
<span>{row.label}</span>
<strong>{row.quantity}</strong>
</div>
<em className={`mobile-detail__resource-mode is-${row.mode}`}>
{resourceBadgeText(row)}
</em>
</div>
))}
</div>
</section>
) : null}
<section className="mobile-detail__trust">
<div className="mobile-detail__section-head">
<div>
<span>平台官方交易保障</span>
<h2>从这里开始,全程有保障</h2>
</div>
</div>
<div className="mobile-detail__trust-grid">
<div>
<span className="is-green">
<CheckShieldOutline />
</span>
<strong>资金托管</strong>
<p>租金押金平台监管,确认收号后结算</p>
</div>
<div>
<span className="is-blue">
<SmileOutline />
</span>
<strong>找回包赔</strong>
<p>按平台规则处理异常,减少租号风险</p>
</div>
<div>
<span className="is-orange">
<PhoneFill />
</span>
<strong>客服协助</strong>
<p>交接遇到问题,平台客服随时介入</p>
</div>
<div>
<span className="is-slate">
<UserContactOutline />
</span>
<strong>双向实名</strong>
<p>租客与号主完成实名后再进入交易</p>
</div>
</div>
</section>
<section className="mobile-detail__notice">
<strong>先看清规则,再决定要不要租</strong>
<p>押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。</p>
+563 -2
View File
@@ -1,5 +1,7 @@
.mobile-detail {
padding: 14px 16px 108px;
min-height: 100dvh;
padding: 0 16px 112px;
background: #f4f6f2;
}
.mobile-detail--loading,
@@ -15,6 +17,564 @@
margin-top: 12px;
}
.mobile-detail__nav {
position: sticky;
top: 0;
z-index: 40;
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 72px;
align-items: center;
min-height: 50px;
margin: 0 -16px 10px;
padding: 0 12px;
border-bottom: 1px solid rgba(217, 224, 217, 0.9);
background: rgba(248, 250, 247, 0.94);
backdrop-filter: blur(14px);
}
.mobile-detail__nav > strong {
overflow: hidden;
color: var(--ink);
font-size: 15px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-detail__nav-back {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 7px 0;
border: 0;
background: transparent;
color: var(--ink-secondary);
font-size: 13px;
}
.mobile-detail__nav-spacer {
display: block;
}
.mobile-detail__fraud-tip {
display: flex;
align-items: flex-start;
gap: 7px;
margin-bottom: 10px;
padding: 9px 11px;
border: 1px solid #eadfca;
border-radius: 9px;
background: #fffaf1;
color: #8a6438;
font-size: 11px;
line-height: 1.55;
}
.mobile-detail__fraud-tip svg {
flex: 0 0 auto;
margin-top: 1px;
color: #b7791f;
font-size: 15px;
}
.mobile-detail__hero-card,
.mobile-detail__panel {
margin-top: 10px;
padding: 16px;
border: 1px solid #e2e7e0;
border-radius: 12px;
background: #fff;
}
.mobile-detail__hero-card {
margin-top: 0;
}
.mobile-detail__hero-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mobile-detail__code {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: #7e8b84;
font-family: 'Inter', monospace;
font-size: 10px;
}
.mobile-detail__code span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-detail__code em {
flex: 0 0 auto;
padding: 2px 5px;
border-radius: 4px;
background: #edf3ee;
color: #48675b;
font-style: normal;
}
.mobile-detail__hero-card h1 {
margin: 10px 0 10px;
color: var(--ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 23px;
font-weight: 600;
line-height: 1.38;
overflow-wrap: anywhere;
}
.mobile-detail__hero-card .mobile-detail__tags {
gap: 5px;
}
.mobile-detail__core-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
margin-top: 14px;
}
.mobile-detail__core-tile {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 9px 8px;
border: 1px solid #e7ebe5;
border-radius: 8px;
background: #f7f9f6;
}
.mobile-detail__core-tile span {
color: #849088;
font-size: 9px;
}
.mobile-detail__core-tile strong {
overflow: hidden;
color: #26322d;
font-size: 14px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-detail__core-tile.is-coin {
border-color: #ead8c5;
background: #fff8f1;
}
.mobile-detail__core-tile.is-coin strong,
.mobile-detail__core-tile.is-warm strong {
color: #a95632;
}
.mobile-detail__core-tile.is-success strong {
color: #25724e;
}
.mobile-detail__core-tile.is-danger {
border-color: #ecd4cf;
background: #fff5f2;
}
.mobile-detail__core-tile.is-danger strong {
color: #a64332;
}
.mobile-detail__panel {
scroll-margin-top: 58px;
}
.mobile-detail__section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.mobile-detail__section-head > div {
min-width: 0;
}
.mobile-detail__section-head span,
.mobile-detail__section-head small {
color: #89958e;
font-size: 10px;
}
.mobile-detail__section-head h2 {
margin: 3px 0 0;
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 18px;
font-weight: 600;
line-height: 1.35;
}
.mobile-detail__section-head small {
flex: 0 0 auto;
}
.mobile-detail__gallery-card {
padding-bottom: 14px;
}
.mobile-detail__stage {
position: relative;
display: flex;
width: 100%;
aspect-ratio: 4 / 3;
align-items: center;
justify-content: center;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 9px;
background: #17221e;
}
.mobile-detail__stage > img {
width: 100%;
height: 100%;
object-fit: contain;
}
.mobile-detail__image-fallback {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #aeb8b1;
}
.mobile-detail__image-fallback em {
font-size: 11px;
font-style: normal;
}
.mobile-detail__stage-label,
.mobile-detail__stage-hint {
position: absolute;
z-index: 2;
padding: 3px 7px;
border-radius: 5px;
background: rgba(17, 28, 23, 0.76);
color: #fff;
font-size: 10px;
backdrop-filter: blur(5px);
}
.mobile-detail__stage-label {
top: 8px;
left: 8px;
}
.mobile-detail__stage-hint {
right: 8px;
bottom: 8px;
border-radius: 999px;
font-size: 9px;
}
.mobile-detail__thumb-strip {
display: flex;
gap: 7px;
margin-top: 9px;
overflow-x: auto;
padding-bottom: 2px;
}
.mobile-detail__thumb {
position: relative;
flex: 0 0 76px;
height: 55px;
padding: 0;
overflow: hidden;
border: 2px solid transparent;
border-radius: 7px;
background: #1b2923;
}
.mobile-detail__thumb.is-active {
border-color: #b45b3f;
}
.mobile-detail__thumb img,
.mobile-detail__thumb-fallback {
width: 100%;
height: 100%;
}
.mobile-detail__thumb img {
object-fit: cover;
}
.mobile-detail__thumb-fallback {
display: grid;
place-items: center;
color: #b5c0ba;
font-size: 9px;
}
.mobile-detail__thumb em {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 3px;
overflow: hidden;
background: rgba(17, 28, 23, 0.78);
color: #fff;
font-size: 8px;
font-style: normal;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-detail__viewer-label {
color: #fff;
font-size: 12px;
}
.mobile-detail__spec-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.mobile-detail__spec-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
padding: 9px 10px;
border: 1px solid #edf0eb;
border-radius: 8px;
background: #f8faf7;
}
.mobile-detail__spec-cell span {
color: #849088;
font-size: 10px;
}
.mobile-detail__spec-cell strong {
color: #26322d;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.mobile-detail__empty {
margin: 0;
color: var(--ink-muted);
font-size: 12px;
}
.mobile-detail__note {
padding: 12px 13px;
border-left: 3px solid #b45b3f;
border-radius: 0 8px 8px 0;
background: #faf7f2;
color: #48564f;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mobile-detail__skin-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-detail__skin-group > div:first-child {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.mobile-detail__skin-group strong {
color: #35443d;
font-size: 12px;
}
.mobile-detail__skin-group > div:first-child span {
padding: 1px 6px;
border-radius: 999px;
background: #f0f3ef;
color: #859189;
font-size: 9px;
}
.mobile-detail__skin-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mobile-detail__skin-pills span {
padding: 4px 8px;
border: 1px solid #e2e7e0;
border-radius: 6px;
background: #f7f9f6;
color: #48564f;
font-size: 10px;
font-weight: 600;
}
.mobile-detail__skin-pills span.is-melee {
border-color: #ecd1bd;
background: #fff8f1;
color: #a95632;
}
.mobile-detail__skin-pills span.is-operatorRed {
border-color: #ecd4cf;
background: #fff5f2;
color: #a64332;
}
.mobile-detail__skin-pills span.is-operatorGold {
border-color: #eadca9;
background: #fffbed;
color: #9b6d18;
}
.mobile-detail__skin-pills span.is-weapon {
border-color: #cfe2d3;
background: #f3faf5;
color: #29744e;
}
.mobile-detail__resource-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.mobile-detail__resource-tile {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #e8ece6;
border-radius: 8px;
background: #f8faf7;
}
.mobile-detail__resource-tile > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 6px;
}
.mobile-detail__resource-tile > div span {
overflow: hidden;
color: #47544d;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-detail__resource-tile > div strong {
flex: 0 0 auto;
color: #26322d;
font-size: 14px;
}
.mobile-detail__resource-tile .mobile-detail__resource-mode {
align-self: flex-start;
margin-left: 0;
}
.mobile-detail__trust {
margin-top: 10px;
padding: 16px;
border: 1px solid #e7e1d5;
border-radius: 12px;
background: #fcfaf5;
}
.mobile-detail__trust-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.mobile-detail__trust-grid > div {
min-width: 0;
padding: 11px;
border: 1px solid #eee8dc;
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.mobile-detail__trust-grid > div > span {
display: grid;
width: 27px;
height: 27px;
margin-bottom: 7px;
place-items: center;
border-radius: 7px;
font-size: 16px;
}
.mobile-detail__trust-grid > div > span.is-green {
background: #eaf5ee;
color: #2d7952;
}
.mobile-detail__trust-grid > div > span.is-blue {
background: #edf3f7;
color: #476d82;
}
.mobile-detail__trust-grid > div > span.is-orange {
background: #faf0e8;
color: #a85e35;
}
.mobile-detail__trust-grid > div > span.is-slate {
background: #eff1ed;
color: #59665f;
}
.mobile-detail__trust-grid strong {
display: block;
color: #35433c;
font-size: 12px;
}
.mobile-detail__trust-grid p {
margin: 4px 0 0;
color: #7d8982;
font-size: 10px;
line-height: 1.55;
}
.mobile-detail__back {
display: inline-flex;
align-items: center;
@@ -74,6 +634,7 @@
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 10px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
@@ -206,7 +767,7 @@
display: flex;
gap: 10px;
width: 100%;
max-width: 560px;
max-width: 480px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);