fix(distribution-admin): bind site context to member and enhance shelf product filters

This commit is contained in:
yml2213
2026-09-09 14:06:29 +08:00
parent 80ed9e7897
commit 2384bb3820
7 changed files with 680 additions and 236 deletions
+4 -1
View File
@@ -16,6 +16,7 @@ import {
WalletOutlined, WalletOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { getMe, logout, type CurrentMember, getStoredToken } from './api' import { getMe, logout, type CurrentMember, getStoredToken } from './api'
import { MemberContext } from './MemberContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Sites from './pages/Sites' import Sites from './pages/Sites'
import Rules from './pages/Rules' import Rules from './pages/Rules'
@@ -61,7 +62,8 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
: '分销成员' : '分销成员'
return ( return (
<Layout className="app"> <MemberContext.Provider value={member}>
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0"> <Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand"> <div className="brand">
<ShopOutlined style={{ fontSize: 20, color: '#1677ff' }} /> <ShopOutlined style={{ fontSize: 20, color: '#1677ff' }} />
@@ -149,6 +151,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
</MemberContext.Provider>
) )
} }
+8
View File
@@ -0,0 +1,8 @@
import { createContext, useContext } from 'react'
import type { CurrentMember } from './api'
export const MemberContext = createContext<CurrentMember | null>(null)
export function useMember(): CurrentMember | null {
return useContext(MemberContext)
}
+477 -155
View File
@@ -1,6 +1,33 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal, InputNumber, Empty } from 'antd' import {
import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined, EditOutlined, DollarCircleOutlined } from '@ant-design/icons' Card,
Table,
Typography,
Space,
Select,
Tag,
message,
Button,
Input,
Modal,
InputNumber,
Empty,
Alert,
Tooltip,
Divider,
} from 'antd'
import {
SyncOutlined,
SearchOutlined,
CheckCircleOutlined,
StopOutlined,
EditOutlined,
DollarCircleOutlined,
ReloadOutlined,
FilterOutlined,
ShopOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import { import {
listAccountDisplays, listAccountDisplays,
listSites, listSites,
@@ -9,70 +36,128 @@ import {
updateAccountMarkup, updateAccountMarkup,
batchUpdateAccountMarkup, batchUpdateAccountMarkup,
formatMoney, formatMoney,
getMe,
type AccountDisplay, type AccountDisplay,
type Site, type Site,
} from '../api' } from '../api'
import { useMember } from '../MemberContext'
const STATUS_LABELS: Record<string, { label: string; color: string }> = { const SOURCE_OPTIONS = [
enabled: { label: '展示中', color: 'success' }, { label: '全部货源', value: 'all' },
disabled: { label: '已下架', color: 'default' }, { label: '主站自营 (平台共享池)', value: 'main' },
sold: { label: '已售出', color: 'blue' }, { label: '本站收号 (独家货源)', value: 'self' },
offline: { label: '已离线', color: 'error' }, { label: '合作分站 (跨站共享)', value: 'collab' },
]
const MARKUP_OPTIONS = [
{ label: '全部提价状态', value: 'all' },
{ label: '已自主提价 (赚差价)', value: 'marked' },
{ label: '未加价 (基准销售价)', value: 'unmarked' },
]
const DISPLAY_OPTIONS = [
{ label: '全部展示状态', value: 'all' },
{ label: '展示中 (在售货架)', value: 'enabled' },
{ label: '已下架 (隐藏货架)', value: 'disabled' },
]
const TX_OPTIONS = [
{ label: '全部租借状态', value: 'all' },
{ label: '待租中 (空闲可租)', value: 'idle' },
{ label: '出租中 (租赁履约)', value: 'rented' },
]
function formatGameName(name?: string) {
if (!name) return '默认游戏'
if (name === 'delta_force') return '三角洲行动'
return name
} }
export default function Accounts() { export default function Accounts() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>() const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [status, setStatus] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [items, setItems] = useState<AccountDisplay[]>([]) const [items, setItems] = useState<AccountDisplay[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]) const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
// Shelf product filter states
const [sourceFilter, setSourceFilter] = useState<string>('all')
const [markupFilter, setMarkupFilter] = useState<string>('all')
const [displayFilter, setDisplayFilter] = useState<string>('all')
const [txFilter, setTxFilter] = useState<string>('all')
const [keyword, setKeyword] = useState<string>('')
// Markup modal state // Markup modal state
const [markupModalVisible, setMarkupModalVisible] = useState(false) const [markupModalVisible, setMarkupModalVisible] = useState(false)
const [targetAccount, setTargetAccount] = useState<AccountDisplay | null>(null) const [targetAccount, setTargetAccount] = useState<AccountDisplay | null>(null)
const [markupRatio, setMarkupRatio] = useState<number>(0) const [markupRatio, setMarkupRatio] = useState<number>(0)
const [markupSaving, setMarkupSaving] = useState(false) const [markupSaving, setMarkupSaving] = useState(false)
async function load(site?: number, statusFilter?: string) { async function load(site?: number) {
if (!site) return if (!site) return
setLoading(true) setLoading(true)
try { try {
setItems(await listAccountDisplays(site, statusFilter)) // Fetch full shelf goods to allow fast client-side multidimensional filtering
const rows = await listAccountDisplays(site, '')
setItems(rows)
} catch { } catch {
message.error('账号展示加载失败') message.error('账号展示货架加载失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
// Initialize site context:
// Normal distribution merchant is strictly bound to member.site_id
// Only superadmin can fetch sites and switch between tenants
useEffect(() => { useEffect(() => {
listSites() async function init() {
.then((rows) => { let currentMember = member
setSites(rows) if (!currentMember) {
if (rows.length > 0) { try {
setSiteID(rows[0].id) currentMember = await getMe()
void load(rows[0].id) } catch {
// ignore
} }
}) }
.catch(() => message.error('站点加载失败'))
}, []) const activeSiteID = currentMember?.site_id
if (activeSiteID) {
setSiteID(activeSiteID)
void load(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member])
async function handleSync() { async function handleSync() {
if (!siteID) { if (!siteID) {
message.warning('请先选择分销站点') message.warning('无法获取当前分销站点信息')
return return
} }
setSyncing(true) setSyncing(true)
try { try {
const res = await syncSiteAccounts(siteID) const res = await syncSiteAccounts(siteID)
message.success( message.success(
`同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(平台在售共 ${res.total_active} 个)`, `同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(主站共享池在售共 ${res.total_active} 个)`,
5, 5,
) )
await load(siteID, status) await load(siteID)
} catch (error: unknown) { } catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '同步主站商品失败') message.error(respMessage ?? '同步主站商品失败')
@@ -85,8 +170,8 @@ export default function Accounts() {
const next = item.display_status === 'enabled' ? 'disabled' : 'enabled' const next = item.display_status === 'enabled' ? 'disabled' : 'enabled'
try { try {
await updateAccountDisplay(item.id, next) await updateAccountDisplay(item.id, next)
message.success(next === 'enabled' ? '已恢复展示' : '已下架') message.success(next === 'enabled' ? '已恢复货架展示' : '已从货架下架')
if (siteID) void load(siteID, status) if (siteID) void load(siteID)
} catch (error: unknown) { } catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '状态更新失败') message.error(respMessage ?? '状态更新失败')
@@ -114,7 +199,7 @@ export default function Accounts() {
} }
message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`) message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`)
setSelectedRowKeys([]) setSelectedRowKeys([])
if (siteID) void load(siteID, status) if (siteID) void load(siteID)
}, },
}) })
} }
@@ -137,7 +222,7 @@ export default function Accounts() {
async function handleSaveMarkup() { async function handleSaveMarkup() {
if (!siteID) return if (!siteID) return
if (markupRatio < 0) { if (markupRatio < 0) {
message.error('加价比例不能小于0') message.error('提价调减比例不能小于0')
return return
} }
setMarkupSaving(true) setMarkupSaving(true)
@@ -154,43 +239,174 @@ export default function Accounts() {
return return
} }
await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio) await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`) message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置提价!`)
setSelectedRowKeys([]) setSelectedRowKeys([])
} }
setMarkupModalVisible(false) setMarkupModalVisible(false)
void load(siteID, status) void load(siteID)
} catch (error: unknown) { } catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage || '加价设置保存失败') message.error(respMessage || '提价设置保存失败')
} finally { } finally {
setMarkupSaving(false) setMarkupSaving(false)
} }
} }
const filteredItems = useMemo(() => { function handleResetFilters() {
const kw = keyword.trim().toLowerCase() setSourceFilter('all')
if (!kw) return items setMarkupFilter('all')
return items.filter( setDisplayFilter('all')
(item) => setTxFilter('all')
(item.title && item.title.toLowerCase().includes(kw)) || setKeyword('')
(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) // Multidimensional client-side filter
const filteredItems = useMemo(() => {
return items.filter((item) => {
// 1. Source filter
if (sourceFilter === 'main') {
if (item.listing_site_id !== null && item.listing_site_id !== undefined) return false
} else if (sourceFilter === 'self') {
if (item.listing_site_id !== siteID) return false
} else if (sourceFilter === 'collab') {
if (item.listing_site_id === null || item.listing_site_id === undefined || item.listing_site_id === siteID) {
return false
}
}
// 2. Markup filter
if (markupFilter === 'marked') {
if (!item.shelf_ratio_delta || item.shelf_ratio_delta <= 0) return false
} else if (markupFilter === 'unmarked') {
if (item.shelf_ratio_delta && item.shelf_ratio_delta > 0) return false
}
// 3. Display status filter
if (displayFilter !== 'all') {
if (item.display_status !== displayFilter) return false
}
// 4. Transaction filter
if (txFilter === 'idle') {
if (item.in_transaction) return false
} else if (txFilter === 'rented') {
if (!item.in_transaction) return false
}
// 5. Keyword search
if (keyword.trim()) {
const kw = keyword.trim().toLowerCase()
const matchTitle = item.title && item.title.toLowerCase().includes(kw)
const matchNo = item.listing_no && item.listing_no.toLowerCase().includes(kw)
const matchGame = item.game_name && item.game_name.toLowerCase().includes(kw)
const matchRegion = item.server_region && item.server_region.toLowerCase().includes(kw)
const matchPlatform = item.login_platform && item.login_platform.toLowerCase().includes(kw)
const matchId = String(item.account_id).includes(kw) || String(item.listing_id).includes(kw)
if (!matchTitle && !matchNo && !matchGame && !matchRegion && !matchPlatform && !matchId) {
return false
}
}
return true
})
}, [items, sourceFilter, markupFilter, displayFilter, txFilter, keyword, siteID])
// Statistics counters
const counts = useMemo(() => {
let mainCount = 0
let selfCount = 0
let collabCount = 0
let markedCount = 0
let enabledCount = 0
for (const item of items) {
if (item.listing_site_id === null || item.listing_site_id === undefined) {
mainCount++
} else if (item.listing_site_id === siteID) {
selfCount++
} else {
collabCount++
}
if (item.shelf_ratio_delta && item.shelf_ratio_delta > 0) {
markedCount++
}
if (item.display_status === 'enabled') {
enabledCount++
}
}
const hasActiveFilters =
sourceFilter !== 'all' ||
markupFilter !== 'all' ||
displayFilter !== 'all' ||
txFilter !== 'all' ||
keyword.trim() !== ''
return {
total: items.length,
main: mainCount,
self: selfCount,
collab: collabCount,
marked: markedCount,
enabled: enabledCount,
filtered: filteredItems.length,
hasActiveFilters,
}
}, [items, siteID, sourceFilter, markupFilter, displayFilter, txFilter, keyword, filteredItems.length])
const currentSiteName =
member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商'
return ( return (
<Space direction="vertical" className="page" style={{ width: '100%' }}> <Space direction="vertical" className="page" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {/* Superadmin Cross-tenant Inspection Banner */}
{member?.is_superadmin && sites.length > 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 4 }}
message={
<Space align="center" wrap>
<Tag color="purple">平台超管巡检模式</Tag>
<span>切换巡查分销站点:</span>
<Select
style={{ width: 260 }}
value={siteID}
options={sites.map((s) => ({
label: `${s.site_name} (${s.site_code})`,
value: s.id,
}))}
onChange={(val) => {
setSiteID(val)
setSelectedRowKeys([])
void load(val)
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
(普通分销商登录时已完全自动锁定所属本站,无需选择分销网站)
</Typography.Text>
</Space>
}
/>
)}
{/* Page Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div> <div>
<Typography.Title level={3} style={{ marginBottom: 4 }}> <Space align="baseline">
商品与账号展示 <Typography.Title level={3} style={{ marginBottom: 4 }}>
</Typography.Title> 商品与账号展示
<Typography.Text type="secondary"> </Typography.Title>
管理当前分销网站对外展示的商品货架。所有账号源均来自主站及平台共享池,支持一键同步主站商品,并进行自主提价(调小比例)销售。 <Tag color="blue" style={{ fontSize: 13, padding: '2px 8px' }}>
</Typography.Text> <ShopOutlined /> {currentSiteName}
</Tag>
</Space>
<div>
<Typography.Text type="secondary">
管理本站对外展示与销售的货架商品。所有账号源均来自主站及平台共享池,支持一键铺货并自主提价(调小兑换比例)销售,<strong>加价溢价全额归本站所有</strong>。
</Typography.Text>
</div>
</div> </div>
<Button <Button
type="primary" type="primary"
@@ -203,77 +419,124 @@ export default function Accounts() {
</Button> </Button>
</div> </div>
{/* Shelf Filter Bar */}
<Card size="small"> <Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space wrap> {/* Controls row */}
<Select <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
placeholder="分销网站" <Space wrap size="small">
style={{ width: 220 }} {/* 货源归属 */}
value={siteID} <Select
options={sites.map((site) => ({ style={{ width: 170 }}
label: `${site.site_name} (${site.site_code})`, value={sourceFilter}
value: site.id, options={SOURCE_OPTIONS}
}))} onChange={setSourceFilter}
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> <Select
<Typography.Text type="secondary">已勾选 {selectedRowKeys.length} 项</Typography.Text> style={{ width: 170 }}
<Button value={markupFilter}
size="small" options={MARKUP_OPTIONS}
icon={<DollarCircleOutlined />} onChange={setMarkupFilter}
onClick={() => handleOpenMarkup()} />
>
批量加价 {/* 展示状态 */}
</Button> <Select
<Button style={{ width: 160 }}
size="small" value={displayFilter}
icon={<CheckCircleOutlined />} options={DISPLAY_OPTIONS}
onClick={() => handleBatchUpdate('enabled')} onChange={setDisplayFilter}
> />
批量展示
</Button> {/* 交易租借状态 */}
<Button <Select
size="small" style={{ width: 150 }}
danger value={txFilter}
icon={<StopOutlined />} options={TX_OPTIONS}
onClick={() => handleBatchUpdate('disabled')} onChange={setTxFilter}
> />
批量下架
</Button> {/* 搜索框 */}
<Input
placeholder="搜索商品编号/账号标题/游戏/区服"
prefix={<SearchOutlined />}
style={{ width: 250 }}
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{counts.hasActiveFilters && (
<Button icon={<ReloadOutlined />} onClick={handleResetFilters}>
重置筛选
</Button>
)}
</Space> </Space>
)}
{/* Batch actions */}
{selectedRowKeys.length > 0 && (
<Space>
<Typography.Text strong style={{ color: '#1677ff' }}>
已选 {selectedRowKeys.length} 项
</Typography.Text>
<Button
size="small"
type="primary"
ghost
icon={<DollarCircleOutlined />}
onClick={() => handleOpenMarkup()}
>
批量加价
</Button>
<Button
size="small"
icon={<CheckCircleOutlined />}
onClick={() => handleBatchUpdate('enabled')}
>
批量展示
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleBatchUpdate('disabled')}
>
批量下架
</Button>
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
取消勾选
</Button>
</Space>
)}
</div>
<Divider style={{ margin: '4px 0' }} />
{/* Quick Statistics and Data Scope Badges */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap size={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, marginRight: 4 }}>
<FilterOutlined /> 货架概览:
</Typography.Text>
<Tag color="default">货架总计 {counts.total} 件</Tag>
<Tag color="green">主站自营 {counts.main} 件</Tag>
<Tag color="purple">本站收号 {counts.self} 件</Tag>
{counts.collab > 0 && <Tag color="blue">合作分站 {counts.collab} 件</Tag>}
<Tag color="gold">已自主加价 {counts.marked} 件</Tag>
<Tag color="cyan">正常展示中 {counts.enabled} 件</Tag>
</Space>
{counts.hasActiveFilters && (
<Tag color="processing" style={{ fontWeight: 'bold' }}>
已按条件筛选:展示 {counts.filtered} / {counts.total} 件
</Tag>
)}
</div>
</Space> </Space>
</Card> </Card>
{/* Products Table */}
<Card> <Card>
<Table <Table
rowKey="id" rowKey="id"
@@ -283,16 +546,26 @@ export default function Accounts() {
emptyText: ( emptyText: (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前分站货架暂无商品。点击下方按钮一键同步主站账号池,即可在此设置提价并开始销售。" description={
counts.hasActiveFilters
? '没有符合当前筛选条件的货架商品,请尝试重置筛选条件'
: '当前分站货架暂无商品。点击下方按钮一键同步主站共享池,即可在此设置提价并对外展示。'
}
> >
<Button {counts.hasActiveFilters ? (
type="primary" <Button icon={<ReloadOutlined />} onClick={handleResetFilters}>
icon={<SyncOutlined spin={syncing} />} 重置筛选条件
loading={syncing} </Button>
onClick={handleSync} ) : (
> <Button
一键同步主站商品 type="primary"
</Button> icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
>
一键同步主站商品
</Button>
)}
</Empty> </Empty>
), ),
}} }}
@@ -315,7 +588,15 @@ export default function Accounts() {
<Typography.Text copyable strong> <Typography.Text copyable strong>
{v || `#${row.listing_id}`} {v || `#${row.listing_id}`}
</Typography.Text> </Typography.Text>
{row.in_transaction && <Tag color="warning">出租中</Tag>} {row.in_transaction ? (
<Tag color="warning" style={{ margin: 0 }}>
出租中
</Tag>
) : (
<Tag color="green" style={{ margin: 0 }}>
在售空闲
</Tag>
)}
</Space> </Space>
), ),
}, },
@@ -324,12 +605,24 @@ export default function Accounts() {
width: 110, width: 110,
render: (_: unknown, row: AccountDisplay) => { render: (_: unknown, row: AccountDisplay) => {
if (row.listing_site_id === null || row.listing_site_id === undefined) { if (row.listing_site_id === null || row.listing_site_id === undefined) {
return <Tag color="green">主站自营</Tag> return (
<Tooltip title="来自主站官方统一共享池,分销商可提价转售">
<Tag color="green">主站自营</Tag>
</Tooltip>
)
} }
if (row.listing_site_id === siteID) { if (row.listing_site_id === siteID) {
return <Tag color="purple">本站收号</Tag> return (
<Tooltip title="本分销站专属收录上架的号源">
<Tag color="purple">本站收号</Tag>
</Tooltip>
)
} }
return <Tag color="blue">合作分站</Tag> return (
<Tooltip title="其他合作分销站铺过来的共享号源">
<Tag color="blue">合作分站</Tag>
</Tooltip>
)
}, },
}, },
{ {
@@ -342,7 +635,7 @@ export default function Accounts() {
{row.title || `账号 #${row.account_id}`} {row.title || `账号 #${row.account_id}`}
</Typography.Text> </Typography.Text>
<Space size={4} style={{ marginTop: 4 }}> <Space size={4} style={{ marginTop: 4 }}>
<Tag color="blue">{row.game_name || '默认游戏'}</Tag> <Tag color="blue">{formatGameName(row.game_name)}</Tag>
{row.server_region && <Tag>{row.server_region}</Tag>} {row.server_region && <Tag>{row.server_region}</Tag>}
{row.login_platform && <Tag>{row.login_platform}</Tag>} {row.login_platform && <Tag>{row.login_platform}</Tag>}
</Space> </Space>
@@ -365,17 +658,26 @@ export default function Accounts() {
}, },
{ {
title: '租金 / 押金', title: '租金 / 押金',
width: 160, width: 170,
render: (_: unknown, row: AccountDisplay) => ( render: (_: unknown, row: AccountDisplay) => {
<div> const finalPrice = row.site_price_cent || row.price_cent
<Typography.Text strong style={{ color: '#cf1322' }}> const hasMarkup = row.shelf_ratio_delta && row.shelf_ratio_delta > 0
{formatMoney(row.site_price_cent || row.price_cent)} return (
</Typography.Text> <div>
<div style={{ fontSize: 12, color: '#666' }}> <Typography.Text strong style={{ color: '#cf1322', fontSize: 15 }}>
押金: {formatMoney(row.deposit_amount_cent)} {formatMoney(finalPrice)}
</Typography.Text>
<div style={{ fontSize: 12, color: '#666' }}>
押金: {formatMoney(row.deposit_amount_cent)}
</div>
{hasMarkup && (
<div style={{ fontSize: 11, color: '#389e0d', marginTop: 2 }}>
含提价溢价: +{formatMoney(row.markup_amount_cent)}
</div>
)}
</div> </div>
</div> )
), },
}, },
{ {
title: '加价比例', title: '加价比例',
@@ -385,17 +687,17 @@ export default function Accounts() {
if (v && v > 0) { if (v && v > 0) {
return ( return (
<Tag color="gold" style={{ fontWeight: 'bold' }}> <Tag color="gold" style={{ fontWeight: 'bold' }}>
+{v} +{v} 比例点
</Tag> </Tag>
) )
} }
return <span style={{ color: '#aaa', fontSize: 12 }}>无加价</span> return <span style={{ color: '#aaa', fontSize: 12 }}>无加价 (基准)</span>
}, },
}, },
{ {
title: '主站状态', title: '主站状态',
dataIndex: 'listing_status', dataIndex: 'listing_status',
width: 110, width: 100,
render: (v: string | undefined) => ( render: (v: string | undefined) => (
<Tag color={v === 'published' ? 'green' : 'default'}> <Tag color={v === 'published' ? 'green' : 'default'}>
{v === 'published' ? '主站在售' : v || '未知'} {v === 'published' ? '主站在售' : v || '未知'}
@@ -405,15 +707,16 @@ export default function Accounts() {
{ {
title: '分销展示', title: '分销展示',
dataIndex: 'display_status', dataIndex: 'display_status',
width: 110, width: 100,
render: (v: string) => { render: (v: string) => (
const conf = STATUS_LABELS[v] ?? { label: v, color: 'default' } <Tag color={v === 'enabled' ? 'success' : 'default'}>
return <Tag color={conf.color}>{conf.label}</Tag> {v === 'enabled' ? '展示中' : '已下架'}
}, </Tag>
),
}, },
{ {
title: '操作', title: '操作',
width: 160, width: 140,
render: (_: unknown, row: AccountDisplay) => ( render: (_: unknown, row: AccountDisplay) => (
<Space size={2}> <Space size={2}>
<Button <Button
@@ -439,8 +742,13 @@ export default function Accounts() {
/> />
</Card> </Card>
{/* Markup Modal */}
<Modal <Modal
title={targetAccount ? `设置提价 - ${targetAccount.title || targetAccount.listing_no}` : `批量提价 (${selectedRowKeys.length} 个账号商品)`} title={
targetAccount
? `设置提价 - ${targetAccount.title || targetAccount.listing_no}`
: `批量提价 (${selectedRowKeys.length} 个账号商品)`
}
open={markupModalVisible} open={markupModalVisible}
onCancel={() => setMarkupModalVisible(false)} onCancel={() => setMarkupModalVisible(false)}
onOk={handleSaveMarkup} onOk={handleSaveMarkup}
@@ -450,7 +758,8 @@ export default function Accounts() {
> >
<Space direction="vertical" style={{ width: '100%', padding: '12px 0' }}> <Space direction="vertical" style={{ width: '100%', padding: '12px 0' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;<strong>提价多挣的差价收益 100% 归本分站所有</strong>。 分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;
<strong style={{ color: '#389e0d' }}>提价多挣的差价收益 100% 归本分站所有</strong>。
</Typography.Paragraph> </Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text strong>提价调减比例:</Typography.Text> <Typography.Text strong>提价调减比例:</Typography.Text>
@@ -466,13 +775,26 @@ export default function Accounts() {
/> />
</div> </div>
{targetAccount && ( {targetAccount && (
<div style={{ marginTop: 8, padding: '8px 12px', background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', fontSize: 12 }}> <div
style={{
marginTop: 8,
padding: '8px 12px',
background: '#f6ffed',
borderRadius: 4,
border: '1px solid #b7eb8f',
fontSize: 12,
}}
>
主站基准比例 {targetAccount.base_ratio || '-'} → 分站提价后比例{' '} 主站基准比例 {targetAccount.base_ratio || '-'} → 分站提价后比例{' '}
<strong>{Math.max((targetAccount.base_ratio || 0) - markupRatio, 0.1).toFixed(1)}</strong>, <strong>{Math.max((targetAccount.base_ratio || 0) - markupRatio, 0.1).toFixed(1)}</strong>
预计售价{' '} ,预计售价{' '}
<strong style={{ color: '#cf1322' }}> <strong style={{ color: '#cf1322' }}>
{markupRatio > 0 && targetAccount.base_ratio {markupRatio > 0 && targetAccount.base_ratio
? `¥${(((targetAccount.haf_coin_amount || 0) / 10000) / Math.max(targetAccount.base_ratio - markupRatio, 0.1)).toFixed(2)}` ? `¥${(
(targetAccount.haf_coin_amount || 0) /
10000 /
Math.max(targetAccount.base_ratio - markupRatio, 0.1)
).toFixed(2)}`
: formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)} : formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)}
</strong> </strong>
(加价溢价全额归本站独享) (加价溢价全额归本站独享)
+48 -22
View File
@@ -27,14 +27,17 @@ import {
listSites, listSites,
fetchDashboardAnalytics, fetchDashboardAnalytics,
formatMoney, formatMoney,
getMe,
type Site, type Site,
type DistributionAnalytics, type DistributionAnalytics,
type DistributionOrder, type DistributionOrder,
} from '../api' } from '../api'
import { useMember } from '../MemberContext'
export default function Dashboard() { export default function Dashboard() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>() const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [analytics, setAnalytics] = useState<DistributionAnalytics | null>(null) const [analytics, setAnalytics] = useState<DistributionAnalytics | null>(null)
const [recentOrders, setRecentOrders] = useState<DistributionOrder[]>([]) const [recentOrders, setRecentOrders] = useState<DistributionOrder[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -56,18 +59,39 @@ export default function Dashboard() {
}, []) }, [])
useEffect(() => { useEffect(() => {
listSites() async function init() {
.then((rows) => { let currentMember = member
setSites(rows) if (!currentMember) {
if (rows.length > 0) { try {
setSiteID(rows[0].id) currentMember = await getMe()
void loadData(rows[0].id) } catch {
// ignore
} }
}) }
.catch(() => message.error('站点加载失败')) const activeSiteID = currentMember?.site_id
}, [loadData]) if (activeSiteID) {
setSiteID(activeSiteID)
void loadData(activeSiteID)
}
const selectedSite = sites.find((s) => s.id === siteID) if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
void loadData(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member, loadData])
const selectedSiteName =
member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商'
return ( return (
<Space direction="vertical" size="large" className="page" style={{ width: '100%' }}> <Space direction="vertical" size="large" className="page" style={{ width: '100%' }}>
@@ -77,20 +101,22 @@ export default function Dashboard() {
运营数据大盘 运营数据大盘
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
当前站点:{selectedSite?.site_name || '默认分销站'} ({selectedSite?.site_code}) · 实时聚合收号、货架、订单与结算收益 当前站点:{selectedSiteName} · 实时聚合收号、货架、订单与结算收益
</Typography.Text> </Typography.Text>
</div> </div>
<Space> <Space>
<Select {member?.is_superadmin && sites.length > 1 && (
placeholder="切换分销网站" <Select
style={{ width: 220 }} placeholder="切换分销网站"
value={siteID} style={{ width: 220 }}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} value={siteID}
onChange={(val) => { options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
setSiteID(val) onChange={(val) => {
void loadData(val) setSiteID(val)
}} void loadData(val)
/> }}
/>
)}
<Link to="/wallet"> <Link to="/wallet">
<Button type="primary" icon={<WalletOutlined />}> <Button type="primary" icon={<WalletOutlined />}>
去提现 去提现
+57 -23
View File
@@ -54,18 +54,20 @@ const LISTING_STATUS_LABELS: Record<string, { label: string; color: string }> =
} }
export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) { export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
const member = useMember()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>() const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [activeTab, setActiveTab] = useState<'received_listings' | 'orders'>('received_listings')
// Orders Tab State // Orders Tab State
const [status, setStatus] = useState<string>('')
const [orders, setOrders] = useState<DistributionOrder[]>([]) const [orders, setOrders] = useState<DistributionOrder[]>([])
const [orderTotal, setOrderTotal] = useState(0) const [orderTotal, setOrderTotal] = useState(0)
const [orderPage, setOrderPage] = useState(1) const [orderPage, setOrderPage] = useState(1)
const [orderLoading, setOrderLoading] = useState(false) const [orderLoading, setOrderLoading] = useState(false)
const [status, setStatus] = useState<string>('')
const [orderKeyword, setOrderKeyword] = useState<string>('')
// Received Listings Tab State // Received Listings Tab State
const [activeTab, setActiveTab] = useState<'received_listings' | 'orders'>('received_listings')
const [receivedListings, setReceivedListings] = useState<ReceivedListing[]>([]) const [receivedListings, setReceivedListings] = useState<ReceivedListing[]>([])
const [recvTotal, setRecvTotal] = useState(0) const [recvTotal, setRecvTotal] = useState(0)
const [recvPage, setRecvPage] = useState(1) const [recvPage, setRecvPage] = useState(1)
@@ -77,9 +79,17 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
const pageSize = 20 const pageSize = 20
const loadOrders = useCallback(async (targetPage: number) => { const loadOrders = useCallback(async (targetPage: number) => {
if (!siteID) return
setOrderLoading(true) setOrderLoading(true)
try { try {
const result = await listOrders({ kind, status, siteID, page: targetPage, pageSize }) const result = await listOrders({
kind,
status,
siteID,
page: targetPage,
pageSize,
keyword: orderKeyword || undefined
})
setOrders(result.items) setOrders(result.items)
setOrderTotal(result.total) setOrderTotal(result.total)
setOrderPage(result.page) setOrderPage(result.page)
@@ -88,7 +98,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
} finally { } finally {
setOrderLoading(false) setOrderLoading(false)
} }
}, [kind, status, siteID]) }, [kind, status, siteID, orderKeyword])
const loadReceivedListings = useCallback(async (targetPage: number) => { const loadReceivedListings = useCallback(async (targetPage: number) => {
if (!siteID) return if (!siteID) return
@@ -98,9 +108,9 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
siteID, siteID,
page: targetPage, page: targetPage,
pageSize, pageSize,
status: recvStatus, status: recvStatus || undefined,
reviewStatus: recvReviewStatus, reviewStatus: recvReviewStatus || undefined,
keyword: recvKeyword, keyword: recvKeyword || undefined,
}) })
setReceivedListings(result.items) setReceivedListings(result.items)
setRecvTotal(result.total) setRecvTotal(result.total)
@@ -113,15 +123,37 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
}, [siteID, recvStatus, recvReviewStatus, recvKeyword]) }, [siteID, recvStatus, recvReviewStatus, recvKeyword])
useEffect(() => { useEffect(() => {
listSites() async function init() {
.then((rows) => { let currentMember = member
setSites(rows) if (!currentMember) {
if (rows.length > 0) setSiteID(rows[0].id) try {
}) currentMember = await getMe()
.catch(() => message.error('站点加载失败')) } catch {
}, []) // ignore
}
}
const activeSiteID = currentMember?.site_id
if (activeSiteID) {
setSiteID(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member])
useEffect(() => { useEffect(() => {
if (!siteID) return
if (kind === 'receive') { if (kind === 'receive') {
if (activeTab === 'received_listings') { if (activeTab === 'received_listings') {
void loadReceivedListings(1) void loadReceivedListings(1)
@@ -165,13 +197,15 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
: '查看分销订单交易及财务明细'} : '查看分销订单交易及财务明细'}
</Typography.Text> </Typography.Text>
</div> </div>
<Select {member?.is_superadmin && sites.length > 1 && (
placeholder="切换分销网站" <Select
style={{ width: 220 }} placeholder="切换分销网站"
value={siteID} style={{ width: 220 }}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} value={siteID}
onChange={(value) => setSiteID(value)} options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
/> onChange={(value) => setSiteID(value)}
/>
)}
</div> </div>
{kind === 'receive' ? ( {kind === 'receive' ? (
+45 -20
View File
@@ -7,15 +7,18 @@ import {
listSettlementItems, listSettlementItems,
listSites, listSites,
settleBatch, settleBatch,
getMe,
type SettlementBatch, type SettlementBatch,
type SettlementItem, type SettlementItem,
type Site, type Site,
} from '../api' } from '../api'
import { useMember } from '../MemberContext'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
export default function Settlements() { export default function Settlements() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>() const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [batches, setBatches] = useState<SettlementBatch[]>([]) const [batches, setBatches] = useState<SettlementBatch[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null) const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
@@ -34,16 +37,36 @@ export default function Settlements() {
} }
useEffect(() => { useEffect(() => {
listSites() async function init() {
.then((rows) => { let currentMember = member
setSites(rows) if (!currentMember) {
if (rows.length > 0) { try {
setSiteID(rows[0].id) currentMember = await getMe()
void load(rows[0].id) } catch {
// ignore
} }
}) }
.catch(() => message.error('站点加载失败')) const activeSiteID = currentMember?.site_id
}, []) if (activeSiteID) {
setSiteID(activeSiteID)
void load(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member])
async function handleCreate() { async function handleCreate() {
if (!siteID || !range?.[0] || !range?.[1]) { if (!siteID || !range?.[0] || !range?.[1]) {
@@ -84,16 +107,18 @@ export default function Settlements() {
<Space direction="vertical" className="page"> <Space direction="vertical" className="page">
<Typography.Title level={3}>结算管理</Typography.Title> <Typography.Title level={3}>结算管理</Typography.Title>
<Space> <Space>
<Select {member?.is_superadmin && sites.length > 1 && (
placeholder="分销网站" <Select
style={{ width: 200 }} placeholder="分销网站"
value={siteID} style={{ width: 200 }}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))} value={siteID}
onChange={(value) => { options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
setSiteID(value) onChange={(value) => {
void load(value) setSiteID(value)
}} void load(value)
/> }}
/>
)}
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} /> <DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" onClick={handleCreate}>创建批次</Button> <Button type="primary" onClick={handleCreate}>创建批次</Button>
</Space> </Space>
+41 -15
View File
@@ -31,10 +31,12 @@ import {
applySiteWithdrawal, applySiteWithdrawal,
listSites, listSites,
formatMoney, formatMoney,
getMe,
type SiteWallet, type SiteWallet,
type SiteWithdrawal, type SiteWithdrawal,
type Site, type Site,
} from '../api' } from '../api'
import { useMember } from '../MemberContext'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = { const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: 'orange' }, pending: { label: '待审核', color: 'orange' },
@@ -44,8 +46,9 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
} }
export default function Wallet() { export default function Wallet() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>() const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [wallet, setWallet] = useState<SiteWallet | null>(null) const [wallet, setWallet] = useState<SiteWallet | null>(null)
const [withdrawals, setWithdrawals] = useState<SiteWithdrawal[]>([]) const [withdrawals, setWithdrawals] = useState<SiteWithdrawal[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -77,13 +80,34 @@ export default function Wallet() {
}, [siteID]) }, [siteID])
useEffect(() => { useEffect(() => {
listSites() async function init() {
.then((rows) => { let currentMember = member
setSites(rows) if (!currentMember) {
if (rows.length > 0) setSiteID(rows[0].id) try {
}) currentMember = await getMe()
.catch(() => message.error('站点加载失败')) } catch {
}, []) // ignore
}
}
const activeSiteID = currentMember?.site_id
if (activeSiteID) {
setSiteID(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member])
useEffect(() => { useEffect(() => {
void loadData(1) void loadData(1)
@@ -145,13 +169,15 @@ export default function Wallet() {
</Typography.Text> </Typography.Text>
</div> </div>
<Space> <Space>
<Select {member?.is_superadmin && sites.length > 1 && (
placeholder="分销网站" <Select
style={{ width: 220 }} placeholder="分销网站"
value={siteID} style={{ width: 220 }}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} value={siteID}
onChange={(value) => setSiteID(value)} options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
/> onChange={(value) => setSiteID(value)}
/>
)}
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}