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 分销后台
setKey(e.key)} items={[{ key: 'dashboard', 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: '比例与税费' }, { key: 'qrcodes', icon: , label: '客服二维码' }, { key: 'chats', icon: , label: '客服消息' }]} />主站 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 ( + + 账号展示 + + ({ label, value }))} + onChange={(value) => { + setStatus(value ?? '') + void load(siteID, value ?? '') + }} + /> + + +
{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: 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 ( + + 支付通道 + +
{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 ( + + 比例与税费规则 + +
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 ( + + 结算管理 + +
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}%` : '关闭'), + }, + ]} + /> + + + ) +}