= [
+ {
+ title: '商品',
+ key: 'listing',
+ width: 280,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {formatText(row.title)}
+ {formatText(row.listing_no)}
+
+ {formatText(row.game_name)} · {formatText(row.server_region)} ·{' '}
+ {formatText(row.rank_level)}
+
+
+ ),
+ },
+ {
+ title: '号主',
+ key: 'owner',
+ width: 160,
+ render: (_, row) => (
+
+ {formatText(row.owner_nickname)}
+ {maskPhone(row.owner_phone)}
+ ID #{row.owner_id}
+
+ ),
+ },
+ {
+ title: '价格',
+ key: 'price',
+ width: 180,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.price_cent)}
+ 卖家 {formatMoney(row.seller_total_price_cent)}
+ 押金 {formatMoney(row.deposit_amount_cent)}
+ 哈夫币 {row.haf_coin_amount}M
+
+ ),
+ },
+ {
+ title: '比例',
+ key: 'ratio',
+ width: 140,
+ align: 'right',
+ render: (_, row) => (
+
+ 卖家 {row.seller_ratio.toFixed(2)}
+ 买家 {row.buyer_ratio.toFixed(2)}
+
+ ),
+ },
+ {
+ title: '状态',
+ key: 'status',
+ width: 150,
+ render: (_, row) => (
+
+
+ 审核 {readStatusMeta(reviewStatusMap, row.review_status).label}
+ {row.review_reason ? {row.review_reason} : null}
+
+ ),
+ },
+ {
+ title: '时间',
+ key: 'time',
+ width: 180,
+ render: (_, row) => (
+
+ 上架 {formatDateTime(row.published_at)}
+ 创建 {formatDateTime(row.created_at)}
+
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 120,
+ fixed: 'right',
+ render: (_, row) =>
+ row.status === 'offline' ? (
+ }
+ disabled={!canManage}
+ onClick={() => changeReceivedStatus(row, 'published')}
+ >
+ 上架
+
+ ) : (
+ }
+ disabled={!canManage}
+ onClick={() => changeReceivedStatus(row, 'offline')}
+ >
+ 下架
+
+ ),
+ },
+ ]
+
+ const accountColumns: TableColumnsType = [
+ {
+ title: '商品',
+ key: 'listing',
+ width: 280,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {formatText(row.title)}
+ {formatText(row.listing_no)}
+
+ {formatText(row.game_name)} · {formatText(row.server_region)} ·{' '}
+ {formatText(row.rank_level)}
+
+
+ ),
+ },
+ {
+ title: '展示',
+ key: 'display',
+ width: 140,
+ render: (_, row) => (
+
+
+ 商品 {readStatusMeta(listingStatusMap, row.listing_status).label}
+ {row.in_transaction ? 交易中 : null}
+
+ ),
+ },
+ {
+ title: '分成比例',
+ key: 'ratio',
+ width: 150,
+ align: 'right',
+ render: (_, row) => (
+
+ 基础 {Number(row.base_ratio ?? 0).toFixed(2)}
+ 站点 {Number(row.site_ratio ?? 0).toFixed(2)}
+ 调整 {Number(row.shelf_ratio_delta ?? 0).toFixed(2)} 点
+
+ ),
+ },
+ {
+ title: '价格',
+ key: 'price',
+ width: 160,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.site_price_cent)}
+ 基础 {formatMoney(row.price_cent)}
+ 加价 {formatMoney(row.markup_amount_cent)}
+ 押金 {formatMoney(row.deposit_amount_cent)}
+
+ ),
+ },
+ {
+ title: '来源',
+ key: 'source',
+ width: 130,
+ render: (_, row) => (
+
+ {formatText(row.source_channel)}
+ 展示 ID #{row.id}
+
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 190,
+ fixed: 'right',
+ render: (_, row) => (
+
+ : }
+ disabled={!canManage}
+ onClick={() => toggleDisplay(row)}
+ >
+ {row.display_status === 'enabled' ? '隐藏' : '展示'}
+
+ }
+ disabled={!canManage}
+ onClick={() => openMarkup(row)}
+ >
+ 加价
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ 收货商品 {receivedQuery.data?.total ?? 0}
+
+ 账号展示 {accountRows.length}
+ {receivedQuery.isFetching || accountsQuery.isFetching ? (
+ 正在更新
+ ) : null}
+
+ }
+ extra={
+ }
+ onClick={refresh}
+ loading={receivedQuery.isFetching || accountsQuery.isFetching}
+ disabled={!canView}
+ >
+ 刷新
+
+ }
+ />
+
+ {!canView ? (
+
+ ) : (
+
+ )}
+
+ {
+ setTab(next)
+ setSelectedAccountIds([])
+ }}
+ items={[
+ {
+ key: 'received',
+ label: '收货商品',
+ children: (
+ <>
+ {
+ setReceivedFilters({
+ ...receivedDraft,
+ keyword: receivedDraft.keyword?.trim() || undefined,
+ })
+ setReceivedPage(1)
+ }}
+ onReset={
+ Object.values(receivedDraft).some(Boolean) ||
+ Object.values(receivedFilters).some(Boolean)
+ ? () => {
+ setReceivedDraft({})
+ setReceivedFilters({})
+ setReceivedPage(1)
+ }
+ : undefined
+ }
+ loading={receivedQuery.isFetching}
+ extra={
+
+ {receivedQuery.isPending
+ ? '加载中'
+ : `当前 ${receivedRows.length} 件`}
+
+ }
+ >
+
+
+ setReceivedDraft(current => ({
+ ...current,
+ keyword: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+
+
+ {receivedQuery.isError ? (
+ void receivedQuery.refetch()}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+ title="站点收货商品"
+ rowKey="listing_id"
+ columns={receivedColumns}
+ dataSource={receivedRows}
+ loading={receivedQuery.isFetching}
+ total={receivedQuery.data?.total ?? 0}
+ page={receivedPage}
+ pageSize={receivedPageSize}
+ onPageChange={(nextPage, nextSize) => {
+ setReceivedPage(nextPage)
+ setReceivedPageSize(nextSize)
+ }}
+ emptyDescription={canView ? '该站点还没有收货商品' : '当前账号没有查看权限'}
+ />
+ >
+ ),
+ },
+ {
+ key: 'accounts',
+ label: '账号展示',
+ children: (
+ <>
+ {
+ setAccountStatus(accountStatus || undefined)
+ setAccountPage(1)
+ }}
+ onReset={
+ accountStatus || accountKeyword
+ ? () => {
+ setAccountStatus(undefined)
+ setAccountKeyword('')
+ setAccountPage(1)
+ setSelectedAccountIds([])
+ }
+ : undefined
+ }
+ loading={accountsQuery.isFetching}
+ extra={
+ selectedAccountIds.length > 0 ? (
+
+ }
+ disabled={!canManage}
+ onClick={() => batchChangeDisplay('enabled')}
+ >
+ 批量展示
+
+ }
+ disabled={!canManage}
+ onClick={() => batchChangeDisplay('disabled')}
+ >
+ 批量隐藏
+
+ }
+ disabled={!canManage}
+ onClick={() => {
+ batchMarkupForm.setFieldsValue({ shelf_ratio_delta: 0 })
+ setBatchMarkupOpen(true)
+ }}
+ >
+ 批量加价
+
+
+ ) : (
+
+ {accountsQuery.isPending ? '加载中' : `当前 ${filteredAccounts.length} 个账号`}
+
+ )
+ }
+ >
+
+ {
+ setAccountKeyword(event.target.value)
+ setAccountPage(1)
+ }}
+ />
+
+
+
+
+
+ {accountsQuery.isError ? (
+ void accountsQuery.refetch()}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+ title="站点账号展示"
+ rowKey="id"
+ columns={accountColumns}
+ dataSource={pagedAccounts}
+ loading={accountsQuery.isFetching}
+ total={filteredAccounts.length}
+ page={accountPage}
+ pageSize={accountPageSize}
+ onPageChange={(nextPage, nextSize) => {
+ setAccountPage(nextPage)
+ setAccountPageSize(nextSize)
+ }}
+ rowSelection={{
+ selectedRowKeys: selectedAccountIds,
+ preserveSelectedRowKeys: true,
+ onChange: keys => setSelectedAccountIds(keys.map(Number)),
+ }}
+ emptyDescription={canView ? '该站点还没有账号展示' : '当前账号没有查看权限'}
+ />
+ >
+ ),
+ },
+ ]}
+ />
+
+ (working ? undefined : setMarkupTarget(undefined))}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ (working ? undefined : setBatchMarkupOpen(false))}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionOrdersPage.tsx b/frontend/src/features/admin/AdminDistributionOrdersPage.tsx
new file mode 100644
index 0000000..52e314c
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionOrdersPage.tsx
@@ -0,0 +1,494 @@
+import { useMemo, useState } from 'react'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import {
+ Alert,
+ Button,
+ Input,
+ Select,
+ Space,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import { ReloadOutlined, ShareAltOutlined, WalletOutlined } from '@ant-design/icons'
+
+import {
+ fetchAdminDistributionOrder,
+ fetchAdminDistributionOrders,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DetailDrawer,
+ type DetailSection,
+} from '@/features/admin/components/DetailDrawer'
+import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import {
+ orderStatusMap,
+ orderStatusOptions,
+ readStatusMeta,
+ refundStatusMap,
+ type StatusMeta,
+} from '@/features/admin/labels'
+import type {
+ AdminDistributionOrder,
+ AdminDistributionOrderQuery,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { useDistributionSiteSelection } from '@/features/admin/components/DistributionSiteSelect'
+import { formatDateTime, formatMoney, formatText, maskPhone } from '@/shared/utils/format'
+
+const modeLabels: Record = {
+ two_party_distributor_receive: '两方 · 分站收号',
+ two_party_platform_receive: '两方 · 平台收号',
+ three_party: '三方分账',
+ site_self_sell: '分站自营',
+}
+
+const modeOptions = Object.entries(modeLabels).map(([value, label]) => ({ value, label }))
+
+const releaseStatusMap: Record = {
+ pending: { label: '待释放', tone: 'gold' },
+ settled: { label: '已释放入账', tone: 'success' },
+}
+
+const releaseStatusOptions = Object.entries(releaseStatusMap).map(([value, meta]) => ({
+ value,
+ label: meta.label,
+}))
+
+const emptyFilters: AdminDistributionOrderQuery = {}
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+function roleLabel(role?: string) {
+ if (!role) return '—'
+ return role
+ .split(',')
+ .filter(Boolean)
+ .map(item => (item === 'sell' ? '卖出分成' : item === 'receive' ? '收号分成' : item))
+ .join(' · ')
+}
+
+function buildSections(
+ order: AdminDistributionOrder,
+ siteName: (siteId: number) => string
+): DetailSection[] {
+ return [
+ {
+ key: 'overview',
+ title: '订单与站点',
+ icon: ,
+ fields: [
+ { key: 'order_no', label: '订单号', value: {order.order_no} },
+ { key: 'site', label: '分销站点', value: siteName(order.site_id) },
+ { key: 'mode', label: '分销模式', value: modeLabels[order.distribution_mode] ?? formatText(order.distribution_mode) },
+ { key: 'listing', label: '商品', value: formatText(order.title || order.listing_no) },
+ { key: 'game', label: '游戏/区服', value: `${formatText(order.game_name)} · ${formatText(order.server_region)}` },
+ { key: 'platform', label: '登录平台', value: formatText(order.login_platform) },
+ { key: 'hours', label: '租用时长', value: order.rent_duration_hours ? `${order.rent_duration_hours} 小时` : '—' },
+ {
+ key: 'renter',
+ label: '租客',
+ value: `${formatText(order.renter_nickname)} · ${maskPhone(order.renter_phone)}`,
+ },
+ { key: 'created', label: '下单时间', value: formatDateTime(order.created_at) },
+ { key: 'rented', label: '起租时间', value: formatDateTime(order.rented_at) },
+ ],
+ },
+ {
+ key: 'amounts',
+ title: '金额快照',
+ icon: ,
+ fields: [
+ { key: 'gross', label: '实付金额', value: formatMoney(order.gross_paid_amount_cent) },
+ { key: 'refund', label: '退款金额', value: formatMoney(order.refund_amount_cent) },
+ { key: 'net', label: '净付款', value: {formatMoney(order.net_paid_amount_cent)} },
+ { key: 'payment_fee', label: '支付手续费', value: formatMoney(order.payment_fee_cent) },
+ { key: 'service_fee', label: '平台服务费', value: formatMoney(order.service_fee_cent) },
+ { key: 'markup', label: '分站加价', value: formatMoney(order.markup_amount_cent) },
+ { key: 'tax', label: '税费', value: formatMoney(order.tax_amount_cent) },
+ { key: 'split_base', label: '分账基数', value: formatMoney(order.split_base_amount_cent) },
+ { key: 'rounding', label: '取整调整', value: formatMoney(order.rounding_adjustment_cent) },
+ { key: 'deposit', label: '押金', value: formatMoney(order.deposit_amount_cent) },
+ { key: 'owner_rent', label: '号主租金', value: formatMoney(order.owner_rent_amount_cent) },
+ { key: 'base_price', label: '基础价', value: formatMoney(order.base_price_cent) },
+ ],
+ },
+ {
+ key: 'distribution',
+ title: '分销收益与状态',
+ icon: ,
+ fields: [
+ {
+ key: 'earning',
+ label: '分站收益',
+ value: {formatMoney(order.distributor_earning_cent)},
+ },
+ { key: 'role', label: '分站角色', value: roleLabel(order.distributor_role) },
+ { key: 'distributor_markup', label: '其中加价', value: formatMoney(order.distributor_markup_cent) },
+ { key: 'distributor_split', label: '其中分成', value: formatMoney(order.distributor_split_cent) },
+ {
+ key: 'release',
+ label: '释放状态',
+ value: (
+
+ ),
+ },
+ { key: 'release_at', label: '预计释放', value: formatDateTime(order.release_at) },
+ {
+ key: 'order_status',
+ label: '订单状态',
+ value: ,
+ },
+ {
+ key: 'refund_status',
+ label: '退款状态',
+ value: ,
+ },
+ { key: 'settlement_status', label: '履约结算', value: formatText(order.settlement_status) },
+ { key: 'handoff_status', label: '交接状态', value: formatText(order.handoff_status) },
+ { key: 'order_id', label: '订单 ID', value: #{order.order_id} },
+ { key: 'listing_no', label: '商品编号', value: {formatText(order.listing_no)} },
+ ],
+ },
+ ]
+}
+
+export function AdminDistributionOrdersPage() {
+ const [draftFilters, setDraftFilters] = useState(emptyFilters)
+ const [filters, setFilters] = useState(emptyFilters)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [detailId, setDetailId] = useState()
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:order_view')
+ )
+ const selection = useDistributionSiteSelection({ autoSelect: false })
+
+ const ordersQuery = useQuery({
+ queryKey: ['admin-distribution-orders', filters, page, pageSize],
+ queryFn: () => fetchAdminDistributionOrders({ ...filters, page, page_size: pageSize }),
+ enabled: canView,
+ placeholderData: keepPreviousData,
+ })
+
+ const detailQuery = useQuery({
+ queryKey: ['admin-distribution-order', detailId],
+ queryFn: () => fetchAdminDistributionOrder(detailId as number),
+ enabled: canView && Boolean(detailId),
+ })
+
+ const siteName = (siteId: number) => {
+ const site = selection.sites.find(item => item.id === siteId)
+ return site ? site.site_name : `站点 #${siteId}`
+ }
+
+ const siteOptions = useMemo(
+ () => [
+ { value: 0, label: '全部分站' },
+ ...selection.sites.map(site => ({
+ value: site.id,
+ label: `${site.site_name} · ${site.site_code}`,
+ })),
+ ],
+ [selection.sites]
+ )
+
+ const columns: TableColumnsType = [
+ {
+ title: '订单',
+ key: 'order',
+ width: 230,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {row.order_no}
+ {formatText(row.title)}
+ {formatText(row.listing_no)}
+
+ ),
+ },
+ {
+ title: '站点 / 模式',
+ key: 'site',
+ width: 190,
+ render: (_, row) => (
+
+ {siteName(row.site_id)}
+ {modeLabels[row.distribution_mode] ?? formatText(row.distribution_mode)}
+ {roleLabel(row.distributor_role)}
+
+ ),
+ },
+ {
+ title: '净付款 / 退款',
+ key: 'paid',
+ width: 150,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.net_paid_amount_cent)}
+ 实付 {formatMoney(row.gross_paid_amount_cent)}
+ 退款 {formatMoney(row.refund_amount_cent)}
+
+ ),
+ },
+ {
+ title: '利润基数 / 服务费',
+ key: 'split',
+ width: 160,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.split_base_amount_cent)}
+ 服务费 {formatMoney(row.service_fee_cent)}
+ 加价 {formatMoney(row.markup_amount_cent)}
+
+ ),
+ },
+ {
+ title: '分站收益',
+ key: 'earning',
+ width: 150,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.distributor_earning_cent)}
+ 加价 {formatMoney(row.distributor_markup_cent)}
+ 分成 {formatMoney(row.distributor_split_cent)}
+
+ ),
+ },
+ {
+ title: '订单状态',
+ key: 'status',
+ width: 140,
+ render: (_, row) => (
+
+
+ 退款 {readStatusMeta(refundStatusMap, row.refund_status).label}
+
+ ),
+ },
+ {
+ title: '分销入账',
+ key: 'release',
+ width: 150,
+ render: (_, row) => (
+
+
+ 释放 {formatDateTime(row.release_at)}
+
+ ),
+ },
+ {
+ title: '下单时间',
+ dataIndex: 'created_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 90,
+ fixed: 'right',
+ render: (_, row) => (
+
+ ),
+ },
+ ]
+
+ const applyFilters = () => {
+ setFilters({
+ site_id: draftFilters.site_id || undefined,
+ mode: draftFilters.mode,
+ status: draftFilters.status,
+ financial_status: draftFilters.financial_status,
+ keyword: draftFilters.keyword?.trim() || undefined,
+ })
+ setPage(1)
+ }
+
+ const resetFilters = () => {
+ setDraftFilters(emptyFilters)
+ setFilters(emptyFilters)
+ setPage(1)
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx
new file mode 100644
index 0000000..8f679c2
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx
@@ -0,0 +1,368 @@
+import { useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Form,
+ InputNumber,
+ Modal,
+ Select,
+ Space,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import { DeleteOutlined, LinkOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
+
+import {
+ bindAdminPaymentChannel,
+ fetchAdminPaymentChannels,
+ fetchAdminPaymentConfigOptions,
+ unbindAdminPaymentChannel,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import type { AdminPaymentChannel } from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+
+interface BindFormValues {
+ config_id: number
+ priority: number
+}
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+const providerLabels: Record = {
+ leshua: '乐刷',
+ lakala: '拉卡拉',
+ shuncheng: '顺诚',
+ mock: 'Mock',
+}
+
+export function AdminDistributionPaymentChannelsPage() {
+ const { message, modal } = App.useApp()
+ const queryClient = useQueryClient()
+ const [form] = Form.useForm()
+ const [bindOpen, setBindOpen] = useState(false)
+ const [binding, setBinding] = useState(false)
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:payment_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:payment_manage')
+ )
+ const canReadConfigs = Boolean(
+ permissions?.includes('*') || permissions?.includes('payment_config:list')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ const channelsQuery = useQuery({
+ queryKey: ['admin-payment-channels', siteId],
+ queryFn: () => fetchAdminPaymentChannels(siteId as number),
+ enabled: canView && Boolean(siteId),
+ })
+
+ const configsQuery = useQuery({
+ queryKey: ['admin-payment-config-options'],
+ queryFn: fetchAdminPaymentConfigOptions,
+ enabled: canView && canReadConfigs,
+ staleTime: 60_000,
+ })
+
+ const channels = channelsQuery.data ?? []
+ const configs = configsQuery.data ?? []
+
+ const configMap = useMemo(
+ () => new Map(configs.map(config => [config.id, config])),
+ [configs]
+ )
+
+ const openBind = () => {
+ form.setFieldsValue({ config_id: undefined as unknown as number, priority: 0 })
+ setBindOpen(true)
+ }
+
+ const bind = async () => {
+ if (!siteId) return
+ const values = await form.validateFields()
+ setBinding(true)
+ try {
+ await bindAdminPaymentChannel(siteId, values.config_id, values.priority ?? 0)
+ message.success('支付通道已绑定')
+ setBindOpen(false)
+ await queryClient.invalidateQueries({ queryKey: ['admin-payment-channels', siteId] })
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setBinding(false)
+ }
+ }
+
+ const unbind = (channel: AdminPaymentChannel) => {
+ modal.confirm({
+ title: `确认解绑支付配置 #${channel.config_id}?`,
+ content: '解绑后该站点不再使用这条支付通道,已支付订单不受影响。',
+ okText: '解绑',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await unbindAdminPaymentChannel(channel.id)
+ message.success('支付通道已解绑')
+ await queryClient.invalidateQueries({
+ queryKey: ['admin-payment-channels', siteId],
+ })
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const columns: TableColumnsType = [
+ {
+ title: '绑定记录',
+ dataIndex: 'id',
+ width: 120,
+ fixed: 'left',
+ render: value => #{value},
+ },
+ {
+ title: '支付配置',
+ key: 'config',
+ width: 320,
+ render: (_, row) => {
+ const config = configMap.get(row.config_id)
+ return (
+
+ {config?.name || `配置 #${row.config_id}`}
+
+ {config ? providerLabels[config.provider] ?? config.provider : '未知服务商'}
+ {config?.pay_way ? ` · ${config.pay_way}` : ''}
+
+ 配置 ID {row.config_id}
+
+ )
+ },
+ },
+ {
+ title: '环境 / 状态',
+ key: 'status',
+ width: 170,
+ render: (_, row) => {
+ const config = configMap.get(row.config_id)
+ return (
+
+ {row.status}
+ {config ? (
+
+ {config.environment || '—'} · {config.status}
+
+ ) : null}
+ {config?.is_default ? 主站默认配置 : null}
+
+ )
+ },
+ },
+ {
+ title: '优先级',
+ dataIndex: 'priority',
+ width: 100,
+ align: 'right',
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 100,
+ fixed: 'right',
+ render: (_, row) => (
+ }
+ disabled={!canManage}
+ onClick={() => unbind(row)}
+ >
+ 解绑
+
+ ),
+ },
+ ]
+
+ return (
+
+
+ 已绑定 {channels.length} 条
+ {selection.site ? {selection.site.site_name} : null}
+ {channelsQuery.isFetching ? 正在更新 : null}
+
+ }
+ extra={
+
+ {canManage ? (
+ }
+ disabled={!siteId}
+ onClick={openBind}
+ >
+ 绑定支付配置
+
+ ) : null}
+ }
+ onClick={() => {
+ void channelsQuery.refetch()
+ void configsQuery.refetch()
+ void selection.refetch()
+ }}
+ loading={channelsQuery.isFetching}
+ disabled={!canView}
+ >
+ 刷新
+
+
+ }
+ />
+
+ {!canView ? (
+
+ ) : (
+
+ )}
+
+ {canView && !canReadConfigs ? (
+
+ ) : null}
+
+ {configsQuery.isError ? (
+
+ ) : null}
+
+ {channelsQuery.isError ? (
+ void channelsQuery.refetch()}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+ title="站点支付通道"
+ rowKey="id"
+ columns={columns}
+ dataSource={channels}
+ loading={channelsQuery.isFetching}
+ emptyDescription={canView ? '该站点还没有绑定支付通道' : '当前账号没有查看权限'}
+ extra={
+
+ 绑定顺序按优先级升序,未绑定时回退主站默认支付配置。
+
+ }
+ />
+
+ (binding ? undefined : setBindOpen(false))}
+ footer={
+
+
+ }
+ loading={binding}
+ onClick={() => void bind()}
+ >
+ 确认绑定
+
+
+ }
+ >
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionQrCodesPage.tsx b/frontend/src/features/admin/AdminDistributionQrCodesPage.tsx
new file mode 100644
index 0000000..ce55076
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionQrCodesPage.tsx
@@ -0,0 +1,780 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Col,
+ DatePicker,
+ Form,
+ Image,
+ Input,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+ Upload,
+ type TableColumnsType,
+} from 'antd'
+import {
+ DeleteOutlined,
+ EditOutlined,
+ InboxOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ StopOutlined,
+ ThunderboltOutlined,
+} from '@ant-design/icons'
+import dayjs, { type Dayjs } from 'dayjs'
+
+import {
+ batchCreateAdminDistributionQrCodes,
+ batchDeleteAdminDistributionQrCodes,
+ deleteAdminDistributionQrCode,
+ fetchAdminDistributionQrCodes,
+ fetchAdminDistributionQrCodeStats,
+ updateAdminDistributionQrCode,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import { readStatusMeta, type StatusMeta } from '@/features/admin/labels'
+import type {
+ AdminDistributionQrCode,
+ AdminDistributionQrCodeQuery,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { uploadAdminFile } from '@/shared/api/files'
+import { formatDateTime, formatText } from '@/shared/utils/format'
+
+const qrStatusMap: Record = {
+ unused: { label: '待用', tone: 'success' },
+ used: { label: '已发放', tone: 'blue' },
+ disabled: { label: '已停用', tone: 'default' },
+}
+
+const statusOptions = [
+ { value: 'unused', label: '待用' },
+ { value: 'used', label: '已发放' },
+ { value: 'disabled', label: '已停用' },
+]
+
+const boundOptions = [
+ { value: 'true', label: '已绑定' },
+ { value: 'false', label: '未绑定' },
+]
+
+const expireOptions = [
+ { value: 'valid', label: '未过期' },
+ { value: 'expiring_soon', label: '即将过期' },
+ { value: 'expired', label: '已过期' },
+ { value: 'permanent', label: '永久有效' },
+]
+
+interface PendingImage {
+ id: string
+ file: File
+ preview: string
+ url?: string
+ groupName: string
+}
+
+interface EditFormValues {
+ group_name: string
+ note: string
+ status: string
+ expires_at?: Dayjs | null
+ clear_expires_at: boolean
+ wecom_renamed: boolean
+}
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+export function AdminDistributionQrCodesPage() {
+ const { message, modal } = App.useApp()
+ const queryClient = useQueryClient()
+ const [editForm] = Form.useForm()
+ const [draftFilters, setDraftFilters] = useState({})
+ const [filters, setFilters] = useState({})
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [selectedIds, setSelectedIds] = useState([])
+ const [uploadOpen, setUploadOpen] = useState(false)
+ const [uploadNote, setUploadNote] = useState('')
+ const [pendingImages, setPendingImages] = useState([])
+ const [uploading, setUploading] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const pendingRef = useRef([])
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:view'))
+ const canManage = Boolean(permissions?.includes('*') || permissions?.includes('qrcode:manage'))
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ pendingRef.current = pendingImages
+
+ useEffect(() => {
+ return () => {
+ pendingRef.current.forEach(item => URL.revokeObjectURL(item.preview))
+ }
+ }, [])
+
+ const listQuery = useQuery({
+ queryKey: ['admin-distribution-qrcodes', siteId, filters, page, pageSize],
+ queryFn: () =>
+ fetchAdminDistributionQrCodes(siteId as number, {
+ ...filters,
+ page,
+ limit: pageSize,
+ }),
+ enabled: canView && Boolean(siteId),
+ placeholderData: keepPreviousData,
+ })
+
+ const statsQuery = useQuery({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ queryFn: () => fetchAdminDistributionQrCodeStats(siteId as number),
+ enabled: canView && Boolean(siteId),
+ })
+
+ const stats = statsQuery.data
+ const rows = listQuery.data?.items ?? []
+
+ const refresh = () => {
+ void listQuery.refetch()
+ void statsQuery.refetch()
+ void selection.refetch()
+ }
+
+ const applyFilters = () => {
+ setFilters({
+ status: draftFilters.status,
+ keyword: draftFilters.keyword?.trim() || undefined,
+ bound: draftFilters.bound,
+ expire_status: draftFilters.expire_status,
+ })
+ setPage(1)
+ setSelectedIds([])
+ }
+
+ const resetFilters = () => {
+ setDraftFilters({})
+ setFilters({})
+ setPage(1)
+ setSelectedIds([])
+ }
+
+ const addFile = (file: File) => {
+ if (!file.type.startsWith('image/')) {
+ message.warning('只能上传图片')
+ return
+ }
+ setPendingImages(current => {
+ if (current.length >= 20) {
+ message.warning('单次最多上传 20 张')
+ return current
+ }
+ return [
+ ...current,
+ {
+ id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
+ file,
+ preview: URL.createObjectURL(file),
+ groupName: '',
+ },
+ ]
+ })
+ }
+
+ const removePending = (id: string) => {
+ setPendingImages(current => {
+ const target = current.find(item => item.id === id)
+ if (target) URL.revokeObjectURL(target.preview)
+ return current.filter(item => item.id !== id)
+ })
+ }
+
+ const closeUpload = () => {
+ if (uploading) return
+ pendingImages.forEach(item => URL.revokeObjectURL(item.preview))
+ setPendingImages([])
+ setUploadNote('')
+ setUploadOpen(false)
+ }
+
+ const submitUpload = async () => {
+ if (!siteId || pendingImages.length === 0) {
+ message.warning('请先选择二维码图片')
+ return
+ }
+ setUploading(true)
+ try {
+ const items = []
+ for (const item of pendingImages) {
+ const uploaded = item.url
+ ? { url: item.url }
+ : await uploadAdminFile(item.file, 'qrcode', { optimize: false })
+ items.push({
+ image_url: uploaded.url,
+ group_name: item.groupName.trim(),
+ note: uploadNote.trim(),
+ })
+ }
+ await batchCreateAdminDistributionQrCodes(siteId, items)
+ message.success(`已向 ${selection.site?.site_name ?? '当前站点'} 码池放入 ${items.length} 张`)
+ closeUpload()
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcodes', siteId],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ const changeStatus = (row: AdminDistributionQrCode, status: string) => {
+ if (!siteId) return
+ const action = status === 'disabled' ? '停用' : '启用'
+ modal.confirm({
+ title: `确认${action}这张二维码?`,
+ content:
+ status === 'disabled'
+ ? '停用后这张码不会再发给该站点的新发布群,历史发放记录保留。'
+ : '启用后这张码会重新进入待用池;已发放过的码不能改回待用。',
+ okText: `确认${action}`,
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await updateAdminDistributionQrCode(siteId, row.id, { status })
+ message.success(`已${action}`)
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcodes', siteId],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const removeOne = (row: AdminDistributionQrCode) => {
+ if (!siteId) return
+ modal.confirm({
+ title: '确认删除这张二维码?',
+ content: `删除后无法恢复,码池统计会同步减少。编号 #${row.id}`,
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await deleteAdminDistributionQrCode(siteId, row.id)
+ message.success('二维码已删除')
+ setSelectedIds(current => current.filter(id => id !== row.id))
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcodes', siteId],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const removeSelected = () => {
+ if (!siteId || selectedIds.length === 0) return
+ modal.confirm({
+ title: `确认删除选中的 ${selectedIds.length} 张二维码?`,
+ content: '批量删除不会影响已发放记录之外的其他数据,但删除后无法恢复。',
+ okText: '批量删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ const deleted = await batchDeleteAdminDistributionQrCodes(siteId, selectedIds)
+ message.success(`已删除 ${deleted} 张二维码`)
+ setSelectedIds([])
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcodes', siteId],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const openEdit = (row: AdminDistributionQrCode) => {
+ setEditing(row)
+ editForm.setFieldsValue({
+ group_name: row.group_name,
+ note: row.note,
+ status: row.status,
+ expires_at: row.expires_at ? dayjs(row.expires_at) : null,
+ clear_expires_at: false,
+ wecom_renamed: row.wecom_renamed,
+ })
+ }
+
+ const saveEdit = async () => {
+ if (!siteId || !editing) return
+ const values = await editForm.validateFields()
+ setSaving(true)
+ try {
+ await updateAdminDistributionQrCode(siteId, editing.id, {
+ group_name: values.group_name.trim(),
+ note: values.note.trim(),
+ status: values.status,
+ wecom_renamed: values.wecom_renamed,
+ expires_at: values.expires_at ? values.expires_at.toISOString() : undefined,
+ clear_expires_at: values.clear_expires_at,
+ })
+ message.success('二维码已更新')
+ setEditing(null)
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['admin-distribution-qrcodes', siteId] }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-qrcode-stats', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const columns = useMemo>(
+ () => [
+ {
+ title: '二维码',
+ dataIndex: 'image_url',
+ width: 90,
+ render: value => (
+
+ ),
+ },
+ {
+ title: '分组 / 备注',
+ key: 'group',
+ width: 220,
+ render: (_, row) => (
+
+ {formatText(row.group_name)}
+ {formatText(row.note)}
+ ID #{row.id}
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: value => ,
+ },
+ {
+ title: '绑定群',
+ key: 'bound',
+ width: 200,
+ render: (_, row) => (
+
+
+ {row.conversation_id
+ ? row.bound_conversation_title || `会话 #${row.conversation_id}`
+ : '未绑定'}
+
+ 企微改名:{row.wecom_renamed ? '是' : '否'}
+
+ ),
+ },
+ {
+ title: '有效期',
+ key: 'expire',
+ width: 190,
+ render: (_, row) => (
+
+ 过期 {formatDateTime(row.expires_at)}
+ 发放 {formatDateTime(row.used_at)}
+
+ ),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 220,
+ fixed: 'right',
+ render: (_, row) => (
+
+ {row.status === 'unused' ? (
+ }
+ disabled={!canManage}
+ onClick={() => changeStatus(row, 'disabled')}
+ >
+ 停用
+
+ ) : null}
+ {row.status === 'disabled' ? (
+ }
+ disabled={!canManage}
+ onClick={() => changeStatus(row, 'unused')}
+ >
+ 启用
+
+ ) : null}
+ }
+ disabled={!canManage}
+ onClick={() => openEdit(row)}
+ >
+ 编辑
+
+ }
+ disabled={!canManage}
+ onClick={() => removeOne(row)}
+ >
+ 删除
+
+
+ ),
+ },
+ ],
+ // 操作回调依赖当前站点与权限,站点切换时需要重建列。
+ [canManage, siteId]
+ )
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionRulesPage.tsx b/frontend/src/features/admin/AdminDistributionRulesPage.tsx
new file mode 100644
index 0000000..4b3c4af
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionRulesPage.tsx
@@ -0,0 +1,689 @@
+import { useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Col,
+ DatePicker,
+ Form,
+ InputNumber,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import {
+ CheckCircleOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ SafetyCertificateOutlined,
+ StopOutlined,
+} from '@ant-design/icons'
+import dayjs, { type Dayjs } from 'dayjs'
+
+import {
+ createAdminDistributionRule,
+ fetchAdminDistributionRules,
+ updateAdminDistributionRuleStatus,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import { readStatusMeta, type StatusMeta } from '@/features/admin/labels'
+import type {
+ AdminDistributionRule,
+ CreateAdminDistributionRulePayload,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { useDistributionSiteSelection } from '@/features/admin/components/DistributionSiteSelect'
+import { formatDateTime, formatText } from '@/shared/utils/format'
+
+const modeLabels: Record = {
+ two_party_distributor_receive: '两方 · 分站收号',
+ two_party_platform_receive: '两方 · 平台收号',
+ three_party: '三方分账',
+ site_self_sell: '分站自营',
+}
+
+const modeOptions = Object.entries(modeLabels).map(([value, label]) => ({ value, label }))
+
+const ruleStatusMap: Record = {
+ draft: { label: '草稿', tone: 'gold' },
+ active: { label: '生效中', tone: 'success' },
+ disabled: { label: '已停用', tone: 'default' },
+}
+
+const serviceFeeUnitLabels: Record = {
+ percent: '按比例',
+ fixed_amount: '固定金额(分)',
+}
+
+interface RuleFormValues {
+ scope: 'global' | 'site'
+ site_id?: number
+ mode: 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?: Dayjs
+}
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+function defaultRatios(mode: string) {
+ switch (mode) {
+ case 'two_party_platform_receive':
+ return {
+ platform_receive_ratio: 35,
+ platform_sell_ratio: 0,
+ distributor_receive_ratio: 0,
+ distributor_sell_ratio: 40,
+ distributor_a_ratio: 0,
+ distributor_b_ratio: 0,
+ service_fee_value: 25,
+ }
+ case 'three_party':
+ return {
+ platform_receive_ratio: 0,
+ platform_sell_ratio: 0,
+ distributor_receive_ratio: 0,
+ distributor_sell_ratio: 0,
+ distributor_a_ratio: 35,
+ distributor_b_ratio: 40,
+ service_fee_value: 25,
+ }
+ case 'site_self_sell':
+ return {
+ platform_receive_ratio: 0,
+ platform_sell_ratio: 0,
+ distributor_receive_ratio: 0,
+ distributor_sell_ratio: 75,
+ distributor_a_ratio: 0,
+ distributor_b_ratio: 0,
+ service_fee_value: 25,
+ }
+ default:
+ return {
+ platform_receive_ratio: 0,
+ platform_sell_ratio: 40,
+ distributor_receive_ratio: 35,
+ distributor_sell_ratio: 0,
+ distributor_a_ratio: 0,
+ distributor_b_ratio: 0,
+ service_fee_value: 25,
+ }
+ }
+}
+
+function ratioSummary(row: AdminDistributionRule) {
+ switch (row.mode) {
+ case 'two_party_distributor_receive':
+ return `分站收号 ${row.distributor_receive_ratio}% · 平台卖出 ${row.platform_sell_ratio}%`
+ case 'two_party_platform_receive':
+ return `平台收号 ${row.platform_receive_ratio}% · 分站卖出 ${row.distributor_sell_ratio}%`
+ case 'three_party':
+ return `A ${row.distributor_a_ratio}% · B ${row.distributor_b_ratio}%`
+ case 'site_self_sell':
+ return `分站卖出 ${row.distributor_sell_ratio}%`
+ default:
+ return '—'
+ }
+}
+
+function serviceFeeText(row: AdminDistributionRule) {
+ if (row.service_fee_unit === 'fixed_amount') return `固定 ${row.service_fee_value} 分`
+ return `${row.service_fee_value}%`
+}
+
+function formatBps(bps?: number | null) {
+ if (bps == null) return '—'
+ return `${(bps / 100).toFixed(2)}%`
+}
+
+export function AdminDistributionRulesPage() {
+ const { message, modal } = App.useApp()
+ const queryClient = useQueryClient()
+ const [form] = Form.useForm()
+ const [draftSiteId, setDraftSiteId] = useState('all')
+ const [draftMode, setDraftMode] = useState()
+ const [siteFilter, setSiteFilter] = useState('all')
+ const [modeFilter, setModeFilter] = useState()
+ const [editorOpen, setEditorOpen] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [statusSaving, setStatusSaving] = useState(false)
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:rule_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:rule_manage')
+ )
+ const selection = useDistributionSiteSelection({ autoSelect: false })
+
+ const rulesQuery = useQuery({
+ queryKey: ['admin-distribution-rules', siteFilter, modeFilter],
+ queryFn: () =>
+ fetchAdminDistributionRules({
+ site_id: siteFilter === 'all' ? undefined : siteFilter,
+ mode: modeFilter,
+ }),
+ enabled: canView,
+ })
+
+ const scope = Form.useWatch('scope', form) ?? 'global'
+ const mode = Form.useWatch('mode', form) ?? 'two_party_distributor_receive'
+ const serviceFeeUnit = Form.useWatch('service_fee_unit', form) ?? 'percent'
+ const rows = rulesQuery.data ?? []
+
+ const siteName = (siteId?: number | null) => {
+ if (!siteId) return '全局默认'
+ const site = selection.sites.find(item => item.id === siteId)
+ return site ? `${site.site_name} · ${site.site_code}` : `站点 #${siteId}`
+ }
+
+ const summary = useMemo(() => {
+ return {
+ total: rows.length,
+ active: rows.filter(row => row.status === 'active').length,
+ sites: new Set(rows.map(row => row.site_id ?? 0)).size,
+ }
+ }, [rows])
+
+ const openCreate = () => {
+ const ratios = defaultRatios('two_party_distributor_receive')
+ form.setFieldsValue({
+ scope: 'global',
+ site_id: undefined,
+ mode: 'two_party_distributor_receive',
+ ...ratios,
+ service_fee_unit: 'percent',
+ payment_fee_bps: 50,
+ tax_enabled: false,
+ tax_rate_bps: 100,
+ effective_from: dayjs(),
+ })
+ setEditorOpen(true)
+ }
+
+ const applyModeDefaults = (nextMode: string) => {
+ form.setFieldsValue(defaultRatios(nextMode))
+ }
+
+ const createRule = async () => {
+ const values = await form.validateFields()
+ const payload: CreateAdminDistributionRulePayload = {
+ site_id: values.scope === 'site' ? values.site_id : null,
+ mode: values.mode,
+ platform_receive_ratio: values.platform_receive_ratio ?? 0,
+ platform_sell_ratio: values.platform_sell_ratio ?? 0,
+ distributor_receive_ratio: values.distributor_receive_ratio ?? 0,
+ distributor_sell_ratio: values.distributor_sell_ratio ?? 0,
+ distributor_a_ratio: values.distributor_a_ratio ?? 0,
+ distributor_b_ratio: values.distributor_b_ratio ?? 0,
+ service_fee_value: values.service_fee_value ?? 0,
+ service_fee_unit: values.service_fee_unit,
+ payment_fee_bps: values.payment_fee_bps ?? 0,
+ tax_enabled: values.tax_enabled,
+ tax_rate_bps: values.tax_rate_bps ?? 0,
+ effective_from: values.effective_from?.toISOString(),
+ }
+ setCreating(true)
+ try {
+ await createAdminDistributionRule(payload)
+ message.success('规则草稿已创建,确认无误后再启用')
+ setEditorOpen(false)
+ await queryClient.invalidateQueries({ queryKey: ['admin-distribution-rules'] })
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const changeStatus = (row: AdminDistributionRule, status: 'active' | 'disabled' | 'draft') => {
+ const label = status === 'active' ? '启用' : status === 'disabled' ? '停用' : '转回草稿'
+ modal.confirm({
+ title: `确认${label}规则 v${row.version}?`,
+ content:
+ status === 'active'
+ ? '启用后该规则会从生效时间开始参与新订单分账;同站点同模式的历史版本不会自动停用。'
+ : '停用后新订单不再命中该规则,已生成的订单快照不受影响。',
+ okText: `确认${label}`,
+ cancelText: '取消',
+ onOk: async () => {
+ setStatusSaving(true)
+ try {
+ await updateAdminDistributionRuleStatus(row.id, status)
+ message.success(`规则已${label}`)
+ await queryClient.invalidateQueries({ queryKey: ['admin-distribution-rules'] })
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setStatusSaving(false)
+ }
+ },
+ })
+ }
+
+ const columns: TableColumnsType = [
+ {
+ title: '版本',
+ key: 'version',
+ width: 150,
+ fixed: 'left',
+ render: (_, row) => (
+
+ v{row.version}
+ {siteName(row.site_id)}
+ ID #{row.id}
+
+ ),
+ },
+ {
+ title: '模式',
+ dataIndex: 'mode',
+ width: 180,
+ render: value => {modeLabels[value] ?? formatText(value)},
+ },
+ {
+ title: '分账比例',
+ key: 'ratios',
+ width: 260,
+ render: (_, row) => (
+
+ {ratioSummary(row)}
+ 服务费 {serviceFeeText(row)}
+ 支付费率 {formatBps(row.payment_fee_bps)}
+
+ ),
+ },
+ {
+ title: '税费',
+ key: 'tax',
+ width: 150,
+ render: (_, row) =>
+ row.tax_enabled ? (
+ 税率 {formatBps(row.tax_rate_bps)}
+ ) : (
+ 不启用
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ render: value => ,
+ },
+ {
+ title: '生效时间',
+ dataIndex: 'effective_from',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 170,
+ fixed: 'right',
+ render: (_, row) => (
+
+ {row.status !== 'active' ? (
+ }
+ disabled={!canManage || statusSaving}
+ onClick={() => changeStatus(row, 'active')}
+ >
+ 启用
+
+ ) : null}
+ {row.status === 'active' ? (
+ }
+ disabled={!canManage || statusSaving}
+ onClick={() => changeStatus(row, 'disabled')}
+ >
+ 停用
+
+ ) : null}
+ {row.status === 'disabled' ? (
+
+ ) : null}
+
+ ),
+ },
+ ]
+
+ return (
+
+
+ 共 {summary.total} 个版本
+ 生效中 {summary.active}
+ {summary.sites} 个作用域
+ {rulesQuery.isFetching ? 正在更新 : null}
+
+ }
+ extra={
+
+ {canManage ? (
+ } onClick={openCreate}>
+ 新建规则
+
+ ) : null}
+ }
+ onClick={() => void rulesQuery.refetch()}
+ loading={rulesQuery.isFetching}
+ disabled={!canView}
+ >
+ 刷新
+
+
+ }
+ />
+
+ {!canView ? (
+
+ ) : null}
+
+ {
+ setSiteFilter(draftSiteId)
+ setModeFilter(draftMode)
+ }}
+ onReset={
+ draftSiteId !== 'all' || draftMode || siteFilter !== 'all' || modeFilter
+ ? () => {
+ setDraftSiteId('all')
+ setDraftMode(undefined)
+ setSiteFilter('all')
+ setModeFilter(undefined)
+ }
+ : undefined
+ }
+ loading={rulesQuery.isFetching}
+ extra={
+
+ {rulesQuery.isPending ? '加载中' : `当前 ${rows.length} 个版本`}
+
+ }
+ >
+
+
+
+
+
+
+ {rulesQuery.isError ? (
+ void rulesQuery.refetch()}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+ title="规则版本列表"
+ rowKey="id"
+ columns={columns}
+ dataSource={rows}
+ loading={rulesQuery.isFetching}
+ emptyDescription={canView ? '还没有规则版本' : '当前账号没有查看权限'}
+ />
+
+ (creating ? undefined : setEditorOpen(false))}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+ {mode === 'two_party_platform_receive' ? (
+
+
+
+
+
+ ) : null}
+ {mode === 'two_party_distributor_receive' ? (
+
+
+
+
+
+ ) : null}
+ {mode === 'two_party_distributor_receive' ? (
+
+
+
+
+
+ ) : null}
+ {mode === 'two_party_platform_receive' ? (
+
+
+
+
+
+ ) : null}
+ {mode === 'three_party' ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ {mode === 'site_self_sell' ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新建规则默认是草稿;只有启用后的规则才会参与新订单分账。
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionSMSPage.tsx b/frontend/src/features/admin/AdminDistributionSMSPage.tsx
new file mode 100644
index 0000000..1d3d2a9
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionSMSPage.tsx
@@ -0,0 +1,426 @@
+import { useEffect, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Card,
+ Col,
+ Divider,
+ Form,
+ Input,
+ Row,
+ Select,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+} from 'antd'
+import { MessageOutlined, ReloadOutlined, SendOutlined } from '@ant-design/icons'
+
+import {
+ fetchAdminDistributionSMSConfig,
+ testAdminDistributionSMSConfig,
+ updateAdminDistributionSMSConfig,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import type { UpdateAdminDistributionSMSConfigPayload } from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { formatDateTime } from '@/shared/utils/format'
+
+interface SMSFormValues {
+ enabled: boolean
+ provider: string
+ access_key_id: string
+ access_key_secret?: string
+ endpoint: string
+ sign_name: string
+ login_template_code: string
+ business_templates: Record
+}
+
+const businessNodes = [
+ {
+ code: 'listing_review_rejected',
+ name: '账号审核未通过',
+ description: '号主上架账号被拒绝时提醒修改后重新上架。',
+ template: 'SMS_512520993',
+ },
+ {
+ code: 'order_handoff_assist',
+ name: '账号被租用需辅助登录',
+ description: '订单支付成功进入待交接后提醒号主及时辅助登录。',
+ template: 'SMS_512540945',
+ },
+ {
+ code: 'listing_stale_price_suggestion',
+ name: '上架多日未成交降价建议',
+ description: '账号上架多日仍未成交时建议号主调整比例。',
+ template: 'SMS_512620985',
+ },
+]
+
+const emptyBusinessTemplates = businessNodes.reduce>((acc, node) => {
+ acc[node.code] = ''
+ return acc
+}, {})
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+export function AdminDistributionSMSPage() {
+ const { message } = App.useApp()
+ const queryClient = useQueryClient()
+ const [form] = Form.useForm()
+ const [testForm] = Form.useForm<{ phone: string }>()
+ const [saving, setSaving] = useState(false)
+ const [testing, setTesting] = useState(false)
+ const [updatedAt, setUpdatedAt] = useState()
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:site_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:site_manage')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ const configQuery = useQuery({
+ queryKey: ['admin-distribution-sms-config', siteId],
+ queryFn: () => fetchAdminDistributionSMSConfig(siteId as number),
+ enabled: canView && Boolean(siteId),
+ })
+ const config = configQuery.data
+
+ useEffect(() => {
+ if (!config) return
+ form.setFieldsValue({
+ enabled: config.enabled,
+ provider: config.provider || 'aliyun',
+ access_key_id: config.access_key_id,
+ access_key_secret: '',
+ endpoint: config.endpoint,
+ sign_name: config.sign_name,
+ login_template_code: config.login_template_code,
+ business_templates: {
+ ...emptyBusinessTemplates,
+ ...(config.business_templates ?? {}),
+ },
+ })
+ }, [config, form])
+
+ useEffect(() => {
+ setUpdatedAt(undefined)
+ }, [siteId])
+
+ const buildPayload = (values: SMSFormValues): UpdateAdminDistributionSMSConfigPayload => {
+ const payload: UpdateAdminDistributionSMSConfigPayload = {
+ enabled: values.enabled,
+ provider: values.provider,
+ access_key_id: values.access_key_id,
+ endpoint: values.endpoint,
+ sign_name: values.sign_name,
+ login_template_code: values.login_template_code,
+ business_templates: Object.fromEntries(
+ Object.entries(values.business_templates ?? {}).map(([key, value]) => [
+ key,
+ (value ?? '').trim(),
+ ])
+ ),
+ }
+ const secret = values.access_key_secret?.trim()
+ if (secret) payload.access_key_secret = secret
+ return payload
+ }
+
+ const save = async () => {
+ if (!siteId) return
+ const values = await form.validateFields()
+ setSaving(true)
+ try {
+ const result = await updateAdminDistributionSMSConfig(siteId, buildPayload(values))
+ queryClient.setQueryData(['admin-distribution-sms-config', siteId], result)
+ setUpdatedAt(new Date().toISOString())
+ form.setFieldValue('access_key_secret', '')
+ message.success('站点短信配置已保存')
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const sendTest = async () => {
+ if (!siteId) return
+ const { phone } = await testForm.validateFields()
+ const values = await form.validateFields()
+ setTesting(true)
+ try {
+ const payload = buildPayload(values)
+ await testAdminDistributionSMSConfig(siteId, {
+ phone: phone.trim(),
+ provider: payload.provider,
+ access_key_id: payload.access_key_id,
+ access_key_secret: payload.access_key_secret,
+ endpoint: payload.endpoint,
+ sign_name: payload.sign_name,
+ login_template_code: payload.login_template_code,
+ })
+ message.success('测试短信已发送,请留意手机')
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ const refresh = () => {
+ void configQuery.refetch()
+ void selection.refetch()
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx b/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx
new file mode 100644
index 0000000..2a9e7df
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx
@@ -0,0 +1,501 @@
+import { useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ DatePicker,
+ Form,
+ Modal,
+ Space,
+ Table,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import {
+ CheckCircleOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ StopOutlined,
+ UnorderedListOutlined,
+} from '@ant-design/icons'
+import dayjs, { type Dayjs } from 'dayjs'
+
+import {
+ cancelAdminDistributionSettlementBatch,
+ createAdminDistributionSettlementBatch,
+ fetchAdminDistributionSettlementBatches,
+ fetchAdminDistributionSettlementItems,
+ settleAdminDistributionSettlementBatch,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import { readStatusMeta, type StatusMeta } from '@/features/admin/labels'
+import type {
+ AdminDistributionSettlementBatch,
+ AdminDistributionSettlementItem,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { formatDateTime, formatMoney } from '@/shared/utils/format'
+
+const batchStatusMap: Record = {
+ draft: { label: '待入账', tone: 'gold' },
+ pending: { label: '待入账', tone: 'gold' },
+ settled: { label: '已释放入账', tone: 'success' },
+ cancelled: { label: '已作废', tone: 'default' },
+}
+
+const partyRoleLabels: Record = {
+ distributor_receive: '收号分成',
+ distributor_sell: '卖出分成',
+ distributor_a: '分销 A',
+ distributor_b: '分销 B',
+}
+
+interface BatchFormValues {
+ period: [Dayjs, Dayjs]
+}
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+function batchKind(batchNo: string) {
+ if (batchNo.startsWith('DA')) return { label: '自动对账', color: 'blue' }
+ if (batchNo.startsWith('DS')) return { label: '人工批次', color: 'gold' }
+ return { label: '批次', color: 'default' }
+}
+
+export function AdminDistributionSettlementsPage() {
+ const { message, modal } = App.useApp()
+ const queryClient = useQueryClient()
+ const [form] = Form.useForm()
+ const [createOpen, setCreateOpen] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [workingId, setWorkingId] = useState()
+ const [itemsBatch, setItemsBatch] = useState()
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:settlement_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:settlement_manage')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ const batchesQuery = useQuery({
+ queryKey: ['admin-distribution-settlement-batches', siteId],
+ queryFn: () => fetchAdminDistributionSettlementBatches(siteId as number),
+ enabled: canView && Boolean(siteId),
+ })
+
+ const itemsQuery = useQuery({
+ queryKey: ['admin-distribution-settlement-items', itemsBatch?.id],
+ queryFn: () => fetchAdminDistributionSettlementItems(itemsBatch?.id as number),
+ enabled: canView && Boolean(itemsBatch?.id),
+ })
+
+ const batches = batchesQuery.data ?? []
+
+ const summary = useMemo(() => {
+ const pending = batches.filter(
+ batch => batch.status !== 'settled' && batch.status !== 'cancelled'
+ )
+ const settled = batches.filter(batch => batch.status === 'settled')
+ return {
+ total: batches.length,
+ pendingCount: pending.length,
+ pendingAmount: pending.reduce((sum, batch) => sum + batch.total_amount_cent, 0),
+ settledAmount: settled.reduce((sum, batch) => sum + batch.total_amount_cent, 0),
+ }
+ }, [batches])
+
+ const openCreate = () => {
+ form.setFieldsValue({
+ period: [dayjs().subtract(30, 'day').startOf('day'), dayjs()],
+ })
+ setCreateOpen(true)
+ }
+
+ const createBatch = async () => {
+ if (!siteId) return
+ const values = await form.validateFields()
+ const [start, end] = values.period
+ if (!start || !end || !end.isAfter(start)) {
+ message.error('结算周期结束时间必须晚于开始时间')
+ return
+ }
+ setCreating(true)
+ try {
+ await createAdminDistributionSettlementBatch({
+ site_id: siteId,
+ period_start: start.toISOString(),
+ period_end: end.toISOString(),
+ })
+ message.success('人工结算批次已生成,可核对明细后释放入账')
+ setCreateOpen(false)
+ await queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-settlement-batches', siteId],
+ })
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const settleBatch = (batch: AdminDistributionSettlementBatch) => {
+ modal.confirm({
+ title: `确认释放批次 ${batch.batch_no}?`,
+ content: (
+
+ 入账金额:{formatMoney(batch.total_amount_cent)}
+ 释放后会立即转入站点可提现余额,人工批次不走冷却期。
+
+ ),
+ okText: '确认入账',
+ cancelText: '取消',
+ onOk: async () => {
+ setWorkingId(batch.id)
+ try {
+ await settleAdminDistributionSettlementBatch(batch.id)
+ message.success('批次已释放到站点可提现余额')
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-settlement-batches', siteId],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-settlement-items', batch.id],
+ }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-wallet', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setWorkingId(undefined)
+ }
+ },
+ })
+ }
+
+ const cancelBatch = (batch: AdminDistributionSettlementBatch) => {
+ modal.confirm({
+ title: `确认作废批次 ${batch.batch_no}?`,
+ content: '作废后批次内订单可重新进入自动释放或新的人工批次。',
+ okText: '确认作废',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ setWorkingId(batch.id)
+ try {
+ await cancelAdminDistributionSettlementBatch(batch.id)
+ message.success('批次已作废')
+ await queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-settlement-batches', siteId],
+ })
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setWorkingId(undefined)
+ }
+ },
+ })
+ }
+
+ const columns: TableColumnsType = [
+ {
+ title: '批次号',
+ key: 'batch',
+ width: 260,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {row.batch_no}
+ {batchKind(row.batch_no).label}
+
+ ),
+ },
+ {
+ title: '对账周期',
+ key: 'period',
+ width: 250,
+ render: (_, row) => (
+
+ {formatDateTime(row.period_start)} ~ {formatDateTime(row.period_end)}
+
+ ),
+ },
+ {
+ title: '入账金额',
+ dataIndex: 'total_amount_cent',
+ width: 150,
+ align: 'right',
+ render: value => {formatMoney(value)},
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 120,
+ render: value => ,
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 260,
+ fixed: 'right',
+ render: (_, row) => {
+ const closed = row.status === 'settled' || row.status === 'cancelled'
+ return (
+
+ }
+ onClick={() => setItemsBatch(row)}
+ >
+ 明细
+
+ {!closed ? (
+ }
+ disabled={!canManage || workingId === row.id || row.total_amount_cent <= 0}
+ onClick={() => settleBatch(row)}
+ >
+ 释放入账
+
+ ) : null}
+ {!closed ? (
+ }
+ disabled={!canManage || workingId === row.id}
+ onClick={() => cancelBatch(row)}
+ >
+ 作废
+
+ ) : null}
+
+ )
+ },
+ },
+ ]
+
+ const itemColumns: TableColumnsType = [
+ {
+ title: '订单 ID',
+ dataIndex: 'order_id',
+ width: 120,
+ render: value => #{value},
+ },
+ {
+ title: '分成角色',
+ dataIndex: 'party_role',
+ width: 150,
+ render: value => partyRoleLabels[value] ?? value,
+ },
+ {
+ title: '入账金额',
+ dataIndex: 'amount_cent',
+ width: 140,
+ align: 'right',
+ render: value => formatMoney(value),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ render: value =>
+ value === 'settled' ? (
+ 已入账
+ ) : (
+ 待入账
+ ),
+ },
+ ]
+
+ const refresh = () => {
+ void batchesQuery.refetch()
+ void selection.refetch()
+ }
+
+ return (
+
+
+ 共 {summary.total} 个批次
+ 待入账 {summary.pendingCount}
+ 待入账金额 {formatMoney(summary.pendingAmount)}
+ 已入账 {formatMoney(summary.settledAmount)}
+
+ }
+ extra={
+
+ {canManage ? (
+ }
+ disabled={!siteId}
+ onClick={openCreate}
+ >
+ 生成人工批次
+
+ ) : null}
+ }
+ onClick={refresh}
+ loading={batchesQuery.isFetching}
+ disabled={!canView}
+ >
+ 刷新
+
+
+ }
+ />
+
+ {!canView ? (
+
+ ) : (
+
+ )}
+
+ {canView && siteId ? (
+
+ ) : null}
+
+ {batchesQuery.isError ? (
+
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+ title="站点结算批次"
+ rowKey="id"
+ columns={columns}
+ dataSource={batches}
+ loading={batchesQuery.isFetching}
+ emptyDescription={canView ? '该站点还没有结算批次' : '当前账号没有查看权限'}
+ extra={
+
+ {selection.site ? `${selection.site.site_name} · ${selection.site.site_code}` : ''}
+
+ }
+ />
+
+ (creating ? undefined : setCreateOpen(false))}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+
+
+ 周期采用左闭右开:开始时间 ≤ 财务快照创建时间 < 结束时间。
+
+
+
+
+ setItemsBatch(undefined)}
+ footer={
+
+ }
+ >
+
+
+ {itemsBatch ? batchKind(itemsBatch.batch_no).label : '批次'}
+
+ {itemsBatch ? (
+
+ ) : null}
+
+ 合计 {formatMoney(itemsBatch?.total_amount_cent ?? 0)}
+
+
+
+ rowKey="id"
+ size="small"
+ columns={itemColumns}
+ dataSource={itemsQuery.data ?? []}
+ loading={itemsQuery.isFetching}
+ pagination={false}
+ scroll={{ y: 360 }}
+ locale={{ emptyText: '该批次没有明细' }}
+ />
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionUsersPage.tsx b/frontend/src/features/admin/AdminDistributionUsersPage.tsx
new file mode 100644
index 0000000..93617c3
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionUsersPage.tsx
@@ -0,0 +1,376 @@
+import { useMemo, useState } from 'react'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import {
+ Alert,
+ Avatar,
+ Button,
+ Input,
+ Select,
+ Space,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import { ReloadOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons'
+
+import { fetchAdminDistributionUsers } from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DetailDrawer,
+ type DetailSection,
+} from '@/features/admin/components/DetailDrawer'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import {
+ readStatusMeta,
+ realnameStatusMap,
+ userStatusMap,
+} from '@/features/admin/labels'
+import type {
+ AdminDistributionUser,
+ AdminDistributionUserQuery,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import {
+ formatDateTime,
+ formatMoney,
+ formatText,
+ maskPhone,
+} from '@/shared/utils/format'
+
+const sourceLabels: Record = {
+ register: '本站注册',
+ order: '本站下单',
+ both: '注册并下单',
+}
+
+const sourceOptions = [
+ { value: 'all', label: '全部客户' },
+ { value: 'register', label: '本站注册' },
+ { value: 'order', label: '本站下单' },
+]
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+function buildSections(user: AdminDistributionUser): DetailSection[] {
+ return [
+ {
+ key: 'profile',
+ title: '客户资料',
+ icon: ,
+ fields: [
+ { key: 'id', label: '用户 ID', value: #{user.id} },
+ { key: 'phone', label: '手机号', value: {formatText(user.phone)} },
+ { key: 'nickname', label: '昵称', value: formatText(user.nickname) },
+ {
+ key: 'source',
+ label: '客户来源',
+ value: {sourceLabels[user.source] ?? formatText(user.source)},
+ },
+ {
+ key: 'realname',
+ label: '实名状态',
+ value: ,
+ },
+ {
+ key: 'status',
+ label: '账号状态',
+ value: ,
+ },
+ { key: 'created', label: '注册时间', value: formatDateTime(user.created_at) },
+ { key: 'login', label: '最近登录', value: formatDateTime(user.last_login_at) },
+ ],
+ },
+ {
+ key: 'orders',
+ title: '交易贡献',
+ icon: ,
+ fields: [
+ { key: 'order_count', label: '有效订单', value: `${user.order_count} 单` },
+ {
+ key: 'spent',
+ label: '累计消费',
+ value: {formatMoney(user.total_spent_cent)},
+ },
+ { key: 'last_order', label: '最近下单', value: formatDateTime(user.last_order_at) },
+ ],
+ },
+ ]
+}
+
+export function AdminDistributionUsersPage() {
+ const [draftFilters, setDraftFilters] = useState({})
+ const [filters, setFilters] = useState({})
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [selected, setSelected] = useState()
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:site_view')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ const usersQuery = useQuery({
+ queryKey: ['admin-distribution-users', siteId, filters, page, pageSize],
+ queryFn: () =>
+ fetchAdminDistributionUsers(siteId as number, {
+ ...filters,
+ page,
+ page_size: pageSize,
+ }),
+ enabled: canView && Boolean(siteId),
+ placeholderData: keepPreviousData,
+ })
+
+ const data = usersQuery.data
+ const rows = data?.items ?? []
+
+ const columns: TableColumnsType = [
+ {
+ title: '客户',
+ key: 'customer',
+ width: 240,
+ fixed: 'left',
+ render: (_, row) => (
+
+
}
+ className="admin-user-cell__avatar"
+ />
+
+ {formatText(row.nickname)}
+ {maskPhone(row.phone)}
+ ID #{row.id}
+
+
+ ),
+ },
+ {
+ title: '来源',
+ dataIndex: 'source',
+ width: 130,
+ render: value => (
+ {sourceLabels[value] ?? formatText(value)}
+ ),
+ },
+ {
+ title: '实名 / 状态',
+ key: 'status',
+ width: 150,
+ render: (_, row) => (
+
+
+
+
+ ),
+ },
+ {
+ title: '有效订单',
+ dataIndex: 'order_count',
+ width: 110,
+ align: 'right',
+ render: value => `${value} 单`,
+ },
+ {
+ title: '累计消费',
+ dataIndex: 'total_spent_cent',
+ width: 140,
+ align: 'right',
+ render: value => (
+ {formatMoney(value)}
+ ),
+ },
+ {
+ title: '最近下单',
+ dataIndex: 'last_order_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '注册时间',
+ dataIndex: 'created_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '最近登录',
+ dataIndex: 'last_login_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 90,
+ fixed: 'right',
+ render: (_, row) => (
+
+ ),
+ },
+ ]
+
+ const summary = useMemo(
+ () => ({
+ totalUsers: data?.total_users ?? 0,
+ orderUsers: data?.order_users ?? 0,
+ filtered: data?.total ?? 0,
+ }),
+ [data]
+ )
+
+ const applyFilters = () => {
+ setFilters({
+ keyword: draftFilters.keyword?.trim() || undefined,
+ source: draftFilters.source === 'all' ? undefined : draftFilters.source,
+ })
+ setPage(1)
+ }
+
+ const resetFilters = () => {
+ setDraftFilters({})
+ setFilters({})
+ setPage(1)
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminDistributionWalletPage.tsx b/frontend/src/features/admin/AdminDistributionWalletPage.tsx
new file mode 100644
index 0000000..bc5089b
--- /dev/null
+++ b/frontend/src/features/admin/AdminDistributionWalletPage.tsx
@@ -0,0 +1,474 @@
+import { useMemo, useState } from 'react'
+import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Card,
+ Col,
+ Form,
+ Input,
+ Row,
+ Select,
+ Space,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import {
+ BankOutlined,
+ ClockCircleOutlined,
+ ReloadOutlined,
+ WalletOutlined,
+} from '@ant-design/icons'
+
+import {
+ applyAdminDistributionWithdrawal,
+ fetchAdminDistributionWallet,
+ fetchAdminDistributionWithdrawals,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import { readStatusMeta, withdrawalStatusMap } from '@/features/admin/labels'
+import type {
+ AdminDistributionWallet,
+ AdminDistributionWithdrawal,
+ ApplyAdminDistributionWithdrawalPayload,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
+import { parseYuanToCent } from '@/shared/utils/pricing'
+
+interface WithdrawalFormValues {
+ amount_yuan: string
+ account_type: 'alipay' | 'bank'
+ account_name: string
+ account_no: string
+ bank_name?: string
+ bank_branch?: string
+}
+
+const walletMetrics: Array<{
+ key: keyof Pick<
+ AdminDistributionWallet,
+ | 'available_balance_cent'
+ | 'frozen_balance_cent'
+ | 'pending_release_cent'
+ | 'total_settled_cent'
+ | 'total_withdrawn_cent'
+ >
+ label: string
+ hint: string
+ accent?: boolean
+}> = [
+ { key: 'available_balance_cent', label: '可用余额', hint: '可立即申请提现', accent: true },
+ { key: 'frozen_balance_cent', label: '冻结余额', hint: '提现审核中暂时冻结' },
+ { key: 'pending_release_cent', label: '待释放收益', hint: '订单已完成、尚未进入结算批次' },
+ { key: 'total_settled_cent', label: '累计结算', hint: '已进入结算批次的站内收益' },
+ { key: 'total_withdrawn_cent', label: '累计提现', hint: '已完成打款的历史提现' },
+]
+
+const withdrawalColumns: TableColumnsType = [
+ {
+ title: '提现单号',
+ dataIndex: 'withdraw_no',
+ width: 200,
+ fixed: 'left',
+ render: value => {value},
+ },
+ {
+ title: '金额',
+ key: 'amount',
+ width: 150,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.amount_cent)}
+ 手续费 {formatMoney(row.fee_cent)}
+ 到账 {formatMoney(row.actual_amount_cent)}
+
+ ),
+ },
+ {
+ title: '收款账户',
+ key: 'account',
+ width: 230,
+ render: (_, row) => (
+
+
+ {row.account_type === 'alipay' ? '支付宝' : '银行卡'} · {formatText(row.account_name)}
+
+ {formatText(row.account_no)}
+ {row.bank_name ? (
+
+ {row.bank_name}
+ {row.bank_branch ? ` · ${row.bank_branch}` : ''}
+
+ ) : null}
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ render: value => ,
+ },
+ {
+ title: '备注',
+ key: 'remark',
+ width: 200,
+ render: (_, row) => (
+
+ 审核:{formatText(row.review_remark)}
+ 打款:{formatText(row.payment_remark)}
+
+ ),
+ },
+ {
+ title: '时间',
+ key: 'time',
+ width: 180,
+ render: (_, row) => (
+
+ 申请 {formatDateTime(row.created_at)}
+ 打款 {formatDateTime(row.paid_at)}
+
+ ),
+ },
+]
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+export function AdminDistributionWalletPage() {
+ const { message, modal } = App.useApp()
+ const queryClient = useQueryClient()
+ const [form] = Form.useForm()
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [submitting, setSubmitting] = useState(false)
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:settlement_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:settlement_manage')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+ const accountType = Form.useWatch('account_type', form) ?? 'alipay'
+
+ const walletQuery = useQuery({
+ queryKey: ['admin-distribution-wallet', siteId],
+ queryFn: () => fetchAdminDistributionWallet(siteId as number),
+ enabled: canView && Boolean(siteId),
+ })
+
+ const withdrawalsQuery = useQuery({
+ queryKey: ['admin-distribution-withdrawals', siteId, page, pageSize],
+ queryFn: () =>
+ fetchAdminDistributionWithdrawals(siteId as number, { page, page_size: pageSize }),
+ enabled: canView && Boolean(siteId),
+ placeholderData: keepPreviousData,
+ })
+
+ const wallet = walletQuery.data
+ const withdrawals = withdrawalsQuery.data
+ const errorMessage = walletQuery.isError
+ ? errorText(walletQuery.error)
+ : withdrawalsQuery.isError
+ ? errorText(withdrawalsQuery.error)
+ : ''
+
+ const submit = async () => {
+ if (!siteId || !wallet) return
+ const values = await form.validateFields()
+ const amountCent = parseYuanToCent(values.amount_yuan)
+ if (!Number.isFinite(amountCent) || amountCent <= 0) {
+ message.error('提现金额格式不正确')
+ return
+ }
+ if (amountCent > wallet.available_balance_cent) {
+ message.error('提现金额超过可用余额')
+ return
+ }
+ const payload: ApplyAdminDistributionWithdrawalPayload = {
+ amount_cent: amountCent,
+ account_type: values.account_type,
+ account_name: values.account_name.trim(),
+ account_no: values.account_no.trim(),
+ bank_name: values.account_type === 'bank' ? values.bank_name?.trim() : '',
+ bank_branch: values.account_type === 'bank' ? values.bank_branch?.trim() : '',
+ }
+
+ modal.confirm({
+ title: '确认提交提现申请',
+ content: (
+
+ 站点:{selection.site?.site_name ?? `#${siteId}`}
+ 提现金额:{formatMoney(amountCent)}
+ 收款人:{payload.account_name}
+ 提交后对应金额会从可用余额转入冻结余额。
+
+ ),
+ okText: '确认提交',
+ cancelText: '取消',
+ onOk: async () => {
+ setSubmitting(true)
+ try {
+ await applyAdminDistributionWithdrawal(siteId, payload)
+ message.success('提现申请已提交')
+ form.resetFields(['amount_yuan'])
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['admin-distribution-wallet', siteId] }),
+ queryClient.invalidateQueries({
+ queryKey: ['admin-distribution-withdrawals', siteId],
+ }),
+ ])
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setSubmitting(false)
+ }
+ },
+ })
+ }
+
+ const refresh = () => {
+ void walletQuery.refetch()
+ void withdrawalsQuery.refetch()
+ void selection.refetch()
+ }
+
+ const availableHint = useMemo(() => {
+ if (!wallet) return ''
+ return `可用 ${formatMoney(wallet.available_balance_cent)},结算冷却 ${wallet.settlement_hold_days} 天`
+ }, [wallet])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx b/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx
new file mode 100644
index 0000000..38833a5
--- /dev/null
+++ b/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx
@@ -0,0 +1,557 @@
+import { useState } from 'react'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import {
+ Alert,
+ App,
+ Button,
+ Col,
+ Form,
+ Input,
+ InputNumber,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import {
+ DeleteOutlined,
+ EditOutlined,
+ InboxOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ SendOutlined,
+ StopOutlined,
+} from '@ant-design/icons'
+
+import {
+ archiveAdminSiteAnnouncement,
+ createAdminSiteAnnouncement,
+ deleteAdminSiteAnnouncement,
+ fetchAdminSiteAnnouncements,
+ publishAdminSiteAnnouncement,
+ unarchiveAdminSiteAnnouncement,
+ updateAdminSiteAnnouncement,
+} from '@/features/admin/api'
+import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
+import { DataTable } from '@/features/admin/components/DataTable'
+import {
+ DetailDrawer,
+ type DetailSection,
+} from '@/features/admin/components/DetailDrawer'
+import {
+ DistributionSiteSelect,
+ useDistributionSiteSelection,
+} from '@/features/admin/components/DistributionSiteSelect'
+import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
+import { StatusTag } from '@/features/admin/components/StatusTag'
+import {
+ announcementCategoryMap,
+ announcementCategoryOptions,
+ announcementStatusMap,
+ announcementStatusOptions,
+ readStatusMeta,
+} from '@/features/admin/labels'
+import type {
+ AdminSiteAnnouncement,
+ AdminSiteAnnouncementPayload,
+} from '@/features/admin/types'
+import { useAdminSessionStore } from '@/stores/adminSession'
+import { formatDateTime, formatText } from '@/shared/utils/format'
+
+function errorText(error: unknown) {
+ const response = (error as { response?: { data?: { message?: string } } })?.response
+ if (response?.data?.message) return response.data.message
+ return error instanceof Error ? error.message : '请求失败,请稍后重试'
+}
+
+function buildSections(item: AdminSiteAnnouncement): DetailSection[] {
+ return [
+ {
+ key: 'content',
+ title: '公告内容',
+ icon: ,
+ fields: [
+ {
+ key: 'flags',
+ label: '标记',
+ value: (
+
+ {item.is_pinned ? 置顶 : null}
+ {item.is_important ? 重要 : null}
+ {item.is_popup ? 弹窗 : null}
+ {!item.is_pinned && !item.is_important && !item.is_popup ? 普通公告 : null}
+
+ ),
+ },
+ {
+ key: 'category',
+ label: '分类',
+ value: announcementCategoryMap[item.category] ?? formatText(item.category),
+ },
+ { key: 'priority', label: '排序权重', value: item.priority },
+ { key: 'views', label: '浏览量', value: item.view_count },
+ { key: 'published', label: '发布时间', value: formatDateTime(item.published_at) },
+ { key: 'updated', label: '更新时间', value: formatDateTime(item.updated_at) },
+ {
+ key: 'body',
+ label: '正文',
+ span: 2,
+ value: {item.content}
,
+ },
+ ],
+ },
+ ]
+}
+
+export function AdminSiteAnnouncementsPage() {
+ const { message, modal } = App.useApp()
+ const [form] = Form.useForm()
+ const [status, setStatus] = useState()
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [editorOpen, setEditorOpen] = useState(false)
+ const [editing, setEditing] = useState()
+ const [submitting, setSubmitting] = useState(false)
+ const [detail, setDetail] = useState()
+ const permissions = useAdminSessionStore(state => state.admin?.permissions)
+ const canView = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:announcement_view')
+ )
+ const canManage = Boolean(
+ permissions?.includes('*') || permissions?.includes('distribution:announcement_manage')
+ )
+ const selection = useDistributionSiteSelection()
+ const siteId = selection.siteId
+
+ const listQuery = useQuery({
+ queryKey: ['admin-site-announcements', siteId, status, page, pageSize],
+ queryFn: () =>
+ fetchAdminSiteAnnouncements(siteId as number, {
+ status,
+ page,
+ page_size: pageSize,
+ }),
+ enabled: canView && Boolean(siteId),
+ placeholderData: keepPreviousData,
+ })
+
+ const data = listQuery.data
+ const rows = data?.items ?? []
+
+ const openCreate = () => {
+ setEditing(undefined)
+ form.setFieldsValue({
+ title: '',
+ content: '',
+ category: 'notice',
+ priority: 0,
+ is_pinned: false,
+ is_important: false,
+ is_popup: false,
+ })
+ setEditorOpen(true)
+ }
+
+ const openEdit = (item: AdminSiteAnnouncement) => {
+ setEditing(item)
+ form.setFieldsValue({
+ title: item.title,
+ content: item.content,
+ category: item.category,
+ priority: item.priority,
+ is_pinned: item.is_pinned,
+ is_important: item.is_important,
+ is_popup: item.is_popup,
+ })
+ setEditorOpen(true)
+ }
+
+ const save = async () => {
+ if (!siteId) return
+ const values = await form.validateFields()
+ setSubmitting(true)
+ try {
+ if (editing) {
+ await updateAdminSiteAnnouncement(siteId, editing.id, values)
+ message.success('站点公告已更新')
+ } else {
+ await createAdminSiteAnnouncement(siteId, values)
+ message.success('站点公告已创建为草稿')
+ }
+ setEditorOpen(false)
+ await listQuery.refetch()
+ } catch (error) {
+ message.error(errorText(error))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const changeStatus = (item: AdminSiteAnnouncement, action: 'publish' | 'archive' | 'unarchive') => {
+ if (!siteId) return
+ const label = action === 'publish' ? '发布' : action === 'archive' ? '归档' : '取消归档'
+ modal.confirm({
+ title: `确认${label}公告「${item.title}」?`,
+ content:
+ action === 'publish'
+ ? '发布后该公告会在本站前台展示;弹窗公告会按每日一次规则弹出。'
+ : action === 'archive'
+ ? '归档后公告不再在前台展示,但内容会保留。'
+ : '取消归档后公告会重新发布到前台。',
+ okText: `确认${label}`,
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ if (action === 'publish') await publishAdminSiteAnnouncement(siteId, item.id)
+ if (action === 'archive') await archiveAdminSiteAnnouncement(siteId, item.id)
+ if (action === 'unarchive') await unarchiveAdminSiteAnnouncement(siteId, item.id)
+ message.success(`公告已${label}`)
+ await listQuery.refetch()
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const remove = (item: AdminSiteAnnouncement) => {
+ if (!siteId) return
+ modal.confirm({
+ title: `确认删除公告「${item.title}」?`,
+ content: '删除后无法恢复;如果只是暂时下线,建议使用归档。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ await deleteAdminSiteAnnouncement(siteId, item.id)
+ message.success('站点公告已删除')
+ await listQuery.refetch()
+ } catch (error) {
+ message.error(errorText(error))
+ }
+ },
+ })
+ }
+
+ const columns: TableColumnsType = [
+ {
+ title: '公告',
+ key: 'title',
+ width: 300,
+ fixed: 'left',
+ render: (_, row) => (
+
+
+ {row.content.slice(0, 80)}{row.content.length > 80 ? '…' : ''}
+
+ {announcementCategoryMap[row.category] ?? row.category}
+ {row.is_pinned ? 置顶 : null}
+ {row.is_important ? 重要 : null}
+ {row.is_popup ? 弹窗 : null}
+
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ render: value => ,
+ },
+ {
+ title: '排序',
+ dataIndex: 'priority',
+ width: 90,
+ align: 'right',
+ },
+ {
+ title: '浏览',
+ dataIndex: 'view_count',
+ width: 90,
+ align: 'right',
+ },
+ {
+ title: '发布时间',
+ dataIndex: 'published_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updated_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 250,
+ fixed: 'right',
+ render: (_, row) => (
+
+ {row.status === 'draft' || row.status === 'archived' ? (
+ }
+ disabled={!canManage}
+ onClick={() => changeStatus(row, row.status === 'archived' ? 'unarchive' : 'publish')}
+ >
+ {row.status === 'archived' ? '取消归档' : '发布'}
+
+ ) : null}
+ {row.status === 'published' ? (
+ }
+ disabled={!canManage}
+ onClick={() => changeStatus(row, 'archive')}
+ >
+ 归档
+
+ ) : null}
+ }
+ disabled={!canManage}
+ onClick={() => openEdit(row)}
+ >
+ 编辑
+
+ }
+ disabled={!canManage}
+ onClick={() => remove(row)}
+ >
+ 删除
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index c04d1ea..a31cb08 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -1,6 +1,7 @@
import { adminClient } from '@/shared/api/adminClient'
import type { ApiResponse } from '@/shared/api/types'
import type {
+ AdminAccountDisplay,
AdminAnnouncement,
AdminAnnouncementPage,
AdminAnnouncementPayload,
@@ -12,8 +13,30 @@ import type {
AdminDispute,
AdminDisputePage,
AdminDisputeQuery,
+ AdminDistributionInvitationConfig,
+ AdminDistributionOrder,
+ AdminDistributionOrderPage,
+ AdminDistributionOrderQuery,
+ AdminDistributionQrCode,
+ AdminDistributionQrCodePage,
+ AdminDistributionQrCodeQuery,
+ AdminDistributionQrCodeStats,
+ AdminDistributionRule,
+ AdminDistributionRuleQuery,
+ AdminDistributionSettlementBatch,
+ AdminDistributionSettlementItem,
AdminDistributionSite,
AdminDistributionSiteUpdatePayload,
+ AdminDistributionSMSConfig,
+ AdminDistributionUserPage,
+ AdminDistributionUserQuery,
+ AdminDistributionWallet,
+ AdminDistributionWithdrawal,
+ AdminDistributionWithdrawalPage,
+ AdminSiteAnnouncement,
+ AdminSiteAnnouncementPage,
+ AdminSiteAnnouncementPayload,
+ AdminSiteAnnouncementQuery,
AdminListing,
AdminListingPage,
AdminListingQuery,
@@ -21,6 +44,10 @@ import type {
AdminOrderDetail,
AdminOrderPage,
AdminOrderQuery,
+ AdminPaymentChannel,
+ AdminPaymentConfigOption,
+ AdminReceivedListingPage,
+ AdminReceivedListingQuery,
AdminSystemConfig,
AdminSystemConfigUpdatePayload,
AdminUser,
@@ -30,6 +57,10 @@ import type {
AdminWithdrawal,
AdminWithdrawalPage,
AdminWithdrawalQuery,
+ ApplyAdminDistributionWithdrawalPayload,
+ CreateAdminDistributionQrCodeItem,
+ CreateAdminDistributionRulePayload,
+ CreateAdminDistributionSettlementBatchPayload,
DashboardData,
FinanceDashboard,
FinanceDetailPage,
@@ -39,6 +70,10 @@ import type {
OperatingExpensePage,
OperatingExpensePayload,
OperatingExpenseQuery,
+ TestAdminDistributionSMSConfigPayload,
+ UpdateAdminDistributionInvitationConfigPayload,
+ UpdateAdminDistributionQrCodePayload,
+ UpdateAdminDistributionSMSConfigPayload,
} from '@/features/admin/types'
export async function fetchAdminCaptcha() {
@@ -469,6 +504,438 @@ export async function updateAdminDistributionSite(
return data.data
}
+export async function fetchAdminDistributionWallet(id: number | string) {
+ const { data } = await adminClient.get>(
+ `/admin/distribution/sites/${id}/wallet`
+ )
+ return data.data
+}
+
+export async function applyAdminDistributionWithdrawal(
+ id: number | string,
+ payload: ApplyAdminDistributionWithdrawalPayload
+) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/sites/${id}/withdrawals`,
+ payload
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionWithdrawals(
+ id: number | string,
+ query: { page?: number; page_size?: number }
+) {
+ const { data } = await adminClient.get>>(
+ `/admin/distribution/sites/${id}/withdrawals`,
+ { params: compactParams(query) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ }
+}
+
+export async function fetchAdminDistributionSMSConfig(id: number | string) {
+ const { data } = await adminClient.get>(
+ `/admin/distribution/sites/${id}/sms-config`
+ )
+ return data.data
+}
+
+export async function updateAdminDistributionSMSConfig(
+ id: number | string,
+ payload: UpdateAdminDistributionSMSConfigPayload
+) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/sites/${id}/sms-config`,
+ payload
+ )
+ return data.data
+}
+
+export async function testAdminDistributionSMSConfig(
+ id: number | string,
+ payload: TestAdminDistributionSMSConfigPayload
+) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/sites/${id}/sms-config/test`,
+ payload
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionInvitationConfig(id: number | string) {
+ const { data } = await adminClient.get>(
+ `/admin/distribution/sites/${id}/invitation-config`
+ )
+ return data.data
+}
+
+export async function updateAdminDistributionInvitationConfig(
+ id: number | string,
+ payload: UpdateAdminDistributionInvitationConfigPayload
+) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/sites/${id}/invitation-config`,
+ payload
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionQrCodes(
+ id: number | string,
+ query: AdminDistributionQrCodeQuery
+) {
+ const { data } = await adminClient.get>>(
+ `/admin/distribution/sites/${id}/qrcodes`,
+ { params: compactParams(query) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.limit ?? 20,
+ }
+}
+
+export async function fetchAdminDistributionQrCodeStats(id: number | string) {
+ const { data } = await adminClient.get>>(
+ `/admin/distribution/sites/${id}/qrcodes/stats`
+ )
+ return {
+ unused_count: data.data.unused_count ?? 0,
+ used_count: data.data.used_count ?? 0,
+ disabled_count: data.data.disabled_count ?? 0,
+ total_count: data.data.total_count ?? 0,
+ }
+}
+
+export async function batchCreateAdminDistributionQrCodes(
+ id: number | string,
+ items: CreateAdminDistributionQrCodeItem[]
+) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/sites/${id}/qrcodes/batch`,
+ { items }
+ )
+ return data.data
+}
+
+export async function batchDeleteAdminDistributionQrCodes(
+ id: number | string,
+ ids: number[]
+) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/sites/${id}/qrcodes/batch-delete`,
+ { ids }
+ )
+ return data.data.deleted ?? 0
+}
+
+export async function updateAdminDistributionQrCode(
+ id: number | string,
+ qrcodeId: number,
+ payload: UpdateAdminDistributionQrCodePayload
+) {
+ await adminClient.patch(`/admin/distribution/sites/${id}/qrcodes/${qrcodeId}`, payload)
+}
+
+export async function deleteAdminDistributionQrCode(id: number | string, qrcodeId: number) {
+ await adminClient.delete(`/admin/distribution/sites/${id}/qrcodes/${qrcodeId}`)
+}
+
+export async function fetchAdminDistributionRules(query: AdminDistributionRuleQuery) {
+ const { data } = await adminClient.get>(
+ '/admin/distribution/rules',
+ { params: compactParams(query) }
+ )
+ return data.data.items ?? []
+}
+
+export async function createAdminDistributionRule(payload: CreateAdminDistributionRulePayload) {
+ const { data } = await adminClient.post>(
+ '/admin/distribution/rules',
+ payload
+ )
+ return data.data
+}
+
+export async function updateAdminDistributionRuleStatus(
+ id: number,
+ status: 'draft' | 'active' | 'disabled'
+) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/rules/${id}/status`,
+ { status }
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionOrders(query: AdminDistributionOrderQuery) {
+ const { data } = await adminClient.get>>(
+ '/admin/distribution/orders',
+ { params: compactParams(query) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ }
+}
+
+export async function fetchAdminDistributionOrder(id: number) {
+ const { data } = await adminClient.get>(
+ `/admin/distribution/orders/${id}`
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionSettlementBatches(siteId: number) {
+ const { data } = await adminClient.get<
+ ApiResponse<{ items?: AdminDistributionSettlementBatch[] }>
+ >('/admin/distribution/settlement-batches', { params: { site_id: siteId } })
+ return data.data.items ?? []
+}
+
+export async function createAdminDistributionSettlementBatch(
+ payload: CreateAdminDistributionSettlementBatchPayload
+) {
+ const { data } = await adminClient.post>(
+ '/admin/distribution/settlement-batches',
+ payload
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionSettlementItems(batchId: number) {
+ const { data } = await adminClient.get<
+ ApiResponse<{ items?: AdminDistributionSettlementItem[] }>
+ >(`/admin/distribution/settlement-batches/${batchId}/items`)
+ return data.data.items ?? []
+}
+
+export async function settleAdminDistributionSettlementBatch(batchId: number) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/settlement-batches/${batchId}/settle`
+ )
+ return data.data
+}
+
+export async function cancelAdminDistributionSettlementBatch(batchId: number) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/settlement-batches/${batchId}/cancel`
+ )
+ return data.data
+}
+
+export async function fetchAdminDistributionUsers(
+ siteId: number,
+ query: AdminDistributionUserQuery
+) {
+ const { data } = await adminClient.get>>(
+ '/admin/distribution/users',
+ { params: compactParams({ ...query, site_id: siteId }) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ total_users: data.data.total_users ?? 0,
+ order_users: data.data.order_users ?? 0,
+ }
+}
+
+export async function fetchAdminSiteAnnouncements(
+ siteId: number,
+ query: AdminSiteAnnouncementQuery
+) {
+ const { data } = await adminClient.get>>(
+ '/admin/distribution/announcements',
+ { params: compactParams({ ...query, site_id: siteId }) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ }
+}
+
+export async function createAdminSiteAnnouncement(
+ siteId: number,
+ payload: AdminSiteAnnouncementPayload
+) {
+ const { data } = await adminClient.post>(
+ '/admin/distribution/announcements',
+ payload,
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function updateAdminSiteAnnouncement(
+ siteId: number,
+ id: number,
+ payload: AdminSiteAnnouncementPayload
+) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/announcements/${id}`,
+ payload,
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function publishAdminSiteAnnouncement(siteId: number, id: number) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/announcements/${id}/publish`,
+ {},
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function archiveAdminSiteAnnouncement(siteId: number, id: number) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/announcements/${id}/archive`,
+ {},
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function unarchiveAdminSiteAnnouncement(siteId: number, id: number) {
+ const { data } = await adminClient.post>(
+ `/admin/distribution/announcements/${id}/unarchive`,
+ {},
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function deleteAdminSiteAnnouncement(siteId: number, id: number) {
+ await adminClient.delete(`/admin/distribution/announcements/${id}`, {
+ params: { site_id: siteId },
+ })
+}
+
+export async function fetchAdminReceivedListings(
+ siteId: number,
+ query: AdminReceivedListingQuery
+) {
+ const { data } = await adminClient.get>>(
+ '/admin/distribution/received-listings',
+ { params: compactParams({ ...query, site_id: siteId }) }
+ )
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ }
+}
+
+export async function updateAdminReceivedListingStatus(
+ listingId: number,
+ status: 'published' | 'offline'
+) {
+ await adminClient.put(`/admin/distribution/received-listings/${listingId}/status`, { status })
+}
+
+export async function fetchAdminAccountDisplays(siteId: number, status?: string) {
+ const { data } = await adminClient.get>(
+ '/admin/distribution/accounts',
+ { params: compactParams({ site_id: siteId, status }) }
+ )
+ return data.data.items ?? []
+}
+
+export async function updateAdminAccountDisplay(
+ id: number,
+ displayStatus: 'enabled' | 'disabled'
+) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/accounts/${id}/display`,
+ { display_status: displayStatus }
+ )
+ return data.data
+}
+
+export async function updateAdminAccountMarkup(id: number, shelfRatioDelta: number) {
+ const { data } = await adminClient.put>(
+ `/admin/distribution/accounts/${id}/markup`,
+ { shelf_ratio_delta: shelfRatioDelta }
+ )
+ return data.data
+}
+
+export async function batchUpdateAdminAccountDisplays(
+ siteId: number,
+ displayIds: number[],
+ targetStatus: 'enabled' | 'disabled'
+) {
+ await adminClient.post(
+ '/admin/distribution/accounts/batch-display',
+ {
+ display_ids: displayIds,
+ target_status: targetStatus,
+ },
+ { params: { site_id: siteId } }
+ )
+}
+
+export async function batchUpdateAdminAccountMarkup(
+ siteId: number,
+ displayIds: number[],
+ shelfRatioDelta: number
+) {
+ await adminClient.post(
+ '/admin/distribution/accounts/batch-markup',
+ {
+ display_ids: displayIds,
+ shelf_ratio_delta: shelfRatioDelta,
+ },
+ { params: { site_id: siteId } }
+ )
+}
+
+export async function fetchAdminPaymentChannels(siteId: number) {
+ const { data } = await adminClient.get>(
+ '/admin/distribution/payment-channels',
+ { params: { site_id: siteId } }
+ )
+ return data.data.items ?? []
+}
+
+export async function bindAdminPaymentChannel(
+ siteId: number,
+ configId: number,
+ priority: number
+) {
+ const { data } = await adminClient.post>(
+ '/admin/distribution/payment-channels',
+ { config_id: configId, priority },
+ { params: { site_id: siteId } }
+ )
+ return data.data
+}
+
+export async function unbindAdminPaymentChannel(id: number) {
+ await adminClient.delete(`/admin/distribution/payment-channels/${id}`)
+}
+
+export async function fetchAdminPaymentConfigOptions() {
+ const { data } = await adminClient.get<
+ ApiResponse<{ items?: AdminPaymentConfigOption[] }>
+ >('/admin/payment-configs', { params: { page: 1, page_size: 100 } })
+ return data.data.items ?? []
+}
+
export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) {
const { data } = await adminClient.get>>(
'/admin/finance/disbursements',
diff --git a/frontend/src/features/admin/components/DataTable.tsx b/frontend/src/features/admin/components/DataTable.tsx
index c53642d..7299c24 100644
--- a/frontend/src/features/admin/components/DataTable.tsx
+++ b/frontend/src/features/admin/components/DataTable.tsx
@@ -15,6 +15,7 @@ interface DataTableProps {
onRowClick?: (row: T) => void
emptyDescription?: ReactNode
scroll?: TableProps['scroll']
+ rowSelection?: TableProps['rowSelection']
className?: string
}
@@ -36,6 +37,7 @@ export function DataTable({
onRowClick,
emptyDescription = '没有符合条件的数据',
scroll = { x: 'max-content' },
+ rowSelection,
className,
}: DataTableProps) {
return (
@@ -47,6 +49,7 @@ export function DataTable({
dataSource={dataSource}
loading={loading}
scroll={scroll}
+ rowSelection={rowSelection}
onRow={
onRowClick
? row => ({
diff --git a/frontend/src/features/admin/components/DistributionSiteSelect.tsx b/frontend/src/features/admin/components/DistributionSiteSelect.tsx
new file mode 100644
index 0000000..250226d
--- /dev/null
+++ b/frontend/src/features/admin/components/DistributionSiteSelect.tsx
@@ -0,0 +1,106 @@
+import { useEffect } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { Select, Space, Tag, Typography } from 'antd'
+
+import { fetchAdminDistributionSites } from '@/features/admin/api'
+import type { AdminDistributionSite } from '@/features/admin/types'
+
+interface DistributionSiteSelection {
+ sites: AdminDistributionSite[]
+ siteId?: number
+ site?: AdminDistributionSite
+ setSiteId: (id: number) => void
+ isLoading: boolean
+ isError: boolean
+ refetch: () => void
+}
+
+/**
+ * 分销长尾页面统一的站点选择状态:站点 ID 存在 URL 上,刷新和分享链接都不会丢。
+ * 未指定或指定了无效站点时自动落到第一个站点。
+ */
+export function useDistributionSiteSelection(
+ options: { autoSelect?: boolean } = {}
+): DistributionSiteSelection {
+ const autoSelect = options.autoSelect ?? true
+ const [searchParams, setSearchParams] = useSearchParams()
+ const { data = [], isPending, isError, refetch } = useQuery({
+ queryKey: ['admin-distribution-site-options'],
+ queryFn: fetchAdminDistributionSites,
+ staleTime: 60_000,
+ })
+
+ const raw = searchParams.get('site_id')
+ const parsed = raw ? Number(raw) : NaN
+ const fallback = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
+ const matched = data.some(site => site.id === parsed) ? parsed : undefined
+ const siteId = matched ?? (autoSelect ? (data[0]?.id ?? fallback) : fallback)
+
+ useEffect(() => {
+ if (!autoSelect || !siteId || parsed === siteId) return
+ setSearchParams(
+ current => {
+ const next = new URLSearchParams(current)
+ next.set('site_id', String(siteId))
+ return next
+ },
+ { replace: true }
+ )
+ }, [autoSelect, parsed, setSearchParams, siteId])
+
+ const setSiteId = (id: number) => {
+ setSearchParams(current => {
+ const next = new URLSearchParams(current)
+ next.set('site_id', String(id))
+ return next
+ })
+ }
+
+ return {
+ sites: data,
+ siteId,
+ site: data.find(site => site.id === siteId),
+ setSiteId,
+ isLoading: isPending,
+ isError,
+ refetch,
+ }
+}
+
+interface DistributionSiteSelectProps {
+ selection: DistributionSiteSelection
+ disabled?: boolean
+}
+
+export function DistributionSiteSelect({ selection, disabled }: DistributionSiteSelectProps) {
+ const { sites, siteId, site, setSiteId, isLoading } = selection
+
+ return (
+
+
+ 当前站点
+
+
+ )
+}
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index fab321a..6437e88 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -797,3 +797,462 @@ export interface OperatingExpensePayload {
remark: string
voucher_url?: string
}
+
+/** 分站钱包余额快照,金额单位全部为分。 */
+export interface AdminDistributionWallet {
+ site_id: number
+ available_balance_cent: number
+ frozen_balance_cent: number
+ pending_release_cent: number
+ total_settled_cent: number
+ total_withdrawn_cent: number
+ settlement_hold_days: number
+}
+
+export type AdminDistributionWithdrawalStatus =
+ | 'pending'
+ | 'processing'
+ | 'completed'
+ | 'rejected'
+ | 'cancelled'
+
+export interface AdminDistributionWithdrawal {
+ id: number
+ withdraw_no: string
+ site_id: number
+ amount_cent: number
+ fee_cent: number
+ actual_amount_cent: number
+ account_type: string
+ account_name: string
+ account_no: string
+ bank_name: string
+ bank_branch: string
+ status: string
+ review_remark: string
+ payment_proof_url: string
+ payment_remark: string
+ paid_at?: string | null
+ created_at: string
+}
+
+export interface AdminDistributionWithdrawalPage {
+ items: AdminDistributionWithdrawal[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface ApplyAdminDistributionWithdrawalPayload {
+ amount_cent: number
+ account_type: 'alipay' | 'bank'
+ account_name: string
+ account_no: string
+ bank_name?: string
+ bank_branch?: string
+}
+
+export interface AdminDistributionSMSConfig {
+ enabled: boolean
+ provider: string
+ access_key_id: string
+ masked_secret: string
+ has_secret: boolean
+ endpoint: string
+ sign_name: string
+ login_template_code: string
+ business_templates: Record
+}
+
+export interface UpdateAdminDistributionSMSConfigPayload {
+ enabled?: boolean
+ provider?: string
+ access_key_id?: string
+ access_key_secret?: string
+ endpoint?: string
+ sign_name?: string
+ login_template_code?: string
+ business_templates?: Record
+}
+
+export interface TestAdminDistributionSMSConfigPayload {
+ phone: string
+ provider?: string
+ access_key_id?: string
+ access_key_secret?: string
+ endpoint?: string
+ sign_name?: string
+ login_template_code?: string
+}
+
+/** 邀请返利比例沿用后端 0-1 小数口径,展示时再转百分比。 */
+export interface AdminDistributionInvitationConfig {
+ distribution_site_id: number
+ first_month_rate: number
+ second_month_rate: number
+ subsequent_rate: number
+ enabled: boolean
+ updated_at: string
+}
+
+export interface UpdateAdminDistributionInvitationConfigPayload {
+ first_month_rate: number
+ second_month_rate: number
+ subsequent_rate: number
+ enabled?: boolean
+}
+
+export interface AdminDistributionQrCode {
+ id: number
+ image_url: string
+ group_name: string
+ distribution_site_id?: number | null
+ status: string
+ conversation_id?: number | null
+ used_at?: string | null
+ expires_at?: string | null
+ created_by: number
+ note: string
+ wecom_renamed: boolean
+ created_at: string
+ updated_at: string
+ bound_conversation_title?: string
+}
+
+export interface AdminDistributionQrCodePage {
+ items: AdminDistributionQrCode[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface AdminDistributionQrCodeStats {
+ unused_count: number
+ used_count: number
+ disabled_count: number
+ total_count: number
+}
+
+export interface AdminDistributionQrCodeQuery {
+ page?: number
+ limit?: number
+ status?: string
+ keyword?: string
+ bound?: string
+ expire_status?: string
+ wecom_renamed?: string
+ created_start?: string
+ created_end?: string
+}
+
+export interface CreateAdminDistributionQrCodeItem {
+ image_url: string
+ group_name?: string
+ note?: string
+ expires_at?: string
+}
+
+export interface UpdateAdminDistributionQrCodePayload {
+ image_url?: string
+ group_name?: string
+ note?: string
+ status?: string
+ wecom_renamed?: boolean
+ expires_at?: string
+ clear_expires_at?: boolean
+}
+
+export interface AdminDistributionRule {
+ id: number
+ site_id?: number | null
+ 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 | null
+ created_by?: number | null
+ created_at: string
+}
+
+export interface AdminDistributionRuleQuery {
+ site_id?: number
+ mode?: string
+}
+
+export interface CreateAdminDistributionRulePayload {
+ site_id?: number | null
+ mode: 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
+}
+
+/** 分销订单列表/详情快照,金额全部为分。 */
+export interface AdminDistributionOrder {
+ order_id: number
+ order_no: string
+ site_id: number
+ listing_id: number
+ account_id: number
+ owner_id: number
+ renter_id: number
+ status: string
+ handoff_status: string
+ refund_status: string
+ settlement_status: string
+ distributor_settlement_status: string
+ release_at?: string | null
+ distribution_mode: string
+ gross_paid_amount_cent: number
+ refund_amount_cent: number
+ 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
+ listing_no?: string
+ title?: string
+ game_name?: string
+ server_region?: string
+ login_platform?: string
+ rent_duration_hours?: number
+ rented_at?: string | null
+ renter_phone?: string
+ renter_nickname?: string
+ owner_rent_amount_cent?: number
+ base_price_cent?: number
+ shelf_markup_cent?: number
+ deposit_amount_cent?: number
+ distributor_earning_cent: number
+ distributor_role?: string
+ distributor_markup_cent: number
+ distributor_split_cent: number
+}
+
+export interface AdminDistributionOrderQuery {
+ site_id?: number
+ kind?: string
+ role?: string
+ status?: string
+ mode?: string
+ financial_status?: string
+ keyword?: string
+ page?: number
+ page_size?: number
+}
+
+export interface AdminDistributionOrderPage {
+ items: AdminDistributionOrder[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface AdminDistributionSettlementBatch {
+ id: number
+ site_id: number
+ batch_no: string
+ period_start: string
+ period_end: string
+ total_amount_cent: number
+ status: string
+}
+
+export interface AdminDistributionSettlementItem {
+ id: number
+ order_id: number
+ party_role: string
+ amount_cent: number
+ status: string
+}
+
+export interface CreateAdminDistributionSettlementBatchPayload {
+ site_id: number
+ period_start: string
+ period_end: string
+}
+
+/** 分销站点客户:register=本站注册,order=本站下单,both=注册并下单。 */
+export interface AdminDistributionUser {
+ id: number
+ phone: string
+ nickname: string
+ avatar_url: string
+ realname_status: string
+ status: string
+ source: string
+ order_count: number
+ total_spent_cent: number
+ last_order_at?: string | null
+ created_at: string
+ last_login_at?: string | null
+}
+
+export interface AdminDistributionUserPage {
+ items: AdminDistributionUser[]
+ total: number
+ page: number
+ page_size: number
+ total_users: number
+ order_users: number
+}
+
+export interface AdminDistributionUserQuery {
+ keyword?: string
+ source?: string
+ page?: number
+ page_size?: number
+}
+
+/** 分站公告:站长自助维护,与主站公告隔离。 */
+export interface AdminSiteAnnouncement {
+ id: number
+ distribution_site_id: number
+ title: string
+ content: string
+ category: string
+ priority: number
+ is_pinned: boolean
+ is_important: boolean
+ is_popup: boolean
+ view_count: number
+ status: string
+ published_at?: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface AdminSiteAnnouncementPage {
+ items: AdminSiteAnnouncement[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface AdminSiteAnnouncementQuery {
+ status?: string
+ page?: number
+ page_size?: number
+}
+
+export interface AdminSiteAnnouncementPayload {
+ title: string
+ content: string
+ category: string
+ priority: number
+ is_pinned: boolean
+ is_important: boolean
+ is_popup: boolean
+}
+
+/** 分销站点收到的账号资产(商品级)。 */
+export interface AdminReceivedListing {
+ listing_id: number
+ listing_no: string
+ site_id: number
+ account_id: number
+ owner_id: number
+ owner_phone: string
+ owner_nickname: string
+ title: string
+ game_name: string
+ server_region: string
+ login_platform: string
+ rank_level: string
+ haf_coin_amount: number
+ seller_ratio: number
+ seller_total_price_cent: number
+ buyer_ratio: number
+ price_cent: number
+ deposit_amount_cent: number
+ status: string
+ review_status: string
+ review_reason: string
+ published_at?: string | null
+ created_at: string
+}
+
+export interface AdminReceivedListingPage {
+ items: AdminReceivedListing[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface AdminReceivedListingQuery {
+ status?: string
+ review_status?: string
+ game_name?: string
+ keyword?: string
+ page?: number
+ page_size?: number
+}
+
+/** 站点账号展示配置:控制分站是否展示、加价与展示价。 */
+export interface AdminAccountDisplay {
+ id: number
+ site_id: number
+ account_id: number
+ listing_id: number
+ display_status: string
+ markup_amount_cent: number
+ shelf_ratio_delta: number
+ base_ratio?: number
+ site_ratio?: number
+ listing_no?: string
+ title?: string
+ game_name?: string
+ server_region?: string
+ login_platform?: string
+ rank_level?: string
+ haf_coin_amount?: number
+ price_cent?: number
+ site_price_cent?: number
+ deposit_amount_cent?: number
+ listing_status?: string
+ in_transaction?: boolean
+ listing_site_id?: number | null
+ source_channel?: string
+}
+
+export interface AdminPaymentChannel {
+ id: number
+ site_id: number
+ config_id: number
+ priority: number
+ status: string
+}
+
+/** 可绑定的主站支付配置(密钥字段后端默认不返回)。 */
+export interface AdminPaymentConfigOption {
+ id: number
+ name: string
+ provider: string
+ pay_way: string
+ status: string
+ environment: string
+ is_default: boolean
+}
diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx
index 13c9a4c..79a6847 100644
--- a/frontend/src/layouts/AdminLayout.tsx
+++ b/frontend/src/layouts/AdminLayout.tsx
@@ -99,6 +99,29 @@ const allNavGroups: NavGroup[] = [
icon: ,
children: [
{ key: '/distribution/sites', label: '站点管理', permission: 'distribution:site_view' },
+ { key: '/distribution/orders', label: '分销订单', permission: 'distribution:order_view' },
+ {
+ key: '/distribution/settlements',
+ label: '结算批次',
+ permission: 'distribution:settlement_view',
+ },
+ { key: '/distribution/users', label: '分销用户', permission: 'distribution:site_view' },
+ {
+ key: '/distribution/announcements',
+ label: '站点公告',
+ permission: 'distribution:announcement_view',
+ },
+ { key: '/distribution/listings', label: '分销商品', permission: 'distribution:account_view' },
+ {
+ key: '/distribution/payment-channels',
+ label: '支付通道',
+ permission: 'distribution:payment_view',
+ },
+ { key: '/distribution/wallet', label: '站点钱包', permission: 'distribution:settlement_view' },
+ { key: '/distribution/rules', label: '规则版本', permission: 'distribution:rule_view' },
+ { key: '/distribution/qrcodes', label: '二维码池', permission: 'qrcode:view' },
+ { key: '/distribution/sms', label: '站点短信', permission: 'distribution:site_view' },
+ { key: '/distribution/invitation', label: '邀请返利', permission: 'distribution:site_view' },
],
},
{
@@ -130,6 +153,17 @@ const sectionMeta: Record = {
'/finance/operating-expenses': { title: '运营开支', description: '登记与核对日常运营支出。' },
'/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' },
'/distribution/sites': { title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
+ '/distribution/orders': { title: '分销订单', description: '核对分销订单快照与冷却期释放状态。' },
+ '/distribution/settlements': { title: '结算批次', description: '生成人工批次并核对、释放站内份额。' },
+ '/distribution/users': { title: '分销用户', description: '查看站点注册客户、下单客户与消费贡献。' },
+ '/distribution/announcements': { title: '站点公告', description: '维护分站自己的公告中心与弹窗公告。' },
+ '/distribution/listings': { title: '分销商品', description: '查看收货商品并控制账号展示与加价。' },
+ '/distribution/payment-channels': { title: '支付通道', description: '为分站绑定主站支付商户配置。' },
+ '/distribution/wallet': { title: '站点钱包', description: '查看分站余额、结算进度与提现记录。' },
+ '/distribution/rules': { title: '规则版本', description: '维护全局与站点级分账规则版本。' },
+ '/distribution/qrcodes': { title: '二维码池', description: '按站点维护发布群二维码池与发放状态。' },
+ '/distribution/sms': { title: '站点短信', description: '为分站配置独立短信签名、模板与密钥。' },
+ '/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' },
'/announcements': { title: '公告管理', description: '维护主站与分站公告。' },
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
diff --git a/frontend/src/shared/api/files.ts b/frontend/src/shared/api/files.ts
index d622ed5..c5c1b96 100644
--- a/frontend/src/shared/api/files.ts
+++ b/frontend/src/shared/api/files.ts
@@ -1,4 +1,5 @@
import { apiClient } from './client'
+import { adminClient } from './adminClient'
import type { ApiResponse } from './types'
import { optimizeImageForUpload } from '@/shared/utils/imageUpload'
@@ -23,6 +24,25 @@ export async function uploadFile(file: File, scene: string) {
return data.data
}
+/** 后台文件上传走 HttpOnly Cookie,不能复用用户端 Bearer 客户端。 */
+export async function uploadAdminFile(
+ file: File,
+ scene: string,
+ options: { optimize?: boolean } = {}
+) {
+ const uploadTarget =
+ options.optimize === false ? file : await optimizeImageForUpload(file, scene)
+ const form = new FormData()
+ form.append('file', uploadTarget)
+ form.append('scene', scene)
+ const { data } = await adminClient.post>(
+ '/admin/files/upload',
+ form,
+ { headers: { 'Content-Type': 'multipart/form-data' } }
+ )
+ return data.data
+}
+
export function isImageFileURL(url: string) {
return (
/\.(png|jpe?g|webp|gif|bmp)(\?.*)?$/i.test(url) ||
diff --git a/frontend/src/styles/admin-distribution.css b/frontend/src/styles/admin-distribution.css
new file mode 100644
index 0000000..94ad7a0
--- /dev/null
+++ b/frontend/src/styles/admin-distribution.css
@@ -0,0 +1,235 @@
+/* 分销长尾页:站点钱包、短信、邀请、二维码池、规则版本。 */
+
+.admin-site-picker {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 16px;
+ padding: 12px 16px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--paper-warm);
+}
+
+.admin-site-picker__select {
+ min-width: 280px;
+}
+
+.admin-panel-card {
+ height: 100%;
+ border-color: var(--line);
+ background: var(--paper-warm);
+}
+
+.admin-panel-card .ant-card-head {
+ border-color: var(--line);
+}
+
+.admin-form-hint {
+ margin: 12px 0 0 !important;
+ font-size: 12px;
+}
+
+.admin-form-actions {
+ margin-top: 8px;
+}
+
+.admin-kv-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.admin-kv-list > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px dashed var(--line);
+}
+
+.admin-kv-list > div:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+}
+
+.admin-kv-list span {
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+.admin-kv-list strong {
+ color: var(--ink);
+ font-weight: 500;
+ text-align: right;
+}
+
+/* 钱包 */
+.admin-wallet-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ margin-bottom: 16px;
+}
+
+.admin-wallet-card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 112px;
+ border: 1px solid var(--line);
+ background: var(--paper-warm);
+}
+
+.admin-wallet-card--accent {
+ border-color: var(--brand-ink);
+ background: var(--brand-ink);
+}
+
+.admin-wallet-card__label {
+ color: var(--ink-muted);
+ font-size: 12px;
+ letter-spacing: 0.04em;
+}
+
+.admin-wallet-card__value {
+ color: var(--brand-ink);
+ font-family: 'Inter', 'DIN Alternate', sans-serif;
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.admin-wallet-card__hint {
+ color: var(--ink-muted);
+ font-size: 11px;
+}
+
+.admin-wallet-card--accent .admin-wallet-card__label,
+.admin-wallet-card--accent .admin-wallet-card__hint {
+ color: #b9cbc3;
+}
+
+.admin-wallet-card--accent .admin-wallet-card__value {
+ color: #f0f4f2;
+}
+
+.admin-wallet-body {
+ align-items: stretch;
+}
+
+/* 邀请返利 */
+.admin-invite-preview {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin-bottom: 18px;
+ padding: 14px;
+ border: 1px dashed var(--line);
+ border-radius: 4px;
+ background: #f8f4ea;
+}
+
+.admin-invite-preview > div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.admin-invite-preview span {
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+.admin-invite-preview strong {
+ color: var(--brand-ink);
+ font-family: 'Inter', 'DIN Alternate', sans-serif;
+ font-size: 20px;
+}
+
+/* 分销用户 */
+.admin-user-cell {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.admin-user-cell__avatar {
+ flex: 0 0 auto;
+ background: #eef2ee;
+ color: var(--brand-ink);
+}
+
+/* 结算批次 */
+.admin-settlement-detail-head {
+ margin-bottom: 14px;
+}
+
+/* 二维码池 */
+.admin-qr-thumb {
+ border: 1px solid var(--line);
+ border-radius: 3px;
+ object-fit: cover;
+}
+
+.admin-qr-uploader {
+ margin-bottom: 14px;
+}
+
+.admin-qr-note {
+ margin-bottom: 12px;
+}
+
+.admin-qr-pending {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.admin-qr-pending__item {
+ display: grid;
+ align-items: center;
+ gap: 10px;
+ grid-template-columns: 54px minmax(0, 1fr) auto;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--paper-warm);
+}
+
+.admin-qr-pending__item img {
+ width: 54px;
+ height: 54px;
+ border-radius: 3px;
+ object-fit: cover;
+}
+
+@media (max-width: 1200px) {
+ .admin-wallet-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .admin-site-picker {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .admin-site-picker__select {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .admin-wallet-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .admin-invite-preview {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 2e753bd..734a74f 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -8,6 +8,7 @@
@import './admin-misc.css';
@import './admin-login.css';
@import './admin-shell.css';
+@import './admin-distribution.css';
@import './mobile-base.css';
@import './mobile-listing.css';
@import './mobile-profile-orders.css';