diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go
index 046132d..c3eeded 100644
--- a/backend/internal/modules/distribution/dto.go
+++ b/backend/internal/modules/distribution/dto.go
@@ -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"`
+}
diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go
index 9e3a431..07bad3e 100644
--- a/backend/internal/modules/distribution/handler.go
+++ b/backend/internal/modules/distribution/handler.go
@@ -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 {
diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go
index 1812c3f..94317ca 100644
--- a/backend/internal/modules/distribution/repository.go
+++ b/backend/internal/modules/distribution/repository.go
@@ -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
+}
diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go
index ae7a964..d3fa0ba 100644
--- a/backend/internal/modules/distribution/service.go
+++ b/backend/internal/modules/distribution/service.go
@@ -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
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 534cc18..ca14100 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -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)
diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx
new file mode 100644
index 0000000..b067ec9
--- /dev/null
+++ b/distribution-admin/src/AppShell.tsx
@@ -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: , label: 仪表盘 },
+ { key: '/sites', icon: , label: 分销网站 },
+ { key: '/sold', icon: , label: 售出账号 },
+ { key: '/receive', icon: , label: 分销收号 },
+ { key: '/settlements', icon: , label: 结算管理 },
+ { key: '/payments', icon: , label: 支付通道 },
+ { key: '/accounts', icon: , label: 账号展示 },
+ { key: '/rules', icon: , label: 比例与税费 },
+]
+
+function pathToSelectedKey(pathname: string): string {
+ return MENU_ITEMS.some((item) => item.key === pathname) ? pathname : '/'
+}
+
+export default function AppShell() {
+ return (
+
+
+
+
+
+ HFB 分销后台
+
+
+
+
+ 主站 hao858.com · 分销子域名 distribution.hao858.com
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+
+ )
+}
diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts
new file mode 100644
index 0000000..afbf63b
--- /dev/null
+++ b/distribution-admin/src/api.ts
@@ -0,0 +1,242 @@
+import axios from 'axios'
+import { Modal } from 'antd'
+
+const api = axios.create({ baseURL: '/api', withCredentials: true })
+
+interface ApiResponse { 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(resp: { data: ApiResponse }): 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 {
+ const resp = await api.get>('/admin/distribution/sites')
+ return extract(resp).items ?? []
+}
+
+export async function listRules(siteID?: number, mode?: string): Promise {
+ const resp = await api.get>('/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 {
+ const resp = await api.post>('/admin/distribution/rules', payload)
+ return extract(resp)
+}
+
+export async function updateRuleStatus(id: number, status: string): Promise {
+ const resp = await api.put>(`/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 {
+ const resp = await api.get>('/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 {
+ const resp = await api.get>('/admin/distribution/accounts', {
+ params: { site_id: siteID, status: status || undefined },
+ })
+ return extract(resp).items ?? []
+}
+
+export async function updateAccountDisplay(id: number, displayStatus: string): Promise {
+ const resp = await api.put>(`/admin/distribution/accounts/${id}/display`, {
+ display_status: displayStatus,
+ })
+ return extract(resp)
+}
+
+export async function listPaymentChannels(siteID: number): Promise {
+ const resp = await api.get>('/admin/distribution/payment-channels', {
+ params: { site_id: siteID },
+ })
+ return extract(resp).items ?? []
+}
+
+export async function bindPaymentChannel(siteID: number, configID: number, priority: number): Promise {
+ const resp = await api.post>(
+ '/admin/distribution/payment-channels',
+ { config_id: configID, priority },
+ { params: { site_id: siteID } },
+ )
+ return extract(resp)
+}
+
+export async function unbindPaymentChannel(id: number): Promise {
+ await api.delete(`/admin/distribution/payment-channels/${id}`)
+}
+
+export async function listSettlementBatches(siteID: number): Promise {
+ const resp = await api.get>('/admin/distribution/settlement-batches', {
+ params: { site_id: siteID },
+ })
+ return extract(resp).items ?? []
+}
+
+export async function createSettlementBatch(siteID: number, periodStart: string, periodEnd: string): Promise {
+ const resp = await api.post>('/admin/distribution/settlement-batches', {
+ site_id: siteID,
+ period_start: periodStart,
+ period_end: periodEnd,
+ })
+ return extract(resp)
+}
+
+export async function settleBatch(id: number): Promise {
+ const resp = await api.post>(`/admin/distribution/settlement-batches/${id}/settle`)
+ return extract(resp)
+}
+
+export async function listSettlementItems(batchID: number): Promise {
+ const resp = await api.get>(`/admin/distribution/settlement-batches/${batchID}/items`)
+ return extract(resp).items ?? []
+}
diff --git a/distribution-admin/src/main.tsx b/distribution-admin/src/main.tsx
index ff7fdc7..2029217 100644
--- a/distribution-admin/src/main.tsx
+++ b/distribution-admin/src/main.tsx
@@ -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([])
- const [orders, setOrders] = useState([])
- 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 分销运营概览
{v} }, { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '创建时间', dataIndex: 'created_at' }]} />
-}
-
-function Sites() {
- const [items, setItems] = useState([])
- useEffect(() => { api.get('/admin/distribution/sites').then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('站点加载失败')) }, [])
- return 分销网站 v ? 开启 : 关闭 }, { title: '税费', render: (_, r) => r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭' }]} />
-}
-
-function Orders({ kind }: { kind: 'sold' | 'receive' }) {
- const [items, setItems] = useState([])
- useEffect(() => { api.get(`/admin/distribution/orders?kind=${kind}`).then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('订单加载失败')) }, [kind])
- return {kind === 'sold' ? '售出账号订单' : '分销收号订单'} `¥${(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' }]} />
-}
-
-function Placeholder({ title }: { title: string }) { return {title}该模块已预留接口入口,可在后台继续配置。 }
-
-function Shell() {
- const [key, setKey] = useState('dashboard')
- const content = key === 'dashboard' ? : key === 'sites' ? : key === 'sold' ? : key === 'receive' ? : )[key] ?? '分销后台'} />
- return HFB 分销后台
主站 hao858.com · 分销子域名 distribution.hao858.com{content}
-}
-
-createRoot(document.getElementById('root')!).render()
+createRoot(document.getElementById('root')!).render()
diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx
new file mode 100644
index 0000000..a4a35dd
--- /dev/null
+++ b/distribution-admin/src/pages/Accounts.tsx
@@ -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 = {
+ enabled: '展示中',
+ disabled: '已下架',
+ sold: '已售出',
+ offline: '已离线',
+}
+
+export default function Accounts() {
+ const [sites, setSites] = useState([])
+ const [siteID, setSiteID] = useState()
+ const [status, setStatus] = useState('')
+ const [items, setItems] = useState([])
+ 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 (
+
+ 账号展示
+
+
+
+ {STATUS_LABELS[v] ?? v},
+ },
+ {
+ title: '操作',
+ render: (_: unknown, row: AccountDisplay) => (
+ toggle(row)}>{row.display_status === 'enabled' ? '下架' : '恢复展示'}
+ ),
+ },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Dashboard.tsx b/distribution-admin/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..a6fe11c
--- /dev/null
+++ b/distribution-admin/src/pages/Dashboard.tsx
@@ -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([])
+ const [orders, setOrders] = useState>['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 (
+
+ 分销运营概览
+
+
+
+
+
+
+ formatMoney(v) },
+ { title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) },
+ { title: '结算', dataIndex: 'settlement_status' },
+ { title: '创建时间', dataIndex: 'created_at' },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx
new file mode 100644
index 0000000..a7d5f33
--- /dev/null
+++ b/distribution-admin/src/pages/Orders.tsx
@@ -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([])
+ const [siteID, setSiteID] = useState()
+ const [status, setStatus] = useState('')
+ const [items, setItems] = useState([])
+ 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 (
+
+ {title}
+
+ ({ label: site.site_name, value: site.id }))}
+ onChange={(value) => setSiteID(value ?? undefined)}
+ />
+ ({ label: value, value }))}
+ onChange={(value) => setStatus(value ?? '')}
+ />
+
+
+ void load(p) }}
+ columns={[
+ { title: '订单号', dataIndex: 'order_no' },
+ { title: '订单状态', dataIndex: 'status', render: (v: string) => {v} },
+ { 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' },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Payments.tsx b/distribution-admin/src/pages/Payments.tsx
new file mode 100644
index 0000000..e93fd01
--- /dev/null
+++ b/distribution-admin/src/pages/Payments.tsx
@@ -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([])
+ const [siteID, setSiteID] = useState()
+ const [channels, setChannels] = useState([])
+ const [configID, setConfigID] = useState(null)
+ const [priority, setPriority] = useState(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 (
+
+ 支付通道
+
+ ({ label: site.site_name, value: site.id }))}
+ onChange={(value) => {
+ setSiteID(value)
+ void load(value)
+ }}
+ />
+ setConfigID(v ?? null)} />
+ setPriority(v ?? 0)} />
+
+
+
+ {v} },
+ {
+ title: '操作',
+ render: (_: unknown, row: PaymentChannel) => handleUnbind(row.id)}>解绑,
+ },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Rules.tsx b/distribution-admin/src/pages/Rules.tsx
new file mode 100644
index 0000000..b947598
--- /dev/null
+++ b/distribution-admin/src/pages/Rules.tsx
@@ -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 = {
+ two_party_distributor_receive: '两方-分销收号',
+ two_party_platform_receive: '两方-分销卖出',
+ three_party: '三方',
+}
+
+export default function Rules() {
+ const [sites, setSites] = useState([])
+ const [siteID, setSiteID] = useState(undefined)
+ const [rules, setRules] = useState([])
+ 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 (
+
+ 比例与税费规则
+
+ ({ label: site.site_name, value: site.id }))}
+ onChange={(value) => {
+ setSiteID(value)
+ void load(value)
+ }}
+ />
+
+
+ MODE_LABELS[v] ?? v },
+ { title: '版本', dataIndex: 'version' },
+ { title: '状态', dataIndex: 'status', render: (v: string) => {v} },
+ {
+ 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) => (
+ toggleStatus(r)}>{r.status === 'active' ? '停用' : '启用'}
+ ),
+ },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Settlements.tsx b/distribution-admin/src/pages/Settlements.tsx
new file mode 100644
index 0000000..ce8ec17
--- /dev/null
+++ b/distribution-admin/src/pages/Settlements.tsx
@@ -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([])
+ const [siteID, setSiteID] = useState()
+ const [batches, setBatches] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
+ const [itemsFor, setItemsFor] = useState(null)
+ const [items, setItems] = useState([])
+
+ 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 (
+
+ 结算管理
+
+ ({ label: site.site_name, value: site.id }))}
+ onChange={(value) => {
+ setSiteID(value)
+ void load(value)
+ }}
+ />
+ setRange(value)} />
+
+
+
+ formatMoney(v) },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ render: (v: string) => {v === 'settled' ? '已结算' : '草稿'},
+ },
+ {
+ title: '操作',
+ render: (_: unknown, row: SettlementBatch) => (
+
+ showItems(row)}>明细
+ {row.status !== 'settled' && handleSettle(row)}>结算}
+
+ ),
+ },
+ ]}
+ />
+
+ setItemsFor(null)}
+ >
+ formatMoney(v) },
+ { title: '状态', dataIndex: 'status' },
+ ]}
+ />
+
+
+ )
+}
diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx
new file mode 100644
index 0000000..4b142a6
--- /dev/null
+++ b/distribution-admin/src/pages/Sites.tsx
@@ -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([])
+ useEffect(() => {
+ listSites()
+ .then(setItems)
+ .catch(() => message.error('站点加载失败'))
+ }, [])
+ return (
+
+ 分销网站
+
+ {v} },
+ {
+ title: '账号展示',
+ dataIndex: 'display_account_enabled',
+ render: (v: boolean) => (v ? 开启 : 关闭),
+ },
+ {
+ title: '税费',
+ render: (_: unknown, row: Site) => (row.tax_enabled ? `${row.tax_rate_bps / 100}%` : '关闭'),
+ },
+ ]}
+ />
+
+
+ )
+}