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

- 新增 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
@@ -178,3 +178,11 @@ type CreateFinancialSnapshotRequest struct {
MarkupAmountCent int64 `json:"markup_amount_cent"`
Parties []SnapshotPartyRequest `json:"parties"`
}
type SettlementItemDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
PartyRole string `json:"party_role"`
AmountCent int64 `json:"amount_cent"`
Status string `json:"status"`
}
@@ -322,6 +322,20 @@ func (h *Handler) CreateSettlementBatch(c *gin.Context) {
response.Created(c, item)
}
func (h *Handler) ListSettlementItems(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
items, err := h.service.ListSettlementItems(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
@@ -582,3 +582,33 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
}
return r.FindOrder(ctx, adminID, orderID)
}
func (r *Repository) ListSettlementItems(ctx context.Context, adminID, batchID uint64) ([]SettlementItemDTO, error) {
var batch model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).First(&batch, batchID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSettlementBatchNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, batch.SiteID); err != nil {
return nil, err
}
var rows []struct {
model.DistributionSettlementItem
PartyRole string
}
if err := r.db.WithContext(ctx).
Table("distribution_settlement_items i").
Select("i.id, i.order_id, i.amount_cent, i.status, p.party_role").
Joins("JOIN distribution_order_parties p ON p.id = i.party_id").
Where("i.batch_id = ?", batchID).
Order("i.id ASC").Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]SettlementItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, SettlementItemDTO{ID: row.ID, OrderID: row.OrderID, PartyRole: row.PartyRole, AmountCent: row.AmountCent, Status: row.Status})
}
return items, nil
}
@@ -152,6 +152,12 @@ func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req
}
return s.repo.CreateSettlementBatch(ctx, adminID, req)
}
func (s *Service) ListSettlementItems(ctx context.Context, adminID, batchID uint64) ([]SettlementItemDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSettlementItems(ctx, adminID, batchID)
}
func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
+1
View File
@@ -612,6 +612,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel)
adminRoutes.GET("/distribution/settlement-batches", requirePerm("distribution:settlement_view"), distributionHandler.ListSettlementBatches)
adminRoutes.POST("/distribution/settlement-batches", requirePerm("distribution:settlement_manage"), distributionHandler.CreateSettlementBatch)
adminRoutes.GET("/distribution/settlement-batches/:id/items", requirePerm("distribution:settlement_view"), distributionHandler.ListSettlementItems)
adminRoutes.POST("/distribution/settlement-batches/:id/settle", requirePerm("distribution:settlement_manage"), distributionHandler.MarkSettlementBatch)
adminRoutes.GET("/files/object", fileHandler.Object)
adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List)
+67
View File
@@ -0,0 +1,67 @@
import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router-dom'
import { App, ConfigProvider, Layout, Menu, Typography } from 'antd'
import { AccountBookOutlined, ApiOutlined, DashboardOutlined, FileSearchOutlined, SettingOutlined, ShopOutlined } from '@ant-design/icons'
import Dashboard from './pages/Dashboard'
import Sites from './pages/Sites'
import Rules from './pages/Rules'
import Payments from './pages/Payments'
import Accounts from './pages/Accounts'
import Settlements from './pages/Settlements'
import Orders from './pages/Orders'
import './style.css'
const MENU_ITEMS = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销网站</Link> },
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号</Link> },
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号</Link> },
{ key: '/settlements', icon: <AccountBookOutlined />, label: <Link to="/settlements">结算管理</Link> },
{ key: '/payments', icon: <ApiOutlined />, label: <Link to="/payments">支付通道</Link> },
{ key: '/accounts', icon: <SettingOutlined />, label: <Link to="/accounts">账号展示</Link> },
{ key: '/rules', icon: <SettingOutlined />, label: <Link to="/rules">比例与税费</Link> },
]
function pathToSelectedKey(pathname: string): string {
return MENU_ITEMS.some((item) => item.key === pathname) ? pathname : '/'
}
export default function AppShell() {
return (
<BrowserRouter>
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<App>
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">HFB 分销后台</div>
<Menu
theme="dark"
mode="inline"
items={MENU_ITEMS}
defaultSelectedKeys={[pathToSelectedKey(window.location.pathname)]}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<Typography.Text>主站 hao858.com · 分销子域名 distribution.hao858.com</Typography.Text>
</Layout.Header>
<Layout.Content>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sites" element={<Sites />} />
<Route path="/rules" element={<Rules />} />
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
</App>
</ConfigProvider>
</BrowserRouter>
)
}
+242
View File
@@ -0,0 +1,242 @@
import axios from 'axios'
import { Modal } from 'antd'
const api = axios.create({ baseURL: '/api', withCredentials: true })
interface ApiResponse<T> { data: T; code?: number; message?: string }
let authNoticeShown = false
api.interceptors.response.use(
(resp) => resp,
(error) => {
// 后台鉴权依赖主站 HttpOnly cookie;401 说明未登录或会话过期。
if (error?.response?.status === 401 && !authNoticeShown) {
authNoticeShown = true
Modal.warning({
title: '登录状态失效',
content: '请先在主站后台登录管理员账号,再返回本页面。',
onOk: () => { authNoticeShown = false },
})
}
return Promise.reject(error)
},
)
export function extract<T>(resp: { data: ApiResponse<T> }): T {
return resp.data.data
}
export function formatMoney(cent: number | undefined | null): string {
return `¥${((cent ?? 0) / 100).toFixed(2)}`
}
export interface Site {
id: number
site_code: string
site_name: string
domain: string
status: string
display_account_enabled: boolean
tax_enabled: boolean
tax_rate_bps: number
created_at: string
}
export interface Rule {
id: number
site_id?: number
mode: string
version: number
status: string
platform_receive_ratio: number
platform_sell_ratio: number
distributor_receive_ratio: number
distributor_sell_ratio: number
distributor_a_ratio: number
distributor_b_ratio: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from: string
effective_to?: string
}
export interface DistributionOrder {
order_id: number
order_no: string
site_id: number
status: string
refund_status: string
settlement_status: string
distribution_mode: string
net_paid_amount_cent: number
payment_fee_cent: number
service_fee_cent: number
markup_amount_cent: number
tax_amount_cent: number
split_base_amount_cent: number
rounding_adjustment_cent: number
created_at: string
}
export interface OrderPage {
items: DistributionOrder[]
total: number
page: number
page_size: number
}
export interface AccountDisplay {
id: number
site_id: number
account_id: number
listing_id: number
display_status: string
}
export interface PaymentChannel {
id: number
site_id: number
config_id: number
priority: number
status: string
}
export interface SettlementBatch {
id: number
site_id: number
batch_no: string
period_start: string
period_end: string
total_amount_cent: number
status: string
}
export interface SettlementItem {
id: number
order_id: number
party_role: string
amount_cent: number
status: string
}
export async function listSites(): Promise<Site[]> {
const resp = await api.get<ApiResponse<{ items: Site[] }>>('/admin/distribution/sites')
return extract(resp).items ?? []
}
export async function listRules(siteID?: number, mode?: string): Promise<Rule[]> {
const resp = await api.get<ApiResponse<{ items: Rule[] }>>('/admin/distribution/rules', {
params: { site_id: siteID || undefined, mode: mode || undefined },
})
return extract(resp).items ?? []
}
export interface CreateRulePayload {
site_id?: number
mode: string
distributor_receive_ratio?: number
platform_sell_ratio?: number
distributor_sell_ratio?: number
platform_receive_ratio?: number
distributor_a_ratio?: number
distributor_b_ratio?: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from?: string
}
export async function createRule(payload: CreateRulePayload): Promise<Rule> {
const resp = await api.post<ApiResponse<Rule>>('/admin/distribution/rules', payload)
return extract(resp)
}
export async function updateRuleStatus(id: number, status: string): Promise<Rule> {
const resp = await api.put<ApiResponse<Rule>>(`/admin/distribution/rules/${id}/status`, { status })
return extract(resp)
}
export async function listOrders(params: {
kind?: string
status?: string
siteID?: number
page?: number
pageSize?: number
}): Promise<OrderPage> {
const resp = await api.get<ApiResponse<OrderPage>>('/admin/distribution/orders', {
params: {
kind: params.kind || undefined,
status: params.status || undefined,
site_id: params.siteID || undefined,
page: params.page || 1,
page_size: params.pageSize || 20,
},
})
return extract(resp)
}
export async function listAccountDisplays(siteID: number, status?: string): Promise<AccountDisplay[]> {
const resp = await api.get<ApiResponse<{ items: AccountDisplay[] }>>('/admin/distribution/accounts', {
params: { site_id: siteID, status: status || undefined },
})
return extract(resp).items ?? []
}
export async function updateAccountDisplay(id: number, displayStatus: string): Promise<AccountDisplay> {
const resp = await api.put<ApiResponse<AccountDisplay>>(`/admin/distribution/accounts/${id}/display`, {
display_status: displayStatus,
})
return extract(resp)
}
export async function listPaymentChannels(siteID: number): Promise<PaymentChannel[]> {
const resp = await api.get<ApiResponse<{ items: PaymentChannel[] }>>('/admin/distribution/payment-channels', {
params: { site_id: siteID },
})
return extract(resp).items ?? []
}
export async function bindPaymentChannel(siteID: number, configID: number, priority: number): Promise<PaymentChannel> {
const resp = await api.post<ApiResponse<PaymentChannel>>(
'/admin/distribution/payment-channels',
{ config_id: configID, priority },
{ params: { site_id: siteID } },
)
return extract(resp)
}
export async function unbindPaymentChannel(id: number): Promise<void> {
await api.delete(`/admin/distribution/payment-channels/${id}`)
}
export async function listSettlementBatches(siteID: number): Promise<SettlementBatch[]> {
const resp = await api.get<ApiResponse<{ items: SettlementBatch[] }>>('/admin/distribution/settlement-batches', {
params: { site_id: siteID },
})
return extract(resp).items ?? []
}
export async function createSettlementBatch(siteID: number, periodStart: string, periodEnd: string): Promise<SettlementBatch> {
const resp = await api.post<ApiResponse<SettlementBatch>>('/admin/distribution/settlement-batches', {
site_id: siteID,
period_start: periodStart,
period_end: periodEnd,
})
return extract(resp)
}
export async function settleBatch(id: number): Promise<SettlementBatch> {
const resp = await api.post<ApiResponse<SettlementBatch>>(`/admin/distribution/settlement-batches/${id}/settle`)
return extract(resp)
}
export async function listSettlementItems(batchID: number): Promise<SettlementItem[]> {
const resp = await api.get<ApiResponse<{ items: SettlementItem[] }>>(`/admin/distribution/settlement-batches/${batchID}/items`)
return extract(resp).items ?? []
}
+2 -38
View File
@@ -1,40 +1,4 @@
import React, { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { App, Card, ConfigProvider, Layout, Menu, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import { AccountBookOutlined, ApiOutlined, DashboardOutlined, FileSearchOutlined, SettingOutlined, ShopOutlined } from '@ant-design/icons'
import axios from 'axios'
import './style.css'
import AppShell from './AppShell'
const api = axios.create({ baseURL: '/api' })
type Site = { id: number; site_name: string; site_code: string; domain: string; status: string; display_account_enabled: boolean; tax_enabled: boolean; tax_rate_bps: number }
type Order = { order_id: number; order_no: string; site_id: number; status: string; settlement_status: string; net_paid_amount_cent: number; payment_fee_cent: number; service_fee_cent: number; tax_amount_cent: number; created_at: string }
function Dashboard() {
const [sites, setSites] = useState<Site[]>([])
const [orders, setOrders] = useState<Order[]>([])
useEffect(() => { Promise.all([api.get('/admin/distribution/sites'), api.get('/admin/distribution/orders?kind=all')]).then(([a, b]) => { setSites(a.data.data.items ?? []); setOrders(b.data.data.items ?? []) }).catch(() => message.error('数据加载失败')) }, [])
const total = 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={orders.length} /></Card><Card><Statistic title="净支付金额" value={(total / 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', render: (v) => <Tag color={v === 'completed' ? 'green' : 'blue'}>{v}</Tag> }, { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '创建时间', dataIndex: 'created_at' }]} /></Card></Space>
}
function Sites() {
const [items, setItems] = useState<Site[]>([])
useEffect(() => { api.get('/admin/distribution/sites').then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('站点加载失败')) }, [])
return <Space direction="vertical" className="page"><Typography.Title level={3}>分销网站</Typography.Title><Card><Table rowKey="id" dataSource={items} columns={[{ title: '名称', dataIndex: 'site_name' }, { title: '编码', dataIndex: 'site_code' }, { title: '域名', dataIndex: 'domain' }, { title: '状态', dataIndex: 'status' }, { title: '账号展示', dataIndex: 'display_account_enabled', render: (v) => v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag> }, { title: '税费', render: (_, r) => r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭' }]} /></Card></Space>
}
function Orders({ kind }: { kind: 'sold' | 'receive' }) {
const [items, setItems] = useState<Order[]>([])
useEffect(() => { api.get(`/admin/distribution/orders?kind=${kind}`).then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('订单加载失败')) }, [kind])
return <Space direction="vertical" className="page"><Typography.Title level={3}>{kind === 'sold' ? '售出账号订单' : '分销收号订单'}</Typography.Title><Card><Table rowKey="order_id" dataSource={items} columns={[{ title: '订单号', dataIndex: 'order_no' }, { title: '订单状态', dataIndex: 'status' }, { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '手续费', dataIndex: 'payment_fee_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '服务费', dataIndex: 'service_fee_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '结算', dataIndex: 'settlement_status' }]} /></Card></Space>
}
function Placeholder({ title }: { title: string }) { return <Space direction="vertical" className="page"><Typography.Title level={3}>{title}</Typography.Title><Card><Typography.Text type="secondary">该模块已预留接口入口,可在后台继续配置。</Typography.Text></Card></Space> }
function Shell() {
const [key, setKey] = useState('dashboard')
const content = key === 'dashboard' ? <Dashboard /> : key === 'sites' ? <Sites /> : key === 'sold' ? <Orders kind="sold" /> : key === 'receive' ? <Orders kind="receive" /> : <Placeholder title={({ settlements: '结算管理', payments: '支付通道', accounts: '账号展示', rules: '比例与税费规则', qrcodes: '客服组与二维码', chats: '客服消息' } as Record<string, string>)[key] ?? '分销后台'} />
return <Layout className="app"><Layout.Sider breakpoint="lg" collapsedWidth="0"><div className="brand">HFB 分销后台</div><Menu theme="dark" selectedKeys={[key]} onClick={(e) => setKey(e.key)} items={[{ key: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' }, { key: 'sites', icon: <ShopOutlined />, label: '分销网站' }, { key: 'sold', icon: <FileSearchOutlined />, label: '售出账号' }, { key: 'receive', icon: <AccountBookOutlined />, label: '分销收号' }, { key: 'settlements', icon: <AccountBookOutlined />, label: '结算管理' }, { key: 'payments', icon: <ApiOutlined />, label: '支付通道' }, { key: 'accounts', icon: <SettingOutlined />, label: '账号展示' }, { key: 'rules', icon: <SettingOutlined />, label: '比例与税费' }, { key: 'qrcodes', icon: <SettingOutlined />, label: '客服二维码' }, { key: 'chats', icon: <SettingOutlined />, label: '客服消息' }]} /></Layout.Sider><Layout><Layout.Header className="header"><Typography.Text>主站 hao858.com · 分销子域名 distribution.hao858.com</Typography.Text></Layout.Header><Layout.Content>{content}</Layout.Content></Layout></Layout>
}
createRoot(document.getElementById('root')!).render(<React.StrictMode><ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}><App><Shell /></App></ConfigProvider></React.StrictMode>)
createRoot(document.getElementById('root')!).render(<AppShell />)
+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>
)
}