feat(listings): 重做商品详情页并补充分组截图数据

This commit is contained in:
yml2213
2026-10-31 12:37:06 +08:00
parent 5f9ad08347
commit 3ea02661ea
5 changed files with 825 additions and 479 deletions
+8 -8
View File
@@ -18,8 +18,8 @@ INSERT INTO game_accounts
VALUES
(@u1, 'delta_force', 'QQ', '扫码', '解忧柜选 · 三角洲行动 高资产账号',
'哈夫币 53M,保险 3*3,刀皮坠星者,红皮四件,适合稳定上分与搬砖。', '黑鹰', 53000000,
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":60,"daily_loss_m":10,"secret_kd":3.2,"early_morning_response":"是","modifiable_name":"是","all_heroes":"是","online_time":{"start":"12点","end":"次日02点"},"skin_groups":{"melee":["坠星者"],"operatorRed":["红狼-电锯惊魂","蜂医-危险物质","蜂医-迷葬人","无名-夜鹰"],"operatorGold":["蚀金玫瑰"],"weapon":["AKM-群星"]},"publish_ratio":35,"resources":[{"key":"level6Ammo","label":"6级弹","quantity":99,"mode":"付费"},{"key":"awmAmmo","label":"AWM子弹","quantity":6,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":8,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":8,"mode":"付费"},{"key":"coffeeBean","label":"高级咖啡豆","quantity":23,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-1/960/600"]' AS JSON), 'published');
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":60,"daily_loss_m":10,"secret_kd":3.2,"early_morning_response":"是","modifiable_name":"是","all_heroes":"是","online_time":{"start":"12点","end":"次日02点"},"skin_groups":{"melee":["坠星者"],"operatorRed":["红狼-电锯惊魂","蜂医-危险物质","蜂医-迷葬人","无名-夜鹰"],"operatorGold":["蚀金玫瑰"],"weapon":["AKM-群星"]},"screenshot_groups":{"coin":["https://picsum.photos/seed/jieyou-1-coin/1200/750"],"gameId":["https://picsum.photos/seed/jieyou-1-game/1200/750"],"totalAsset":["https://picsum.photos/seed/jieyou-1-asset/1200/750"],"tencentSecurity":["https://picsum.photos/seed/jieyou-1-safe/1200/750"],"skin":["https://picsum.photos/seed/jieyou-1-skin/1200/750"]},"publish_ratio":35,"resources":[{"key":"level6Ammo","label":"6级弹","quantity":99,"mode":"付费"},{"key":"awmAmmo","label":"AWM子弹","quantity":6,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":8,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":8,"mode":"付费"},{"key":"coffeeBean","label":"高级咖啡豆","quantity":23,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-1-coin/1200/750","https://picsum.photos/seed/jieyou-1-game/1200/750","https://picsum.photos/seed/jieyou-1-asset/1200/750","https://picsum.photos/seed/jieyou-1-safe/1200/750","https://picsum.photos/seed/jieyou-1-skin/1200/750"]' AS JSON), 'published');
SET @a1 = LAST_INSERT_ID();
INSERT INTO rental_listings (listing_no, account_id, owner_id, price_cent, deposit_amount_cent, status, review_status, published_at)
VALUES ('202610310001', @a1, @u1, 25580, 75000, 'published', 'approved', NOW() - INTERVAL 6 MINUTE);
@@ -31,8 +31,8 @@ INSERT INTO game_accounts
VALUES
(@u2, 'delta_force', '微信', '扫码', '解忧柜选 · 800M 顶配红皮账号',
'哈夫币 800M,红皮与金皮齐全,适合高强度对局与直播。', '铂金', 800000000,
CAST('{"season_insurance":"2*3","stamina_level":"7级","load_level":"7级","fire_level":62,"daily_loss_m":20,"secret_kd":4.1,"early_morning_response":"是","modifiable_name":"否","all_heroes":"是","online_time":{"start":"13点","end":"次日01点"},"skin_groups":{"melee":["暗星"],"operatorRed":["红狼-电锯惊魂","蜂医-危险物质","威龙-吴彦祖","疾风-西部往事"],"operatorGold":["露娜-金牌射手"],"weapon":["AKM-群星"]},"publish_ratio":46,"resources":[{"key":"level6Ammo","label":"6级弹","quantity":217,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":6,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":5,"mode":"付费"},{"key":"gridCard9","label":"9格体验卡","quantity":2,"mode":"赠送"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-2/960/600"]' AS JSON), 'published');
CAST('{"season_insurance":"2*3","stamina_level":"7级","load_level":"7级","fire_level":62,"daily_loss_m":20,"secret_kd":4.1,"early_morning_response":"是","modifiable_name":"否","all_heroes":"是","online_time":{"start":"13点","end":"次日01点"},"skin_groups":{"melee":["暗星"],"operatorRed":["红狼-电锯惊魂","蜂医-危险物质","威龙-吴彦祖","疾风-西部往事"],"operatorGold":["露娜-金牌射手"],"weapon":["AKM-群星"]},"screenshot_groups":{"coin":["https://picsum.photos/seed/jieyou-2-coin/1200/750"],"gameId":["https://picsum.photos/seed/jieyou-2-game/1200/750"],"totalAsset":["https://picsum.photos/seed/jieyou-2-asset/1200/750"],"tencentSecurity":["https://picsum.photos/seed/jieyou-2-safe/1200/750"],"skin":["https://picsum.photos/seed/jieyou-2-skin/1200/750"]},"publish_ratio":46,"resources":[{"key":"level6Ammo","label":"6级弹","quantity":217,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":6,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":5,"mode":"付费"},{"key":"gridCard9","label":"9格体验卡","quantity":2,"mode":"赠送"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-2-coin/1200/750","https://picsum.photos/seed/jieyou-2-game/1200/750","https://picsum.photos/seed/jieyou-2-asset/1200/750","https://picsum.photos/seed/jieyou-2-safe/1200/750","https://picsum.photos/seed/jieyou-2-skin/1200/750"]' AS JSON), 'published');
SET @a2 = LAST_INSERT_ID();
INSERT INTO rental_listings (listing_no, account_id, owner_id, price_cent, deposit_amount_cent, status, review_status, published_at)
VALUES ('202610310002', @a2, @u2, 191350, 30000, 'published', 'approved', NOW() - INTERVAL 5 MINUTE);
@@ -44,8 +44,8 @@ INSERT INTO game_accounts
VALUES
(@u3, 'delta_force', 'QQ', '扫码', '解忧柜选 · 220M 性价比账号',
'哈夫币 220M,AWM 子弹充足,适合狙击位玩家。', '黑鹰', 220000000,
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":58,"daily_loss_m":15,"secret_kd":2.8,"early_morning_response":"是","modifiable_name":"否","all_heroes":"是","online_time":{"start":"08点","end":"次日01点"},"skin_groups":{"melee":["北极星"],"operatorRed":["红狼-电锯惊魂","维什戴尔"],"weapon":["AKM-群星"]},"publish_ratio":40,"resources":[{"key":"awmAmmo","label":"AWM子弹","quantity":77,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":1,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":2,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-3/960/600"]' AS JSON), 'published');
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":58,"daily_loss_m":15,"secret_kd":2.8,"early_morning_response":"是","modifiable_name":"否","all_heroes":"是","online_time":{"start":"08点","end":"次日01点"},"skin_groups":{"melee":["北极星"],"operatorRed":["红狼-电锯惊魂","维什戴尔"],"weapon":["AKM-群星"]},"screenshot_groups":{"coin":["https://picsum.photos/seed/jieyou-3-coin/1200/750"],"gameId":["https://picsum.photos/seed/jieyou-3-game/1200/750"],"totalAsset":["https://picsum.photos/seed/jieyou-3-asset/1200/750"],"tencentSecurity":["https://picsum.photos/seed/jieyou-3-safe/1200/750"],"skin":["https://picsum.photos/seed/jieyou-3-skin/1200/750"]},"publish_ratio":40,"resources":[{"key":"awmAmmo","label":"AWM子弹","quantity":77,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":1,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":2,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-3-coin/1200/750","https://picsum.photos/seed/jieyou-3-game/1200/750","https://picsum.photos/seed/jieyou-3-asset/1200/750","https://picsum.photos/seed/jieyou-3-safe/1200/750","https://picsum.photos/seed/jieyou-3-skin/1200/750"]' AS JSON), 'published');
SET @a3 = LAST_INSERT_ID();
INSERT INTO rental_listings (listing_no, account_id, owner_id, price_cent, deposit_amount_cent, status, review_status, published_at)
VALUES ('202610310003', @a3, @u3, 60940, 50000, 'published', 'approved', NOW() - INTERVAL 4 MINUTE);
@@ -57,8 +57,8 @@ INSERT INTO game_accounts
VALUES
(@u4, 'delta_force', 'Steam', '账号密码', '解忧柜选 · 508M 账密直登账号',
'哈夫币 508M,支持账密直登,仓库物资齐全。', '黑鹰', 508000000,
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":60,"daily_loss_m":12,"secret_kd":3.6,"early_morning_response":"是","modifiable_name":"是","all_heroes":"是","online_time":{"start":"全天","end":"全天"},"skin_groups":{"melee":["重塑"],"operatorRed":["红狼-电锯惊魂"],"weapon":["AKM-群星"]},"publish_ratio":46,"resources":[{"key":"awmAmmo","label":"AWM子弹","quantity":50,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":7,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":5,"mode":"付费"},{"key":"coffeeBean","label":"高级咖啡豆","quantity":3,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-4/960/600"]' AS JSON), 'published');
CAST('{"season_insurance":"3*3","stamina_level":"7级","load_level":"7级","fire_level":60,"daily_loss_m":12,"secret_kd":3.6,"early_morning_response":"是","modifiable_name":"是","all_heroes":"是","online_time":{"start":"全天","end":"全天"},"skin_groups":{"melee":["重塑"],"operatorRed":["红狼-电锯惊魂"],"weapon":["AKM-群星"]},"screenshot_groups":{"coin":["https://picsum.photos/seed/jieyou-4-coin/1200/750"],"gameId":["https://picsum.photos/seed/jieyou-4-game/1200/750"],"totalAsset":["https://picsum.photos/seed/jieyou-4-asset/1200/750"],"tencentSecurity":["https://picsum.photos/seed/jieyou-4-safe/1200/750"],"skin":["https://picsum.photos/seed/jieyou-4-skin/1200/750"]},"publish_ratio":46,"resources":[{"key":"awmAmmo","label":"AWM子弹","quantity":50,"mode":"付费"},{"key":"armor6","label":"6甲","quantity":7,"mode":"付费"},{"key":"helmet6","label":"6头","quantity":5,"mode":"付费"},{"key":"coffeeBean","label":"高级咖啡豆","quantity":3,"mode":"付费"}]}' AS JSON),
CAST('["https://picsum.photos/seed/jieyou-4-coin/1200/750","https://picsum.photos/seed/jieyou-4-game/1200/750","https://picsum.photos/seed/jieyou-4-asset/1200/750","https://picsum.photos/seed/jieyou-4-safe/1200/750","https://picsum.photos/seed/jieyou-4-skin/1200/750"]' AS JSON), 'published');
SET @a4 = LAST_INSERT_ID();
INSERT INTO rental_listings (listing_no, account_id, owner_id, price_cent, deposit_amount_cent, status, review_status, published_at)
VALUES ('202610310004', @a4, @u4, 117180, 60000, 'published', 'approved', NOW() - INTERVAL 3 MINUTE);
@@ -7,12 +7,9 @@ import {
Button,
Card,
Checkbox,
Col,
Descriptions,
Divider,
Image,
Modal,
Row,
Space,
Tag,
Typography,
@@ -20,10 +17,8 @@ import {
import {
ArrowLeftOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
StarFilled,
StarOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { Link, useNavigate, useParams } from 'react-router-dom'
@@ -46,21 +41,39 @@ import { formatCoinM } from '@/shared/utils/format'
import type { Listing } from '@/shared/api/types'
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
return `¥${(cents / 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 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(',')
}
if (Array.isArray(value)) return value.map(String).join('、')
return value == null || value === '' ? '未填写' : String(value)
const skinGroupLabels: Record<string, string> = {
melee: '近战皮肤',
operatorGold: '干员金皮',
operatorRed: '干员红皮',
weapon: '武器皮肤',
}
const screenshotGroupLabels: Record<string, string> = {
coin: '纯币截图',
gameId: '游戏ID截图',
totalAsset: '总资产截图',
tencentSecurity: '腾讯安全截图',
skin: '皮肤截图',
}
function screenshotLabels(item: Listing) {
const map = new Map<string, string>()
const groups = item.asset_summary?.screenshot_groups
if (!groups || typeof groups !== 'object') return map
Object.entries(groups as Record<string, unknown>).forEach(([key, urls]) => {
if (!Array.isArray(urls)) return
const label = screenshotGroupLabels[key] ?? key
urls.forEach(url => {
if (typeof url === 'string' && url) map.set(url, label)
})
})
return map
}
function galleryImages(item: Listing) {
@@ -68,6 +81,78 @@ function galleryImages(item: Listing) {
return images.length > 0 ? Array.from(new Set(images)) : []
}
function textOf(value: unknown) {
return value == null || value === '' ? '' : String(value)
}
function onlineTimeText(value: unknown) {
if (!value || typeof value !== 'object') return ''
const row = value as Record<string, unknown>
const start = textOf(row.start)
const end = textOf(row.end)
if (!start && !end) return ''
return `${start} - ${end}`
}
function skinGroupsOf(item: Listing) {
const groups = item.asset_summary?.skin_groups
if (!groups || typeof groups !== 'object') return []
return Object.entries(groups as Record<string, unknown>)
.map(([key, names]) => ({
key,
label: skinGroupLabels[key] ?? key,
names: Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string') : [],
}))
.filter(group => group.names.length > 0)
}
function resourcesOf(item: Listing) {
const raw = item.asset_summary?.resources
if (!Array.isArray(raw)) return []
return raw
.map(entry => {
const row = (entry ?? {}) as Record<string, unknown>
return {
label: textOf(row.label) || textOf(row.key),
quantity: Number(row.quantity ?? 0),
mode: textOf(row.mode),
}
})
.filter(row => row.label && row.quantity > 0)
}
function ratioOf(item: Listing) {
const published = Number(item.asset_summary?.publish_ratio ?? 0)
if (published > 0) return `${published}w`
const priceYuan = item.price_cent / 100
const coinM = item.haf_coin_amount / 1_000_000
if (priceYuan <= 0 || coinM <= 0) return ''
return `${((coinM / priceYuan) * 100).toFixed(0)}w`
}
function coreStats(item: Listing) {
const summary = item.asset_summary ?? {}
const dailyLoss = Number(summary.daily_loss_m ?? 0)
const secretKd = Number(summary.secret_kd ?? 0)
return [
{ label: '哈夫币', value: formatCoinM(item.haf_coin_amount) },
{ label: '保险格数', value: textOf(summary.season_insurance) },
{ label: '折算比', value: ratioOf(item) ? `1 元 ≈ ${ratioOf(item)}` : '' },
{ label: '火力等级', value: textOf(summary.fire_level) },
{ label: '体力', value: textOf(summary.stamina_level) },
{ label: '负重', value: textOf(summary.load_level) },
{ label: '日消耗', value: dailyLoss > 0 ? `${dailyLoss} M / 天` : '' },
{ label: '绝密 KD', value: secretKd > 0 ? String(secretKd) : '' },
{ label: '段位', value: item.rank_level },
{ label: '登录方式', value: item.login_platform },
{ label: '区服', value: item.server_region },
{ label: '在线时段', value: onlineTimeText(summary.online_time) },
{ label: '可改游戏名', value: textOf(summary.modifiable_name) },
{ label: '全英雄', value: textOf(summary.all_heroes) },
{ label: '凌晨响应', value: textOf(summary.early_morning_response) },
].filter(stat => stat.value)
}
export function ListingDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
@@ -91,6 +176,7 @@ export function ListingDetailPage() {
const [agreementOpen, setAgreementOpen] = useState(false)
const [agreementTitle, setAgreementTitle] = useState('')
const [agreementContent, setAgreementContent] = useState('')
const [activeImage, setActiveImage] = useState('')
useEffect(() => {
if (!data) return
@@ -103,6 +189,12 @@ export function ListingDetailPage() {
recordListingView(data)
}, [data, token, queryClient])
useEffect(() => {
if (!data) return
const images = galleryImages(data)
setActiveImage(current => (current && images.includes(current) ? current : images[0] ?? ''))
}, [data])
const showAgreement = (title: string, content?: string) => {
setAgreementTitle(title)
setAgreementContent(content || '暂无内容')
@@ -161,7 +253,7 @@ export function ListingDetailPage() {
if (isPending) {
return (
<div className="page-section">
<div className="ld-page">
<Card loading />
</div>
)
@@ -169,7 +261,7 @@ export function ListingDetailPage() {
if (isError || !data) {
return (
<div className="page-section">
<div className="ld-page">
<Alert
type="error"
showIcon
@@ -186,12 +278,15 @@ export function ListingDetailPage() {
}
const images = galleryImages(data)
const assetEntries = Object.entries(data.asset_summary ?? {}).filter(
([key]) => key !== 'online_time'
)
const onlineTime = data.asset_summary?.online_time
const previewIndex = Math.max(images.indexOf(activeImage), 0)
const labels = screenshotLabels(data)
const activeLabel = labels.get(activeImage) ?? ''
const skins = skinGroupsOf(data)
const resources = resourcesOf(data)
const stats = coreStats(data)
const isOwner = user?.id === data.owner_id
const favorited = token ? Boolean(favoriteIDsQuery.data?.has(data.id)) : isFavorite(data.id)
const totalCent = data.price_cent + data.deposit_amount_cent
const toggleFavoriteState = async () => {
if (token) {
@@ -210,8 +305,8 @@ export function ListingDetailPage() {
}
return (
<div className="listing-detail-page">
<div className="listing-detail-page__crumb">
<div className="ld-page">
<div className="ld-crumb">
<Breadcrumb
items={[
{ title: <Link to="/">首页</Link> },
@@ -224,200 +319,248 @@ export function ListingDetailPage() {
</Button>
</div>
<Row gutter={[24, 24]} className="listing-detail-page__hero">
<Col xs={24} lg={14}>
<div className="listing-detail-gallery">
<section className="ld-hero">
<div className="ld-gallery">
<div className="ld-gallery__main">
{images.length > 0 ? (
<Image.PreviewGroup>
<div className="listing-detail-gallery__main">
<Image src={images[0]} alt={data.title} />
</div>
{images.length > 1 ? (
<div className="listing-detail-gallery__thumbs">
{images.slice(0, 6).map(url => (
<Image key={url} src={url} alt={data.title} width={92} height={68} />
))}
</div>
) : null}
<Image.PreviewGroup
preview={{ current: previewIndex }}
items={images}
>
<Image src={activeImage || images[0]} alt={data.title} />
</Image.PreviewGroup>
) : (
<div className="listing-detail-gallery__empty">
<div className="ld-gallery__empty">
<BrandLogo height={40} />
<span>暂无商品图片</span>
<span>号主暂未上传截图</span>
</div>
)}
<span className={`ld-status${data.in_transaction ? ' is-busy' : ''}`}>
{data.in_transaction ? '租赁中' : '可租'}
</span>
{images.length > 1 ? (
<span className="ld-gallery__count">
{activeLabel ? `${activeLabel} · ` : ''}
{previewIndex + 1} / {images.length}
</span>
) : null}
</div>
</Col>
<Col xs={24} lg={10}>
<Card className="listing-buy-card">
<div className="listing-buy-card__top">
<Typography.Text className="listing-card__no">{data.listing_no}</Typography.Text>
<Space size={8}>
<Tag color={data.in_transaction ? 'default' : 'green'} bordered={false}>
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
<Button
size="small"
icon={favorited ? <StarFilled /> : <StarOutlined />}
onClick={() => void toggleFavoriteState()}
{images.length > 1 ? (
<div className="ld-gallery__thumbs">
{images.slice(0, 8).map((url, index) => (
<button
key={url}
type="button"
className={`ld-thumb${url === activeImage ? ' is-active' : ''}`}
title={labels.get(url) ?? ''}
onClick={() => setActiveImage(url)}
>
{favorited ? '已收藏' : '收藏'}
</Button>
</Space>
<img src={url} alt={`${data.title} 截图 ${index + 1}`} loading="lazy" />
</button>
))}
</div>
<Typography.Title level={2} className="brand-title listing-buy-card__title">
{data.title}
</Typography.Title>
<Space size={6} wrap>
<Tag bordered={false}>{data.server_region || '区服待定'}</Tag>
<Tag bordered={false}>{data.login_platform || '登录方式待定'}</Tag>
{data.rank_level ? <Tag bordered={false}>{data.rank_level}</Tag> : null}
) : null}
</div>
<aside className="ld-buy">
<div className="ld-buy__head">
<span className="ld-no">{data.listing_no}</span>
<Space size={8}>
<Button
size="small"
icon={favorited ? <StarFilled /> : <StarOutlined />}
onClick={() => void toggleFavoriteState()}
>
{favorited ? '已收藏' : '收藏'}
</Button>
</Space>
</div>
<div className="listing-buy-card__price">
<span>租金</span>
<strong>{money(data.price_cent)}</strong>
<Typography.Title level={2} className="ld-title">
{data.title}
</Typography.Title>
<div className="ld-tags">
<Tag bordered={false}>{data.server_region || '区服待定'}</Tag>
<Tag bordered={false}>{data.login_platform || '登录方式待定'}</Tag>
{data.rank_level ? <Tag bordered={false}>{data.rank_level}</Tag> : null}
{textOf(data.asset_summary?.season_insurance) ? (
<Tag bordered={false}>保险 {textOf(data.asset_summary?.season_insurance)}</Tag>
) : null}
</div>
<div className="ld-price">
<span>总租金</span>
<strong>{money(data.price_cent)}</strong>
<em>
押金 {money(data.deposit_amount_cent)} · 合计 {money(totalCent)}
</em>
</div>
<div className="ld-quick">
<div>
<span>哈夫币</span>
<strong>{formatCoinM(data.haf_coin_amount)}</strong>
</div>
<div className="listing-buy-card__meta">
<div>
<span>押金</span>
<strong>{money(data.deposit_amount_cent)}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{formatCoinM(data.haf_coin_amount)}</strong>
</div>
<div>
<span>号主</span>
<strong>{data.owner_nickname || `#${data.owner_id}`}</strong>
</div>
<div>
<span>折算比</span>
<strong>{ratioOf(data) || '—'}</strong>
</div>
<div>
<span>段位</span>
<strong>{data.rank_level || '—'}</strong>
</div>
</div>
<Alert
type="info"
showIcon
className="listing-buy-card__notice"
message="先看清规则,再决定要不要租"
description="提交订单后会锁定商品;支付成功才进入交接,租金、押金、消耗与退款均按订单账单结算。"
/>
<Button
type="primary"
size="large"
block
className="ld-cta"
disabled={isOwner || data.in_transaction}
onClick={() => openConfirm(data)}
>
{isOwner ? '不能租用自己发布的商品' : data.in_transaction ? '商品租赁中' : '确认租赁'}
</Button>
<Button
type="primary"
size="large"
block
disabled={isOwner || data.in_transaction}
onClick={() => openConfirm(data)}
<div className="ld-links">
<button
type="button"
onClick={() =>
showAgreement(
agreements.data?.virtual_asset_purchase?.title ?? '虚拟资产购买协议',
agreements.data?.virtual_asset_purchase?.content
)
}
>
{isOwner ? '不能租用自己发布的商品' : data.in_transaction ? '商品租赁中' : '确认租赁'}
</Button>
<div className="listing-buy-card__links">
<button
type="button"
onClick={() =>
showAgreement(
agreements.data?.virtual_asset_purchase?.title ?? '虚拟资产购买协议',
agreements.data?.virtual_asset_purchase?.content
)
}
>
《虚拟资产购买协议》
</button>
<button
type="button"
onClick={() =>
showAgreement(
agreements.data?.renter_agreement?.title ?? '租客协议',
agreements.data?.renter_agreement?.content
)
}
>
《租客协议》
</button>
<button
type="button"
onClick={() =>
showAgreement(
postRentalNotice.data?.title ?? '租后须知',
postRentalNotice.data?.content
)
}
>
《租后须知》
</button>
虚拟资产购买协议
</button>
<button
type="button"
onClick={() =>
showAgreement(
agreements.data?.renter_agreement?.title ?? '租客协议',
agreements.data?.renter_agreement?.content
)
}
>
租客协议
</button>
<button
type="button"
onClick={() =>
showAgreement(
postRentalNotice.data?.title ?? '租后须知',
postRentalNotice.data?.content
)
}
>
租后须知
</button>
</div>
<div className="ld-owner">
<SafetyCertificateOutlined />
<span>号主 {data.owner_nickname || `#${data.owner_id}`} · 已完成实名认证</span>
</div>
</aside>
</section>
<section className="ld-body">
<Card title="资产概览" className="ld-panel">
<div className="ld-stats">
{stats.map(stat => (
<div className="ld-stat" key={stat.label}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
))}
</div>
</Card>
{skins.length > 0 ? (
<Card title="皮肤收藏" className="ld-panel">
<div className="ld-skin-groups">
{skins.map(group => (
<div className="ld-skin-group" key={group.key}>
<span className="ld-skin-group__label">
{group.label}
<em>{group.names.length}</em>
</span>
<div className="ld-skin-group__items">
{group.names.map(name => (
<span key={name}>{name}</span>
))}
</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
) : null}
<Row gutter={[24, 24]} className="listing-detail-page__body">
<Col xs={24} lg={16}>
<Card title="账号说明" className="listing-detail-panel">
<Typography.Paragraph className="listing-detail-description">
{resources.length > 0 ? (
<Card title="仓库物资" className="ld-panel">
<div className="ld-resources">
{resources.map(row => (
<div className="ld-resource" key={row.label}>
<span>{row.label}</span>
<strong>{row.quantity}</strong>
{row.mode === '赠送' ? <em>赠送</em> : null}
</div>
))}
</div>
</Card>
) : null}
<div className="ld-split">
<Card title="账号说明" className="ld-panel">
<Typography.Paragraph className="ld-description">
{data.description || '号主暂未填写补充说明。'}
</Typography.Paragraph>
</Card>
<Card title="资产摘要" className="listing-detail-panel">
<Descriptions column={2} size="small" colon={false}>
{assetEntries.map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{assetValue(value)}
</Descriptions.Item>
))}
<Descriptions.Item label="在线时间">{assetValue(onlineTime)}</Descriptions.Item>
</Descriptions>
</Card>
<Card title="交易流程" className="listing-detail-panel">
<div className="listing-process">
{[
['01', '确认下单', '核对租金、押金与账号信息,提交订单。'],
['02', '完成支付', '支付成功后订单进入待交接。'],
['03', '号主交接', '号主提交账号与登录说明,租客确认收号。'],
['04', '使用与归还', '租期内按规则使用,结束时发起结账。'],
['05', '确认结算', '双方确认或客服仲裁,完成押金与租金结算。'],
].map(([no, title, desc]) => (
<div key={no} className="listing-process__step">
<span>{no}</span>
<div>
<strong>{title}</strong>
<p>{desc}</p>
</div>
<Card title="交易流程" className="ld-panel">
<ol className="ld-steps">
<li>
<span>01</span>
<div>
<strong>确认下单</strong>
<p>核对租金、押金与账号信息。</p>
</div>
))}
</div>
</li>
<li>
<span>02</span>
<div>
<strong>完成支付</strong>
<p>支付成功后进入待交接。</p>
</div>
</li>
<li>
<span>03</span>
<div>
<strong>号主交接</strong>
<p>提交账号与登录说明,租客确认。</p>
</div>
</li>
<li>
<span>04</span>
<div>
<strong>使用与归还</strong>
<p>租期内按规则使用,结束归还。</p>
</div>
</li>
<li>
<span>05</span>
<div>
<strong>确认结算</strong>
<p>双方确认或客服仲裁后结算。</p>
</div>
</li>
</ol>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card className="listing-trust-card">
<div className="listing-trust-card__item">
<SafetyCertificateOutlined />
<div>
<strong>实名与审核</strong>
<span>发布、下单前均需身份核验。</span>
</div>
</div>
<Divider />
<div className="listing-trust-card__item">
<ShoppingOutlined />
<div>
<strong>全程留痕</strong>
<span>交接、归还、结账都有记录。</span>
</div>
</div>
<Divider />
<div className="listing-trust-card__item">
<WalletOutlined />
<div>
<strong>账单透明</strong>
<span>租金、押金、消耗逐项列明。</span>
</div>
</div>
</Card>
</Col>
</Row>
</div>
</section>
<Modal
open={confirmOpen}
@@ -454,7 +597,7 @@ export function ListingDetailPage() {
<Alert
type="warning"
showIcon
className="listing-buy-card__notice"
className="ld-modal-notice"
message="提交后会锁定商品并生成待支付订单"
description="请在支付前再次核对金额与账号信息;支付成功后才进入交接流程。"
/>
@@ -475,7 +618,7 @@ export function ListingDetailPage() {
}
onCancel={() => setAgreementOpen(false)}
>
<div className="listing-agreement-content">{agreementContent}</div>
<div className="ld-agreement">{agreementContent}</div>
</Modal>
</div>
)
+1
View File
@@ -4,6 +4,7 @@
@import './storefront-portal.css';
@import './storefront-sections.css';
@import './storefront-listings.css';
@import './storefront-listing-detail.css';
@import './storefront-filters.css';
@import './storefront-content.css';
@import './storefront-help.css';
@@ -0,0 +1,477 @@
/* 商品详情页:画廊 + 购买卡 + 资产分区 */
.ld-page {
max-width: 1240px;
margin: 0 auto;
padding: 22px 24px 64px;
}
.ld-crumb {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.ld-hero {
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.88fr);
gap: 20px;
align-items: start;
}
/* 画廊 */
.ld-gallery {
padding: 12px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--paper-warm);
}
.ld-gallery__main {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 12px;
background: #eef1ea;
}
.ld-gallery__main .ant-image,
.ld-gallery__main img {
width: 100%;
height: 100%;
}
.ld-gallery__main img {
display: block;
object-fit: cover;
}
.ld-status {
position: absolute;
top: 12px;
left: 12px;
padding: 3px 10px;
border-radius: 999px;
background: rgba(47, 125, 92, 0.92);
color: #fff;
font-size: 12px;
letter-spacing: 0.04em;
}
.ld-status.is-busy {
background: rgba(94, 107, 100, 0.9);
}
.ld-gallery__count {
position: absolute;
right: 12px;
bottom: 12px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(21, 58, 50, 0.62);
color: #fff;
font-size: 11px;
}
.ld-gallery__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
height: 100%;
color: var(--ink-muted);
font-size: 12px;
}
.ld-gallery__thumbs {
display: flex;
gap: 8px;
margin-top: 10px;
overflow-x: auto;
scrollbar-width: thin;
}
.ld-thumb {
flex: none;
width: 84px;
height: 58px;
padding: 0;
overflow: hidden;
border: 2px solid transparent;
border-radius: 8px;
background: #eef1ea;
cursor: pointer;
opacity: 0.72;
transition:
opacity 160ms ease,
border-color 160ms ease;
}
.ld-thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.ld-thumb:hover {
opacity: 1;
}
.ld-thumb.is-active {
border-color: var(--brand-seal);
opacity: 1;
}
/* 购买卡 */
.ld-buy {
position: sticky;
top: 92px;
padding: 20px 22px 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--paper-warm);
}
.ld-buy__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ld-no {
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 12px;
letter-spacing: 0.06em;
}
.ld-title {
margin: 10px 0 10px !important;
color: var(--ink) !important;
font-size: clamp(20px, 2vw, 26px) !important;
line-height: 1.4 !important;
}
.ld-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.ld-tags .ant-tag {
margin: 0;
border-radius: 4px;
background: #eef2ec;
color: var(--brand-ink);
}
.ld-price {
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(166, 67, 50, 0.08), rgba(176, 138, 74, 0.1));
}
.ld-price > span {
color: var(--ink-muted);
font-size: 12px;
}
.ld-price strong {
display: block;
margin: 2px 0 4px;
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 34px;
line-height: 1.15;
}
.ld-price em {
color: var(--ink-secondary);
font-size: 12px;
font-style: normal;
}
.ld-quick {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 12px 0 16px;
}
.ld-quick div {
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.ld-quick span {
display: block;
color: var(--ink-muted);
font-size: 11px;
}
.ld-quick strong {
display: block;
margin-top: 2px;
color: var(--brand-ink);
font-size: 16px;
}
.ld-cta {
height: 46px;
border-radius: 10px;
font-size: 16px;
}
.ld-links {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin: 12px 0 14px;
}
.ld-links button {
padding: 0;
border: 0;
background: transparent;
color: var(--ink-muted);
font-size: 12px;
cursor: pointer;
}
.ld-links button:hover {
color: var(--brand-seal);
}
.ld-owner {
display: flex;
align-items: center;
gap: 8px;
padding-top: 12px;
border-top: 1px dashed var(--line);
color: var(--ink-secondary);
font-size: 12px;
}
.ld-owner .anticon {
color: var(--brand-ink);
}
/* 资产区 */
.ld-body {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
}
.ld-panel {
border-color: var(--line);
border-radius: 16px;
background: var(--paper-warm);
}
.ld-panel .ant-card-head {
min-height: 48px;
border-bottom-color: var(--line);
}
.ld-panel .ant-card-head-title {
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 16px;
}
.ld-stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.ld-stat {
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.ld-stat span {
display: block;
color: var(--ink-muted);
font-size: 11px;
}
.ld-stat strong {
display: block;
margin-top: 2px;
color: var(--ink);
font-size: 15px;
font-weight: 600;
}
.ld-skin-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.ld-skin-group {
display: flex;
align-items: flex-start;
gap: 14px;
}
.ld-skin-group__label {
flex: none;
width: 84px;
padding-top: 5px;
color: var(--ink-secondary);
font-size: 12px;
}
.ld-skin-group__label em {
margin-left: 4px;
color: var(--ink-muted);
font-style: normal;
}
.ld-skin-group__items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ld-skin-group__items > span {
padding: 3px 10px;
border-radius: 999px;
background: rgba(166, 67, 50, 0.08);
color: var(--brand-seal);
font-size: 12px;
}
.ld-resources {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ld-resource {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
font-size: 12px;
color: var(--ink-secondary);
}
.ld-resource strong {
color: var(--ink);
font-size: 14px;
}
.ld-resource em {
padding: 0 6px;
border-radius: 999px;
background: rgba(47, 125, 92, 0.12);
color: var(--success);
font-size: 10px;
font-style: normal;
line-height: 16px;
}
.ld-split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.ld-description {
margin-bottom: 0 !important;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.9;
}
.ld-steps {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.ld-steps li {
display: flex;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--line);
}
.ld-steps li:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.ld-steps li > span {
flex: none;
color: var(--brand-seal);
font-family: 'Inter', monospace;
font-size: 11px;
line-height: 20px;
}
.ld-steps strong {
display: block;
color: var(--brand-ink);
font-size: 13px;
}
.ld-steps p {
margin: 2px 0 0;
color: var(--ink-muted);
font-size: 12px;
line-height: 1.6;
}
.ld-modal-notice {
margin: 14px 0 !important;
}
.ld-agreement {
max-height: 60vh;
overflow-y: auto;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.9;
white-space: pre-wrap;
}
@media (max-width: 1024px) {
.ld-hero {
grid-template-columns: minmax(0, 1fr);
}
.ld-buy {
position: static;
}
.ld-split {
grid-template-columns: minmax(0, 1fr);
}
.ld-skin-group {
flex-direction: column;
gap: 6px;
}
}
-275
View File
@@ -117,281 +117,6 @@
color: inherit;
}
.listing-detail-page {
max-width: 1240px;
margin: 0 auto;
padding: 26px 24px 64px;
}
.listing-detail-page__crumb {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.listing-detail-page__hero {
margin-top: 18px;
}
.listing-detail-gallery {
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-warm);
}
.listing-detail-gallery__main {
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 8px;
background: #eef1ea;
}
.listing-detail-gallery__main .ant-image,
.listing-detail-gallery__main img {
width: 100%;
height: 100%;
}
.listing-detail-gallery__main img {
object-fit: cover;
}
.listing-detail-gallery__thumbs {
display: flex;
gap: 8px;
margin-top: 10px;
overflow-x: auto;
}
.listing-detail-gallery__thumbs .ant-image {
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
}
.listing-detail-gallery__thumbs img {
object-fit: cover;
}
.listing-detail-gallery__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 360px;
border-radius: 8px;
background: var(--paper);
color: var(--brand-ink);
letter-spacing: 0.12em;
}
.listing-buy-card {
position: sticky;
top: 84px;
border-color: var(--line);
background: var(--paper-warm);
}
.listing-buy-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.listing-buy-card__title {
margin: 12px 0 10px !important;
color: var(--brand-ink) !important;
font-size: 26px !important;
line-height: 1.35 !important;
}
.listing-buy-card__price {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 18px;
padding: 14px 16px;
border-radius: 8px;
background: var(--paper);
}
.listing-buy-card__price span {
color: var(--ink-muted);
font-size: 12px;
}
.listing-buy-card__price strong {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 32px;
}
.listing-buy-card__meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.listing-buy-card__meta div {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.listing-buy-card__meta span {
color: var(--ink-muted);
font-size: 11px;
}
.listing-buy-card__meta strong {
color: var(--brand-ink);
font-family: 'Inter', monospace;
font-size: 13px;
}
.listing-buy-card__notice {
margin-bottom: 16px;
}
.listing-buy-card__links {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 12px;
}
.listing-buy-card__links button {
padding: 0;
border: 0;
background: none;
color: var(--brand-seal);
font-size: 11px;
cursor: pointer;
}
.listing-buy-card__links button:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.listing-detail-page__body {
margin-top: 24px;
}
.listing-detail-panel {
margin-bottom: 18px;
border-color: var(--line);
background: var(--paper-warm);
}
.listing-detail-description {
margin-bottom: 0 !important;
color: var(--ink-secondary);
line-height: 1.85;
white-space: pre-wrap;
}
.listing-process {
display: flex;
flex-direction: column;
}
.listing-process__step {
display: flex;
gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed var(--line);
}
.listing-process__step:last-child {
border-bottom: 0;
}
.listing-process__step > span {
color: var(--brand-seal);
font-family: 'Inter', monospace;
font-size: 12px;
}
.listing-process__step strong {
color: var(--brand-ink);
font-size: 14px;
}
.listing-process__step p {
margin: 6px 0 0;
color: var(--ink-muted);
font-size: 12px;
}
.listing-trust-card {
border-color: var(--line);
background: var(--paper-warm);
}
.listing-trust-card__item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.listing-trust-card__item > .anticon {
margin-top: 2px;
color: var(--brand-seal);
font-size: 18px;
}
.listing-trust-card__item div {
display: flex;
flex-direction: column;
gap: 4px;
}
.listing-trust-card__item strong {
color: var(--brand-ink);
font-size: 13px;
}
.listing-trust-card__item span {
color: var(--ink-muted);
font-size: 11px;
line-height: 1.6;
}
.listing-agreement-content {
max-height: 62vh;
overflow-y: auto;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 991px) {
.listing-detail-page {
padding: 20px 16px 48px;
}
.listing-buy-card {
position: static;
}
.listing-buy-card__meta {
grid-template-columns: 1fr;
}
}
.pc-orders-page,
.pc-order-detail-page {
max-width: 1240px;