Files
hfb_sys/distribution-admin/src/pages/Storefront.tsx
T
yml2213 766c923a6a fix(distribution): 修复分站搜不到已审核商品并实现审核通过自动铺货
问题:分站上架商品审核通过后主站能搜到、分站看不到,且没有自动同步。

根因:分站前台/搜索只认 distribution_site_accounts 中 display_status='enabled'
的货架行,而主站前台不按站点过滤。货架行此前只由「号主在该站上架」或站长手动
「一键同步主站商品」写入,审核通过不回写;同时一键同步的失效清理把「非
published+approved」的货架行一律置为 disabled,商品重新在售后也不会恢复。

- 新增 EnsureListingShelvedTx:商品变为 published+approved 时自动铺到所有
  display_account_enabled=true 的站点货架(缺行新增、系统下架恢复、人工下架不动),
  经 ListingShelfSyncer 接口注入,避免 listing 反向依赖 distribution
- 新增 distribution_site_accounts.display_disabled_by(operator/system/空)区分下架
  来源;人工上下架各入口写入来源标记
- 一键同步的失效清理收紧为只清理「货源记录已删除」的孤儿行,并支持恢复系统下架行,
  结果新增 reenabled_count
- 新增迁移 000074 回填存量缺失货架行,部署后无需逐站手动同步
- 修复 CreateSite 显式传 false/0 的配置(不允许自动展示、结算冷却 0 天)被 GORM
  default 标签忽略而静默回落默认值
- 前端:去掉会跨站串扰的 localStorage 站点码回退,站点探测完成后再挂载应用,
  避免分站发布因缺少站点码被记成主站商品;同步弹窗文案与统计项同步更新
- 补充分销货架自动铺货、清理口径、建站零值落库与审核铺货回归测试
2026-09-30 21:11:09 +08:00

583 lines
22 KiB
TypeScript

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'
function formatGameName(gameName?: string): string {
if (!gameName) return ''
const lower = gameName.toLowerCase().trim()
if (
lower === 'delta_force' ||
lower === 'deltaforce' ||
lower === 'delta' ||
lower.includes('delta_force') ||
lower.includes('delta') ||
gameName.includes('三角洲')
) {
return '三角洲'
}
if (lower.includes('honor') || gameName.includes('王者')) {
return '王者荣耀'
}
if (lower.includes('genshin') || gameName.includes('原神')) {
return '原神'
}
if (lower.includes('cs') || lower.includes('csgo')) {
return 'CS2'
}
return gameName
}
function formatLoginPlatform(platform?: string): string {
if (!platform) return ''
const lower = platform.toLowerCase().trim()
if (lower === 'qq') return 'QQ'
if (lower === 'wechat' || lower === 'wx') return '微信'
if (lower === 'steam') return 'Steam'
if (lower === 'epic') return 'Epic'
if (lower === 'wegame') return 'WeGame'
if (lower === 'mobile') return '手游端'
if (lower === 'pc') return 'PC端'
return platform
}
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 }}>
{formatGameName(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' }}>
{formatLoginPlatform(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)}
</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="所属游戏">
{formatGameName(selectedListing.game_name)}
</Descriptions.Item>
<Descriptions.Item label="游戏大区">{selectedListing.server_region || '全区通用'}</Descriptions.Item>
<Descriptions.Item label="登录平台">{formatLoginPlatform(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>
)
}