Files
hfb_sys/distribution-admin/src/pages/Accounts.tsx
T
yml2213 48a2961245 fix(distribution): 收紧分销权限与货架状态,开通时创建站点金库
无站点成员不再视为超管;分站下架只改展示状态;装修、批量加价与提现参数对齐。
2026-09-08 17:45:05 +08:00

454 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 [markupYuan, setMarkupYuan] = 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)
setMarkupYuan((item.markup_amount_cent ?? 0) / 100)
} else {
if (selectedRowKeys.length === 0) {
message.warning('请先勾选需要批量加价的账号商品')
return
}
setTargetAccount(null)
setMarkupYuan(0)
}
setMarkupModalVisible(true)
}
async function handleSaveMarkup() {
if (!siteID) return
const markupCent = Math.round(markupYuan * 100)
if (markupCent < 0) {
message.error('加价金额不能小于0')
return
}
setMarkupSaving(true)
try {
if (targetAccount) {
await updateAccountMarkup(targetAccount.id, markupCent)
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, markupCent)
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.price_cent)}
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#888' }}> /小时</span>
</Typography.Text>
<div style={{ fontSize: 12, color: '#666' }}>
押金: {formatMoney(row.deposit_amount_cent)}
</div>
</div>
),
},
{
title: '自主加价',
dataIndex: 'markup_amount_cent',
width: 120,
render: (v: number | undefined) => {
if (v && v > 0) {
return (
<Tag color="gold" style={{ fontWeight: 'bold' }}>
+{formatMoney(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 }}>
自主加价金额将直接叠加在分销前台展示的每小时租金上,该部分溢价产生的收益将在成单结算时归当前分销商所有。
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text strong>每小时加价金额:</Typography.Text>
<InputNumber
min={0}
max={1000}
precision={2}
style={{ width: 180 }}
value={markupYuan}
onChange={(val) => setMarkupYuan(val ?? 0)}
addonAfter="元/小时"
placeholder="0.00"
/>
</div>
{markupYuan > 0 && targetAccount && (
<div style={{ marginTop: 8, padding: '8px 12px', background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', fontSize: 12 }}>
原租金 {formatMoney(targetAccount.price_cent)}/时 ➔ 分销前台标价为{' '}
<strong style={{ color: '#cf1322' }}>
¥{(((targetAccount.price_cent ?? 0) + Math.round(markupYuan * 100)) / 100).toFixed(2)}
</strong>
/时
</div>
)}
</Space>
</Modal>
</Space>
)
}