Files
hfb_sys/distribution-admin/src/pages/Accounts.tsx
T
yml2213 4aab9e7e33 fix(distribution): 分成改为分主站提价利润,分站自主提价溢价全额归卖方
- 文档:升级至 v0.7,固化号主保底、主站提价利润分账(35/40/25)与分站加价溢价独享逻辑
- 定价:修正货架兑换比例调价方向为调减比例提价(site_ratio = base_ratio - delta)
- 分账:分成基数剔除号主成本与分站加价,提价利润按规则切分,分站自主加价溢价 100% 归卖出方
- 前端:更新分销后台提价说明文案,提示调减比例提高售价且溢价归本站
2026-09-08 19:45:05 +08:00

456 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal, InputNumber } from 'antd'
import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined, EditOutlined, DollarCircleOutlined } from '@ant-design/icons'
import {
listAccountDisplays,
listSites,
updateAccountDisplay,
syncSiteAccounts,
updateAccountMarkup,
batchUpdateAccountMarkup,
formatMoney,
type AccountDisplay,
type Site,
} from '../api'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
enabled: { label: '展示中', color: 'success' },
disabled: { label: '已下架', color: 'default' },
sold: { label: '已售出', color: 'blue' },
offline: { label: '已离线', color: 'error' },
}
export default function Accounts() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [status, setStatus] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [items, setItems] = useState<AccountDisplay[]>([])
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
// Markup modal state
const [markupModalVisible, setMarkupModalVisible] = useState(false)
const [targetAccount, setTargetAccount] = useState<AccountDisplay | null>(null)
const [markupRatio, setMarkupRatio] = useState<number>(0)
const [markupSaving, setMarkupSaving] = useState(false)
async function load(site?: number, statusFilter?: string) {
if (!site) return
setLoading(true)
try {
setItems(await listAccountDisplays(site, statusFilter))
} catch {
message.error('账号展示加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
}
})
.catch(() => message.error('站点加载失败'))
}, [])
async function handleSync() {
if (!siteID) {
message.warning('请先选择分销站点')
return
}
setSyncing(true)
try {
const res = await syncSiteAccounts(siteID)
message.success(
`同步完成!新增本站上传货 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(本次候选 ${res.total_active} 个)`,
5,
)
await load(siteID, status)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '同步本站上传商品失败')
} finally {
setSyncing(false)
}
}
async function toggle(item: AccountDisplay) {
const next = item.display_status === 'enabled' ? 'disabled' : 'enabled'
try {
await updateAccountDisplay(item.id, next)
message.success(next === 'enabled' ? '已恢复展示' : '已下架')
if (siteID) void load(siteID, status)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '状态更新失败')
}
}
async function handleBatchUpdate(targetStatus: 'enabled' | 'disabled') {
if (selectedRowKeys.length === 0) {
message.warning('请先勾选需要批量操作的账号')
return
}
const actionLabel = targetStatus === 'enabled' ? '批量恢复展示' : '批量下架'
Modal.confirm({
title: `确认${actionLabel}?`,
content: `即将对选中的 ${selectedRowKeys.length} 个账号执行${actionLabel}操作。`,
onOk: async () => {
let successCount = 0
for (const id of selectedRowKeys) {
try {
await updateAccountDisplay(Number(id), targetStatus)
successCount++
} catch {
// continue next
}
}
message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`)
setSelectedRowKeys([])
if (siteID) void load(siteID, status)
},
})
}
function handleOpenMarkup(item?: AccountDisplay) {
if (item) {
setTargetAccount(item)
setMarkupRatio(item.shelf_ratio_delta ?? 0)
} else {
if (selectedRowKeys.length === 0) {
message.warning('请先勾选需要批量加价的账号商品')
return
}
setTargetAccount(null)
setMarkupRatio(0)
}
setMarkupModalVisible(true)
}
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('商品加价比例设置成功')
} else {
const listingIDs = items
.filter((it) => selectedRowKeys.includes(it.id))
.map((it) => it.listing_id)
if (listingIDs.length === 0) {
message.warning('请先勾选要加价的商品')
return
}
await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`)
setSelectedRowKeys([])
}
setMarkupModalVisible(false)
void load(siteID, status)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage || '加价设置保存失败')
} finally {
setMarkupSaving(false)
}
}
const filteredItems = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return items
return items.filter(
(item) =>
(item.title && item.title.toLowerCase().includes(kw)) ||
(item.listing_no && item.listing_no.toLowerCase().includes(kw)) ||
(item.game_name && item.game_name.toLowerCase().includes(kw)) ||
(item.server_region && item.server_region.toLowerCase().includes(kw)),
)
}, [items, keyword])
const currentSite = sites.find((s) => s.id === siteID)
return (
<Space direction="vertical" className="page" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
商品与账号展示
</Typography.Title>
<Typography.Text type="secondary">
管理当前分销网站对外展示的商品。默认同步本站上传货,主站货需显式勾选,不会灌入全站目录。
</Typography.Text>
</div>
<Button
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
style={{ background: '#1677ff' }}
>
同步本站上传商品
</Button>
</div>
<Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
<Select
placeholder="分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({
label: `${site.site_name} (${site.site_code})`,
value: site.id,
}))}
onChange={(value) => {
setSiteID(value)
setSelectedRowKeys([])
void load(value, status)
}}
/>
<Select
allowClear
placeholder="展示状态"
style={{ width: 140 }}
value={status || undefined}
options={Object.entries(STATUS_LABELS).map(([value, conf]) => ({
label: conf.label,
value,
}))}
onChange={(value) => {
setStatus(value ?? '')
void load(siteID, value ?? '')
}}
/>
<Input
placeholder="搜索商品编号/标题/区服"
prefix={<SearchOutlined />}
style={{ width: 240 }}
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</Space>
{selectedRowKeys.length > 0 && (
<Space>
<Typography.Text type="secondary">已勾选 {selectedRowKeys.length} 项</Typography.Text>
<Button
size="small"
icon={<DollarCircleOutlined />}
onClick={() => handleOpenMarkup()}
>
批量加价
</Button>
<Button
size="small"
icon={<CheckCircleOutlined />}
onClick={() => handleBatchUpdate('enabled')}
>
批量展示
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleBatchUpdate('disabled')}
>
批量下架
</Button>
</Space>
)}
</Space>
</Card>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={filteredItems}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
pageSize: 20,
showTotal: (total) => `共 ${total} 个账号商品`,
showQuickJumper: true,
}}
columns={[
{
title: '商品编号',
dataIndex: 'listing_no',
width: 140,
render: (v: string | undefined, row: AccountDisplay) => (
<Space direction="vertical" size={2}>
<Typography.Text copyable strong>
{v || `#${row.listing_id}`}
</Typography.Text>
{row.in_transaction && <Tag color="warning">出租中</Tag>}
</Space>
),
},
{
title: '账号信息',
dataIndex: 'title',
minWidth: 240,
render: (_: unknown, row: AccountDisplay) => (
<div>
<Typography.Text strong style={{ display: 'block', fontSize: 14 }}>
{row.title || `账号 #${row.account_id}`}
</Typography.Text>
<Space size={4} style={{ marginTop: 4 }}>
<Tag color="blue">{row.game_name || '默认游戏'}</Tag>
{row.server_region && <Tag>{row.server_region}</Tag>}
{row.login_platform && <Tag>{row.login_platform}</Tag>}
</Space>
</div>
),
},
{
title: '段位 / 资产',
width: 140,
render: (_: unknown, row: AccountDisplay) => (
<div>
<div>{row.rank_level ? <Tag color="purple">{row.rank_level}</Tag> : '-'}</div>
{row.haf_coin_amount !== undefined && row.haf_coin_amount > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
哈夫币: {row.haf_coin_amount.toLocaleString()}
</Typography.Text>
)}
</div>
),
},
{
title: '租金 / 押金',
width: 160,
render: (_: unknown, row: AccountDisplay) => (
<div>
<Typography.Text strong style={{ color: '#cf1322' }}>
{formatMoney(row.site_price_cent || row.price_cent)}
</Typography.Text>
<div style={{ fontSize: 12, color: '#666' }}>
押金: {formatMoney(row.deposit_amount_cent)}
</div>
</div>
),
},
{
title: '加价比例',
dataIndex: 'shelf_ratio_delta',
width: 120,
render: (v: number | undefined) => {
if (v && v > 0) {
return (
<Tag color="gold" style={{ fontWeight: 'bold' }}>
+{v}
</Tag>
)
}
return <span style={{ color: '#aaa', fontSize: 12 }}>无加价</span>
},
},
{
title: '主站状态',
dataIndex: 'listing_status',
width: 110,
render: (v: string | undefined) => (
<Tag color={v === 'published' ? 'green' : 'default'}>
{v === 'published' ? '主站在售' : v || '未知'}
</Tag>
),
},
{
title: '分销展示',
dataIndex: 'display_status',
width: 110,
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: AccountDisplay) => (
<Space size={2}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenMarkup(row)}
>
加价
</Button>
<Button
type="link"
size="small"
danger={row.display_status === 'enabled'}
onClick={() => toggle(row)}
>
{row.display_status === 'enabled' ? '下架' : '展示'}
</Button>
</Space>
),
},
]}
/>
</Card>
<Modal
title={targetAccount ? `设置提价 - ${targetAccount.title || targetAccount.listing_no}` : `批量提价 (${selectedRowKeys.length} 个账号商品)`}
open={markupModalVisible}
onCancel={() => setMarkupModalVisible(false)}
onOk={handleSaveMarkup}
confirmLoading={markupSaving}
okText="保存提价"
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%', padding: '12px 0' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;<strong>提价多挣的差价收益 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>
</Modal>
</Space>
)
}