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,
} from '@ant-design/icons'
import { getMe, logout, type CurrentMember, getStoredToken } from './api'
import { MemberContext } from './MemberContext'
import Dashboard from './pages/Dashboard'
import Sites from './pages/Sites'
import Rules from './pages/Rules'
@@ -61,7 +62,8 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
: '分销成员'
return (
<Layout className="app">
<MemberContext.Provider value={member}>
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">
<ShopOutlined style={{ fontSize: 20, color: '#1677ff' }} />
@@ -149,6 +151,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
</Layout.Content>
</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 { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal, InputNumber, Empty } from 'antd'
import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined, EditOutlined, DollarCircleOutlined } from '@ant-design/icons'
import {
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 {
listAccountDisplays,
listSites,
@@ -9,70 +36,128 @@ import {
updateAccountMarkup,
batchUpdateAccountMarkup,
formatMoney,
getMe,
type AccountDisplay,
type Site,
} from '../api'
import { useMember } from '../MemberContext'
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' },
const SOURCE_OPTIONS = [
{ label: '全部货源', value: 'all' },
{ label: '主站自营 (平台共享池)', value: 'main' },
{ label: '本站收号 (独家货源)', value: 'self' },
{ 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() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [status, setStatus] = useState<string>('')
const [keyword, setKeyword] = useState<string>('')
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [items, setItems] = useState<AccountDisplay[]>([])
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
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
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) {
async function load(site?: number) {
if (!site) return
setLoading(true)
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 {
message.error('账号展示加载失败')
message.error('账号展示货架加载失败')
} finally {
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(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} 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() {
if (!siteID) {
message.warning('请先选择分销站点')
message.warning('无法获取当前分销站点信息')
return
}
setSyncing(true)
try {
const res = await syncSiteAccounts(siteID)
message.success(
`同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(平台在售共 ${res.total_active} 个)`,
`同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(主站共享池在售共 ${res.total_active} 个)`,
5,
)
await load(siteID, status)
await load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '同步主站商品失败')
@@ -85,8 +170,8 @@ export default function Accounts() {
const next = item.display_status === 'enabled' ? 'disabled' : 'enabled'
try {
await updateAccountDisplay(item.id, next)
message.success(next === 'enabled' ? '已恢复展示' : '已下架')
if (siteID) void load(siteID, status)
message.success(next === 'enabled' ? '已恢复货架展示' : '已从货架下架')
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '状态更新失败')
@@ -114,7 +199,7 @@ export default function Accounts() {
}
message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`)
setSelectedRowKeys([])
if (siteID) void load(siteID, status)
if (siteID) void load(siteID)
},
})
}
@@ -137,7 +222,7 @@ export default function Accounts() {
async function handleSaveMarkup() {
if (!siteID) return
if (markupRatio < 0) {
message.error('加价比例不能小于0')
message.error('提价调减比例不能小于0')
return
}
setMarkupSaving(true)
@@ -154,43 +239,174 @@ export default function Accounts() {
return
}
await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置提价!`)
setSelectedRowKeys([])
}
setMarkupModalVisible(false)
void load(siteID, status)
void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage || '加价设置保存失败')
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])
function handleResetFilters() {
setSourceFilter('all')
setMarkupFilter('all')
setDisplayFilter('all')
setTxFilter('all')
setKeyword('')
}
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 (
<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>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
商品与账号展示
</Typography.Title>
<Typography.Text type="secondary">
管理当前分销网站对外展示的商品货架。所有账号源均来自主站及平台共享池,支持一键同步主站商品,并进行自主提价(调小比例)销售。
</Typography.Text>
<Space align="baseline">
<Typography.Title level={3} style={{ marginBottom: 4 }}>
商品与账号展示
</Typography.Title>
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px' }}>
<ShopOutlined /> {currentSiteName}
</Tag>
</Space>
<div>
<Typography.Text type="secondary">
管理本站对外展示与销售的货架商品。所有账号源均来自主站及平台共享池,支持一键铺货并自主提价(调小兑换比例)销售,<strong>加价溢价全额归本站所有</strong>。
</Typography.Text>
</div>
</div>
<Button
type="primary"
@@ -203,77 +419,124 @@ export default function Accounts() {
</Button>
</div>
{/* Shelf Filter Bar */}
<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>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{/* Controls row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space wrap size="small">
{/* 货源归属 */}
<Select
style={{ width: 170 }}
value={sourceFilter}
options={SOURCE_OPTIONS}
onChange={setSourceFilter}
/>
{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>
{/* 提价状态 */}
<Select
style={{ width: 170 }}
value={markupFilter}
options={MARKUP_OPTIONS}
onChange={setMarkupFilter}
/>
{/* 展示状态 */}
<Select
style={{ width: 160 }}
value={displayFilter}
options={DISPLAY_OPTIONS}
onChange={setDisplayFilter}
/>
{/* 交易租借状态 */}
<Select
style={{ width: 150 }}
value={txFilter}
options={TX_OPTIONS}
onChange={setTxFilter}
/>
{/* 搜索框 */}
<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>
)}
{/* 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>
</Card>
{/* Products Table */}
<Card>
<Table
rowKey="id"
@@ -283,16 +546,26 @@ export default function Accounts() {
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前分站货架暂无商品。点击下方按钮一键同步主站账号池,即可在此设置提价并开始销售。"
description={
counts.hasActiveFilters
? '没有符合当前筛选条件的货架商品,请尝试重置筛选条件'
: '当前分站货架暂无商品。点击下方按钮一键同步主站共享池,即可在此设置提价并对外展示。'
}
>
<Button
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
>
一键同步主站商品
</Button>
{counts.hasActiveFilters ? (
<Button icon={<ReloadOutlined />} onClick={handleResetFilters}>
重置筛选条件
</Button>
) : (
<Button
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
>
一键同步主站商品
</Button>
)}
</Empty>
),
}}
@@ -315,7 +588,15 @@ export default function Accounts() {
<Typography.Text copyable strong>
{v || `#${row.listing_id}`}
</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>
),
},
@@ -324,12 +605,24 @@ export default function Accounts() {
width: 110,
render: (_: unknown, row: AccountDisplay) => {
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) {
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}`}
</Typography.Text>
<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.login_platform && <Tag>{row.login_platform}</Tag>}
</Space>
@@ -365,17 +658,26 @@ export default function Accounts() {
},
{
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)}
width: 170,
render: (_: unknown, row: AccountDisplay) => {
const finalPrice = row.site_price_cent || row.price_cent
const hasMarkup = row.shelf_ratio_delta && row.shelf_ratio_delta > 0
return (
<div>
<Typography.Text strong style={{ color: '#cf1322', fontSize: 15 }}>
{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>
),
)
},
},
{
title: '加价比例',
@@ -385,17 +687,17 @@ export default function Accounts() {
if (v && v > 0) {
return (
<Tag color="gold" style={{ fontWeight: 'bold' }}>
+{v}
+{v} 比例点
</Tag>
)
}
return <span style={{ color: '#aaa', fontSize: 12 }}>无加价</span>
return <span style={{ color: '#aaa', fontSize: 12 }}>无加价 (基准)</span>
},
},
{
title: '主站状态',
dataIndex: 'listing_status',
width: 110,
width: 100,
render: (v: string | undefined) => (
<Tag color={v === 'published' ? 'green' : 'default'}>
{v === 'published' ? '主站在售' : v || '未知'}
@@ -405,15 +707,16 @@ export default function Accounts() {
{
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>
},
width: 100,
render: (v: string) => (
<Tag color={v === 'enabled' ? 'success' : 'default'}>
{v === 'enabled' ? '展示中' : '已下架'}
</Tag>
),
},
{
title: '操作',
width: 160,
width: 140,
render: (_: unknown, row: AccountDisplay) => (
<Space size={2}>
<Button
@@ -439,8 +742,13 @@ export default function Accounts() {
/>
</Card>
{/* Markup Modal */}
<Modal
title={targetAccount ? `设置提价 - ${targetAccount.title || targetAccount.listing_no}` : `批量提价 (${selectedRowKeys.length} 个账号商品)`}
title={
targetAccount
? `设置提价 - ${targetAccount.title || targetAccount.listing_no}`
: `批量提价 (${selectedRowKeys.length} 个账号商品)`
}
open={markupModalVisible}
onCancel={() => setMarkupModalVisible(false)}
onOk={handleSaveMarkup}
@@ -450,7 +758,8 @@ export default function Accounts() {
>
<Space direction="vertical" style={{ width: '100%', padding: '12px 0' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;<strong>提价多挣的差价收益 100% 归本分站所有</strong>。
分站提价是通过调小「1 元兑换哈夫币」的兑换比例来提高销售价。比例越小售价越高;
<strong style={{ color: '#389e0d' }}>提价多挣的差价收益 100% 归本分站所有</strong>。
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text strong>提价调减比例:</Typography.Text>
@@ -466,13 +775,26 @@ export default function Accounts() {
/>
</div>
{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 || '-'} → 分站提价后比例{' '}
<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' }}>
{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)}
</strong>
(加价溢价全额归本站独享)
+48 -22
View File
@@ -27,14 +27,17 @@ import {
listSites,
fetchDashboardAnalytics,
formatMoney,
getMe,
type Site,
type DistributionAnalytics,
type DistributionOrder,
} from '../api'
import { useMember } from '../MemberContext'
export default function Dashboard() {
const member = useMember()
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 [recentOrders, setRecentOrders] = useState<DistributionOrder[]>([])
const [loading, setLoading] = useState(false)
@@ -56,18 +59,39 @@ export default function Dashboard() {
}, [])
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) {
setSiteID(rows[0].id)
void loadData(rows[0].id)
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} catch {
// ignore
}
})
.catch(() => message.error('站点加载失败'))
}, [loadData])
}
const activeSiteID = currentMember?.site_id
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 (
<Space direction="vertical" size="large" className="page" style={{ width: '100%' }}>
@@ -77,20 +101,22 @@ export default function Dashboard() {
运营数据大盘
</Typography.Title>
<Typography.Text type="secondary">
当前站点:{selectedSite?.site_name || '默认分销站'} ({selectedSite?.site_code}) · 实时聚合收号、货架、订单与结算收益
当前站点:{selectedSiteName} · 实时聚合收号、货架、订单与结算收益
</Typography.Text>
</div>
<Space>
<Select
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(val) => {
setSiteID(val)
void loadData(val)
}}
/>
{member?.is_superadmin && sites.length > 1 && (
<Select
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(val) => {
setSiteID(val)
void loadData(val)
}}
/>
)}
<Link to="/wallet">
<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' }) {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [activeTab, setActiveTab] = useState<'received_listings' | 'orders'>('received_listings')
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
// Orders Tab State
const [status, setStatus] = useState<string>('')
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 [orderKeyword, setOrderKeyword] = useState<string>('')
// Received Listings Tab State
const [activeTab, setActiveTab] = useState<'received_listings' | 'orders'>('received_listings')
const [receivedListings, setReceivedListings] = useState<ReceivedListing[]>([])
const [recvTotal, setRecvTotal] = useState(0)
const [recvPage, setRecvPage] = useState(1)
@@ -77,9 +79,17 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
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 })
const result = await listOrders({
kind,
status,
siteID,
page: targetPage,
pageSize,
keyword: orderKeyword || undefined
})
setOrders(result.items)
setOrderTotal(result.total)
setOrderPage(result.page)
@@ -88,7 +98,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
} finally {
setOrderLoading(false)
}
}, [kind, status, siteID])
}, [kind, status, siteID, orderKeyword])
const loadReceivedListings = useCallback(async (targetPage: number) => {
if (!siteID) return
@@ -98,9 +108,9 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
siteID,
page: targetPage,
pageSize,
status: recvStatus,
reviewStatus: recvReviewStatus,
keyword: recvKeyword,
status: recvStatus || undefined,
reviewStatus: recvReviewStatus || undefined,
keyword: recvKeyword || undefined,
})
setReceivedListings(result.items)
setRecvTotal(result.total)
@@ -113,15 +123,37 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
}, [siteID, recvStatus, recvReviewStatus, recvKeyword])
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) setSiteID(rows[0].id)
})
.catch(() => message.error('站点加载失败'))
}, [])
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} 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(() => {
if (!siteID) return
if (kind === 'receive') {
if (activeTab === 'received_listings') {
void loadReceivedListings(1)
@@ -165,13 +197,15 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
: '查看分销订单交易及财务明细'}
</Typography.Text>
</div>
<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)}
/>
{member?.is_superadmin && sites.length > 1 && (
<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)}
/>
)}
</div>
{kind === 'receive' ? (
+45 -20
View File
@@ -7,15 +7,18 @@ import {
listSettlementItems,
listSites,
settleBatch,
getMe,
type SettlementBatch,
type SettlementItem,
type Site,
} from '../api'
import { useMember } from '../MemberContext'
import type { Dayjs } from 'dayjs'
export default function Settlements() {
const member = useMember()
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 [loading, setLoading] = useState(false)
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
@@ -34,16 +37,36 @@ export default function Settlements() {
}
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} 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() {
if (!siteID || !range?.[0] || !range?.[1]) {
@@ -84,16 +107,18 @@ export default function Settlements() {
<Space direction="vertical" className="page">
<Typography.Title level={3}>结算管理</Typography.Title>
<Space>
<Select
placeholder="分销网站"
style={{ width: 200 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
{member?.is_superadmin && sites.length > 1 && (
<Select
placeholder="分销网站"
style={{ width: 200 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
)}
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" onClick={handleCreate}>创建批次</Button>
</Space>
+41 -15
View File
@@ -31,10 +31,12 @@ import {
applySiteWithdrawal,
listSites,
formatMoney,
getMe,
type SiteWallet,
type SiteWithdrawal,
type Site,
} from '../api'
import { useMember } from '../MemberContext'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: 'orange' },
@@ -44,8 +46,9 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
}
export default function Wallet() {
const member = useMember()
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 [withdrawals, setWithdrawals] = useState<SiteWithdrawal[]>([])
const [total, setTotal] = useState(0)
@@ -77,13 +80,34 @@ export default function Wallet() {
}, [siteID])
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) setSiteID(rows[0].id)
})
.catch(() => message.error('站点加载失败'))
}, [])
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} 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(() => {
void loadData(1)
@@ -145,13 +169,15 @@ export default function Wallet() {
</Typography.Text>
</div>
<Space>
<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)}
/>
{member?.is_superadmin && sites.length > 1 && (
<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)}
/>
)}
<Button
type="primary"
icon={<PlusOutlined />}