diff --git a/frontend/src/features/listings/labels.test.ts b/frontend/src/features/listings/labels.test.ts index 4f199cd..b3e7896 100644 --- a/frontend/src/features/listings/labels.test.ts +++ b/frontend/src/features/listings/labels.test.ts @@ -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: ['天际线'] }, + ]) + }) +}) diff --git a/frontend/src/features/listings/labels.ts b/frontend/src/features/listings/labels.ts index 3974a89..ba1c9ba 100644 --- a/frontend/src/features/listings/labels.ts +++ b/frontend/src/features/listings/labels.ts @@ -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 = { + 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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +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 | 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 | 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 +}): ListingGalleryItem[] { + const seen = new Set() + 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 } diff --git a/frontend/src/features/mobile/MobileListingDetailPage.tsx b/frontend/src/features/mobile/MobileListingDetailPage.tsx index da821ba..183012a 100644 --- a/frontend/src/features/mobile/MobileListingDetailPage.tsx +++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx @@ -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 - 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([]) + 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 ( +
+
+
+ 验号图片展台 +

真实游戏截图

+
+ 共 {items.length} 张 +
+ + + + {items.length > 1 ? ( +
+ {items.map((item, index) => ( + + ))} +
+ ) : null} + + item.url)} + defaultIndex={viewerIndex} + onClose={() => setViewerOpen(false)} + renderFooter={(_image, index) => ( + {availableItems[index]?.label} + )} + /> +
+ ) } 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 (
- +
+ + 账号详情 + +
-
- {data.cover_url ? ( - {data.title} - ) : ( - - )} +
+ + 平台官方担保交易,请按平台交接流程验号,勿脱离平台私下转账。
-
-
{data.listing_no}
-

{data.title}

-
+
+
+ {data.in_transaction ? '租赁中' : '可租'} +
+

{data.title}

+
{data.server_region || '区服待定'} {data.login_platform || '登录方式待定'} {data.rank_level || '段位待定'} + {fireLevel > 0 ? 烽火 {fireLevel} 级 : null} + {data.is_premium ? 极品专区 : null} + {data.owner_is_quality_owner ? 优质号主 : null} + {data.compensation_plan === 'insurance' ? 无忧保险 : null} +
+ +
+ {coreTiles.map(item => ( +
+ {item.label} + {item.value} +
+ ))}
@@ -218,83 +368,135 @@ export function MobileListingDetailPage() {
-
-

账号说明

-

{data.description || '号主暂未填写补充说明。'}

-
+ -
-

资产摘要

-
- {ratio ? ( -
-
折算比
-
1 元 ≈ {ratio}
-
- ) : null} - {assetEntries.length ? ( - assetEntries.map(([key, value]) => ( -
-
{key}
-
{assetValue(value)}
-
- )) - ) : ( -
-
资产
-
未填写
-
- )} - {faceOwner ? ( -
-
人脸情况
-
{faceOwner}
-
- ) : null} - {banRecord ? ( -
-
封禁记录
-
{banRecord}
-
- ) : null} - {gameNameChange ? ( -
-
可改游戏名
-
{gameNameChange}
-
- ) : null} - {rentalDays ? ( -
-
预计租期
-
{rentalDays} 天
-
- ) : null} +
+
-
在线时间
-
{assetValue(onlineTime)}
+ 账号核心档案 +

资产与游戏信息

-
-
- - {resources.length ? ( -
-

仓库物资

-
- {resources.map(row => ( -
-
{row.label}
-
- {row.quantity} 个 - - {resourceBadgeText(row)} - -
+ {assetRows.length} 项信息 +
+ {assetRows.length ? ( +
+ {assetRows.map(row => ( +
+ {row.label} + {row.value}
))} -
+
+ ) : ( +

号主暂未填写更多资产信息。

+ )} + + +
+
+
+ 号主留言与说明 +

交接前请先阅读

+
+
+
+ {data.description || '号主暂未填写特殊交接要求,可正常直接下单租用。'} +
+
+ + {skinGroups.length ? ( +
+
+
+ 精选皮肤展台 +

账号皮肤收藏

+
+ 共 {skinCount} 款 +
+
+ {skinGroups.map(group => ( +
+
+ {group.label} + {group.names.length} 款 +
+
+ {group.names.map(name => ( + + {name} + + ))} +
+
+ ))} +
) : null} + {resources.length ? ( +
+
+
+ 仓库物资与消耗品 +

随号附带资源

+
+ {resources.length} 种 +
+
+ {resources.map(row => ( +
+
+ {row.label} + {row.quantity} +
+ + {resourceBadgeText(row)} + +
+ ))} +
+
+ ) : null} + +
+
+
+ 平台官方交易保障 +

从这里开始,全程有保障

+
+
+
+
+ + + + 资金托管 +

租金押金平台监管,确认收号后结算

+
+
+ + + + 找回包赔 +

按平台规则处理异常,减少租号风险

+
+
+ + + + 客服协助 +

交接遇到问题,平台客服随时介入

+
+
+ + + + 双向实名 +

租客与号主完成实名后再进入交易

+
+
+
+
先看清规则,再决定要不要租

押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。

diff --git a/frontend/src/styles/mobile-listing.css b/frontend/src/styles/mobile-listing.css index c408205..70f5b3c 100644 --- a/frontend/src/styles/mobile-listing.css +++ b/frontend/src/styles/mobile-listing.css @@ -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);