feat(distribution): 独立分销前台商城多租户体系、货源与订单数据严格隔离

This commit is contained in:
yml2213
2026-09-07 17:18:14 +08:00
parent 550b42eb22
commit ecaf15417b
34 changed files with 2397 additions and 119 deletions
+48 -1
View File
@@ -5,6 +5,7 @@ import {
AccountBookOutlined,
ApiOutlined,
DashboardOutlined,
EyeOutlined,
FileSearchOutlined,
LogoutOutlined,
SettingOutlined,
@@ -20,6 +21,7 @@ import Accounts from './pages/Accounts'
import Settlements from './pages/Settlements'
import Orders from './pages/Orders'
import Login from './pages/Login'
import Storefront from './pages/Storefront'
import './style.css'
type MenuItem = Required<MenuProps>['items'][number] & { superadminOnly?: boolean }
@@ -83,6 +85,26 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
</div>
<div className="header-right">
<Space size="middle">
{member.site_code && (
<Button
type="primary"
ghost
icon={<EyeOutlined />}
size="small"
onClick={() => {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const targetUrl = isLocal
? `http://${window.location.hostname}:5189/?site=${member.site_code}`
: member.domain
? `https://${member.domain}`
: `/?site=${member.site_code}`
window.open(targetUrl, '_blank')
}}
style={{ borderColor: '#1677ff', color: '#1677ff' }}
>
预览分销前台商城 ↗
</Button>
)}
<div className="user-profile">
<UserOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong style={{ fontSize: 13 }}>
@@ -127,9 +149,24 @@ function AppContent() {
const navigate = useNavigate()
const location = useLocation()
const isPublicStorefront = location.pathname.startsWith('/site')
useEffect(() => {
let active = true
const checkAuth = async () => {
// For public storefront, we do not force login
if (isPublicStorefront) {
try {
const profile = await getMe()
if (active) setMember(profile)
} catch {
// ignore auth error on public storefront
} finally {
if (active) setLoading(false)
}
return
}
// If no token exists and no session, skip getMe
if (!getStoredToken() && location.pathname === '/login') {
if (active) setLoading(false)
@@ -158,7 +195,7 @@ function AppContent() {
return () => {
active = false
}
}, [location.pathname, navigate])
}, [location.pathname, navigate, isPublicStorefront])
const handleLogout = async () => {
await logout()
@@ -171,6 +208,16 @@ function AppContent() {
navigate('/', { replace: true })
}
if (isPublicStorefront) {
return (
<Routes>
<Route path="/site" element={<Navigate to="/site/demo" replace />} />
<Route path="/site/:siteCode" element={<Storefront />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
+60
View File
@@ -170,6 +170,61 @@ export interface AccountDisplay {
account_id: number
listing_id: number
display_status: string
listing_no?: string
title?: string
game_name?: string
server_region?: string
login_platform?: string
rank_level?: string
haf_coin_amount?: number
price_cent?: number
deposit_amount_cent?: number
listing_status?: string
in_transaction?: boolean
}
export interface SyncSiteAccountsResult {
synced_count: number
skipped_count: number
total_active: number
}
export interface PublicDistributionListing {
listing_id: number
listing_no: string
account_id: number
title: string
game_name: string
server_region: string
login_platform: string
rank_level: string
haf_coin_amount: number
price_cent: number
deposit_amount_cent: number
in_transaction: boolean
site_code: string
site_name: string
}
export interface PublicDistributionListingsResult {
items: PublicDistributionListing[]
total: number
page: number
page_size: number
}
export async function fetchPublicSiteListings(
siteCode: string,
page = 1,
pageSize = 24,
): Promise<PublicDistributionListingsResult> {
const resp = await api.get<ApiResponse<PublicDistributionListingsResult>>(
`/distribution/sites/${siteCode}/listings`,
{
params: { page, page_size: pageSize },
},
)
return extract(resp)
}
export interface PaymentChannel {
@@ -297,6 +352,11 @@ export async function updateAccountDisplay(id: number, displayStatus: string): P
return extract(resp)
}
export async function syncSiteAccounts(siteID: number): Promise<SyncSiteAccountsResult> {
const resp = await api.post<ApiResponse<SyncSiteAccountsResult>>(`/admin/distribution/sites/${siteID}/sync-accounts`)
return extract(resp)
}
export async function listPaymentChannels(siteID: number): Promise<PaymentChannel[]> {
const resp = await api.get<ApiResponse<{ items: PaymentChannel[] }>>('/admin/distribution/payment-channels', {
params: { site_id: siteID },
+260 -39
View File
@@ -1,20 +1,32 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message } from 'antd'
import { listAccountDisplays, listSites, updateAccountDisplay, type AccountDisplay, type Site } from '../api'
import { useEffect, useState, useMemo } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal } from 'antd'
import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined } from '@ant-design/icons'
import {
listAccountDisplays,
listSites,
updateAccountDisplay,
syncSiteAccounts,
formatMoney,
type AccountDisplay,
type Site,
} from '../api'
const STATUS_LABELS: Record<string, string> = {
enabled: '展示中',
disabled: '已下架',
sold: '已售出',
offline: '已离线',
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
enabled: { label: '展示中', color: 'success' },
disabled: { label: '已下架', color: 'default' },
sold: { label: '已售出', color: 'blue' },
offline: { label: '已离线', color: 'error' },
}
export default function Accounts() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [status, setStatus] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [items, setItems] = useState<AccountDisplay[]>([])
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
async function load(site?: number, statusFilter?: string) {
if (!site) return
@@ -40,6 +52,27 @@ export default function Accounts() {
.catch(() => message.error('站点加载失败'))
}, [])
async function handleSync() {
if (!siteID) {
message.warning('请先选择分销站点')
return
}
setSyncing(true)
try {
const res = await syncSiteAccounts(siteID)
message.success(
`同步完成!新增可售账号 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(主站当前在售总数 ${res.total_active})`,
5,
)
await load(siteID, status)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '同步主站商品失败')
} finally {
setSyncing(false)
}
}
async function toggle(item: AccountDisplay) {
const next = item.display_status === 'enabled' ? 'disabled' : 'enabled'
try {
@@ -52,49 +85,237 @@ export default function Accounts() {
}
}
async function handleBatchUpdate(targetStatus: 'enabled' | 'disabled') {
if (selectedRowKeys.length === 0) {
message.warning('请先勾选需要批量操作的账号')
return
}
const actionLabel = targetStatus === 'enabled' ? '批量恢复展示' : '批量下架'
Modal.confirm({
title: `确认${actionLabel}?`,
content: `即将对选中的 ${selectedRowKeys.length} 个账号执行${actionLabel}操作。`,
onOk: async () => {
let successCount = 0
for (const id of selectedRowKeys) {
try {
await updateAccountDisplay(Number(id), targetStatus)
successCount++
} catch {
// continue next
}
}
message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`)
setSelectedRowKeys([])
if (siteID) void load(siteID, status)
},
})
}
const filteredItems = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return items
return items.filter(
(item) =>
(item.title && item.title.toLowerCase().includes(kw)) ||
(item.listing_no && item.listing_no.toLowerCase().includes(kw)) ||
(item.game_name && item.game_name.toLowerCase().includes(kw)) ||
(item.server_region && item.server_region.toLowerCase().includes(kw)),
)
}, [items, keyword])
const currentSite = sites.find((s) => s.id === siteID)
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>账号展示</Typography.Title>
<Space>
<Select
placeholder="分销网站"
style={{ width: 200 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value, status)
}}
/>
<Select
allowClear
placeholder="展示状态"
style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value }))}
onChange={(value) => {
setStatus(value ?? '')
void load(siteID, value ?? '')
}}
/>
</Space>
<Space direction="vertical" className="page" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
商品与账号展示
</Typography.Title>
<Typography.Text type="secondary">
管理当前分销网站对外展示与销售的游戏账号商品,支持实时从主站同步最新上架货源。
</Typography.Text>
</div>
<Button
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
style={{ background: '#1677ff' }}
>
一键同步主站商品
</Button>
</div>
<Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
<Select
placeholder="分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({
label: `${site.site_name} (${site.site_code})`,
value: site.id,
}))}
onChange={(value) => {
setSiteID(value)
setSelectedRowKeys([])
void load(value, status)
}}
/>
<Select
allowClear
placeholder="展示状态"
style={{ width: 140 }}
value={status || undefined}
options={Object.entries(STATUS_LABELS).map(([value, conf]) => ({
label: conf.label,
value,
}))}
onChange={(value) => {
setStatus(value ?? '')
void load(siteID, value ?? '')
}}
/>
<Input
placeholder="搜索商品编号/标题/区服"
prefix={<SearchOutlined />}
style={{ width: 240 }}
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</Space>
{selectedRowKeys.length > 0 && (
<Space>
<Typography.Text type="secondary">已勾选 {selectedRowKeys.length} 项</Typography.Text>
<Button
size="small"
icon={<CheckCircleOutlined />}
onClick={() => handleBatchUpdate('enabled')}
>
批量展示
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleBatchUpdate('disabled')}
>
批量下架
</Button>
</Space>
)}
</Space>
</Card>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ pageSize: 20 }}
dataSource={filteredItems}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
pageSize: 20,
showTotal: (total) => `共 ${total} 个账号商品`,
showQuickJumper: true,
}}
columns={[
{ title: '账号 ID', dataIndex: 'account_id' },
{ title: '商品 ID', dataIndex: 'listing_id' },
{
title: '展示状态',
title: '商品编号',
dataIndex: 'listing_no',
width: 140,
render: (v: string | undefined, row: AccountDisplay) => (
<Space direction="vertical" size={2}>
<Typography.Text copyable strong>
{v || `#${row.listing_id}`}
</Typography.Text>
{row.in_transaction && <Tag color="warning">出租中</Tag>}
</Space>
),
},
{
title: '账号信息',
dataIndex: 'title',
minWidth: 240,
render: (_: unknown, row: AccountDisplay) => (
<div>
<Typography.Text strong style={{ display: 'block', fontSize: 14 }}>
{row.title || `账号 #${row.account_id}`}
</Typography.Text>
<Space size={4} style={{ marginTop: 4 }}>
<Tag color="blue">{row.game_name || '默认游戏'}</Tag>
{row.server_region && <Tag>{row.server_region}</Tag>}
{row.login_platform && <Tag>{row.login_platform}</Tag>}
</Space>
</div>
),
},
{
title: '段位 / 资产',
width: 140,
render: (_: unknown, row: AccountDisplay) => (
<div>
<div>{row.rank_level ? <Tag color="purple">{row.rank_level}</Tag> : '-'}</div>
{row.haf_coin_amount !== undefined && row.haf_coin_amount > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
哈夫币: {row.haf_coin_amount.toLocaleString()}
</Typography.Text>
)}
</div>
),
},
{
title: '租金 / 押金',
width: 160,
render: (_: unknown, row: AccountDisplay) => (
<div>
<Typography.Text strong style={{ color: '#cf1322' }}>
{formatMoney(row.price_cent)}
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#888' }}> /小时</span>
</Typography.Text>
<div style={{ fontSize: 12, color: '#666' }}>
押金: {formatMoney(row.deposit_amount_cent)}
</div>
</div>
),
},
{
title: '主站状态',
dataIndex: 'listing_status',
width: 110,
render: (v: string | undefined) => (
<Tag color={v === 'published' ? 'green' : 'default'}>
{v === 'published' ? '主站在售' : v || '未知'}
</Tag>
),
},
{
title: '分销展示',
dataIndex: 'display_status',
render: (v: string) => <Tag color={v === 'enabled' ? 'green' : 'default'}>{STATUS_LABELS[v] ?? v}</Tag>,
width: 110,
render: (v: string) => {
const conf = STATUS_LABELS[v] ?? { label: v, color: 'default' }
return <Tag color={conf.color}>{conf.label}</Tag>
},
},
{
title: '操作',
width: 120,
render: (_: unknown, row: AccountDisplay) => (
<a onClick={() => toggle(row)}>{row.display_status === 'enabled' ? '下架' : '恢复展示'}</a>
<Button
type="link"
size="small"
danger={row.display_status === 'enabled'}
onClick={() => toggle(row)}
>
{row.display_status === 'enabled' ? '下架' : '恢复展示'}
</Button>
),
},
]}
+544
View File
@@ -0,0 +1,544 @@
import { useEffect, useState, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import {
Layout,
Typography,
Space,
Input,
Radio,
Card,
Tag,
Button,
Modal,
Empty,
Spin,
Badge,
message,
Descriptions,
Divider,
} from 'antd'
import {
ShopOutlined,
SearchOutlined,
SafetyCertificateOutlined,
ThunderboltOutlined,
CustomerServiceOutlined,
CopyOutlined,
CheckCircleOutlined,
RightCircleOutlined,
RocketOutlined,
FireOutlined,
} from '@ant-design/icons'
import {
fetchPublicSiteListings,
formatMoney,
type PublicDistributionListing,
} from '../api'
export default function Storefront() {
const { siteCode = 'demo' } = useParams<{ siteCode: string }>()
const [loading, setLoading] = useState(true)
const [items, setItems] = useState<PublicDistributionListing[]>([])
const [siteName, setSiteName] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [gameFilter, setGameFilter] = useState<string>('all')
const [sortOrder, setSortOrder] = useState<'default' | 'price_asc' | 'price_desc'>('default')
const [selectedListing, setSelectedListing] = useState<PublicDistributionListing | null>(null)
const [csModalVisible, setCsModalVisible] = useState(false)
useEffect(() => {
let active = true
setLoading(true)
fetchPublicSiteListings(siteCode, 1, 100)
.then((res) => {
if (!active) return
setItems(res.items || [])
if (res.items && res.items.length > 0) {
setSiteName(res.items[0].site_name)
}
})
.catch(() => {
if (active) {
message.error('加载分销商城商品失败,请检查站点标识')
}
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [siteCode])
const displayName = siteName || `${siteCode.toUpperCase()} 专属分销商城`
const filteredItems = useMemo(() => {
let list = [...items]
if (gameFilter !== 'all') {
list = list.filter((i) => (i.game_name || '').toLowerCase() === gameFilter.toLowerCase())
}
const kw = keyword.trim().toLowerCase()
if (kw) {
list = list.filter(
(i) =>
(i.title && i.title.toLowerCase().includes(kw)) ||
(i.listing_no && i.listing_no.toLowerCase().includes(kw)) ||
(i.server_region && i.server_region.toLowerCase().includes(kw)) ||
(i.login_platform && i.login_platform.toLowerCase().includes(kw)) ||
(i.rank_level && i.rank_level.toLowerCase().includes(kw)),
)
}
if (sortOrder === 'price_asc') {
list.sort((a, b) => a.price_cent - b.price_cent)
} else if (sortOrder === 'price_desc') {
list.sort((a, b) => b.price_cent - a.price_cent)
}
return list
}, [items, gameFilter, keyword, sortOrder])
function handleCopyListingNo(no: string) {
navigator.clipboard?.writeText(no)
message.success(`已复制商品编号:${no}`)
}
function handleRentNow(listing: PublicDistributionListing) {
setSelectedListing(listing)
}
function openMainCheckout(listing: PublicDistributionListing) {
const mainHost = window.location.hostname
// 默认主站前台运行在 5189 端口,生产环境通过域名反代
const mainUrl = window.location.port
? `http://${mainHost}:5189/orders/create?listing_id=${listing.listing_id}&site_code=${listing.site_code}`
: `/orders/create?listing_id=${listing.listing_id}&site_code=${listing.site_code}`
window.open(mainUrl, '_blank')
}
return (
<Layout style={{ minHeight: '100vh', background: '#0f172a' }}>
{/* 顶部导航栏 */}
<Layout.Header
style={{
background: 'rgba(15, 23, 42, 0.95)',
backdropFilter: 'blur(8px)',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 24px',
position: 'sticky',
top: 0,
zIndex: 100,
}}
>
<Space size="middle" align="center">
<div
style={{
width: 38,
height: 38,
borderRadius: 8,
background: 'linear-gradient(135deg, #2563eb, #3b82f6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 0 16px rgba(59, 130, 246, 0.5)',
}}
>
<ShopOutlined style={{ fontSize: 20 }} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text strong style={{ color: '#fff', fontSize: 16 }}>
{displayName}
</Typography.Text>
<Tag color="gold" style={{ margin: 0, fontSize: 11 }}>
官方合作分销
</Tag>
</div>
<Typography.Text style={{ color: '#94a3b8', fontSize: 11, display: 'block' }}>
站点标识:{siteCode} · 主站直营货源保障
</Typography.Text>
</div>
</Space>
<Space size="middle">
<Button
type="text"
icon={<CustomerServiceOutlined />}
style={{ color: '#e2e8f0' }}
onClick={() => setCsModalVisible(true)}
>
联系专属客服
</Button>
<Link to="/">
<Button size="small" style={{ background: 'rgba(255,255,255,0.08)', color: '#94a3b8', borderColor: 'rgba(255,255,255,0.15)' }}>
分销商后台
</Button>
</Link>
</Space>
</Layout.Header>
{/* Hero Banner */}
<div
style={{
background: 'linear-gradient(180deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 1) 100%)',
padding: '36px 24px 24px',
textAlign: 'center',
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
}}
>
<div style={{ maxWidth: 840, margin: '0 auto' }}>
<Space direction="vertical" size={12}>
<Tag color="blue" icon={<FireOutlined />} style={{ padding: '2px 10px', fontSize: 12 }}>
实时同步主站精品号源 · 一键租号
</Tag>
<Typography.Title
level={2}
style={{
color: '#fff',
margin: 0,
fontWeight: 700,
letterSpacing: 1,
}}
>
【{displayName}】精选游戏账号商城
</Typography.Title>
<Typography.Paragraph style={{ color: '#94a3b8', fontSize: 14, margin: '0 0 12px' }}>
高阶战备 · 王者段位 · 百万哈夫币 · 极速自动交接 · 押金秒退担保
</Typography.Paragraph>
<Space wrap size="large" style={{ justifyContent: 'center', marginTop: 8 }}>
<div style={{ color: '#cbd5e1', fontSize: 13 }}>
<ThunderboltOutlined style={{ color: '#38bdf8', marginRight: 6 }} />
秒级自动交接上号
</div>
<div style={{ color: '#cbd5e1', fontSize: 13 }}>
<SafetyCertificateOutlined style={{ color: '#34d399', marginRight: 6 }} />
主站资金与资产存管
</div>
<div style={{ color: '#cbd5e1', fontSize: 13 }}>
<CheckCircleOutlined style={{ color: '#fbbf24', marginRight: 6 }} />
租期结束秒退押金
</div>
</Space>
</Space>
</div>
</div>
{/* 筛选与内容主体 */}
<Layout.Content style={{ maxWidth: 1280, width: '100%', margin: '0 auto', padding: '24px 20px 60px' }}>
{/* 筛选栏 */}
<Card
style={{
background: '#1e293b',
border: '1px solid rgba(255, 255, 255, 0.08)',
marginBottom: 24,
}}
bodyStyle={{ padding: '16px 20px' }}
>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Radio.Group
value={gameFilter}
onChange={(e) => setGameFilter(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="all">全部游戏</Radio.Button>
<Radio.Button value="delta_force">三角洲行动</Radio.Button>
<Radio.Button value="honor_of_kings">王者荣耀</Radio.Button>
</Radio.Group>
<Radio.Group
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
size="middle"
>
<Radio.Button value="default">默认推荐</Radio.Button>
<Radio.Button value="price_asc">租金从低到高</Radio.Button>
<Radio.Button value="price_desc">租金从高到低</Radio.Button>
</Radio.Group>
</Space>
<Input
placeholder="搜索账号标题 / 区服 / 段位"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 260, background: '#0f172a', borderColor: 'rgba(255,255,255,0.1)', color: '#fff' }}
/>
</div>
</Space>
</Card>
{/* 列表区域 */}
{loading ? (
<div style={{ textAlign: 'center', padding: '80px 0' }}>
<Spin size="large" tip="正在载入在售商品..." />
</div>
) : filteredItems.length === 0 ? (
<Card style={{ background: '#1e293b', border: '1px solid rgba(255, 255, 255, 0.08)', padding: '60px 0' }}>
<Empty
description={
<span style={{ color: '#94a3b8' }}>
{items.length === 0
? '当前分销站点暂无上架商品,站长可通过分销后台一键同步主站商品。'
: '未找到符合条件的账号商品,请更换搜索词重试。'}
</span>
}
/>
</Card>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 20,
}}
>
{filteredItems.map((item) => (
<Card
key={item.listing_id}
hoverable
style={{
background: '#1e293b',
borderColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: 10,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.25s ease',
}}
bodyStyle={{ padding: 16, display: 'flex', flexDirection: 'column', flex: 1 }}
onClick={() => handleRentNow(item)}
>
{/* 卡片头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text style={{ color: '#64748b', fontSize: 12 }}>
#{item.listing_no}
</Typography.Text>
{item.in_transaction ? (
<Badge status="warning" text={<span style={{ color: '#fbbf24', fontSize: 12 }}>出租中</span>} />
) : (
<Badge status="success" text={<span style={{ color: '#34d399', fontSize: 12 }}>可租用</span>} />
)}
</div>
{/* 账号标题 */}
<Typography.Paragraph
ellipsis={{ rows: 2 }}
strong
style={{
color: '#f8fafc',
fontSize: 15,
lineHeight: 1.4,
minHeight: 42,
marginBottom: 10,
}}
>
{item.title}
</Typography.Paragraph>
{/* 标签属性 */}
<Space wrap size={4} style={{ marginBottom: 12 }}>
<Tag color="blue" style={{ margin: 0 }}>
{item.game_name === 'delta_force' ? '三角洲行动' : item.game_name}
</Tag>
{item.server_region && (
<Tag style={{ margin: 0, background: 'rgba(255,255,255,0.06)', color: '#cbd5e1', border: 'none' }}>
{item.server_region}
</Tag>
)}
{item.login_platform && (
<Tag style={{ margin: 0, background: 'rgba(255,255,255,0.06)', color: '#cbd5e1', border: 'none' }}>
{item.login_platform}
</Tag>
)}
{item.rank_level && (
<Tag color="purple" style={{ margin: 0 }}>
{item.rank_level}
</Tag>
)}
</Space>
{/* 资产特色 */}
{item.haf_coin_amount > 0 && (
<div
style={{
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.25)',
borderRadius: 6,
padding: '4px 8px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<span style={{ fontSize: 12 }}>🪙</span>
<Typography.Text style={{ color: '#fbbf24', fontSize: 12 }}>
哈夫币:{item.haf_coin_amount.toLocaleString()}
</Typography.Text>
</div>
)}
<div style={{ marginTop: 'auto', paddingTop: 10, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<div style={{ color: '#ef4444', fontSize: 18, fontWeight: 700, lineHeight: 1 }}>
{formatMoney(item.price_cent)}
<span style={{ fontSize: 12, color: '#94a3b8', fontWeight: 400 }}> /时</span>
</div>
<div style={{ color: '#64748b', fontSize: 11, marginTop: 4 }}>
押金:{formatMoney(item.deposit_amount_cent)}
</div>
</div>
<Button
type="primary"
size="middle"
icon={<RocketOutlined />}
disabled={item.in_transaction}
style={{
background: item.in_transaction ? undefined : 'linear-gradient(135deg, #2563eb, #3b82f6)',
border: 'none',
borderRadius: 6,
}}
onClick={(e) => {
e.stopPropagation()
handleRentNow(item)
}}
>
{item.in_transaction ? '出租中' : '立即租用'}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</Layout.Content>
{/* 商品详情 & 租号下单弹窗 */}
<Modal
title={
<Space>
<ShopOutlined style={{ color: '#3b82f6' }} />
<span>账号租赁详情</span>
</Space>
}
open={Boolean(selectedListing)}
onCancel={() => setSelectedListing(null)}
footer={null}
width={620}
destroyOnClose
>
{selectedListing && (
<div style={{ paddingTop: 8 }}>
<Typography.Title level={4} style={{ marginBottom: 8 }}>
{selectedListing.title}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
商品唯一编号:{selectedListing.listing_no} · 所属分销商:{selectedListing.site_name}
</Typography.Text>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="所属游戏">
{selectedListing.game_name === 'delta_force' ? '三角洲行动' : selectedListing.game_name}
</Descriptions.Item>
<Descriptions.Item label="游戏大区">{selectedListing.server_region || '全区通用'}</Descriptions.Item>
<Descriptions.Item label="登录平台">{selectedListing.login_platform || 'PC端'}</Descriptions.Item>
<Descriptions.Item label="段位天梯">{selectedListing.rank_level || '暂未定级'}</Descriptions.Item>
<Descriptions.Item label="哈夫币资产">
{selectedListing.haf_coin_amount > 0
? `${selectedListing.haf_coin_amount.toLocaleString()} 币`
: '无'}
</Descriptions.Item>
<Descriptions.Item label="交接模式">自动快速交接</Descriptions.Item>
<Descriptions.Item label="租金标准">
<span style={{ color: '#cf1322', fontWeight: 700, fontSize: 16 }}>
{formatMoney(selectedListing.price_cent)}
</span>{' '}
/ 小时
</Descriptions.Item>
<Descriptions.Item label="安全押金">
<span style={{ fontWeight: 600 }}>{formatMoney(selectedListing.deposit_amount_cent)}</span>
</Descriptions.Item>
</Descriptions>
<Divider style={{ margin: '16px 0' }} />
<div
style={{
background: '#f8fafc',
padding: '12px 16px',
borderRadius: 8,
border: '1px solid #e2e8f0',
marginBottom: 20,
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, color: '#334155' }}>
<SafetyCertificateOutlined style={{ color: '#16a34a', marginRight: 6 }} />
租号官方保障须知
</Typography.Text>
<ul style={{ paddingLeft: 18, margin: 0, fontSize: 12, color: '#64748b', lineHeight: 1.8 }}>
<li>本账号经平台安全审核,支持密码自动交接或专属上号器直连。</li>
<li>严禁在游戏内使用任何作弊辅助、脚本外挂,违规将扣除全部押金并封停账户。</li>
<li>租期结束并安全下线后,系统将自动结算并全额退还押金。</li>
</ul>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopyListingNo(selectedListing.listing_no)}
style={{ flex: 1 }}
>
复制商品编号
</Button>
<Button
type="primary"
icon={<RightCircleOutlined />}
onClick={() => openMainCheckout(selectedListing)}
style={{ flex: 2, background: 'linear-gradient(135deg, #2563eb, #3b82f6)', border: 'none' }}
>
前往主站极速上号
</Button>
</div>
</div>
)}
</Modal>
{/* 专属客服弹窗 */}
<Modal
title="联系分销站点专属客服"
open={csModalVisible}
onCancel={() => setCsModalVisible(false)}
footer={[
<Button key="ok" type="primary" onClick={() => setCsModalVisible(false)}>
我知道了
</Button>,
]}
>
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<CustomerServiceOutlined style={{ fontSize: 48, color: '#3b82f6', marginBottom: 16 }} />
<Typography.Title level={4} style={{ margin: '0 0 8px' }}>
【{displayName}】专属客服通道
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 16 }}>
如有换号、上号异常、咨询租号或批量合作需求,请联系本站值班客服。
</Typography.Paragraph>
<div style={{ background: '#f1f5f9', padding: '12px', borderRadius: 8 }}>
<Typography.Text strong>分销站点标识:</Typography.Text>
<Tag color="blue">{siteCode}</Tag>
<div style={{ marginTop: 8, fontSize: 12, color: '#64748b' }}>
主站 7x24 小时技术保障支持
</div>
</div>
</div>
</Modal>
</Layout>
)
}