feat(distribution): 独立分销前台商城多租户体系、货源与订单数据严格隔离
This commit is contained in:
@@ -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' }}>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user