分销后台前端对接与结算明细接口

- 新增 GET /admin/distribution/settlement-batches/:id/items 明细查询
- distribution-admin 重构为 react-router 多页面:仪表盘/网站/规则(启停)/订单(筛选分页)/结算(创建/明细/结算)/支付通道(绑定解绑)/账号展示(上下架)
- axios 统一 401 提示,引导回主站后台登录;金额统一整数分格式化
- npm run build(tsc 严格模式) 通过
This commit is contained in:
yml2213
2026-09-05 11:17:43 +08:00
parent ac777f5b16
commit 3162d15425
15 changed files with 1006 additions and 38 deletions
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message } from 'antd'
import { listAccountDisplays, listSites, updateAccountDisplay, type AccountDisplay, type Site } from '../api'
const STATUS_LABELS: Record<string, string> = {
enabled: '展示中',
disabled: '已下架',
sold: '已售出',
offline: '已离线',
}
export default function Accounts() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [status, setStatus] = useState<string>('')
const [items, setItems] = useState<AccountDisplay[]>([])
const [loading, setLoading] = 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 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 ?? '状态更新失败')
}
}
return (
<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, status)
}}
/>
<Select
allowClear
placeholder="展示状态"
style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value }))}
onChange={(value) => {
setStatus(value ?? '')
void load(siteID, value ?? '')
}}
/>
</Space>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ pageSize: 20 }}
columns={[
{ title: '账号 ID', dataIndex: 'account_id' },
{ title: '商品 ID', dataIndex: 'listing_id' },
{
title: '展示状态',
dataIndex: 'display_status',
render: (v: string) => <Tag color={v === 'enabled' ? 'green' : 'default'}>{STATUS_LABELS[v] ?? v}</Tag>,
},
{
title: '操作',
render: (_: unknown, row: AccountDisplay) => (
<a onClick={() => toggle(row)}>{row.display_status === 'enabled' ? '下架' : '恢复展示'}</a>
),
},
]}
/>
</Card>
</Space>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Statistic, Space, message } from 'antd'
import { listOrders, listSites, formatMoney, type Site } from '../api'
export default function Dashboard() {
const [sites, setSites] = useState<Site[]>([])
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>['items']>([])
const [total, setTotal] = useState(0)
useEffect(() => {
Promise.all([listSites(), listOrders({ pageSize: 100 })])
.then(([siteItems, page]) => {
setSites(siteItems)
setOrders(page.items)
setTotal(page.total)
})
.catch(() => message.error('数据加载失败'))
}, [])
const netPaid = orders.reduce((sum, item) => sum + item.net_paid_amount_cent, 0)
return (
<Space direction="vertical" size="large" className="page">
<Typography.Title level={3}>分销运营概览</Typography.Title>
<div className="stats">
<Card><Statistic title="分销网站" value={sites.length} /></Card>
<Card><Statistic title="分销订单" value={total} /></Card>
<Card><Statistic title="最近净支付" value={(netPaid / 100).toFixed(2)} suffix="元" /></Card>
</div>
<Card title="最近订单">
<Table
rowKey="order_id"
dataSource={orders.slice(0, 10)}
pagination={false}
columns={[
{ title: '订单号', dataIndex: 'order_no' },
{ title: '状态', dataIndex: 'status' },
{ title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '结算', dataIndex: 'settlement_status' },
{ title: '创建时间', dataIndex: 'created_at' },
]}
/>
</Card>
</Space>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message } from 'antd'
import { formatMoney, listOrders, listSites, type DistributionOrder, type Site } from '../api'
const ORDER_STATUSES = [
'pending_payment',
'pending_handoff',
'renting',
'overdue',
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'disputing',
'abnormal',
'completed',
'refunded',
'cancelled',
]
export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [status, setStatus] = useState<string>('')
const [items, setItems] = useState<DistributionOrder[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 20
const [loading, setLoading] = useState(false)
const load = useCallback(async (targetPage: number) => {
setLoading(true)
try {
const result = await listOrders({ kind, status, siteID, page: targetPage, pageSize })
setItems(result.items)
setTotal(result.total)
setPage(result.page)
} catch {
message.error('订单加载失败')
} finally {
setLoading(false)
}
}, [kind, status, siteID])
useEffect(() => {
listSites()
.then((rows) => {
setSites(rows)
if (rows.length > 0) setSiteID(rows[0].id)
})
.catch(() => message.error('站点加载失败'))
}, [])
useEffect(() => {
void load(1)
}, [load])
const title = kind === 'sold' ? '售出账号订单' : kind === 'receive' ? '分销收号订单' : '全部分销订单'
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>{title}</Typography.Title>
<Space>
<Select
allowClear
placeholder="分销网站"
style={{ width: 200 }}
value={siteID}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => setSiteID(value ?? undefined)}
/>
<Select
allowClear
placeholder="订单状态"
style={{ width: 200 }}
options={ORDER_STATUSES.map((value) => ({ label: value, value }))}
onChange={(value) => setStatus(value ?? '')}
/>
</Space>
<Card>
<Table
rowKey="order_id"
loading={loading}
dataSource={items}
pagination={{ current: page, pageSize, total, onChange: (p) => void load(p) }}
columns={[
{ title: '订单号', dataIndex: 'order_no' },
{ title: '订单状态', dataIndex: 'status', render: (v: string) => <Tag>{v}</Tag> },
{ title: '退款状态', dataIndex: 'refund_status' },
{ title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '服务费', dataIndex: 'service_fee_cent', render: (v: number) => formatMoney(v) },
{ title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
{ title: '尾差', dataIndex: 'rounding_adjustment_cent', render: (v: number) => formatMoney(v) },
{ title: '结算', dataIndex: 'settlement_status' },
{ title: '创建时间', dataIndex: 'created_at' },
]}
/>
</Card>
</Space>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, InputNumber, Button, message } from 'antd'
import { bindPaymentChannel, listPaymentChannels, listSites, unbindPaymentChannel, type PaymentChannel, type Site } from '../api'
export default function Payments() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [channels, setChannels] = useState<PaymentChannel[]>([])
const [configID, setConfigID] = useState<number | null>(null)
const [priority, setPriority] = useState<number>(0)
const [loading, setLoading] = useState(false)
async function load(id: number) {
setLoading(true)
try {
setChannels(await listPaymentChannels(id))
} catch {
message.error('支付通道加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
listSites()
.then((items) => {
setSites(items)
if (items.length > 0) {
setSiteID(items[0].id)
void load(items[0].id)
}
})
.catch(() => message.error('站点加载失败'))
}, [])
async function handleBind() {
if (!siteID || !configID) {
message.warning('请选择站点并填写商户配置 ID')
return
}
try {
await bindPaymentChannel(siteID, configID, priority)
message.success('支付通道已绑定')
setConfigID(null)
void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '绑定失败')
}
}
async function handleUnbind(id: number) {
try {
await unbindPaymentChannel(id)
message.success('已解绑')
if (siteID) void load(siteID)
} catch {
message.error('解绑失败')
}
}
return (
<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)
}}
/>
<InputNumber min={1} placeholder="商户配置 ID" value={configID ?? undefined} onChange={(v) => setConfigID(v ?? null)} />
<InputNumber min={0} placeholder="优先级" value={priority} onChange={(v) => setPriority(v ?? 0)} />
<Button type="primary" onClick={handleBind}>绑定</Button>
</Space>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={channels}
pagination={false}
columns={[
{ title: '商户配置 ID', dataIndex: 'config_id' },
{ title: '优先级', dataIndex: 'priority' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '操作',
render: (_: unknown, row: PaymentChannel) => <a onClick={() => handleUnbind(row.id)}>解绑</a>,
},
]}
/>
</Card>
</Space>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, message } from 'antd'
import { listRules, listSites, updateRuleStatus, type Rule, type Site } from '../api'
const MODE_LABELS: Record<string, string> = {
two_party_distributor_receive: '两方-分销收号',
two_party_platform_receive: '两方-分销卖出',
three_party: '三方',
}
export default function Rules() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number | undefined>(undefined)
const [rules, setRules] = useState<Rule[]>([])
const [loading, setLoading] = useState(false)
async function load(site?: number) {
setLoading(true)
try {
setRules(await listRules(site))
} catch {
message.error('规则加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
listSites().then(setSites).catch(() => message.error('站点加载失败'))
void load(undefined)
}, [])
async function toggleStatus(rule: Rule) {
const next = rule.status === 'active' ? 'disabled' : 'active'
try {
await updateRuleStatus(rule.id, next)
message.success(`规则已${next === 'active' ? '启用' : '停用'}`)
void load(siteID)
} catch {
message.error('状态更新失败')
}
}
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>比例与税费规则</Typography.Title>
<Space>
<Select
allowClear
placeholder="分销网站"
style={{ width: 200 }}
options={sites.map((site) => ({ label: site.site_name, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
</Space>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={rules}
pagination={false}
columns={[
{ title: '模式', dataIndex: 'mode', render: (v: string) => MODE_LABELS[v] ?? v },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '比例',
render: (_: unknown, r: Rule) => {
const parts: string[] = []
if (r.distributor_receive_ratio > 0) parts.push(`分销收 ${r.distributor_receive_ratio}`)
if (r.platform_sell_ratio > 0) parts.push(`主站卖 ${r.platform_sell_ratio}`)
if (r.distributor_sell_ratio > 0) parts.push(`分销卖 ${r.distributor_sell_ratio}`)
if (r.platform_receive_ratio > 0) parts.push(`主站收 ${r.platform_receive_ratio}`)
if (r.distributor_a_ratio > 0) parts.push(`分销A ${r.distributor_a_ratio}`)
if (r.distributor_b_ratio > 0) parts.push(`分销B ${r.distributor_b_ratio}`)
return parts.join(' / ') || '-'
},
},
{ title: '服务费', render: (_: unknown, r: Rule) => `${r.service_fee_value} (${r.service_fee_unit})` },
{ title: '手续费', dataIndex: 'payment_fee_bps', render: (v: number) => `${v / 100}%` },
{ title: '税费', render: (_: unknown, r: Rule) => (r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭') },
{ title: '生效时间', dataIndex: 'effective_from' },
{
title: '操作',
render: (_: unknown, r: Rule) => (
<a onClick={() => toggleStatus(r)}>{r.status === 'active' ? '停用' : '启用'}</a>
),
},
]}
/>
</Card>
</Space>
)
}
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Select, Tag, DatePicker, Button, message, Modal } from 'antd'
import {
createSettlementBatch,
formatMoney,
listSettlementBatches,
listSettlementItems,
listSites,
settleBatch,
type SettlementBatch,
type SettlementItem,
type Site,
} from '../api'
import type { Dayjs } from 'dayjs'
export default function Settlements() {
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number>()
const [batches, setBatches] = useState<SettlementBatch[]>([])
const [loading, setLoading] = useState(false)
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
const [itemsFor, setItemsFor] = useState<SettlementBatch | null>(null)
const [items, setItems] = useState<SettlementItem[]>([])
async function load(id: number) {
setLoading(true)
try {
setBatches(await listSettlementBatches(id))
} 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 handleCreate() {
if (!siteID || !range?.[0] || !range?.[1]) {
message.warning('请选择站点和结算周期')
return
}
try {
const batch = await createSettlementBatch(siteID, range[0].toISOString(), range[1].toISOString())
message.success(`批次 ${batch.batch_no} 已创建,合计 ${formatMoney(batch.total_amount_cent)}`)
void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '批次创建失败')
}
}
async function handleSettle(batch: SettlementBatch) {
try {
await settleBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已结算`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '结算失败')
}
}
async function showItems(batch: SettlementBatch) {
try {
setItemsFor(batch)
setItems(await listSettlementItems(batch.id))
} catch {
message.error('结算明细加载失败')
}
}
return (
<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)
}}
/>
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" onClick={handleCreate}>创建批次</Button>
</Space>
<Card>
<Table
rowKey="id"
loading={loading}
dataSource={batches}
pagination={false}
columns={[
{ title: '批次号', dataIndex: 'batch_no' },
{ title: '周期开始', dataIndex: 'period_start' },
{ title: '周期结束', dataIndex: 'period_end' },
{ title: '合计金额', dataIndex: 'total_amount_cent', render: (v: number) => formatMoney(v) },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => <Tag color={v === 'settled' ? 'green' : 'gold'}>{v === 'settled' ? '已结算' : '草稿'}</Tag>,
},
{
title: '操作',
render: (_: unknown, row: SettlementBatch) => (
<Space>
<a onClick={() => showItems(row)}>明细</a>
{row.status !== 'settled' && <a onClick={() => handleSettle(row)}>结算</a>}
</Space>
),
},
]}
/>
</Card>
<Modal
open={itemsFor !== null}
title={`结算明细 · ${itemsFor?.batch_no ?? ''}`}
footer={null}
onCancel={() => setItemsFor(null)}
>
<Table
rowKey="id"
dataSource={items}
pagination={false}
columns={[
{ title: '订单 ID', dataIndex: 'order_id' },
{ title: '参与方', dataIndex: 'party_role' },
{ title: '金额', dataIndex: 'amount_cent', render: (v: number) => formatMoney(v) },
{ title: '状态', dataIndex: 'status' },
]}
/>
</Modal>
</Space>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react'
import { Card, Table, Typography, Space, Tag, message } from 'antd'
import { listSites, type Site } from '../api'
export default function Sites() {
const [items, setItems] = useState<Site[]>([])
useEffect(() => {
listSites()
.then(setItems)
.catch(() => message.error('站点加载失败'))
}, [])
return (
<Space direction="vertical" className="page">
<Typography.Title level={3}>分销网站</Typography.Title>
<Card>
<Table
rowKey="id"
dataSource={items}
pagination={false}
columns={[
{ title: '名称', dataIndex: 'site_name' },
{ title: '编码', dataIndex: 'site_code' },
{ title: '域名', dataIndex: 'domain' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '账号展示',
dataIndex: 'display_account_enabled',
render: (v: boolean) => (v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag>),
},
{
title: '税费',
render: (_: unknown, row: Site) => (row.tax_enabled ? `${row.tax_rate_bps / 100}%` : '关闭'),
},
]}
/>
</Card>
</Space>
)
}