feat(distribution): 重构分销后台为SaaS商户工作台并全面商业化优化

- 商品货架 (Accounts.tsx): 支持期望售价/固定金额/百分比多模式实时联动加价与批量调价,高亮100%独享溢价
- 订单中心 (Orders.tsx): 透传账号游戏租客信息,新增收益对账抽屉透明拆解底价、手续费、分成与自主溢价
- 结算与规则 (Settlements.tsx & Rules.tsx): 纠正角色倒错,商户视角展现周期对账单与分成合作政策
- 店铺装修 (SiteSettings.tsx): 废除手写JSON,提供横幅/公告/页脚可视化列表编辑器与前台实时模拟器
- 数据大盘 (Dashboard.tsx): 对齐最新订单字段,大字展示分销商净收益
- 后端数据层 (dto.go, repository.go, handler.go): 聚合计算分销商订单收益,支持商品与租客搜索
This commit is contained in:
yml2213
2026-09-09 14:44:02 +08:00
parent aa466051ac
commit f6ad7c34ee
12 changed files with 2257 additions and 465 deletions
+437 -62
View File
@@ -15,6 +15,9 @@ import {
Alert,
Tooltip,
Divider,
Radio,
Row,
Col,
} from 'antd'
import {
SyncOutlined,
@@ -89,12 +92,20 @@ export default function Accounts() {
const [txFilter, setTxFilter] = useState<string>('all')
const [keyword, setKeyword] = useState<string>('')
// Markup modal state
// Multi-mode Pricing modal state
const [markupModalVisible, setMarkupModalVisible] = useState(false)
const [targetAccount, setTargetAccount] = useState<AccountDisplay | null>(null)
const [pricingMode, setPricingMode] = useState<'target_price' | 'add_amount' | 'percent' | 'ratio_delta'>('target_price')
const [targetPriceYuan, setTargetPriceYuan] = useState<number>(0)
const [addAmountYuan, setAddAmountYuan] = useState<number>(0)
const [percentMarkup, setPercentMarkup] = useState<number>(0)
const [markupRatio, setMarkupRatio] = useState<number>(0)
const [markupSaving, setMarkupSaving] = useState(false)
// Batch pricing state
const [batchMode, setBatchMode] = useState<'percent' | 'add_amount' | 'ratio_delta'>('percent')
const [batchValue, setBatchValue] = useState<number>(10)
async function load(site?: number) {
if (!site) return
setLoading(true)
@@ -204,42 +215,154 @@ export default function Accounts() {
})
}
function roundRatio(value: number): number {
if (value <= 0 || isNaN(value) || !isFinite(value)) return 0
return Math.round(value * 10) / 10
}
function calcDeltaFromTargetPrice(hafCoinAmount: number, basePriceCent: number, targetPriceYuan: number): number {
if (basePriceCent <= 0 || targetPriceYuan <= 0) return 0
const coinWan = hafCoinAmount / 10000
const basePriceYuan = basePriceCent / 100
if (targetPriceYuan <= basePriceYuan) return 0
const baseRatio = roundRatio(coinWan / basePriceYuan)
if (baseRatio <= 0) return 0
const siteRatio = roundRatio(coinWan / targetPriceYuan)
const delta = roundRatio(baseRatio - siteRatio)
return Math.max(0, delta)
}
function calcPreviewPrice(hafCoinAmount: number, basePriceCent: number, delta: number): { priceCent: number; siteRatio: number; markupCent: number } {
const coinWan = hafCoinAmount / 10000
const basePriceYuan = basePriceCent / 100
const baseRatio = roundRatio(coinWan / basePriceYuan)
if (delta <= 0 || baseRatio <= 0) {
return { priceCent: basePriceCent, siteRatio: baseRatio, markupCent: 0 }
}
let siteRatio = roundRatio(baseRatio - delta)
if (siteRatio <= 0.01) siteRatio = 0.01
const priceYuan = coinWan / siteRatio
const priceCent = Math.max(basePriceCent, Math.round(priceYuan * 100))
return {
priceCent,
siteRatio,
markupCent: priceCent - basePriceCent,
}
}
function handleOpenMarkup(item?: AccountDisplay) {
if (item) {
setTargetAccount(item)
setMarkupRatio(item.shelf_ratio_delta ?? 0)
const currentDelta = item.shelf_ratio_delta ?? 0
setMarkupRatio(currentDelta)
const basePriceYuan = (item.price_cent || 0) / 100
const currentPriceYuan = (item.site_price_cent || item.price_cent || 0) / 100
setTargetPriceYuan(currentPriceYuan)
setAddAmountYuan(Math.max(0, Number((currentPriceYuan - basePriceYuan).toFixed(2))))
setPercentMarkup(
basePriceYuan > 0
? Number((((currentPriceYuan - basePriceYuan) / basePriceYuan) * 100).toFixed(1))
: 0
)
setPricingMode('target_price')
} else {
if (selectedRowKeys.length === 0) {
message.warning('请先勾选需要批量加价的账号商品')
return
}
setTargetAccount(null)
setMarkupRatio(0)
setBatchMode('percent')
setBatchValue(10)
}
setMarkupModalVisible(true)
}
function onTargetPriceChange(val: number | null) {
const nextTarget = Math.max(0, val ?? 0)
setTargetPriceYuan(nextTarget)
if (!targetAccount) return
const basePriceYuan = (targetAccount.price_cent || 0) / 100
const addYuan = Math.max(0, Number((nextTarget - basePriceYuan).toFixed(2)))
setAddAmountYuan(addYuan)
setPercentMarkup(basePriceYuan > 0 ? Number(((addYuan / basePriceYuan) * 100).toFixed(1)) : 0)
const delta = calcDeltaFromTargetPrice(targetAccount.haf_coin_amount || 0, targetAccount.price_cent || 0, nextTarget)
setMarkupRatio(delta)
}
function onAddAmountChange(val: number | null) {
const nextAdd = Math.max(0, val ?? 0)
setAddAmountYuan(nextAdd)
if (!targetAccount) return
const basePriceYuan = (targetAccount.price_cent || 0) / 100
const nextTarget = Number((basePriceYuan + nextAdd).toFixed(2))
setTargetPriceYuan(nextTarget)
setPercentMarkup(basePriceYuan > 0 ? Number(((nextAdd / basePriceYuan) * 100).toFixed(1)) : 0)
const delta = calcDeltaFromTargetPrice(targetAccount.haf_coin_amount || 0, targetAccount.price_cent || 0, nextTarget)
setMarkupRatio(delta)
}
function onPercentMarkupChange(val: number | null) {
const nextPct = Math.max(0, val ?? 0)
setPercentMarkup(nextPct)
if (!targetAccount) return
const basePriceYuan = (targetAccount.price_cent || 0) / 100
const nextTarget = Number((basePriceYuan * (1 + nextPct / 100)).toFixed(2))
setTargetPriceYuan(nextTarget)
setAddAmountYuan(Number((nextTarget - basePriceYuan).toFixed(2)))
const delta = calcDeltaFromTargetPrice(targetAccount.haf_coin_amount || 0, targetAccount.price_cent || 0, nextTarget)
setMarkupRatio(delta)
}
function onMarkupRatioChange(val: number | null) {
const nextDelta = Math.max(0, val ?? 0)
setMarkupRatio(nextDelta)
if (!targetAccount) return
const preview = calcPreviewPrice(targetAccount.haf_coin_amount || 0, targetAccount.price_cent || 0, nextDelta)
const previewYuan = Number((preview.priceCent / 100).toFixed(2))
const basePriceYuan = (targetAccount.price_cent || 0) / 100
setTargetPriceYuan(previewYuan)
const addYuan = Number((preview.markupCent / 100).toFixed(2))
setAddAmountYuan(addYuan)
setPercentMarkup(basePriceYuan > 0 ? Number(((addYuan / basePriceYuan) * 100).toFixed(1)) : 0)
}
async function handleSaveMarkup() {
if (!siteID) return
if (markupRatio < 0) {
message.error('提价调减比例不能小于0')
return
}
setMarkupSaving(true)
try {
if (targetAccount) {
await updateAccountMarkup(targetAccount.id, markupRatio)
message.success('商品加价比例设置成功')
message.success(`商品《${targetAccount.title || targetAccount.listing_no}》加价成功!`)
} else {
const listingIDs = items
.filter((it) => selectedRowKeys.includes(it.id))
.map((it) => it.listing_id)
if (listingIDs.length === 0) {
const selectedItems = items.filter((it) => selectedRowKeys.includes(it.id))
if (selectedItems.length === 0) {
message.warning('请先勾选要加价的商品')
return
}
await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置提价!`)
if (batchMode === 'ratio_delta') {
const listingIDs = selectedItems.map((it) => it.listing_id)
await batchUpdateAccountMarkup(siteID, listingIDs, batchValue)
} else if (batchMode === 'percent') {
await Promise.all(
selectedItems.map((it) => {
const basePriceYuan = (it.price_cent || 0) / 100
const targetYuan = basePriceYuan * (1 + batchValue / 100)
const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan)
return updateAccountMarkup(it.id, delta)
})
)
} else if (batchMode === 'add_amount') {
await Promise.all(
selectedItems.map((it) => {
const basePriceYuan = (it.price_cent || 0) / 100
const targetYuan = basePriceYuan + batchValue
const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan)
return updateAccountMarkup(it.id, delta)
})
)
}
message.success(`已成功为选中的 ${selectedItems.length} 个商品批量设置提价!`)
setSelectedRowKeys([])
}
setMarkupModalVisible(false)
@@ -742,63 +865,315 @@ export default function Accounts() {
/>
</Card>
{/* Markup Modal */}
{/* Rich Multi-mode Markup Modal */}
<Modal
title={
targetAccount
? `设置提价 - ${targetAccount.title || targetAccount.listing_no}`
: `批量提价 (${selectedRowKeys.length} 个账号商品)`
<Space>
<DollarCircleOutlined style={{ color: '#52c41a' }} />
<span>{targetAccount ? `商品加价设置 - ${targetAccount.title || targetAccount.listing_no}` : `批量调价 (${selectedRowKeys.length} 个账号商品)`}</span>
</Space>
}
open={markupModalVisible}
onCancel={() => setMarkupModalVisible(false)}
onOk={handleSaveMarkup}
confirmLoading={markupSaving}
okText="保存提价"
okText="保存并同步货架"
cancelText="取消"
width={580}
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%', padding: '12px 0' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;
<strong style={{ color: '#389e0d' }}>提价多挣的差价收益 100% 归本分站所有</strong>。
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text strong>提价调减比例:</Typography.Text>
<InputNumber
min={0}
max={100}
precision={1}
style={{ width: 180 }}
value={markupRatio}
onChange={(val) => setMarkupRatio(val ?? 0)}
addonAfter="比例点"
placeholder="0"
/>
</div>
{targetAccount && (
<div
style={{
marginTop: 8,
padding: '8px 12px',
background: '#f6ffed',
borderRadius: 4,
border: '1px solid #b7eb8f',
fontSize: 12,
}}
>
主站基准比例 {targetAccount.base_ratio || '-'} → 分站提价后比例{' '}
<strong>{Math.max((targetAccount.base_ratio || 0) - markupRatio, 0.1).toFixed(1)}</strong>
,预计售价{' '}
<strong style={{ color: '#cf1322' }}>
{markupRatio > 0 && targetAccount.base_ratio
? `¥${(
(targetAccount.haf_coin_amount || 0) /
10000 /
Math.max(targetAccount.base_ratio - markupRatio, 0.1)
).toFixed(2)}`
: formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)}
</strong>
(加价溢价全额归本站独享)
</div>
<Space direction="vertical" style={{ orientation: 'vertical', width: '100%', padding: '8px 0' }} size="middle">
<Alert
type="success"
showIcon
message="分销商 100% 独享溢价说明"
description="在主站基准租金之上增加的全部溢价金额(加价差价),100% 独立结算至本分销商账户,不与任何第三方分成!"
/>
{targetAccount ? (
<>
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 6,
padding: '12px 16px',
}}
>
<Row gutter={[16, 8]}>
<Col span={24}>
<Typography.Text strong style={{ fontSize: 14 }}>
{targetAccount.title || targetAccount.listing_no}
</Typography.Text>
</Col>
<Col span={12}>
<Typography.Text type="secondary">游戏区服:</Typography.Text>
<span>{targetAccount.game_name || '-'} / {targetAccount.server_region || '-'}</span>
</Col>
<Col span={12}>
<Typography.Text type="secondary">哈夫币资产:</Typography.Text>
<span>
{(targetAccount.haf_coin_amount || 0).toLocaleString()} 币
<span style={{ color: '#8c8c8c', marginLeft: 4 }}>
({((targetAccount.haf_coin_amount || 0) / 10000).toFixed(1)} 万)
</span>
</span>
</Col>
<Col span={12}>
<Typography.Text type="secondary">主站基准价:</Typography.Text>
<strong style={{ color: '#1677ff' }}>¥{((targetAccount.price_cent || 0) / 100).toFixed(2)}</strong>
</Col>
<Col span={12}>
<Typography.Text type="secondary">主站基准比例:</Typography.Text>
<span>{targetAccount.base_ratio || '-'} 点</span>
</Col>
</Row>
</div>
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
加价模式选择:
</Typography.Text>
<Radio.Group
value={pricingMode}
onChange={(e) => setPricingMode(e.target.value)}
buttonStyle="solid"
style={{ width: '100%' }}
>
<Radio.Button value="target_price" style={{ width: '25%', textAlign: 'center' }}>期望售价</Radio.Button>
<Radio.Button value="add_amount" style={{ width: '25%', textAlign: 'center' }}>固定加价</Radio.Button>
<Radio.Button value="percent" style={{ width: '25%', textAlign: 'center' }}>百分比上浮</Radio.Button>
<Radio.Button value="ratio_delta" style={{ width: '25%', textAlign: 'center' }}>比例调减</Radio.Button>
</Radio.Group>
</div>
<div style={{ background: '#fafafa', padding: 12, borderRadius: 6 }}>
{pricingMode === 'target_price' && (
<div>
<div style={{ marginBottom: 6 }}>
<Typography.Text strong>最终售价(元):</Typography.Text>
</div>
<InputNumber
prefix="¥"
min={Number(((targetAccount.price_cent || 0) / 100).toFixed(2))}
precision={2}
step={1}
style={{ width: '100%' }}
value={targetPriceYuan}
onChange={onTargetPriceChange}
placeholder="输入租客最终看到的标价"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
输入最终期望标价,系统自动换算对应比例点
</Typography.Text>
</div>
)}
{pricingMode === 'add_amount' && (
<div>
<div style={{ marginBottom: 6 }}>
<Typography.Text strong>加价金额(元):</Typography.Text>
</div>
<InputNumber
prefix="+¥"
min={0}
precision={2}
step={1}
style={{ width: '100%' }}
value={addAmountYuan}
onChange={onAddAmountChange}
placeholder="输入在主站价格基础上增加的金额"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
在主站基准价上直接增加的纯利润金额
</Typography.Text>
</div>
)}
{pricingMode === 'percent' && (
<div>
<div style={{ marginBottom: 6 }}>
<Typography.Text strong>上浮百分比(%):</Typography.Text>
</div>
<InputNumber
prefix="+"
addonAfter="%"
min={0}
max={500}
precision={1}
step={5}
style={{ width: '100%' }}
value={percentMarkup}
onChange={onPercentMarkupChange}
placeholder="例如 15"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
按基准价百分比上浮,例如 10% 或 20%
</Typography.Text>
</div>
)}
{pricingMode === 'ratio_delta' && (
<div>
<div style={{ marginBottom: 6 }}>
<Typography.Text strong>调减比例点(点):</Typography.Text>
</div>
<InputNumber
min={0}
max={100}
precision={1}
step={0.1}
addonAfter="点"
style={{ width: '100%' }}
value={markupRatio}
onChange={onMarkupRatioChange}
placeholder="0"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
高级模式:直接调减 1 元兑换哈夫币比例,比例越小售价越高
</Typography.Text>
</div>
)}
</div>
{/* Real-time Earnings Comparison Card */}
<div
style={{
padding: '14px 16px',
background: '#f6ffed',
borderRadius: 6,
border: '1px solid #b7eb8f',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text type="secondary">预计本站标价:</Typography.Text>
<Typography.Text strong style={{ fontSize: 16, color: '#cf1322' }}>
¥{targetPriceYuan.toFixed(2)}
</Typography.Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text type="secondary">本站 100% 独享溢价:</Typography.Text>
<Typography.Text strong style={{ fontSize: 18, color: '#389e0d' }}>
+¥{addAmountYuan.toFixed(2)}
</Typography.Text>
</div>
<Divider style={{ margin: '8px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#595959' }}>
<span>兑换比例换算:</span>
<span>
基准 {targetAccount.base_ratio || '-'} → 标价{' '}
<strong>{Math.max((targetAccount.base_ratio || 0) - markupRatio, 0.01).toFixed(1)}</strong>{' '}
(调减 {markupRatio.toFixed(1)} 点)
</span>
</div>
</div>
</>
) : (
<>
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
批量加价模式(已勾选 {selectedRowKeys.length} 件商品):
</Typography.Text>
<Radio.Group
value={batchMode}
onChange={(e) => setBatchMode(e.target.value)}
buttonStyle="solid"
style={{ width: '100%' }}
>
<Radio.Button value="percent" style={{ width: '33.3%', textAlign: 'center' }}>
按百分比批量上浮 (推荐)
</Radio.Button>
<Radio.Button value="add_amount" style={{ width: '33.3%', textAlign: 'center' }}>
固定加价金额
</Radio.Button>
<Radio.Button value="ratio_delta" style={{ width: '33.3%', textAlign: 'center' }}>
统一调减比例点
</Radio.Button>
</Radio.Group>
</div>
<div style={{ background: '#fafafa', padding: 16, borderRadius: 6 }}>
{batchMode === 'percent' && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 6 }}>
统一上浮百分比(%):
</Typography.Text>
<InputNumber
prefix="+"
addonAfter="%"
min={0}
max={500}
precision={1}
step={5}
style={{ width: '100%' }}
value={batchValue}
onChange={(v) => setBatchValue(v ?? 0)}
placeholder="例如 10 或 15"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
所有勾选商品将按各自底价自动上浮 {batchValue}%,并自动为每件商品精确匹配比例点
</Typography.Text>
</div>
)}
{batchMode === 'add_amount' && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 6 }}>
统一加价金额(元):
</Typography.Text>
<InputNumber
prefix="+¥"
addonAfter="元"
min={0}
precision={2}
step={1}
style={{ width: '100%' }}
value={batchValue}
onChange={(v) => setBatchValue(v ?? 0)}
placeholder="例如 5.00"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
所有勾选商品统一定价加价 ¥{batchValue.toFixed(2)},溢价收益全额入账本站
</Typography.Text>
</div>
)}
{batchMode === 'ratio_delta' && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 6 }}>
统一调减比例点数:
</Typography.Text>
<InputNumber
min={0}
max={100}
precision={1}
step={0.1}
addonAfter="点"
style={{ width: '100%' }}
value={batchValue}
onChange={(v) => setBatchValue(v ?? 0)}
placeholder="例如 0.5"
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
统一在各商品原比例基础上调减 {batchValue} 点
</Typography.Text>
</div>
)}
</div>
<div
style={{
padding: '12px 16px',
background: '#f6ffed',
borderRadius: 6,
border: '1px solid #b7eb8f',
fontSize: 13,
color: '#389e0d',
}}
>
💡 已勾选 <strong>{selectedRowKeys.length}</strong> 件商品,批量调价后差价收益将 100% 归本分销商账户独享!
</div>
</>
)}
</Space>
</Modal>
+59 -7
View File
@@ -267,13 +267,65 @@ export default function Dashboard() {
dataSource={recentOrders}
pagination={false}
columns={[
{ title: '订单号', dataIndex: 'order_no', render: (v: string) => <Typography.Text copyable>{v}</Typography.Text> },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{v}</Tag> },
{ title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '结算状态', dataIndex: 'settlement_status', render: (v: string) => <Tag color={v === 'settled' ? 'success' : 'default'}>{v}</Tag> },
{ title: '创建时间', dataIndex: 'created_at' },
{
title: '订单号',
dataIndex: 'order_no',
width: 170,
render: (v: string) => <Typography.Text copyable strong>{v}</Typography.Text>,
},
{
title: '租赁账号与游戏',
minWidth: 180,
render: (_: unknown, row: DistributionOrder) => (
<div>
<Typography.Text strong style={{ display: 'block' }}>
{row.title || (row.listing_no ? `商品 #${row.listing_no}` : `订单 #${row.order_id}`)}
</Typography.Text>
{row.game_name && <Tag color="blue" style={{ marginTop: 2 }}>{row.game_name}</Tag>}
{row.server_region && <Tag>{row.server_region}</Tag>}
</div>
),
},
{
title: '实付金额',
width: 120,
render: (_: unknown, row: DistributionOrder) => (
<Typography.Text strong style={{ color: '#1677ff' }}>
{formatMoney(row.net_paid_amount_cent || row.gross_paid_amount_cent || 0)}
</Typography.Text>
),
},
{
title: '本站净收益',
width: 140,
render: (_: unknown, row: DistributionOrder) => (
<Typography.Text strong style={{ color: '#389e0d', fontSize: 15 }}>
+{formatMoney(row.distributor_earning_cent || 0)}
</Typography.Text>
),
},
{
title: '订单状态',
dataIndex: 'status',
width: 110,
render: (v: string) => <Tag color={v === 'completed' || v === 'renting' ? 'green' : 'default'}>{v}</Tag>,
},
{
title: '结算状态',
dataIndex: 'settlement_status',
width: 110,
render: (v: string) => <Tag color={v === 'settled' ? 'success' : 'warning'}>{v === 'settled' ? '已结算' : '待结算'}</Tag>,
},
{
title: '下单时间',
dataIndex: 'created_at',
width: 160,
render: (v: string) => (
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
]}
/>
</Card>
+543 -106
View File
@@ -11,8 +11,22 @@ import {
Input,
Button,
Modal,
Drawer,
Descriptions,
Divider,
Row,
Col,
Statistic,
Alert,
} from 'antd'
import { SearchOutlined, CheckCircleOutlined, StopOutlined } from '@ant-design/icons'
import {
SearchOutlined,
CheckCircleOutlined,
StopOutlined,
FileTextOutlined,
DollarCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
formatMoney,
listOrders,
@@ -26,20 +40,26 @@ import {
} from '../api'
import { useMember } from '../MemberContext'
const ORDER_STATUSES = [
'pending_payment',
'pending_handoff',
'renting',
'overdue',
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'disputing',
'abnormal',
'completed',
'refunded',
'cancelled',
]
const ORDER_STATUS_MAP: Record<string, { label: string; color: string }> = {
pending_payment: { label: '待支付', color: 'orange' },
pending_handoff: { label: '待上号交付', color: 'processing' },
renting: { label: '在租中', color: 'cyan' },
overdue: { label: '已逾期', color: 'volcano' },
pending_checkout_confirm: { label: '待结单确认', color: 'blue' },
pending_checkout_accept: { label: '待号主验收', color: 'blue' },
checkout_disputing: { label: '结单争议中', color: 'magenta' },
disputing: { label: '维权中', color: 'magenta' },
abnormal: { label: '异常', color: 'error' },
completed: { label: '已完成', color: 'success' },
refunded: { label: '已退款', color: 'purple' },
cancelled: { label: '已取消', color: 'default' },
}
const SETTLEMENT_STATUS_MAP: Record<string, { label: string; color: string }> = {
settled: { label: '已结算入账', color: 'success' },
pending: { label: '履约中待结算', color: 'warning' },
failed: { label: '结算异常', color: 'error' },
}
const REVIEW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: 'orange' },
@@ -55,20 +75,25 @@ const LISTING_STATUS_LABELS: Record<string, { label: string; color: string }> =
draft: { label: '草稿', color: 'warning' },
}
function maskPhone(phone?: string): string {
if (!phone || phone.length < 7) return phone || '-'
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
// Orders Tab State
// Orders State
const [orders, setOrders] = useState<DistributionOrder[]>([])
const [orderTotal, setOrderTotal] = useState(0)
const [orderPage, setOrderPage] = useState(1)
const [orderLoading, setOrderLoading] = useState(false)
const [status, setStatus] = useState<string>('')
const [orderStatus, setOrderStatus] = useState<string>('')
const [orderKeyword, setOrderKeyword] = useState<string>('')
// Received Listings Tab State
// Received Listings Tab State (for kind === 'receive')
const [activeTab, setActiveTab] = useState<'received_listings' | 'orders'>('received_listings')
const [receivedListings, setReceivedListings] = useState<ReceivedListing[]>([])
const [recvTotal, setRecvTotal] = useState(0)
@@ -78,51 +103,61 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
const [recvReviewStatus, setRecvReviewStatus] = useState<string>('')
const [recvKeyword, setRecvKeyword] = useState<string>('')
// Order Details & Earnings Drawer State
const [selectedOrder, setSelectedOrder] = useState<DistributionOrder | null>(null)
const [drawerVisible, setDrawerVisible] = useState(false)
const pageSize = 20
const loadOrders = useCallback(async (targetPage: number) => {
if (!siteID) return
setOrderLoading(true)
try {
const result = await listOrders({
kind,
status,
siteID,
page: targetPage,
pageSize,
keyword: orderKeyword || undefined
})
setOrders(result.items)
setOrderTotal(result.total)
setOrderPage(result.page)
} catch {
message.error('订单加载失败')
} finally {
setOrderLoading(false)
}
}, [kind, status, siteID, orderKeyword])
const loadOrders = useCallback(
async (targetPage: number) => {
if (!siteID) return
setOrderLoading(true)
try {
const result = await listOrders({
kind,
status: orderStatus || undefined,
siteID,
page: targetPage,
pageSize,
keyword: orderKeyword || undefined,
})
setOrders(result.items)
setOrderTotal(result.total)
setOrderPage(result.page)
} catch {
message.error('订单加载失败')
} finally {
setOrderLoading(false)
}
},
[kind, orderStatus, siteID, orderKeyword]
)
const loadReceivedListings = useCallback(async (targetPage: number) => {
if (!siteID) return
setRecvLoading(true)
try {
const result = await fetchReceivedListings({
siteID,
page: targetPage,
pageSize,
status: recvStatus || undefined,
reviewStatus: recvReviewStatus || undefined,
keyword: recvKeyword || undefined,
})
setReceivedListings(result.items)
setRecvTotal(result.total)
setRecvPage(result.page)
} catch {
message.error('分销收号资产加载失败')
} finally {
setRecvLoading(false)
}
}, [siteID, recvStatus, recvReviewStatus, recvKeyword])
const loadReceivedListings = useCallback(
async (targetPage: number) => {
if (!siteID) return
setRecvLoading(true)
try {
const result = await fetchReceivedListings({
siteID,
page: targetPage,
pageSize,
status: recvStatus || undefined,
reviewStatus: recvReviewStatus || undefined,
keyword: recvKeyword || undefined,
})
setReceivedListings(result.items)
setRecvTotal(result.total)
setRecvPage(result.page)
} catch {
message.error('分销收号资产加载失败')
} finally {
setRecvLoading(false)
}
},
[siteID, recvStatus, recvReviewStatus, recvKeyword]
)
useEffect(() => {
async function init() {
@@ -165,7 +200,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
} else {
void loadOrders(1)
}
}, [kind, activeTab, loadOrders, loadReceivedListings])
}, [kind, activeTab, siteID, loadOrders, loadReceivedListings])
async function handleToggleListingStatus(item: ReceivedListing) {
const nextStatus = item.status === 'offline' ? 'published' : 'offline'
@@ -186,17 +221,161 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
})
}
const orderColumns = [
{
title: '订单号 / 时间',
width: 190,
render: (_: unknown, row: DistributionOrder) => (
<div>
<Typography.Text copyable strong style={{ fontSize: 13 }}>
{row.order_no}
</Typography.Text>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>
{row.created_at ? new Date(row.created_at).toLocaleString('zh-CN', { hour12: false }) : '-'}
</div>
</div>
),
},
{
title: '商品与游戏',
minWidth: 220,
render: (_: unknown, row: DistributionOrder) => (
<div>
<Typography.Text strong style={{ display: 'block', fontSize: 14 }}>
{row.title || (row.listing_no ? `商品 #${row.listing_no}` : `订单商品 #${row.order_id}`)}
</Typography.Text>
<Space size={4} style={{ marginTop: 4 }} wrap>
{row.game_name && <Tag color="blue">{row.game_name}</Tag>}
{row.server_region && <Tag>{row.server_region}</Tag>}
{row.login_platform && <Tag color="cyan">{row.login_platform}</Tag>}
{row.rent_duration_hours ? <Tag color="purple">租期: {row.rent_duration_hours}h</Tag> : null}
</Space>
</div>
),
},
{
title: '租客',
width: 140,
render: (_: unknown, row: DistributionOrder) => (
<div>
<div style={{ fontWeight: 500 }}>{row.renter_nickname || '平台租客'}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{maskPhone(row.renter_phone)}
</Typography.Text>
</div>
),
},
{
title: '实付租金 / 押金',
width: 140,
render: (_: unknown, row: DistributionOrder) => {
const netPaid = row.net_paid_amount_cent || row.gross_paid_amount_cent || 0
return (
<div>
<Typography.Text strong style={{ color: '#1677ff', fontSize: 15 }}>
{formatMoney(netPaid)}
</Typography.Text>
{row.deposit_amount_cent ? (
<div style={{ fontSize: 12, color: '#8c8c8c' }}>押金: {formatMoney(row.deposit_amount_cent)}</div>
) : null}
</div>
)
},
},
{
title: '本站净收益',
width: 180,
render: (_: unknown, row: DistributionOrder) => {
const earning = row.distributor_earning_cent ?? 0
const roleLabel =
row.distributor_role === 'seller'
? '售出方'
: row.distributor_role === 'receiver'
? '收号方'
: row.distributor_role === 'two_party_distributor'
? '分销方'
: '本站收益'
return (
<div>
<Typography.Text strong style={{ color: '#389e0d', fontSize: 16 }}>
+{formatMoney(earning)}
</Typography.Text>
<div style={{ marginTop: 2 }}>
<Tag color={earning > 0 ? 'green' : 'default'} style={{ fontSize: 11 }}>
{roleLabel}
</Tag>
</div>
{row.distributor_markup_cent && row.distributor_markup_cent > 0 ? (
<div style={{ fontSize: 11, color: '#52c41a' }}>
含自主溢价: +{formatMoney(row.distributor_markup_cent)}
</div>
) : null}
</div>
)
},
},
{
title: '订单履约状态',
width: 120,
render: (_: unknown, row: DistributionOrder) => {
const conf = ORDER_STATUS_MAP[row.status] || { label: row.status, color: 'default' }
return (
<div>
<Tag color={conf.color}>{conf.label}</Tag>
{row.refund_status && row.refund_status !== 'none' && (
<div style={{ marginTop: 2 }}>
<Tag color="magenta" style={{ fontSize: 11 }}>
退款: {row.refund_status}
</Tag>
</div>
)}
</div>
)
},
},
{
title: '结算状态',
width: 110,
render: (_: unknown, row: DistributionOrder) => {
const conf = SETTLEMENT_STATUS_MAP[row.settlement_status] || {
label: row.settlement_status,
color: 'default',
}
return <Tag color={conf.color}>{conf.label}</Tag>
},
},
{
title: '操作',
width: 110,
render: (_: unknown, row: DistributionOrder) => (
<Button
type="link"
size="small"
icon={<FileTextOutlined />}
onClick={() => {
setSelectedOrder(row)
setDrawerVisible(true)
}}
>
收益对账
</Button>
),
},
]
const title = kind === 'sold' ? '售出账号订单' : kind === 'receive' ? '分销收号管理' : '全部分销订单'
return (
<Space direction="vertical" className="page" style={{ width: '100%' }}>
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>{title}</Typography.Title>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{title}
</Typography.Title>
<Typography.Text type="secondary">
{kind === 'receive'
? '查看由当前分销网站上传的号源资产及审核流转状态,并跟踪对应的收号分成订单'
: '查看分销订单交易及财务明细'}
: '查看分销订单履约进展与收益拆解对账单(本站独享自主溢价与提成明细)'}
</Typography.Text>
</div>
{member?.is_superadmin && sites.length > 1 && (
@@ -228,9 +407,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
style={{ width: 140 }}
value={recvReviewStatus || undefined}
options={Object.entries(REVIEW_STATUS_LABELS).map(([k, v]) => ({ label: v.label, value: k }))}
onChange={(val) => {
setRecvReviewStatus(val ?? '')
}}
onChange={(val) => setRecvReviewStatus(val ?? '')}
/>
<Select
allowClear
@@ -238,9 +415,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
style={{ width: 140 }}
value={recvStatus || undefined}
options={Object.entries(LISTING_STATUS_LABELS).map(([k, v]) => ({ label: v.label, value: k }))}
onChange={(val) => {
setRecvStatus(val ?? '')
}}
onChange={(val) => setRecvStatus(val ?? '')}
/>
<Input
placeholder="搜索编号/标题/联系电话/昵称"
@@ -251,9 +426,20 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
onChange={(e) => setRecvKeyword(e.target.value)}
onPressEnter={() => void loadReceivedListings(1)}
/>
<Button type="primary" onClick={() => void loadReceivedListings(1)}>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void loadReceivedListings(1)}>
筛选
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setRecvReviewStatus('')
setRecvStatus('')
setRecvKeyword('')
void loadReceivedListings(1)
}}
>
重置
</Button>
</Space>
<Table
@@ -272,7 +458,11 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
title: '商品编号',
dataIndex: 'listing_no',
width: 140,
render: (v: string) => <Typography.Text strong copyable>{v}</Typography.Text>,
render: (v: string) => (
<Typography.Text strong copyable>
{v}
</Typography.Text>
),
},
{
title: '账号标题与游戏',
@@ -377,37 +567,50 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
label: `收号分成订单 (${orderTotal})`,
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Space style={{ marginBottom: 16 }}>
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="订单状态"
style={{ width: 180 }}
options={ORDER_STATUSES.map((value) => ({ label: value, value }))}
onChange={(value) => setStatus(value ?? '')}
style={{ width: 160 }}
value={orderStatus || undefined}
options={Object.entries(ORDER_STATUS_MAP).map(([k, v]) => ({ label: v.label, value: k }))}
onChange={(value) => setOrderStatus(value ?? '')}
/>
<Input
placeholder="搜索订单号/商品/游戏/租客手机"
prefix={<SearchOutlined />}
style={{ width: 280 }}
allowClear
value={orderKeyword}
onChange={(e) => setOrderKeyword(e.target.value)}
onPressEnter={() => void loadOrders(1)}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void loadOrders(1)}>
筛选
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setOrderStatus('')
setOrderKeyword('')
void loadOrders(1)
}}
>
重置
</Button>
</Space>
<Table
rowKey="order_id"
loading={orderLoading}
dataSource={orders}
columns={orderColumns}
pagination={{
current: orderPage,
pageSize,
total: orderTotal,
showTotal: (tot) => `共 ${tot} 笔分成订单`,
onChange: (p) => void loadOrders(p),
}}
columns={[
{ title: '订单号', dataIndex: 'order_no' },
{ title: '订单状态', dataIndex: 'status', render: (v: string) => <Tag>{v}</Tag> },
{ title: '退款状态', dataIndex: 'refund_status' },
{ title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '服务费', dataIndex: 'service_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '尾差', dataIndex: 'rounding_adjustment_cent', render: (v: number) => formatMoney(v) },
{ title: '结算', dataIndex: 'settlement_status' },
{ title: '创建时间', dataIndex: 'created_at' },
]}
/>
</Space>
),
@@ -417,35 +620,269 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
</Card>
) : (
<Card>
<Space style={{ marginBottom: 16 }}>
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="订单状态"
style={{ width: 200 }}
options={ORDER_STATUSES.map((value) => ({ label: value, value }))}
onChange={(value) => setStatus(value ?? '')}
style={{ width: 160 }}
value={orderStatus || undefined}
options={Object.entries(ORDER_STATUS_MAP).map(([k, v]) => ({ label: v.label, value: k }))}
onChange={(value) => setOrderStatus(value ?? '')}
/>
<Input
placeholder="搜索订单号/商品/游戏/租客手机"
prefix={<SearchOutlined />}
style={{ width: 280 }}
allowClear
value={orderKeyword}
onChange={(e) => setOrderKeyword(e.target.value)}
onPressEnter={() => void loadOrders(1)}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void loadOrders(1)}>
筛选
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setOrderStatus('')
setOrderKeyword('')
void loadOrders(1)
}}
>
重置
</Button>
</Space>
<Table
rowKey="order_id"
loading={orderLoading}
dataSource={orders}
pagination={{ current: orderPage, pageSize, total: orderTotal, onChange: (p) => void loadOrders(p) }}
columns={[
{ title: '订单号', dataIndex: 'order_no' },
{ title: '订单状态', dataIndex: 'status', render: (v: string) => <Tag>{v}</Tag> },
{ title: '退款状态', dataIndex: 'refund_status' },
{ title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '服务费', dataIndex: 'service_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '尾差', dataIndex: 'rounding_adjustment_cent', render: (v: number) => formatMoney(v) },
{ title: '结算', dataIndex: 'settlement_status' },
{ title: '创建时间', dataIndex: 'created_at' },
]}
columns={orderColumns}
pagination={{
current: orderPage,
pageSize,
total: orderTotal,
showTotal: (tot) => `共 ${tot} 笔分销订单`,
onChange: (p) => void loadOrders(p),
}}
/>
</Card>
)}
{/* Order Details & Earnings Drawer */}
<Drawer
title={
<Space>
<DollarCircleOutlined style={{ color: '#52c41a' }} />
<span>订单收益对账单</span>
{selectedOrder && (
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 'normal' }}>
({selectedOrder.order_no})
</Typography.Text>
)}
</Space>
}
open={drawerVisible}
onClose={() => {
setDrawerVisible(false)
setSelectedOrder(null)
}}
width={620}
destroyOnClose
>
{selectedOrder && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* Top Stat Summary */}
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '16px 20px',
}}
>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="本站净收益"
value={((selectedOrder.distributor_earning_cent || 0) / 100).toFixed(2)}
precision={2}
prefix="+¥"
valueStyle={{ color: '#389e0d', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<Statistic
title="租客实付金额"
value={(
(selectedOrder.net_paid_amount_cent || selectedOrder.gross_paid_amount_cent || 0) /
100
).toFixed(2)}
precision={2}
prefix="¥"
valueStyle={{ color: '#1677ff' }}
/>
</Col>
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
<Tag
color={SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.color || 'default'}
style={{ fontSize: 13, padding: '2px 8px' }}
>
{SETTLEMENT_STATUS_MAP[selectedOrder.settlement_status]?.label || selectedOrder.settlement_status}
</Tag>
</Col>
</Row>
</div>
{/* Product & Rental Info */}
<Descriptions
title="商品与租赁履约信息"
bordered
size="small"
column={2}
items={[
{
key: 'title',
label: '商品名称',
span: 2,
children: selectedOrder.title || selectedOrder.listing_no || '-',
},
{
key: 'game',
label: '游戏 / 区服',
children: `${selectedOrder.game_name || '-'} / ${selectedOrder.server_region || '-'}`,
},
{
key: 'platform',
label: '登录平台',
children: selectedOrder.login_platform || '-',
},
{
key: 'renter',
label: '租客昵称',
children: selectedOrder.renter_nickname || '匿名租客',
},
{
key: 'renter_phone',
label: '租客手机',
children: selectedOrder.renter_phone || '-',
},
{
key: 'duration',
label: '租用时长',
children: selectedOrder.rent_duration_hours ? `${selectedOrder.rent_duration_hours} 小时` : '-',
},
{
key: 'status',
label: '订单履约状态',
children: (
<Tag color={ORDER_STATUS_MAP[selectedOrder.status]?.color || 'default'}>
{ORDER_STATUS_MAP[selectedOrder.status]?.label || selectedOrder.status}
</Tag>
),
},
{
key: 'created_at',
label: '下单时间',
span: 2,
children: selectedOrder.created_at
? new Date(selectedOrder.created_at).toLocaleString('zh-CN', { hour12: false })
: '-',
},
]}
/>
{/* Clear Transparent Accounting Breakdown Card */}
<div>
<Typography.Title level={5} style={{ marginBottom: 8 }}>
订单清算与商户收益对账单
</Typography.Title>
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
padding: '16px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>1. 租客实付租金总额:</span>
<strong style={{ color: '#1677ff' }}>
¥
{(
(selectedOrder.net_paid_amount_cent || selectedOrder.gross_paid_amount_cent || 0) /
100
).toFixed(2)}
</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>2. 减:号主底价成本(全额归号主,不分润):</span>
<span style={{ color: '#cf1322' }}>
- ¥{((selectedOrder.owner_rent_amount_cent || 0) / 100).toFixed(2)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>3. 减:支付通道手续费(千分之五):</span>
<span style={{ color: '#cf1322' }}>
- ¥{((selectedOrder.payment_fee_cent || 0) / 100).toFixed(2)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#595959' }}>4. 可分润主站提价利润池:</span>
<strong>¥{((selectedOrder.split_base_amount_cent || 0) / 100).toFixed(2)}</strong>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 8 }}>
<Tag color="blue">
本分销站角色:
{selectedOrder.distributor_role === 'seller'
? '售出方 (享 40% 提成分润 + 100% 自主加价溢价)'
: selectedOrder.distributor_role === 'receiver'
? '收号方 (享 35% 提成分润)'
: '分销合作方'}
</Tag>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#389e0d' }}>
✦ 分站自主加价溢价(100% 独享收益):
</span>
<strong style={{ color: '#389e0d' }}>
+ ¥{((selectedOrder.distributor_markup_cent || 0) / 100).toFixed(2)}
</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ color: '#389e0d' }}>
✦ 提价利润分账分成:
</span>
<strong style={{ color: '#389e0d' }}>
+ ¥{((selectedOrder.distributor_split_cent || 0) / 100).toFixed(2)}
</strong>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 15, fontWeight: 'bold' }}>本单应收净利润合计:</span>
<span style={{ fontSize: 20, fontWeight: 'bold', color: '#389e0d' }}>
+ ¥{((selectedOrder.distributor_earning_cent || 0) / 100).toFixed(2)}
</span>
</div>
</div>
</div>
<Alert
type="info"
showIcon
message="履约结算保障"
description="租金结算将于订单正常完成履约且双方验收确认后自动打入本站商户账户,加价收益与分润资金可随时发起提现申请。"
/>
</Space>
)}
</Drawer>
</Space>
)
}
+223 -41
View File
@@ -1,12 +1,20 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message } from 'antd'
import { useEffect, useState, useCallback } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message, Row, Col, Divider, Alert, Button } from 'antd'
import {
SafetyCertificateOutlined,
DollarCircleOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
LockOutlined,
} from '@ant-design/icons'
import { listRules, listSites, updateRuleStatus, getMe, type Rule, type Site } from '../api'
import { useMember } from '../MemberContext'
const MODE_LABELS: Record<string, string> = {
two_party_distributor_receive: '两方-分销收号',
two_party_platform_receive: '两方-分销卖出',
three_party: '三方',
two_party_distributor_receive: '分销收号模式 (主站售出 / 散户在本站上传)',
two_party_platform_receive: '分销卖号模式 (分站售出 / 主站共享号源池)',
three_party: '分销跨站协同模式 (分站A收号 + 分站B卖出)',
site_self_sell: '分站自收自卖模式',
}
export default function Rules() {
@@ -16,16 +24,16 @@ export default function Rules() {
const [rules, setRules] = useState<Rule[]>([])
const [loading, setLoading] = useState(false)
async function load(site?: number) {
const load = useCallback(async (site?: number) => {
setLoading(true)
try {
setRules(await listRules(site))
} catch {
message.error('规则加载失败')
message.error('分成规则加载失败')
} finally {
setLoading(false)
}
}
}, [])
useEffect(() => {
async function init() {
@@ -56,7 +64,7 @@ export default function Rules() {
}
}
void init()
}, [member])
}, [member, load])
async function toggleStatus(rule: Rule) {
const next = rule.status === 'active' ? 'disabled' : 'active'
@@ -69,57 +77,231 @@ export default function Rules() {
}
}
const isSuperadmin = member?.is_superadmin
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>比例与税费规则</Typography.Title>
<Space>
{member?.is_superadmin && sites.length > 1 && (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{isSuperadmin ? '分成规则配置与管理' : '分销商合作协议与分成政策'}
</Typography.Title>
<Typography.Text type="secondary">
{isSuperadmin
? '主站超管可维护各交易模式的分账比例、服务费与税费政策'
: '查看当前分销站签约的收益权益、分成政策与资金结算恒等式说明'}
</Typography.Text>
</div>
{isSuperadmin && sites.length > 1 && (
<Select
allowClear
placeholder="分销网站"
style={{ width: 200 }}
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
)}
</Space>
<Card>
</div>
{/* 4 Core Rights Cards */}
<Card title="分销商核心权益与收益构成">
<Row gutter={[16, 16]}>
<Col xs={24} md={6}>
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
padding: '16px',
height: '100%',
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#52c41a', fontSize: 20 }} />
<Typography.Text strong style={{ fontSize: 16, color: '#389e0d' }}>
自主加价 100% 独享
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
分销站在主站基准价上自行上浮的全部溢价差额,<strong>100% 独立结算至商户账户</strong>,主站不参与任何分润抽成。
</Typography.Text>
</Space>
</div>
</Col>
<Col xs={24} md={6}>
<div
style={{
background: '#e6f4ff',
border: '1px solid #91caff',
borderRadius: 8,
padding: '16px',
height: '100%',
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DollarCircleOutlined style={{ color: '#1677ff', fontSize: 20 }} />
<Typography.Text strong style={{ fontSize: 16, color: '#0958d9' }}>
卖号分成 40%
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
租客通过本分销站点租赁主站共享号源池账号,获得扣除号主底价及通道费后的<strong>主站提价利润池 40% 分成</strong>。
</Typography.Text>
</Space>
</div>
</Col>
<Col xs={24} md={6}>
<div
style={{
background: '#f9f0ff',
border: '1px solid #d3adf7',
borderRadius: 8,
padding: '16px',
height: '100%',
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SafetyCertificateOutlined style={{ color: '#722ed1', fontSize: 20 }} />
<Typography.Text strong style={{ fontSize: 16, color: '#531dab' }}>
收号分成 35%
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
散户号主通过本站上传账号并上架,在全网产生租赁订单后,本站长期享受该账号产生的<strong>主站提价利润 35% 分成</strong>。
</Typography.Text>
</Space>
</div>
</Col>
<Col xs={24} md={6}>
<div
style={{
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: 8,
padding: '16px',
height: '100%',
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CheckCircleOutlined style={{ color: '#fa8c16', fontSize: 20 }} />
<Typography.Text strong style={{ fontSize: 16, color: '#d46b08' }}>
底价与结算保障
</Typography.Text>
</div>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
号主本金 100% 刚兑保障,支付通道手续费固定为 0.5%,订单履约验收后系统自动打款至商户钱包。
</Typography.Text>
</Space>
</div>
</Col>
</Row>
</Card>
{/* Rules Table */}
<Card title="当前生效的交易分账规则版本">
<Table
rowKey="id"
loading={loading}
dataSource={rules}
pagination={false}
columns={[
{ title: '模式', dataIndex: 'mode', render: (v: string) => MODE_LABELS[v] ?? v },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '比例',
render: (_: unknown, r: Rule) => {
const parts: string[] = []
if (r.distributor_receive_ratio > 0) parts.push(`分销收 ${r.distributor_receive_ratio}%`)
if (r.platform_sell_ratio > 0) parts.push(`主站卖 ${r.platform_sell_ratio}%`)
if (r.distributor_sell_ratio > 0) parts.push(`分销卖 ${r.distributor_sell_ratio}%`)
if (r.platform_receive_ratio > 0) parts.push(`主站收 ${r.platform_receive_ratio}%`)
if (r.distributor_a_ratio > 0) parts.push(`分销A ${r.distributor_a_ratio}%`)
if (r.distributor_b_ratio > 0) parts.push(`分销B ${r.distributor_b_ratio}%`)
return parts.join(' / ') || '-'
},
},
{ title: '服务费', render: (_: unknown, r: Rule) => `${r.service_fee_value}%` },
{ title: '手续费', dataIndex: 'payment_fee_bps', render: (v: number) => `${v / 100}%` },
{ title: '税费', render: (_: unknown, r: Rule) => (r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭') },
{ title: '生效时间', dataIndex: 'effective_from' },
{
title: '操作',
render: (_: unknown, r: Rule) => (
<a onClick={() => toggleStatus(r)}>{r.status === 'active' ? '停用' : '启用'}</a>
title: '业务交易模式',
dataIndex: 'mode',
render: (v: string) => (
<div>
<strong>{MODE_LABELS[v] ?? v}</strong>
<div style={{ fontSize: 11, color: '#8c8c8c' }}>模式标识: {v}</div>
</div>
),
},
{
title: '版本号',
dataIndex: 'version',
width: 90,
render: (v: number) => <Tag color="blue">v{v}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => (
<Tag color={v === 'active' ? 'success' : 'default'}>
{v === 'active' ? '生效中' : '已停用'}
</Tag>
),
},
{
title: '分成权益比例',
render: (_: unknown, r: Rule) => {
const parts: string[] = []
if (r.distributor_receive_ratio > 0) parts.push(`分销收号: ${r.distributor_receive_ratio}%`)
if (r.distributor_sell_ratio > 0) parts.push(`分销卖出: ${r.distributor_sell_ratio}%`)
if (r.platform_receive_ratio > 0) parts.push(`主站收号: ${r.platform_receive_ratio}%`)
if (r.platform_sell_ratio > 0) parts.push(`主站卖出: ${r.platform_sell_ratio}%`)
if (r.distributor_a_ratio > 0) parts.push(`分销A: ${r.distributor_a_ratio}%`)
if (r.distributor_b_ratio > 0) parts.push(`分销B: ${r.distributor_b_ratio}%`)
return (
<Space direction="vertical" size={2}>
{parts.map((p, idx) => (
<Tag color="cyan" key={idx}>
{p}
</Tag>
))}
</Space>
)
},
},
{
title: '平台服务费',
width: 110,
render: (_: unknown, r: Rule) => <span>{r.service_fee_value}%</span>,
},
{
title: '通道手续费',
width: 110,
dataIndex: 'payment_fee_bps',
render: (v: number) => <span>{(v / 100).toFixed(2)}%</span>,
},
{
title: '生效时间',
dataIndex: 'effective_from',
width: 160,
render: (v: string) => (
<span style={{ fontSize: 12, color: '#595959' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
...(isSuperadmin
? [
{
title: '操作',
width: 100,
render: (_: unknown, r: Rule) => (
<Button
type="link"
size="small"
danger={r.status === 'active'}
onClick={() => void toggleStatus(r)}
>
{r.status === 'active' ? '停用' : '启用'}
</Button>
),
},
]
: []),
]}
/>
</Card>
+267 -48
View File
@@ -1,5 +1,30 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, DatePicker, Button, message, Modal } from 'antd'
import { useEffect, useState, useCallback } from 'react'
import {
Card,
Table,
Typography,
Space,
Select,
Tag,
DatePicker,
Button,
message,
Modal,
Alert,
Descriptions,
Divider,
Statistic,
Row,
Col,
Empty,
} from 'antd'
import {
DollarCircleOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
PlusOutlined,
} from '@ant-design/icons'
import {
createSettlementBatch,
formatMoney,
@@ -15,6 +40,21 @@ import {
import { useMember } from '../MemberContext'
import type { Dayjs } from 'dayjs'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
settled: { label: '已打款结算', color: 'success' },
draft: { label: '待审核出账', color: 'warning' },
processing: { label: '结算处理中', color: 'processing' },
}
const PARTY_ROLE_LABELS: Record<string, string> = {
seller: '卖出提成分账 (40%)',
distributor_b: '卖出提成分账 (40%)',
receiver: '收号提成分账 (35%)',
distributor_a: '收号提成分账 (35%)',
two_party_distributor: '分销商分润',
platform: '平台服务费',
}
export default function Settlements() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
@@ -24,17 +64,19 @@ export default function Settlements() {
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
const [itemsFor, setItemsFor] = useState<SettlementBatch | null>(null)
const [items, setItems] = useState<SettlementItem[]>([])
const [itemsLoading, setItemsLoading] = useState(false)
const [creating, setCreating] = useState(false)
async function load(id: number) {
const load = useCallback(async (id: number) => {
setLoading(true)
try {
setBatches(await listSettlementBatches(id))
} catch {
message.error('结算批次加载失败')
message.error('结算账单加载失败')
} finally {
setLoading(false)
}
}
}, [])
useEffect(() => {
async function init() {
@@ -66,107 +108,284 @@ export default function Settlements() {
}
}
void init()
}, [member])
}, [member, load])
async function handleCreate() {
if (!siteID || !range?.[0] || !range?.[1]) {
message.warning('请选择站点和结算周期')
return
}
setCreating(true)
try {
const batch = await createSettlementBatch(siteID, range[0].toISOString(), range[1].toISOString())
message.success(`批次 ${batch.batch_no} 已创建,合计 ${formatMoney(batch.total_amount_cent)}`)
message.success(`结算批次 ${batch.batch_no} 已创建,合计金额 ${formatMoney(batch.total_amount_cent)}`)
void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '批次创建失败')
} finally {
setCreating(false)
}
}
async function handleSettle(batch: SettlementBatch) {
try {
await settleBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已结算`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '结算失败')
}
Modal.confirm({
title: '确认打款结算?',
content: `即将对批次【${batch.batch_no}】执行结算操作,打款金额 ${formatMoney(batch.total_amount_cent)} 将计入商户可用余额。`,
onOk: async () => {
try {
await settleBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已成功结算入账!`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '结算失败')
}
},
})
}
async function showItems(batch: SettlementBatch) {
setItemsFor(batch)
setItemsLoading(true)
try {
setItemsFor(batch)
setItems(await listSettlementItems(batch.id))
} catch {
message.error('结算明细加载失败')
message.error('结算对账明细加载失败')
} finally {
setItemsLoading(false)
}
}
const isSuperadmin = member?.is_superadmin
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>结算管理</Typography.Title>
<Space>
{member?.is_superadmin && sites.length > 1 && (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{isSuperadmin ? '结算管理与批次出账' : '周期结算单与财务对账'}
</Typography.Title>
<Typography.Text type="secondary">
{isSuperadmin
? '主站财务管理员可按周期创建结算批次并执行打款审核'
: '查看主站定期出具的结算账单,核对订单分成明细与资金入账凭证'}
</Typography.Text>
</div>
{isSuperadmin && sites.length > 1 && (
<Select
placeholder="分销网站"
style={{ width: 200 }}
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
)}
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" onClick={handleCreate}>创建批次</Button>
</Space>
<Card>
</div>
<Alert
type="info"
showIcon
message="财务对账与结算说明"
description="分销订单在完成履约且双方验收确认后自动归集出账。结算单经主站财务审核打款后,收益将100%直接计入商户钱包可用余额,可随时在「资金与提现」中申请提现。"
/>
{/* Superadmin batch creation controls */}
{isSuperadmin && (
<Card title="管理员批次出账操作" size="small" style={{ background: '#f8fafc' }}>
<Space wrap>
<Typography.Text strong>选择结算结算周期:</Typography.Text>
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
生成结算账单批次
</Button>
</Space>
</Card>
)}
<Card title={isSuperadmin ? '结算批次列表' : '历史结算对账单'}>
<Table
rowKey="id"
loading={loading}
dataSource={batches}
pagination={false}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无结算账单。当期订单履约完成后将在下一个结算周期出具对账单。"
/>
),
}}
columns={[
{ title: '批次号', dataIndex: 'batch_no' },
{ title: '周期开始', dataIndex: 'period_start' },
{ title: '周期结束', dataIndex: 'period_end' },
{ title: '合计金额', dataIndex: 'total_amount_cent', render: (v: number) => formatMoney(v) },
{
title: '状态',
title: '账单批次号',
dataIndex: 'batch_no',
width: 200,
render: (v: string) => <Typography.Text strong copyable>{v}</Typography.Text>,
},
{
title: '结算周期开始',
dataIndex: 'period_start',
width: 170,
render: (v: string) => (
<span style={{ fontSize: 13, color: '#595959' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
{
title: '结算周期截止',
dataIndex: 'period_end',
width: 170,
render: (v: string) => (
<span style={{ fontSize: 13, color: '#595959' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
{
title: '账单合计收益',
dataIndex: 'total_amount_cent',
width: 160,
render: (v: number) => (
<Typography.Text strong style={{ color: '#389e0d', fontSize: 16 }}>
+{formatMoney(v)}
</Typography.Text>
),
},
{
title: '结算状态',
dataIndex: 'status',
render: (v: string) => <Tag color={v === 'settled' ? 'green' : 'gold'}>{v === 'settled' ? '已结算' : '草稿'}</Tag>,
width: 130,
render: (v: string) => {
const conf = STATUS_LABELS[v] || { label: v, color: 'default' }
return <Tag color={conf.color}>{conf.label}</Tag>
},
},
{
title: '操作',
width: 160,
render: (_: unknown, row: SettlementBatch) => (
<Space>
<a onClick={() => showItems(row)}>明细</a>
{row.status !== 'settled' && <a onClick={() => handleSettle(row)}>结算</a>}
<Button
type="link"
size="small"
icon={<FileTextOutlined />}
onClick={() => void showItems(row)}
>
查看明细
</Button>
{isSuperadmin && row.status !== 'settled' && (
<Button
type="link"
size="small"
style={{ color: '#52c41a' }}
onClick={() => void handleSettle(row)}
>
确认打款结算
</Button>
)}
</Space>
),
},
]}
/>
</Card>
{/* Itemized Settlement Details Modal */}
<Modal
open={itemsFor !== null}
title={`结算明细 · ${itemsFor?.batch_no ?? ''}`}
title={
<Space>
<DollarCircleOutlined style={{ color: '#52c41a' }} />
<span>结算账单对账明细 · {itemsFor?.batch_no ?? ''}</span>
</Space>
}
footer={null}
width={680}
onCancel={() => setItemsFor(null)}
destroyOnClose
>
<Table
rowKey="id"
dataSource={items}
pagination={false}
columns={[
{ title: '订单 ID', dataIndex: 'order_id' },
{ title: '参与方', dataIndex: 'party_role' },
{ title: '金额', dataIndex: 'amount_cent', render: (v: number) => formatMoney(v) },
{ title: '状态', dataIndex: 'status' },
]}
/>
{itemsFor && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '12px 16px',
}}
>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="账单结算总额"
value={((itemsFor.total_amount_cent || 0) / 100).toFixed(2)}
precision={2}
prefix="+¥"
valueStyle={{ color: '#389e0d', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
<Tag
color={STATUS_LABELS[itemsFor.status]?.color || 'default'}
style={{ fontSize: 13, padding: '2px 8px' }}
>
{STATUS_LABELS[itemsFor.status]?.label || itemsFor.status}
</Tag>
</Col>
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>包含明细笔数</div>
<strong style={{ fontSize: 18 }}>{items.length} 笔</strong>
</Col>
</Row>
</div>
<Table
rowKey="id"
loading={itemsLoading}
dataSource={items}
pagination={false}
columns={[
{
title: '关联订单 ID',
dataIndex: 'order_id',
render: (v: number) => <Typography.Text strong>订单 #{v}</Typography.Text>,
},
{
title: '分润角色 / 收益类型',
dataIndex: 'party_role',
render: (v: string) => (
<Tag color="blue">{PARTY_ROLE_LABELS[v] || v}</Tag>
),
},
{
title: '分成到账金额',
dataIndex: 'amount_cent',
render: (v: number) => (
<Typography.Text strong style={{ color: '#389e0d', fontSize: 15 }}>
+{formatMoney(v)}
</Typography.Text>
),
},
{
title: '入账状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'settled' ? 'success' : 'default'}>
{v === 'settled' ? '已入账钱包' : v || '待打款'}
</Tag>
),
},
]}
/>
</Space>
)}
</Modal>
</Space>
)
+504 -155
View File
@@ -12,14 +12,20 @@ import {
Col,
Divider,
Image,
Alert,
Tag,
Tabs,
Collapse,
} from 'antd'
import {
SaveOutlined,
PictureOutlined,
NotificationOutlined,
EyeOutlined,
PlusOutlined,
DeleteOutlined,
InfoCircleOutlined,
SkinOutlined,
GlobalOutlined,
} from '@ant-design/icons'
import {
listSites,
@@ -30,6 +36,23 @@ import {
} from '../api'
import { useMember } from '../MemberContext'
interface BannerItem {
image_url: string
title?: string
link?: string
}
interface AnnouncementItem {
title: string
content?: string
}
interface FooterInfo {
copyright?: string
icp?: string
contact?: string
}
export default function SiteSettings() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
@@ -38,7 +61,16 @@ export default function SiteSettings() {
const [saving, setSaving] = useState(false)
const [currentSite, setCurrentSite] = useState<Site | null>(null)
const [form] = Form.useForm()
// Visual state for customizer
const [logoUrl, setLogoUrl] = useState<string>('')
const [faviconUrl, setFaviconUrl] = useState<string>('')
const [banners, setBanners] = useState<BannerItem[]>([])
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([])
const [footerInfo, setFooterInfo] = useState<FooterInfo>({
copyright: '',
icp: '',
contact: '',
})
const loadSite = useCallback(async (id: number) => {
setLoading(true)
@@ -48,20 +80,33 @@ export default function SiteSettings() {
const found = rows.find((s) => s.id === id)
if (found) {
setCurrentSite(found)
form.setFieldsValue({
logo_url: found.logo_url || '',
favicon_url: found.favicon_url || '',
banners_json: found.banners_json || '[]',
announcements_json: found.announcements_json || '[]',
footer_info_json: found.footer_info_json || '{}',
})
setLogoUrl(found.logo_url || '')
setFaviconUrl(found.favicon_url || '')
try {
setBanners(found.banners_json ? JSON.parse(found.banners_json) : [])
} catch {
setBanners([])
}
try {
setAnnouncements(found.announcements_json ? JSON.parse(found.announcements_json) : [])
} catch {
setAnnouncements([])
}
try {
setFooterInfo(found.footer_info_json ? JSON.parse(found.footer_info_json) : {})
} catch {
setFooterInfo({})
}
}
} catch {
message.error('加载站点设置失败')
} finally {
setLoading(false)
}
}, [form])
}, [])
useEffect(() => {
async function init() {
@@ -95,21 +140,59 @@ export default function SiteSettings() {
void init()
}, [member, loadSite])
async function handleSave(values: SiteBrandingPayload) {
if (!siteID) return
// Validate JSON formatting
try {
if (values.banners_json) JSON.parse(values.banners_json)
if (values.announcements_json) JSON.parse(values.announcements_json)
if (values.footer_info_json) JSON.parse(values.footer_info_json)
} catch {
message.error('轮播图、公告或页脚配置的 JSON 格式不正确,请检查!')
return
}
function handleAddBanner() {
setBanners([
...banners,
{
image_url: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=1200&q=80',
title: '热门推荐活动',
link: '',
},
])
}
function handleUpdateBanner(index: number, field: keyof BannerItem, value: string) {
const next = [...banners]
next[index] = { ...next[index], [field]: value }
setBanners(next)
}
function handleDeleteBanner(index: number) {
setBanners(banners.filter((_, i) => i !== index))
}
function handleAddAnnouncement() {
setAnnouncements([
...announcements,
{
title: '平台全新上线精选商品,免押畅玩!',
content: '',
},
])
}
function handleUpdateAnnouncement(index: number, field: keyof AnnouncementItem, value: string) {
const next = [...announcements]
next[index] = { ...next[index], [field]: value }
setAnnouncements(next)
}
function handleDeleteAnnouncement(index: number) {
setAnnouncements(announcements.filter((_, i) => i !== index))
}
async function handleSave() {
if (!siteID) return
setSaving(true)
try {
const updated = await updateSiteBranding(siteID, values)
const payload: SiteBrandingPayload = {
logo_url: logoUrl.trim(),
favicon_url: faviconUrl.trim(),
banners_json: JSON.stringify(banners),
announcements_json: JSON.stringify(announcements),
footer_info_json: JSON.stringify(footerInfo),
}
const updated = await updateSiteBranding(siteID, payload)
setCurrentSite(updated)
message.success('分销站点品牌与装修配置保存成功!')
} catch (error: unknown) {
@@ -120,39 +203,21 @@ export default function SiteSettings() {
}
}
const logoUrl = Form.useWatch('logo_url', form)
const bannersJsonStr = Form.useWatch('banners_json', form)
const announcementsJsonStr = Form.useWatch('announcements_json', form)
let banners: { image_url: string; title?: string }[] = []
try {
if (bannersJsonStr) banners = JSON.parse(bannersJsonStr)
} catch {
// ignore json error in preview
}
let announcements: { title: string; content?: string }[] = []
try {
if (announcementsJsonStr) announcements = JSON.parse(announcementsJsonStr)
} catch {
// ignore
}
return (
<Space direction="vertical" className="page" style={{ width: '100%' }}>
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
品牌与站点装修
品牌与站点可视化装修
</Typography.Title>
<Typography.Text type="secondary">
自定义当前分销站前台 Logo、浏览器图标、轮播横幅、站点公告及页脚备案信息。
可视化配置当前分销独立站的 Logo、首页轮播海报、跑马灯公告及页脚客服信息。
</Typography.Text>
</div>
<Space>
{member?.is_superadmin && sites.length > 1 && (
<Select
placeholder="分销网站"
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
@@ -166,148 +231,432 @@ export default function SiteSettings() {
<Button
icon={<EyeOutlined />}
onClick={() => {
window.open(`http://${window.location.hostname}:5189/?site=${currentSite.site_code}`, '_blank')
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const targetUrl = isLocal
? `http://${window.location.hostname}:5189/?site=${currentSite.site_code}`
: currentSite.domain
? `https://${currentSite.domain}`
: `/?site=${currentSite.site_code}`
window.open(targetUrl, '_blank')
}}
>
预览分销前台
</Button>
)}
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
保存全部装修
</Button>
</Space>
</div>
<Row gutter={24}>
<Col span={14}>
<Card title="装修配置表单" loading={loading}>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="站点 Logo 图片地址"
name="logo_url"
extra="支持 PNG / SVG / JPG 格式的公开图片链接"
>
<Input placeholder="https://.../logo.png" allowClear />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="浏览器 Favicon 图标地址"
name="favicon_url"
extra="浏览器标签页小图标 (ICO / PNG)"
>
<Input placeholder="https://.../favicon.ico" allowClear />
</Form.Item>
</Col>
</Row>
{/* Left Visual Form Customizer */}
<Col xs={24} lg={14}>
<Card loading={loading}>
<Tabs
defaultActiveKey="branding"
items={[
{
key: 'branding',
label: '品牌基础资料',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong>站点 Logo 图片链接:</Typography.Text>
<Input
placeholder="https://.../logo.png"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
allowClear
style={{ marginTop: 6 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
建议尺寸 240x60 像素,透明底色 PNG 或 SVG 格式
</Typography.Text>
</div>
<Divider style={{ margin: '12px 0' }} />
<div>
<Typography.Text strong>浏览器标签页 Favicon 图标:</Typography.Text>
<Input
placeholder="https://.../favicon.ico"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
allowClear
style={{ marginTop: 6 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
浏览器选项卡显示的小图标,建议 32x32 像素
</Typography.Text>
</div>
</Space>
),
},
{
key: 'banners',
label: `首页轮播横幅 (${banners.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
为分销商城首页配置吸睛的促销横幅(支持自动轮播展示)。
</Typography.Text>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddBanner}>
添加轮播横幅
</Button>
</div>
<Form.Item
label={
<Space>
<PictureOutlined />
<span>首页轮播图配置 (Banners JSON)</span>
</Space>
}
name="banners_json"
extra='格式:[{"image_url": "图片URL", "title": "标题", "link": "点击跳转链接"}]'
>
<Input.TextArea rows={4} placeholder='[{"image_url":"https://.../banner1.jpg","title":"暑期特惠"}]' />
</Form.Item>
{banners.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
暂未添加轮播横幅,点击上方按钮新增
</div>
) : (
banners.map((item, idx) => (
<div
key={idx}
style={{
background: '#fbfbfb',
border: '1px solid #e8e8e8',
borderRadius: 8,
padding: '12px 16px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<strong style={{ color: '#1677ff' }}>横幅 #{idx + 1}</strong>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteBanner(idx)}
>
删除
</Button>
</div>
<Row gutter={12}>
<Col span={6}>
<div style={{ height: 80, background: '#eee', borderRadius: 4, overflow: 'hidden' }}>
{item.image_url ? (
<Image
src={item.image_url}
width="100%"
height={80}
style={{ objectFit: 'cover' }}
/>
) : (
<div style={{ lineHeight: '80px', textAlign: 'center', color: '#aaa', fontSize: 12 }}>无图片</div>
)}
</div>
</Col>
<Col span={18}>
<Space orientation="vertical" style={{ width: '100%' }} size="small">
<Input
placeholder="输入图片 URL (https://...)"
value={item.image_url}
onChange={(e) => handleUpdateBanner(idx, 'image_url', e.target.value)}
/>
<Input
placeholder="横幅标题 (例如:特惠专区)"
value={item.title}
onChange={(e) => handleUpdateBanner(idx, 'title', e.target.value)}
/>
<Input
placeholder="跳转链接 (可选)"
value={item.link}
onChange={(e) => handleUpdateBanner(idx, 'link', e.target.value)}
/>
</Space>
</Col>
</Row>
</div>
))
)}
</Space>
),
},
{
key: 'announcements',
label: `跑马灯公告 (${announcements.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
在商城顶部轮播滚动的最新活动或服务保障公告。
</Typography.Text>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddAnnouncement}>
添加公告
</Button>
</div>
<Form.Item
label={
<Space>
<NotificationOutlined />
<span>站点跑马灯公告 (Announcements JSON)</span>
</Space>
}
name="announcements_json"
extra='格式:[{"title": "公告标题", "content": "详细内容"}]'
>
<Input.TextArea rows={3} placeholder='[{"title":"本站三角洲账号货源充足,上号快!"}]' />
</Form.Item>
{announcements.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
暂无公告,点击上方按钮新增
</div>
) : (
announcements.map((item, idx) => (
<div
key={idx}
style={{
background: '#fbfbfb',
border: '1px solid #e8e8e8',
borderRadius: 8,
padding: '12px 16px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<strong>公告 #{idx + 1}</strong>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteAnnouncement(idx)}
>
删除
</Button>
</div>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Input
placeholder="公告标题"
value={item.title}
onChange={(e) => handleUpdateAnnouncement(idx, 'title', e.target.value)}
/>
<Input.TextArea
placeholder="公告详细内容 (可选)"
rows={2}
value={item.content}
onChange={(e) => handleUpdateAnnouncement(idx, 'content', e.target.value)}
/>
</Space>
</div>
))
)}
</Space>
),
},
{
key: 'footer',
label: '页脚与客服备案',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong>商城版权文案:</Typography.Text>
<Input
placeholder="例如:© 2026 游戏账号分销商城 版权所有"
value={footerInfo.copyright}
onChange={(e) => setFooterInfo({ ...footerInfo, copyright: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
<Form.Item
label="页脚信息配置 (Footer Info JSON)"
name="footer_info_json"
extra='格式:{"copyright": "© 2026 分销站点", "icp": "京ICP备XXXXX号", "contact": "客服QQ: 123456"}'
>
<Input.TextArea rows={3} placeholder='{"copyright":"© 2026 演示分销网","icp":"浙ICP备000000号"}' />
</Form.Item>
<div>
<Typography.Text strong>ICP 备案编号:</Typography.Text>
<Input
placeholder="例如:京ICP备20260001号-1"
value={footerInfo.icp}
onChange={(e) => setFooterInfo({ ...footerInfo, icp: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving} size="large">
保存装修配置
</Button>
</Form.Item>
</Form>
<div>
<Typography.Text strong>官方客服微信 / QQ:</Typography.Text>
<Input
placeholder="例如:客服微信: hfb_kf01 / QQ群: 987654321"
value={footerInfo.contact}
onChange={(e) => setFooterInfo({ ...footerInfo, contact: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
</Space>
),
},
]}
/>
<Divider style={{ margin: '16px 0' }} />
{/* Advanced Raw JSON Inspector (Collapsed) */}
<Collapse
size="small"
items={[
{
key: 'raw',
label: '开发者底包 JSON 数据(自动同步生成,无需手写)',
children: (
<pre style={{ fontSize: 11, background: '#f5f5f5', padding: 8, borderRadius: 4, maxHeight: 120, overflow: 'auto' }}>
{JSON.stringify(
{
banners,
announcements,
footer_info: footerInfo,
},
null,
2
)}
</pre>
),
},
]}
/>
</Card>
</Col>
<Col span={10}>
<Card title="效果实时预览" style={{ background: '#f5f7fa' }}>
<div style={{ background: '#fff', borderRadius: 8, padding: 16, border: '1px solid #e8e8e8' }}>
{/* Header Preview */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #eee', paddingBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{logoUrl ? (
<Image src={logoUrl} height={28} preview={false} style={{ objectFit: 'contain' }} fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='28'%3E%3Ctext x='10' y='20' fill='%23999'%3ELogo 加载中%3C/text%3E%3C/svg%3E" />
) : (
<Typography.Text strong style={{ fontSize: 16, color: '#1677ff' }}>
{currentSite?.site_name || '分销站点名称'}
</Typography.Text>
)}
{/* Right Live Visual Simulation Card */}
<Col xs={24} lg={10}>
<Card title="分销商城前台实时模拟效果" style={{ background: '#f8fafc' }}>
<div
style={{
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: 8,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
{/* Simulated Browser Bar */}
<div
style={{
background: '#f0f2f5',
padding: '6px 12px',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 12,
color: '#666',
}}
>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ff4d4f' }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#faad14' }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#52c41a' }} />
</div>
<div
style={{
flex: 1,
background: '#fff',
borderRadius: 4,
padding: '2px 8px',
fontSize: 11,
textAlign: 'center',
}}
>
https://{currentSite?.domain || `${currentSite?.site_code || 'demo'}.hfb.com`}
</div>
<Tag color="cyan" style={{ margin: 0 }}>分销前台顶栏</Tag>
</div>
{/* Announcement Preview */}
{/* Simulated Header Nav */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{logoUrl ? (
<img src={logoUrl} alt="Logo" style={{ height: 26, objectFit: 'contain' }} />
) : (
<strong style={{ fontSize: 14, color: '#1677ff' }}>
{currentSite?.site_name || 'HFB 分销商城'}
</strong>
)}
<Tag color="blue">分销前台</Tag>
</div>
{/* Simulated Announcements Marquee */}
{announcements.length > 0 && (
<div style={{ marginTop: 12 }}>
<Alert
message={
<Space>
<NotificationOutlined style={{ color: '#fa8c16' }} />
<span>{announcements[0].title}</span>
</Space>
}
type="warning"
banner
style={{ borderRadius: 4, padding: '4px 12px' }}
/>
<div
style={{
background: '#fffbe6',
padding: '4px 12px',
fontSize: 12,
color: '#d46b08',
borderBottom: '1px solid #ffe58f',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<NotificationOutlined />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{announcements[0]?.title}
</span>
</div>
)}
{/* Banner Preview */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>轮播图预览:</div>
{banners.length > 0 && banners[0].image_url ? (
<div style={{ width: '100%', height: 120, borderRadius: 6, overflow: 'hidden', background: '#000' }}>
<img
src={banners[0].image_url}
alt="Banner"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=600&auto=format&fit=crop&q=60'
}}
/>
</div>
{/* Simulated Banner Carousel */}
<div style={{ height: 140, background: '#eee', position: 'relative', overflow: 'hidden' }}>
{banners.length > 0 && banners[0]?.image_url ? (
<img
src={banners[0].image_url}
alt="Banner"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<div style={{ width: '100%', height: 90, background: '#f0f2f5', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', fontSize: 12 }}>
暂无轮播图配置
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: 12,
}}
>
暂无轮播横幅,请在左侧添加
</div>
)}
{banners.length > 0 && banners[0]?.title && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(0,0,0,0.5)',
color: '#fff',
padding: '4px 10px',
fontSize: 12,
}}
>
{banners[0].title}
</div>
)}
</div>
{/* Content Placeholder */}
<div style={{ marginTop: 12, padding: 12, background: '#fafafa', borderRadius: 4, textAlign: 'center', color: '#999', fontSize: 12 }}>
[ 游戏商品列表展示区域 ]
{/* Simulated Account Listings Grid Preview */}
<div style={{ padding: 12 }}>
<Typography.Text strong style={{ fontSize: 13, display: 'block', marginBottom: 8 }}>
精选租号货架
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 4, padding: 8, fontSize: 11 }}>
<strong>三角洲全金典藏号</strong>
<div style={{ color: '#cf1322', marginTop: 4, fontWeight: 'bold' }}>¥35.00 /小时</div>
</div>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 4, padding: 8, fontSize: 11 }}>
<strong>无畏契约全套幻化</strong>
<div style={{ color: '#cf1322', marginTop: 4, fontWeight: 'bold' }}>¥28.00 /小时</div>
</div>
</div>
</div>
{/* Footer Preview */}
<div style={{ marginTop: 16, borderTop: '1px solid #eee', paddingTop: 12, textAlign: 'center', fontSize: 11, color: '#999' }}>
<div>{currentSite?.site_name || '分销站点'} · 技术支持由 HFB 平台提供</div>
{/* Simulated Footer */}
<div
style={{
background: '#fafafa',
borderTop: '1px solid #f0f0f0',
padding: '10px 12px',
fontSize: 11,
color: '#8c8c8c',
textAlign: 'center',
}}
>
<div>{footerInfo.copyright || `© 2026 ${currentSite?.site_name || '分销网'}`}</div>
{footerInfo.icp && <div>{footerInfo.icp}</div>}
{footerInfo.contact && <div style={{ color: '#1677ff' }}>{footerInfo.contact}</div>}
</div>
</div>
</Card>