feat(distribution): 补齐分销长尾后台页面

This commit is contained in:
yml2213
2026-10-30 15:43:29 +08:00
parent 13a3127abc
commit 090ab02a67
25 changed files with 7728 additions and 2 deletions
+81
View File
@@ -180,6 +180,61 @@ const AdminDistributionSitesPage = lazy(() =>
default: module.AdminDistributionSitesPage,
}))
)
const AdminDistributionWalletPage = lazy(() =>
import('@/features/admin/AdminDistributionWalletPage').then(module => ({
default: module.AdminDistributionWalletPage,
}))
)
const AdminDistributionSMSPage = lazy(() =>
import('@/features/admin/AdminDistributionSMSPage').then(module => ({
default: module.AdminDistributionSMSPage,
}))
)
const AdminDistributionInvitationPage = lazy(() =>
import('@/features/admin/AdminDistributionInvitationPage').then(module => ({
default: module.AdminDistributionInvitationPage,
}))
)
const AdminDistributionQrCodesPage = lazy(() =>
import('@/features/admin/AdminDistributionQrCodesPage').then(module => ({
default: module.AdminDistributionQrCodesPage,
}))
)
const AdminDistributionRulesPage = lazy(() =>
import('@/features/admin/AdminDistributionRulesPage').then(module => ({
default: module.AdminDistributionRulesPage,
}))
)
const AdminDistributionOrdersPage = lazy(() =>
import('@/features/admin/AdminDistributionOrdersPage').then(module => ({
default: module.AdminDistributionOrdersPage,
}))
)
const AdminDistributionSettlementsPage = lazy(() =>
import('@/features/admin/AdminDistributionSettlementsPage').then(module => ({
default: module.AdminDistributionSettlementsPage,
}))
)
const AdminDistributionUsersPage = lazy(() =>
import('@/features/admin/AdminDistributionUsersPage').then(module => ({
default: module.AdminDistributionUsersPage,
}))
)
const AdminSiteAnnouncementsPage = lazy(() =>
import('@/features/admin/AdminSiteAnnouncementsPage').then(module => ({
default: module.AdminSiteAnnouncementsPage,
}))
)
const AdminDistributionListingsPage = lazy(() =>
import('@/features/admin/AdminDistributionListingsPage').then(module => ({
default: module.AdminDistributionListingsPage,
}))
)
const AdminDistributionPaymentChannelsPage = lazy(() =>
import('@/features/admin/AdminDistributionPaymentChannelsPage').then(module => ({
default: module.AdminDistributionPaymentChannelsPage,
}))
)
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
@@ -244,6 +299,32 @@ export function AppRouter() {
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
<Route path="distribution/orders" element={<AdminDistributionOrdersPage />} />
<Route
path="distribution/settlements"
element={<AdminDistributionSettlementsPage />}
/>
<Route path="distribution/users" element={<AdminDistributionUsersPage />} />
<Route
path="distribution/announcements"
element={<AdminSiteAnnouncementsPage />}
/>
<Route
path="distribution/listings"
element={<AdminDistributionListingsPage />}
/>
<Route
path="distribution/payment-channels"
element={<AdminDistributionPaymentChannelsPage />}
/>
<Route path="distribution/wallet" element={<AdminDistributionWalletPage />} />
<Route path="distribution/sms" element={<AdminDistributionSMSPage />} />
<Route
path="distribution/invitation"
element={<AdminDistributionInvitationPage />}
/>
<Route path="distribution/qrcodes" element={<AdminDistributionQrCodesPage />} />
<Route path="distribution/rules" element={<AdminDistributionRulesPage />} />
<Route
path="*"
element={
@@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
Divider,
Form,
InputNumber,
Row,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import { GiftOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
import {
fetchAdminDistributionInvitationConfig,
updateAdminDistributionInvitationConfig,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import {
DistributionSiteSelect,
useDistributionSiteSelection,
} from '@/features/admin/components/DistributionSiteSelect'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime } from '@/shared/utils/format'
interface InvitationFormValues {
enabled: boolean
first_month_percent: number
second_month_percent: number
subsequent_percent: 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 : '请求失败,请稍后重试'
}
function rateToPercent(rate?: number | null) {
if (rate == null || Number.isNaN(rate)) return 0
return Number((rate * 100).toFixed(2))
}
function percentToRate(percent?: number | null) {
if (percent == null || Number.isNaN(percent)) return 0
return Number((percent / 100).toFixed(6))
}
export function AdminDistributionInvitationPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<InvitationFormValues>()
const [saving, setSaving] = useState(false)
const [savedAt, setSavedAt] = useState<string>()
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-invitation-config', siteId],
queryFn: () => fetchAdminDistributionInvitationConfig(siteId as number),
enabled: canView && Boolean(siteId),
})
const config = configQuery.data
useEffect(() => {
if (!config) return
form.setFieldsValue({
enabled: config.enabled,
first_month_percent: rateToPercent(config.first_month_rate),
second_month_percent: rateToPercent(config.second_month_rate),
subsequent_percent: rateToPercent(config.subsequent_rate),
})
}, [config, form])
useEffect(() => {
setSavedAt(undefined)
}, [siteId])
const firstMonthPercent = Form.useWatch('first_month_percent', form)
const secondMonthPercent = Form.useWatch('second_month_percent', form)
const subsequentPercent = Form.useWatch('subsequent_percent', form)
const maxPercent = useMemo(
() =>
Math.max(
Number(firstMonthPercent ?? 0),
Number(secondMonthPercent ?? 0),
Number(subsequentPercent ?? 0)
),
[firstMonthPercent, secondMonthPercent, subsequentPercent]
)
const save = async () => {
if (!siteId) return
const values = await form.validateFields()
setSaving(true)
try {
const result = await updateAdminDistributionInvitationConfig(siteId, {
enabled: values.enabled,
first_month_rate: percentToRate(values.first_month_percent),
second_month_rate: percentToRate(values.second_month_percent),
subsequent_rate: percentToRate(values.subsequent_percent),
})
queryClient.setQueryData(['admin-distribution-invitation-config', siteId], result)
setSavedAt(new Date().toISOString())
message.success('邀请返利配置已保存')
} catch (error) {
message.error(errorText(error))
} finally {
setSaving(false)
}
}
const refresh = () => {
void configQuery.refetch()
void selection.refetch()
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="邀请返利"
description="配置分站邀请好友的返利比例;比例按被邀请人实名校验后的月份阶段生效。"
meta={
<Space size={8} wrap>
{config ? (
<Tag color={config.enabled ? 'green' : 'default'}>
{config.enabled ? '返利已启用' : '返利已关闭'}
</Tag>
) : null}
{savedAt ? <Tag color="blue">保存于 {formatDateTime(savedAt)}</Tag> : null}
{config?.updated_at ? (
<Tag>服务端更新 {formatDateTime(config.updated_at)}</Tag>
) : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={configQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销站点查看权限"
description="需要 distribution:site_view 权限才能读取邀请返利配置。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{configQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="邀请返利配置没有加载出来"
description={errorText(configQuery.error)}
action={
<Button size="small" onClick={refresh}>
重试
</Button>
}
/>
) : null}
<Row gutter={[16, 16]}>
<Col xs={24} xl={15}>
<Card
className="admin-panel-card"
loading={configQuery.isPending}
title={
<Space>
<GiftOutlined />
<span>返利比例</span>
</Space>
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="比例填写百分比"
description="例如填写 5 表示返利 5%,后端按 0.05 存储。三项比例独立生效,不做合计校验。"
/>
<Form<InvitationFormValues>
form={form}
layout="vertical"
disabled={!canManage || saving || !config}
initialValues={{
enabled: false,
first_month_percent: 0,
second_month_percent: 0,
subsequent_percent: 0,
}}
>
<Form.Item name="enabled" label="启用邀请返利" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item
name="first_month_percent"
label="首月返利"
rules={[{ required: true, message: '请输入首月比例' }]}
>
<InputNumber
min={0}
max={100}
precision={2}
addonAfter="%"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name="second_month_percent"
label="次月返利"
rules={[{ required: true, message: '请输入次月比例' }]}
>
<InputNumber
min={0}
max={100}
precision={2}
addonAfter="%"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name="subsequent_percent"
label="第三个月起"
rules={[{ required: true, message: '请输入后续比例' }]}
>
<InputNumber
min={0}
max={100}
precision={2}
addonAfter="%"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left">比例预览</Divider>
<div className="admin-invite-preview">
<div>
<span>首月</span>
<strong>{Number(firstMonthPercent ?? 0).toFixed(2)}%</strong>
</div>
<div>
<span>次月</span>
<strong>{Number(secondMonthPercent ?? 0).toFixed(2)}%</strong>
</div>
<div>
<span>第三个月起</span>
<strong>{Number(subsequentPercent ?? 0).toFixed(2)}%</strong>
</div>
<div>
<span>最高比例</span>
<strong>{maxPercent.toFixed(2)}%</strong>
</div>
</div>
<Space className="admin-form-actions">
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => void save()}
disabled={!canManage}
>
保存配置
</Button>
{!canManage ? (
<Typography.Text type="secondary">
需要 distribution:site_manage 权限
</Typography.Text>
) : null}
</Space>
</Form>
</Card>
</Col>
<Col xs={24} xl={9}>
<Card className="admin-panel-card" title="规则说明">
<div className="admin-kv-list">
<div>
<span>生效前提</span>
<strong>被邀请人完成实名认证</strong>
</div>
<div>
<span>首月</span>
<strong>实名校验后 30 天内</strong>
</div>
<div>
<span>次月</span>
<strong>第 31-60 天</strong>
</div>
<div>
<span>第三个月起</span>
<strong>第 61 天以后</strong>
</div>
<div>
<span>返利基数</span>
<strong>订单单边服务费</strong>
</div>
</div>
<Typography.Paragraph type="secondary" className="admin-form-hint">
关闭开关后,新产生的订单不再计入邀请返利;历史返利流水不会回滚。
</Typography.Paragraph>
</Card>
</Col>
</Row>
</div>
)
}
@@ -0,0 +1,848 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Tabs,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import {
EditOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
TagsOutlined,
} from '@ant-design/icons'
import {
batchUpdateAdminAccountDisplays,
batchUpdateAdminAccountMarkup,
fetchAdminAccountDisplays,
fetchAdminReceivedListings,
updateAdminAccountDisplay,
updateAdminAccountMarkup,
updateAdminReceivedListingStatus,
} 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 {
AdminAccountDisplay,
AdminReceivedListing,
AdminReceivedListingQuery,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText, maskPhone } from '@/shared/utils/format'
const listingStatusMap: Record<string, StatusMeta> = {
draft: { label: '草稿', tone: 'default' },
pending_review: { label: '待审核', tone: 'gold' },
published: { label: '已上架', tone: 'success' },
offline: { label: '已下架', tone: 'default' },
rejected: { label: '审核拒绝', tone: 'red' },
disabled: { label: '已停用', tone: 'default' },
locked: { label: '已锁定', tone: 'orange' },
}
const reviewStatusMap: Record<string, StatusMeta> = {
pending: { label: '待审核', tone: 'gold' },
approved: { label: '已通过', tone: 'success' },
rejected: { label: '已拒绝', tone: 'red' },
}
const displayStatusMap: Record<string, StatusMeta> = {
enabled: { label: '展示中', tone: 'success' },
disabled: { label: '已隐藏', tone: 'default' },
sold: { label: '已售出', tone: 'blue' },
offline: { label: '已下架', tone: 'default' },
}
const receivedStatusOptions = [
{ value: 'published', label: '展示中' },
{ value: 'offline', label: '已下架' },
]
const reviewStatusOptions = Object.entries(reviewStatusMap).map(([value, meta]) => ({
value,
label: meta.label,
}))
const displayStatusOptions = Object.entries(displayStatusMap).map(([value, meta]) => ({
value,
label: meta.label,
}))
interface MarkupFormValues {
shelf_ratio_delta: 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 : '请求失败,请稍后重试'
}
export function AdminDistributionListingsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [tab, setTab] = useState('received')
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(
permissions?.includes('*') || permissions?.includes('distribution:account_view')
)
const canManage = Boolean(
permissions?.includes('*') || permissions?.includes('distribution:account_display')
)
const selection = useDistributionSiteSelection()
const siteId = selection.siteId
// 收货商品
const [receivedDraft, setReceivedDraft] = useState<AdminReceivedListingQuery>({})
const [receivedFilters, setReceivedFilters] = useState<AdminReceivedListingQuery>({})
const [receivedPage, setReceivedPage] = useState(1)
const [receivedPageSize, setReceivedPageSize] = useState(20)
// 账号展示
const [accountStatus, setAccountStatus] = useState<string>()
const [accountKeyword, setAccountKeyword] = useState('')
const [accountPage, setAccountPage] = useState(1)
const [accountPageSize, setAccountPageSize] = useState(20)
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([])
const [markupTarget, setMarkupTarget] = useState<AdminAccountDisplay>()
const [markupForm] = Form.useForm<MarkupFormValues>()
const [batchMarkupOpen, setBatchMarkupOpen] = useState(false)
const [batchMarkupForm] = Form.useForm<MarkupFormValues>()
const [working, setWorking] = useState(false)
const receivedQuery = useQuery({
queryKey: ['admin-received-listings', siteId, receivedFilters, receivedPage, receivedPageSize],
queryFn: () =>
fetchAdminReceivedListings(siteId as number, {
...receivedFilters,
page: receivedPage,
page_size: receivedPageSize,
}),
enabled: canView && Boolean(siteId),
placeholderData: keepPreviousData,
})
const accountsQuery = useQuery({
queryKey: ['admin-account-displays', siteId, accountStatus],
queryFn: () => fetchAdminAccountDisplays(siteId as number, accountStatus),
enabled: canView && Boolean(siteId),
})
const receivedRows = receivedQuery.data?.items ?? []
const accountRows = accountsQuery.data ?? []
const filteredAccounts = useMemo(() => {
const keyword = accountKeyword.trim().toLowerCase()
if (!keyword) return accountRows
return accountRows.filter(row =>
[row.title, row.listing_no, row.game_name, row.server_region, row.rank_level, row.login_platform]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(keyword))
)
}, [accountKeyword, accountRows])
const pagedAccounts = useMemo(
() =>
filteredAccounts.slice(
(accountPage - 1) * accountPageSize,
accountPage * accountPageSize
),
[accountPage, accountPageSize, filteredAccounts]
)
const refresh = () => {
void receivedQuery.refetch()
void accountsQuery.refetch()
void selection.refetch()
}
const changeReceivedStatus = (row: AdminReceivedListing, status: 'published' | 'offline') => {
const label = status === 'published' ? '上架' : '下架'
modal.confirm({
title: `确认${label}商品「${row.title}」?`,
content:
status === 'offline'
? '下架后该商品不再在分站展示,已进行中的订单不受影响。'
: '上架后该商品会重新进入分站展示;审核未通过的商品可能被后端拒绝。',
okText: `确认${label}`,
cancelText: '取消',
onOk: async () => {
try {
await updateAdminReceivedListingStatus(row.listing_id, status)
message.success(`商品已${label}`)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-received-listings', siteId] }),
queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] }),
])
} catch (error) {
message.error(errorText(error))
}
},
})
}
const toggleDisplay = (row: AdminAccountDisplay) => {
const next = row.display_status === 'enabled' ? 'disabled' : 'enabled'
const label = next === 'enabled' ? '展示' : '隐藏'
modal.confirm({
title: `确认${label}商品「${row.title || row.listing_no}」?`,
content:
next === 'enabled'
? '展示后该账号会出现在分站商品列表。'
: '隐藏后该账号不再在分站展示,不影响已进行中的订单。',
okText: `确认${label}`,
cancelText: '取消',
onOk: async () => {
try {
await updateAdminAccountDisplay(row.id, next)
message.success(`商品已${label}`)
await queryClient.invalidateQueries({
queryKey: ['admin-account-displays', siteId],
})
} catch (error) {
message.error(errorText(error))
}
},
})
}
const openMarkup = (row: AdminAccountDisplay) => {
setMarkupTarget(row)
markupForm.setFieldsValue({ shelf_ratio_delta: row.shelf_ratio_delta })
}
const saveMarkup = async () => {
if (!markupTarget) return
const values = await markupForm.validateFields()
setWorking(true)
try {
await updateAdminAccountMarkup(markupTarget.id, values.shelf_ratio_delta)
message.success('账号加价已更新')
setMarkupTarget(undefined)
await queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] })
} catch (error) {
message.error(errorText(error))
} finally {
setWorking(false)
}
}
const batchChangeDisplay = (target: 'enabled' | 'disabled') => {
if (!siteId || selectedAccountIds.length === 0) return
const label = target === 'enabled' ? '展示' : '隐藏'
modal.confirm({
title: `确认批量${label} ${selectedAccountIds.length} 个账号?`,
content: '批量操作只影响当前选中的账号展示状态。',
okText: `确认${label}`,
cancelText: '取消',
onOk: async () => {
try {
await batchUpdateAdminAccountDisplays(siteId, selectedAccountIds, target)
message.success(`已批量${label}`)
setSelectedAccountIds([])
await queryClient.invalidateQueries({
queryKey: ['admin-account-displays', siteId],
})
} catch (error) {
message.error(errorText(error))
}
},
})
}
const saveBatchMarkup = async () => {
if (!siteId || selectedAccountIds.length === 0) return
const values = await batchMarkupForm.validateFields()
setWorking(true)
try {
await batchUpdateAdminAccountMarkup(
siteId,
selectedAccountIds,
values.shelf_ratio_delta
)
message.success('批量加价已更新')
setBatchMarkupOpen(false)
setSelectedAccountIds([])
await queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] })
} catch (error) {
message.error(errorText(error))
} finally {
setWorking(false)
}
}
const receivedColumns: TableColumnsType<AdminReceivedListing> = [
{
title: '商品',
key: 'listing',
width: 280,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.title)}</strong>
<span className="admin-mono">{formatText(row.listing_no)}</span>
<span>
{formatText(row.game_name)} · {formatText(row.server_region)} ·{' '}
{formatText(row.rank_level)}
</span>
</div>
),
},
{
title: '号主',
key: 'owner',
width: 160,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>{formatText(row.owner_nickname)}</span>
<span className="admin-mono">{maskPhone(row.owner_phone)}</span>
<span className="admin-mono">ID #{row.owner_id}</span>
</div>
),
},
{
title: '价格',
key: 'price',
width: 180,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.price_cent)}</strong>
<span>卖家 {formatMoney(row.seller_total_price_cent)}</span>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
<span>哈夫币 {row.haf_coin_amount}M</span>
</div>
),
},
{
title: '比例',
key: 'ratio',
width: 140,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<span>卖家 {row.seller_ratio.toFixed(2)}</span>
<span>买家 {row.buyer_ratio.toFixed(2)}</span>
</div>
),
},
{
title: '状态',
key: 'status',
width: 150,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(listingStatusMap, row.status)} />
<span>审核 {readStatusMeta(reviewStatusMap, row.review_status).label}</span>
{row.review_reason ? <span>{row.review_reason}</span> : null}
</div>
),
},
{
title: '时间',
key: 'time',
width: 180,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>上架 {formatDateTime(row.published_at)}</span>
<span>创建 {formatDateTime(row.created_at)}</span>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right',
render: (_, row) =>
row.status === 'offline' ? (
<Button
type="link"
size="small"
icon={<PlayCircleOutlined />}
disabled={!canManage}
onClick={() => changeReceivedStatus(row, 'published')}
>
上架
</Button>
) : (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
disabled={!canManage}
onClick={() => changeReceivedStatus(row, 'offline')}
>
下架
</Button>
),
},
]
const accountColumns: TableColumnsType<AdminAccountDisplay> = [
{
title: '商品',
key: 'listing',
width: 280,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.title)}</strong>
<span className="admin-mono">{formatText(row.listing_no)}</span>
<span>
{formatText(row.game_name)} · {formatText(row.server_region)} ·{' '}
{formatText(row.rank_level)}
</span>
</div>
),
},
{
title: '展示',
key: 'display',
width: 140,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(displayStatusMap, row.display_status)} />
<span>商品 {readStatusMeta(listingStatusMap, row.listing_status).label}</span>
{row.in_transaction ? <Tag color="orange">交易中</Tag> : null}
</div>
),
},
{
title: '分成比例',
key: 'ratio',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<span>基础 {Number(row.base_ratio ?? 0).toFixed(2)}</span>
<span>站点 {Number(row.site_ratio ?? 0).toFixed(2)}</span>
<span>调整 {Number(row.shelf_ratio_delta ?? 0).toFixed(2)} 点</span>
</div>
),
},
{
title: '价格',
key: 'price',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.site_price_cent)}</strong>
<span>基础 {formatMoney(row.price_cent)}</span>
<span>加价 {formatMoney(row.markup_amount_cent)}</span>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
</div>
),
},
{
title: '来源',
key: 'source',
width: 130,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>{formatText(row.source_channel)}</span>
<span className="admin-mono">展示 ID #{row.id}</span>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 190,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={row.display_status === 'enabled' ? <StopOutlined /> : <PlayCircleOutlined />}
disabled={!canManage}
onClick={() => toggleDisplay(row)}
>
{row.display_status === 'enabled' ? '隐藏' : '展示'}
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openMarkup(row)}
>
加价
</Button>
</Space>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="分销商品"
description="查看分站收到的账号资产,控制账号展示、上下架与站点加价。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">
收货商品 {receivedQuery.data?.total ?? 0}
</Tag>
<Tag color="cyan">账号展示 {accountRows.length}</Tag>
{receivedQuery.isFetching || accountsQuery.isFetching ? (
<Tag color="processing">正在更新</Tag>
) : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={receivedQuery.isFetching || accountsQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销商品查看权限"
description="需要 distribution:account_view 权限才能读取收货商品与账号展示。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
<Tabs
activeKey={tab}
onChange={next => {
setTab(next)
setSelectedAccountIds([])
}}
items={[
{
key: 'received',
label: '收货商品',
children: (
<>
<FilterBar
onSearch={() => {
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={
<Typography.Text type="secondary">
{receivedQuery.isPending
? '加载中'
: `当前 ${receivedRows.length} 件`}
</Typography.Text>
}
>
<FilterField label="关键字" width={320}>
<Input
allowClear
value={receivedDraft.keyword}
placeholder="商品号 / 标题 / 号主手机号 / 昵称"
onChange={event =>
setReceivedDraft(current => ({
...current,
keyword: event.target.value,
}))
}
/>
</FilterField>
<FilterField label="展示状态" width={160}>
<Select
allowClear
value={receivedDraft.status}
placeholder="全部"
options={receivedStatusOptions}
onChange={value =>
setReceivedDraft(current => ({ ...current, status: value }))
}
/>
</FilterField>
<FilterField label="审核状态" width={160}>
<Select
allowClear
value={receivedDraft.review_status}
placeholder="全部"
options={reviewStatusOptions}
onChange={value =>
setReceivedDraft(current => ({
...current,
review_status: value,
}))
}
/>
</FilterField>
</FilterBar>
{receivedQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="收货商品没有加载出来"
description={errorText(receivedQuery.error)}
action={
<Button size="small" onClick={() => void receivedQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminReceivedListing>
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: (
<>
<FilterBar
onSearch={() => {
setAccountStatus(accountStatus || undefined)
setAccountPage(1)
}}
onReset={
accountStatus || accountKeyword
? () => {
setAccountStatus(undefined)
setAccountKeyword('')
setAccountPage(1)
setSelectedAccountIds([])
}
: undefined
}
loading={accountsQuery.isFetching}
extra={
selectedAccountIds.length > 0 ? (
<Space size={6} wrap>
<Button
size="small"
icon={<PlayCircleOutlined />}
disabled={!canManage}
onClick={() => batchChangeDisplay('enabled')}
>
批量展示
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
disabled={!canManage}
onClick={() => batchChangeDisplay('disabled')}
>
批量隐藏
</Button>
<Button
size="small"
icon={<TagsOutlined />}
disabled={!canManage}
onClick={() => {
batchMarkupForm.setFieldsValue({ shelf_ratio_delta: 0 })
setBatchMarkupOpen(true)
}}
>
批量加价
</Button>
</Space>
) : (
<Typography.Text type="secondary">
{accountsQuery.isPending ? '加载中' : `当前 ${filteredAccounts.length} 个账号`}
</Typography.Text>
)
}
>
<FilterField label="关键字" width={320}>
<Input
allowClear
value={accountKeyword}
placeholder="商品号 / 标题 / 区服 / 段位 / 平台"
onChange={event => {
setAccountKeyword(event.target.value)
setAccountPage(1)
}}
/>
</FilterField>
<FilterField label="展示状态" width={160}>
<Select
allowClear
value={accountStatus}
placeholder="全部"
options={displayStatusOptions}
onChange={value => {
setAccountStatus(value)
setAccountPage(1)
setSelectedAccountIds([])
}}
/>
</FilterField>
</FilterBar>
{accountsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="账号展示没有加载出来"
description={errorText(accountsQuery.error)}
action={
<Button size="small" onClick={() => void accountsQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminAccountDisplay>
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 ? '该站点还没有账号展示' : '当前账号没有查看权限'}
/>
</>
),
},
]}
/>
<Modal
open={Boolean(markupTarget)}
title={markupTarget ? `调整加价 · ${markupTarget.title || markupTarget.listing_no}` : '调整加价'}
width={520}
onCancel={() => (working ? undefined : setMarkupTarget(undefined))}
footer={
<Space>
<Button onClick={() => setMarkupTarget(undefined)} disabled={working}>
取消
</Button>
<Button type="primary" loading={working} onClick={() => void saveMarkup()}>
保存
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="加价按站点展示比例调整"
description="提交值为后端 shelf_ratio_delta,正数提高站点展示比例。保存后站点价格由后端重新计算。"
/>
<Form form={markupForm} layout="vertical" disabled={working}>
<Form.Item
name="shelf_ratio_delta"
label="比例调整(点)"
rules={[{ required: true, message: '请输入调整值' }]}
>
<InputNumber min={-50} max={50} step={0.1} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
open={batchMarkupOpen}
title={`批量加价 · 已选 ${selectedAccountIds.length} 个账号`}
width={520}
onCancel={() => (working ? undefined : setBatchMarkupOpen(false))}
footer={
<Space>
<Button onClick={() => setBatchMarkupOpen(false)} disabled={working}>
取消
</Button>
<Button type="primary" loading={working} onClick={() => void saveBatchMarkup()}>
批量保存
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="批量操作会覆盖所选账号的加价值"
description="该值直接写入每个账号的 shelf_ratio_delta,不会在原有基础上累加。"
/>
<Form form={batchMarkupForm} layout="vertical" disabled={working}>
<Form.Item
name="shelf_ratio_delta"
label="比例调整(点)"
rules={[{ required: true, message: '请输入调整值' }]}
>
<InputNumber min={-50} max={50} step={0.1} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -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<string, string> = {
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<string, StatusMeta> = {
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: <ShareAltOutlined />,
fields: [
{ key: 'order_no', label: '订单号', value: <span className="admin-mono">{order.order_no}</span> },
{ 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: <WalletOutlined />,
fields: [
{ key: 'gross', label: '实付金额', value: formatMoney(order.gross_paid_amount_cent) },
{ key: 'refund', label: '退款金额', value: formatMoney(order.refund_amount_cent) },
{ key: 'net', label: '净付款', value: <strong>{formatMoney(order.net_paid_amount_cent)}</strong> },
{ 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: <ShareAltOutlined />,
fields: [
{
key: 'earning',
label: '分站收益',
value: <strong>{formatMoney(order.distributor_earning_cent)}</strong>,
},
{ 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: (
<StatusTag
meta={readStatusMeta(releaseStatusMap, order.distributor_settlement_status)}
/>
),
},
{ key: 'release_at', label: '预计释放', value: formatDateTime(order.release_at) },
{
key: 'order_status',
label: '订单状态',
value: <StatusTag meta={readStatusMeta(orderStatusMap, order.status)} />,
},
{
key: 'refund_status',
label: '退款状态',
value: <StatusTag meta={readStatusMeta(refundStatusMap, order.refund_status)} />,
},
{ key: 'settlement_status', label: '履约结算', value: formatText(order.settlement_status) },
{ key: 'handoff_status', label: '交接状态', value: formatText(order.handoff_status) },
{ key: 'order_id', label: '订单 ID', value: <span className="admin-mono">#{order.order_id}</span> },
{ key: 'listing_no', label: '商品编号', value: <span className="admin-mono">{formatText(order.listing_no)}</span> },
],
},
]
}
export function AdminDistributionOrdersPage() {
const [draftFilters, setDraftFilters] = useState<AdminDistributionOrderQuery>(emptyFilters)
const [filters, setFilters] = useState<AdminDistributionOrderQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailId, setDetailId] = useState<number>()
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<AdminDistributionOrder> = [
{
title: '订单',
key: 'order',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<span className="admin-mono">{row.order_no}</span>
<strong>{formatText(row.title)}</strong>
<span>{formatText(row.listing_no)}</span>
</div>
),
},
{
title: '站点 / 模式',
key: 'site',
width: 190,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<Tag color="geekblue">{siteName(row.site_id)}</Tag>
<span>{modeLabels[row.distribution_mode] ?? formatText(row.distribution_mode)}</span>
<span>{roleLabel(row.distributor_role)}</span>
</div>
),
},
{
title: '净付款 / 退款',
key: 'paid',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.net_paid_amount_cent)}</strong>
<span>实付 {formatMoney(row.gross_paid_amount_cent)}</span>
<span>退款 {formatMoney(row.refund_amount_cent)}</span>
</div>
),
},
{
title: '利润基数 / 服务费',
key: 'split',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.split_base_amount_cent)}</strong>
<span>服务费 {formatMoney(row.service_fee_cent)}</span>
<span>加价 {formatMoney(row.markup_amount_cent)}</span>
</div>
),
},
{
title: '分站收益',
key: 'earning',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong className="admin-amount__accent">{formatMoney(row.distributor_earning_cent)}</strong>
<span>加价 {formatMoney(row.distributor_markup_cent)}</span>
<span>分成 {formatMoney(row.distributor_split_cent)}</span>
</div>
),
},
{
title: '订单状态',
key: 'status',
width: 140,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(orderStatusMap, row.status)} />
<span>退款 {readStatusMeta(refundStatusMap, row.refund_status).label}</span>
</div>
),
},
{
title: '分销入账',
key: 'release',
width: 150,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag
meta={readStatusMeta(releaseStatusMap, row.distributor_settlement_status)}
/>
<span>释放 {formatDateTime(row.release_at)}</span>
</div>
),
},
{
title: '下单时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 90,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => setDetailId(row.order_id)}>
查看
</Button>
),
},
]
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="分销订单"
description="核对订单履约、利润基数与冷却期释放状态;订单结算与分销入账要分开看。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {ordersQuery.data?.total ?? 0} 单</Tag>
{ordersQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={() => void ordersQuery.refetch()}
loading={ordersQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销订单查看权限"
description="需要 distribution:order_view 权限才能读取分销订单。"
/>
) : null}
<FilterBar
onSearch={applyFilters}
onReset={
Object.values(draftFilters).some(Boolean) || Object.values(filters).some(Boolean)
? resetFilters
: undefined
}
loading={ordersQuery.isFetching}
extra={
<Typography.Text type="secondary">
{ordersQuery.isPending ? '加载中' : `当前 ${ordersQuery.data?.items.length ?? 0} 单`}
</Typography.Text>
}
>
<FilterField label="分销站点" width={230}>
<Select
value={draftFilters.site_id ?? 0}
options={siteOptions}
onChange={value =>
setDraftFilters(current => ({ ...current, site_id: value || undefined }))
}
/>
</FilterField>
<FilterField label="分销模式" width={190}>
<Select
allowClear
value={draftFilters.mode}
placeholder="全部模式"
options={modeOptions}
onChange={value => setDraftFilters(current => ({ ...current, mode: value }))}
/>
</FilterField>
<FilterField label="订单状态" width={160}>
<Select
allowClear
value={draftFilters.status}
placeholder="全部状态"
options={orderStatusOptions}
onChange={value => setDraftFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="释放状态" width={160}>
<Select
allowClear
value={draftFilters.financial_status}
placeholder="全部"
options={releaseStatusOptions}
onChange={value =>
setDraftFilters(current => ({ ...current, financial_status: value }))
}
/>
</FilterField>
<FilterField label="关键字" width={280}>
<Input
allowClear
value={draftFilters.keyword}
placeholder="订单号 / 商品号 / 标题 / 租客手机号"
onChange={event =>
setDraftFilters(current => ({ ...current, keyword: event.target.value }))
}
/>
</FilterField>
</FilterBar>
{ordersQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="分销订单没有加载出来"
description={errorText(ordersQuery.error)}
action={
<Button size="small" onClick={() => void ordersQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDistributionOrder>
title="订单快照"
rowKey="order_id"
columns={columns}
dataSource={ordersQuery.data?.items ?? []}
loading={ordersQuery.isFetching}
total={ordersQuery.data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription={canView ? '没有符合条件的分销订单' : '当前账号没有查看权限'}
/>
<DetailDrawer
open={Boolean(detailId)}
onClose={() => setDetailId(undefined)}
title={detailQuery.data?.order_no ?? '分销订单详情'}
subtitle={
detailQuery.data
? `${siteName(detailQuery.data.site_id)} · ${
modeLabels[detailQuery.data.distribution_mode] ??
detailQuery.data.distribution_mode
}`
: undefined
}
tags={
detailQuery.data ? (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(orderStatusMap, detailQuery.data.status)} />
<StatusTag
meta={readStatusMeta(
releaseStatusMap,
detailQuery.data.distributor_settlement_status
)}
/>
<Tag>退款 {readStatusMeta(refundStatusMap, detailQuery.data.refund_status).label}</Tag>
</Space>
) : undefined
}
loading={detailQuery.isPending}
error={detailQuery.isError ? errorText(detailQuery.error) : null}
onRetry={() => void detailQuery.refetch()}
sections={detailQuery.data ? buildSections(detailQuery.data, siteName) : []}
width={820}
/>
</div>
)
}
@@ -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<string, string> = {
leshua: '乐刷',
lakala: '拉卡拉',
shuncheng: '顺诚',
mock: 'Mock',
}
export function AdminDistributionPaymentChannelsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<BindFormValues>()
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<AdminPaymentChannel> = [
{
title: '绑定记录',
dataIndex: 'id',
width: 120,
fixed: 'left',
render: value => <span className="admin-mono">#{value}</span>,
},
{
title: '支付配置',
key: 'config',
width: 320,
render: (_, row) => {
const config = configMap.get(row.config_id)
return (
<div className="admin-stack">
<strong>{config?.name || `配置 #${row.config_id}`}</strong>
<span>
{config ? providerLabels[config.provider] ?? config.provider : '未知服务商'}
{config?.pay_way ? ` · ${config.pay_way}` : ''}
</span>
<span className="admin-mono">配置 ID {row.config_id}</span>
</div>
)
},
},
{
title: '环境 / 状态',
key: 'status',
width: 170,
render: (_, row) => {
const config = configMap.get(row.config_id)
return (
<div className="admin-stack admin-stack--tiny">
<Tag color={row.status === 'active' ? 'success' : 'default'}>{row.status}</Tag>
{config ? (
<span>
{config.environment || '—'} · {config.status}
</span>
) : null}
{config?.is_default ? <Tag color="gold">主站默认配置</Tag> : null}
</div>
)
},
},
{
title: '优先级',
dataIndex: 'priority',
width: 100,
align: 'right',
},
{
title: '操作',
key: 'actions',
width: 100,
fixed: 'right',
render: (_, row) => (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => unbind(row)}
>
解绑
</Button>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="支付通道"
description="为分站绑定主站支付商户配置;优先级越小越先使用。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">已绑定 {channels.length} 条</Tag>
{selection.site ? <Tag>{selection.site.site_name}</Tag> : null}
{channelsQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canManage ? (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!siteId}
onClick={openBind}
>
绑定支付配置
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => {
void channelsQuery.refetch()
void configsQuery.refetch()
void selection.refetch()
}}
loading={channelsQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有支付通道查看权限"
description="需要 distribution:payment_view 权限才能读取站点支付通道。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{canView && !canReadConfigs ? (
<Alert
type="info"
showIcon
className="admin-table-alert"
message="无法读取主站支付配置列表"
description="当前账号没有 payment_config:list 权限,绑定弹窗只能手动填写支付配置 ID。"
/>
) : null}
{configsQuery.isError ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="支付配置选项没有加载出来"
description={errorText(configsQuery.error)}
/>
) : null}
{channelsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="支付通道没有加载出来"
description={errorText(channelsQuery.error)}
action={
<Button size="small" onClick={() => void channelsQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminPaymentChannel>
title="站点支付通道"
rowKey="id"
columns={columns}
dataSource={channels}
loading={channelsQuery.isFetching}
emptyDescription={canView ? '该站点还没有绑定支付通道' : '当前账号没有查看权限'}
extra={
<Typography.Text type="secondary">
绑定顺序按优先级升序,未绑定时回退主站默认支付配置。
</Typography.Text>
}
/>
<Modal
open={bindOpen}
title="绑定支付配置"
width={560}
maskClosable={!binding}
closable={!binding}
onCancel={() => (binding ? undefined : setBindOpen(false))}
footer={
<Space>
<Button onClick={() => setBindOpen(false)} disabled={binding}>
取消
</Button>
<Button
type="primary"
icon={<LinkOutlined />}
loading={binding}
onClick={() => void bind()}
>
确认绑定
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="支付配置属于主站资金账户"
description="绑定后该站点订单会按优先级使用这些支付商户。请确认配置 ID 与服务商环境后再提交。"
/>
<Form<BindFormValues> form={form} layout="vertical" disabled={binding}>
{canReadConfigs ? (
<Form.Item label="从支付配置中选择">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={configs.length === 0 ? '暂无可用支付配置' : '选择支付配置'}
options={configs.map(config => ({
value: config.id,
label: `#${config.id} · ${config.name} · ${
providerLabels[config.provider] ?? config.provider
} · ${config.status}`,
}))}
onChange={value => form.setFieldValue('config_id', value)}
/>
</Form.Item>
) : null}
<Form.Item
name="config_id"
label="支付配置 ID"
rules={[{ required: true, message: '请输入或选择支付配置 ID' }]}
>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="priority" label="优先级" extra="数值越小越优先,默认 0">
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -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<string, StatusMeta> = {
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<EditFormValues>()
const [draftFilters, setDraftFilters] = useState<AdminDistributionQrCodeQuery>({})
const [filters, setFilters] = useState<AdminDistributionQrCodeQuery>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadNote, setUploadNote] = useState('')
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
const [uploading, setUploading] = useState(false)
const [editing, setEditing] = useState<AdminDistributionQrCode | null>(null)
const [saving, setSaving] = useState(false)
const pendingRef = useRef<PendingImage[]>([])
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<TableColumnsType<AdminDistributionQrCode>>(
() => [
{
title: '二维码',
dataIndex: 'image_url',
width: 90,
render: value => (
<Image
src={value}
width={56}
height={56}
className="admin-qr-thumb"
preview={{ mask: '查看' }}
/>
),
},
{
title: '分组 / 备注',
key: 'group',
width: 220,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.group_name)}</strong>
<span>{formatText(row.note)}</span>
<span className="admin-mono">ID #{row.id}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: value => <StatusTag meta={readStatusMeta(qrStatusMap, value)} />,
},
{
title: '绑定群',
key: 'bound',
width: 200,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>
{row.conversation_id
? row.bound_conversation_title || `会话 #${row.conversation_id}`
: '未绑定'}
</span>
<span>企微改名:{row.wecom_renamed ? '是' : '否'}</span>
</div>
),
},
{
title: '有效期',
key: 'expire',
width: 190,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>过期 {formatDateTime(row.expires_at)}</span>
<span>发放 {formatDateTime(row.used_at)}</span>
</div>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
{row.status === 'unused' ? (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
disabled={!canManage}
onClick={() => changeStatus(row, 'disabled')}
>
停用
</Button>
) : null}
{row.status === 'disabled' ? (
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
disabled={!canManage}
onClick={() => changeStatus(row, 'unused')}
>
启用
</Button>
) : null}
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => removeOne(row)}
>
删除
</Button>
</Space>
),
},
],
// 操作回调依赖当前站点与权限,站点切换时需要重建列。
[canManage, siteId]
)
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="二维码池"
description="每个分站只从自己的码池发码;池空等待补码,不会混发主站或其他分站二维码。"
meta={
<Space size={8} wrap>
<Tag color="green">待用 {stats?.unused_count ?? 0}</Tag>
<Tag color="blue">已发放 {stats?.used_count ?? 0}</Tag>
<Tag>已停用 {stats?.disabled_count ?? 0}</Tag>
<Tag color="geekblue">总计 {stats?.total_count ?? 0}</Tag>
</Space>
}
extra={
<Space>
{canManage ? (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!siteId}
onClick={() => setUploadOpen(true)}
>
批量入池
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={listQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有二维码池查看权限"
description="需要 qrcode:view 权限才能读取站点二维码池。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{listQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="二维码池没有加载出来"
description={errorText(listQuery.error)}
action={
<Button size="small" onClick={refresh}>
重试
</Button>
}
/>
) : null}
<FilterBar
onSearch={applyFilters}
onReset={
Object.values(draftFilters).some(Boolean) || Object.values(filters).some(Boolean)
? resetFilters
: undefined
}
loading={listQuery.isFetching}
extra={
selectedIds.length > 0 ? (
<Button danger icon={<DeleteOutlined />} onClick={removeSelected}>
删除选中 {selectedIds.length}
</Button>
) : (
<Typography.Text type="secondary">共 {listQuery.data?.total ?? 0} 张</Typography.Text>
)
}
>
<FilterField label="关键字" width={300}>
<Input
allowClear
value={draftFilters.keyword}
placeholder="群名 / 备注 / 绑定群 / ID"
onChange={event =>
setDraftFilters(current => ({ ...current, keyword: event.target.value }))
}
/>
</FilterField>
<FilterField label="状态" width={150}>
<Select
allowClear
value={draftFilters.status}
placeholder="全部状态"
options={statusOptions}
onChange={value => setDraftFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="绑定群" width={140}>
<Select
allowClear
value={draftFilters.bound}
placeholder="全部"
options={boundOptions}
onChange={value => setDraftFilters(current => ({ ...current, bound: value }))}
/>
</FilterField>
<FilterField label="过期状态" width={150}>
<Select
allowClear
value={draftFilters.expire_status}
placeholder="全部"
options={expireOptions}
onChange={value =>
setDraftFilters(current => ({ ...current, expire_status: value }))
}
/>
</FilterField>
</FilterBar>
<DataTable<AdminDistributionQrCode>
title="站点二维码"
rowKey="id"
columns={columns}
dataSource={rows}
loading={listQuery.isFetching}
total={listQuery.data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
setSelectedIds([])
}}
rowSelection={{
selectedRowKeys: selectedIds,
preserveSelectedRowKeys: true,
onChange: keys => setSelectedIds(keys.map(Number)),
}}
emptyDescription={canView ? '该站点码池还没有二维码' : '当前账号没有查看权限'}
/>
<Modal
open={uploadOpen}
title="批量放入二维码池"
width={860}
maskClosable={!uploading}
closable={!uploading}
onCancel={closeUpload}
footer={
<Space>
<Button onClick={closeUpload} disabled={uploading}>
取消
</Button>
<Button
type="primary"
loading={uploading}
disabled={pendingImages.length === 0}
onClick={() => void submitUpload()}
>
上传并创建 {pendingImages.length > 0 ? `(${pendingImages.length})` : ''}
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="单次最多 20 张"
description="二维码图片会按原图上传,不做压缩,避免识别失败;默认 7 天过期。"
/>
<Upload.Dragger
multiple
accept="image/*"
showUploadList={false}
disabled={uploading || pendingImages.length >= 20}
beforeUpload={file => {
addFile(file)
return Upload.LIST_IGNORE
}}
className="admin-qr-uploader"
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽二维码图片到这里</p>
<p className="ant-upload-hint">支持批量选择,图片不压缩;可手动填写群名后再提交。</p>
</Upload.Dragger>
<Input
className="admin-qr-note"
value={uploadNote}
maxLength={255}
placeholder="本批次备注(可选),会写入每张二维码"
onChange={event => setUploadNote(event.target.value)}
/>
<div className="admin-qr-pending">
{pendingImages.map(item => (
<div key={item.id} className="admin-qr-pending__item">
<img src={item.preview} alt="待上传二维码" />
<Input
value={item.groupName}
maxLength={128}
placeholder="群名称(可选)"
onChange={event =>
setPendingImages(current =>
current.map(row =>
row.id === item.id ? { ...row, groupName: event.target.value } : row
)
)
}
/>
<Button
type="text"
danger
icon={<DeleteOutlined />}
disabled={uploading}
onClick={() => removePending(item.id)}
/>
</div>
))}
</div>
</Modal>
<Modal
open={Boolean(editing)}
title={editing ? `编辑二维码 #${editing.id}` : '编辑二维码'}
width={640}
onCancel={() => (saving ? undefined : setEditing(null))}
maskClosable={!saving}
closable={!saving}
footer={
<Space>
<Button onClick={() => setEditing(null)} disabled={saving}>
取消
</Button>
<Button type="primary" loading={saving} onClick={() => void saveEdit()}>
保存
</Button>
</Space>
}
>
<Form<EditFormValues> form={editForm} layout="vertical" disabled={saving}>
<Form.Item name="group_name" label="群名称">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="status" label="状态">
<Select options={statusOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="wecom_renamed" label="企微已改名" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Col>
</Row>
<Form.Item name="expires_at" label="过期时间">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="clear_expires_at" label="清除过期时间" valuePropName="checked">
<Switch checkedChildren="清除" unCheckedChildren="保留" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -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<string, string> = {
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<string, StatusMeta> = {
draft: { label: '草稿', tone: 'gold' },
active: { label: '生效中', tone: 'success' },
disabled: { label: '已停用', tone: 'default' },
}
const serviceFeeUnitLabels: Record<string, string> = {
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<RuleFormValues>()
const [draftSiteId, setDraftSiteId] = useState<number | 'all'>('all')
const [draftMode, setDraftMode] = useState<string>()
const [siteFilter, setSiteFilter] = useState<number | 'all'>('all')
const [modeFilter, setModeFilter] = useState<string>()
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<AdminDistributionRule> = [
{
title: '版本',
key: 'version',
width: 150,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>v{row.version}</strong>
<span>{siteName(row.site_id)}</span>
<span className="admin-mono">ID #{row.id}</span>
</div>
),
},
{
title: '模式',
dataIndex: 'mode',
width: 180,
render: value => <Tag color="geekblue">{modeLabels[value] ?? formatText(value)}</Tag>,
},
{
title: '分账比例',
key: 'ratios',
width: 260,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>{ratioSummary(row)}</span>
<span>服务费 {serviceFeeText(row)}</span>
<span>支付费率 {formatBps(row.payment_fee_bps)}</span>
</div>
),
},
{
title: '税费',
key: 'tax',
width: 150,
render: (_, row) =>
row.tax_enabled ? (
<Tag color="gold">税率 {formatBps(row.tax_rate_bps)}</Tag>
) : (
<Tag>不启用</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(ruleStatusMap, 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) => (
<Space size={4} wrap>
{row.status !== 'active' ? (
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
disabled={!canManage || statusSaving}
onClick={() => changeStatus(row, 'active')}
>
启用
</Button>
) : null}
{row.status === 'active' ? (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
disabled={!canManage || statusSaving}
onClick={() => changeStatus(row, 'disabled')}
>
停用
</Button>
) : null}
{row.status === 'disabled' ? (
<Button
type="link"
size="small"
disabled={!canManage || statusSaving}
onClick={() => changeStatus(row, 'draft')}
>
转草稿
</Button>
) : null}
</Space>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="规则版本"
description="维护全局与站点级分账规则;新规则先落草稿,确认比例合计后再启用。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {summary.total} 个版本</Tag>
<Tag color="green">生效中 {summary.active}</Tag>
<Tag>{summary.sites} 个作用域</Tag>
{rulesQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canManage ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建规则
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => void rulesQuery.refetch()}
loading={rulesQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销规则查看权限"
description="需要 distribution:rule_view 权限才能读取规则版本。"
/>
) : null}
<FilterBar
onSearch={() => {
setSiteFilter(draftSiteId)
setModeFilter(draftMode)
}}
onReset={
draftSiteId !== 'all' || draftMode || siteFilter !== 'all' || modeFilter
? () => {
setDraftSiteId('all')
setDraftMode(undefined)
setSiteFilter('all')
setModeFilter(undefined)
}
: undefined
}
loading={rulesQuery.isFetching}
extra={
<Typography.Text type="secondary">
{rulesQuery.isPending ? '加载中' : `当前 ${rows.length} 个版本`}
</Typography.Text>
}
>
<FilterField label="作用域" width={260}>
<Select
value={draftSiteId}
options={[
{ value: 'all', label: '全部作用域' },
...selection.sites.map(site => ({
value: site.id,
label: `${site.site_name} · ${site.site_code}`,
})),
]}
onChange={value => setDraftSiteId(value)}
/>
</FilterField>
<FilterField label="分账模式" width={220}>
<Select
allowClear
value={draftMode}
placeholder="全部模式"
options={modeOptions}
onChange={value => setDraftMode(value)}
/>
</FilterField>
</FilterBar>
{rulesQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="规则版本没有加载出来"
description={errorText(rulesQuery.error)}
action={
<Button size="small" onClick={() => void rulesQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDistributionRule>
title="规则版本列表"
rowKey="id"
columns={columns}
dataSource={rows}
loading={rulesQuery.isFetching}
emptyDescription={canView ? '还没有规则版本' : '当前账号没有查看权限'}
/>
<Modal
open={editorOpen}
title="新建分销规则"
width={820}
maskClosable={!creating}
closable={!creating}
onCancel={() => (creating ? undefined : setEditorOpen(false))}
footer={
<Space>
<Button onClick={() => setEditorOpen(false)} disabled={creating}>
取消
</Button>
<Button type="primary" loading={creating} onClick={() => void createRule()}>
创建草稿
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="比例按百分数填写"
description="比例服务费模式下,各参与方比例与服务费合计必须为 100(允许 0.15 的误差)。"
/>
<Form<RuleFormValues>
form={form}
layout="vertical"
disabled={creating}
initialValues={{
scope: 'global',
mode: 'two_party_distributor_receive',
service_fee_unit: 'percent',
payment_fee_bps: 50,
tax_enabled: false,
tax_rate_bps: 100,
...defaultRatios('two_party_distributor_receive'),
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="scope" label="规则作用域">
<Select
options={[
{ value: 'global', label: '全局默认' },
{ value: 'site', label: '指定站点' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="site_id"
label="站点"
rules={[
{
validator: async (_, value) => {
if (form.getFieldValue('scope') === 'site' && !value) {
throw new Error('请选择站点')
}
},
},
]}
>
<Select
disabled={scope !== 'site'}
placeholder={scope === 'site' ? '请选择站点' : '全局规则无需选择站点'}
options={selection.sites.map(site => ({
value: site.id,
label: `${site.site_name} · ${site.site_code}`,
}))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="mode" label="分账模式" rules={[{ required: true }]}>
<Select options={modeOptions} onChange={value => applyModeDefaults(value)} />
</Form.Item>
<Row gutter={16}>
{mode === 'two_party_platform_receive' ? (
<Col span={8}>
<Form.Item name="platform_receive_ratio" label="平台收号比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
) : null}
{mode === 'two_party_distributor_receive' ? (
<Col span={8}>
<Form.Item name="distributor_receive_ratio" label="分站收号比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
) : null}
{mode === 'two_party_distributor_receive' ? (
<Col span={8}>
<Form.Item name="platform_sell_ratio" label="平台卖出比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
) : null}
{mode === 'two_party_platform_receive' ? (
<Col span={8}>
<Form.Item name="distributor_sell_ratio" label="分站卖出比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
) : null}
{mode === 'three_party' ? (
<>
<Col span={8}>
<Form.Item name="distributor_a_ratio" label="分销 A 比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="distributor_b_ratio" label="分销 B 比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
</>
) : null}
{mode === 'site_self_sell' ? (
<Col span={8}>
<Form.Item name="distributor_sell_ratio" label="分站卖出比例 %">
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
) : null}
<Col span={8}>
<Form.Item name="service_fee_unit" label="服务费单位">
<Select
options={Object.entries(serviceFeeUnitLabels).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="service_fee_value"
label={serviceFeeUnit === 'percent' ? '服务费比例 %' : '固定服务费(分)'}
>
<InputNumber
min={0}
max={serviceFeeUnit === 'percent' ? 100 : undefined}
precision={serviceFeeUnit === 'percent' ? 2 : 0}
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="payment_fee_bps" label="支付费率(bps)" extra="50 bps = 0.5%">
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="tax_enabled" label="启用税费" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="tax_rate_bps" label="税率(bps)" extra="100 bps = 1%">
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="effective_from" label="生效时间">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Typography.Paragraph type="secondary" className="admin-form-hint">
<SafetyCertificateOutlined /> 新建规则默认是草稿;只有启用后的规则才会参与新订单分账。
</Typography.Paragraph>
</Form>
</Modal>
</div>
)
}
@@ -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<string, string>
}
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<Record<string, string>>((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<SMSFormValues>()
const [testForm] = Form.useForm<{ phone: string }>()
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [updatedAt, setUpdatedAt] = useState<string>()
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="站点短信"
description="为分站配置独立的短信服务商、签名与模板;未配置时回退主站短信通道。"
meta={
<Space size={8} wrap>
{config ? (
<Tag color={config.enabled ? 'green' : 'default'}>
{config.enabled ? '已启用' : '未启用'}
</Tag>
) : null}
{config?.has_secret ? <Tag color="gold">密钥已配置</Tag> : <Tag>密钥未配置</Tag>}
{updatedAt ? <Tag color="blue">保存于 {formatDateTime(updatedAt)}</Tag> : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={configQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销站点查看权限"
description="需要 distribution:site_view 权限才能读取站点短信配置。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{configQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="站点短信配置没有加载出来"
description={errorText(configQuery.error)}
action={
<Button size="small" onClick={refresh}>
重试
</Button>
}
/>
) : null}
<Row gutter={[16, 16]}>
<Col xs={24} xl={15}>
<Card
className="admin-panel-card"
loading={configQuery.isPending}
title={
<Space>
<MessageOutlined />
<span>短信通道</span>
</Space>
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="密钥只写不读"
description="已保存的 AccessKeySecret 只显示掩码,留空保存表示不修改。"
/>
<Form<SMSFormValues>
form={form}
layout="vertical"
disabled={!canManage || saving || !config}
initialValues={{
enabled: false,
provider: 'aliyun',
business_templates: emptyBusinessTemplates,
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="enabled" label="启用站点短信" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="provider" label="服务商">
<Select options={[{ value: 'aliyun', label: '阿里云短信' }]} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="access_key_id" label="AccessKey ID">
<Input maxLength={128} autoComplete="off" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="access_key_secret"
label="AccessKey Secret"
extra={
config?.masked_secret
? `当前密钥:${config.masked_secret},留空表示不修改`
: '尚未配置密钥,开启短信前必须填写'
}
>
<Input.Password
maxLength={255}
autoComplete="new-password"
placeholder={config?.has_secret ? '留空不修改' : '请输入 AccessKeySecret'}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="endpoint" label="接入地址">
<Input maxLength={255} placeholder="dysmsapi.aliyuncs.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sign_name" label="短信签名">
<Input maxLength={64} placeholder="例如:解忧商行" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="login_template_code"
label="登录验证码模板"
extra="开启站点短信时,登录验证码模板为必填项。"
>
<Input maxLength={64} placeholder="SMS_xxxxxxxx" />
</Form.Item>
<Divider orientation="left">业务节点模板</Divider>
<Typography.Paragraph type="secondary" className="admin-form-hint">
留空表示该节点回退主站模板;保存时会整体覆盖本站节点映射。
</Typography.Paragraph>
{businessNodes.map(node => (
<Form.Item
key={node.code}
name={['business_templates', node.code]}
label={
<Space size={6} wrap>
<span>{node.name}</span>
<Tag>{node.code}</Tag>
<Typography.Text type="secondary">参考 {node.template}</Typography.Text>
</Space>
}
extra={node.description}
>
<Input maxLength={64} placeholder="SMS_xxxxxxxx,留空回退主站" />
</Form.Item>
))}
<Space>
<Button
type="primary"
loading={saving}
onClick={() => void save()}
disabled={!canManage || !config}
>
保存短信配置
</Button>
{!canManage ? (
<Typography.Text type="secondary">
需要 distribution:site_manage 权限
</Typography.Text>
) : null}
</Space>
</Form>
</Card>
</Col>
<Col xs={24} xl={9}>
<Card
className="admin-panel-card"
title={
<Space>
<SendOutlined />
<span>发送测试短信</span>
</Space>
}
>
<Typography.Paragraph type="secondary">
测试将使用当前表单中的配置(含尚未保存的修改),不会写入站点配置。
</Typography.Paragraph>
<Form form={testForm} layout="vertical" onFinish={() => void sendTest()}>
<Form.Item
name="phone"
label="接收手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '手机号格式不正确' },
]}
>
<Input maxLength={11} inputMode="numeric" placeholder="11 位手机号" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<SendOutlined />}
loading={testing}
disabled={!canManage || !config}
block
>
发送测试
</Button>
</Form>
<Divider />
<div className="admin-kv-list">
<div>
<span>服务商</span>
<strong>{config?.provider || 'aliyun'}</strong>
</div>
<div>
<span>签名</span>
<strong>{config?.sign_name || '未配置'}</strong>
</div>
<div>
<span>登录模板</span>
<strong>{config?.login_template_code || '未配置'}</strong>
</div>
</div>
</Card>
</Col>
</Row>
</div>
)
}
@@ -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<string, StatusMeta> = {
draft: { label: '待入账', tone: 'gold' },
pending: { label: '待入账', tone: 'gold' },
settled: { label: '已释放入账', tone: 'success' },
cancelled: { label: '已作废', tone: 'default' },
}
const partyRoleLabels: Record<string, string> = {
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<BatchFormValues>()
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [workingId, setWorkingId] = useState<number>()
const [itemsBatch, setItemsBatch] = useState<AdminDistributionSettlementBatch>()
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: (
<div className="admin-stack">
<span>入账金额:{formatMoney(batch.total_amount_cent)}</span>
<span>释放后会立即转入站点可提现余额,人工批次不走冷却期。</span>
</div>
),
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<AdminDistributionSettlementBatch> = [
{
title: '批次号',
key: 'batch',
width: 260,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<span className="admin-mono">{row.batch_no}</span>
<Tag color={batchKind(row.batch_no).color}>{batchKind(row.batch_no).label}</Tag>
</div>
),
},
{
title: '对账周期',
key: 'period',
width: 250,
render: (_, row) => (
<span>
{formatDateTime(row.period_start)} ~ {formatDateTime(row.period_end)}
</span>
),
},
{
title: '入账金额',
dataIndex: 'total_amount_cent',
width: 150,
align: 'right',
render: value => <strong className="admin-amount__accent">{formatMoney(value)}</strong>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: value => <StatusTag meta={readStatusMeta(batchStatusMap, value)} />,
},
{
title: '操作',
key: 'actions',
width: 260,
fixed: 'right',
render: (_, row) => {
const closed = row.status === 'settled' || row.status === 'cancelled'
return (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={<UnorderedListOutlined />}
onClick={() => setItemsBatch(row)}
>
明细
</Button>
{!closed ? (
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
disabled={!canManage || workingId === row.id || row.total_amount_cent <= 0}
onClick={() => settleBatch(row)}
>
释放入账
</Button>
) : null}
{!closed ? (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
disabled={!canManage || workingId === row.id}
onClick={() => cancelBatch(row)}
>
作废
</Button>
) : null}
</Space>
)
},
},
]
const itemColumns: TableColumnsType<AdminDistributionSettlementItem> = [
{
title: '订单 ID',
dataIndex: 'order_id',
width: 120,
render: value => <span className="admin-mono">#{value}</span>,
},
{
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' ? (
<Tag color="success">已入账</Tag>
) : (
<Tag color="gold">待入账</Tag>
),
},
]
const refresh = () => {
void batchesQuery.refetch()
void selection.refetch()
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="结算批次"
description="冷却期满后系统按站点按天生成自动对账单;人工批次用于立即结算,不走冷却期。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {summary.total} 个批次</Tag>
<Tag color="gold">待入账 {summary.pendingCount}</Tag>
<Tag color="cyan">待入账金额 {formatMoney(summary.pendingAmount)}</Tag>
<Tag color="green">已入账 {formatMoney(summary.settledAmount)}</Tag>
</Space>
}
extra={
<Space>
{canManage ? (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!siteId}
onClick={openCreate}
>
生成人工批次
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={batchesQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销结算查看权限"
description="需要 distribution:settlement_view 权限才能读取结算批次。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{canView && siteId ? (
<Alert
type="info"
showIcon
className="admin-table-alert"
message={`当前站点结算冷却期 ${selection.site?.settlement_hold_days ?? 0} 天`}
description="自动对账单以 DA 开头;人工立即结算批次以 DS 开头。释放入账前请先核对批次明细。"
/>
) : null}
{batchesQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="结算批次没有加载出来"
description={errorText(batchesQuery.error)}
action={
<Button size="small" onClick={refresh}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDistributionSettlementBatch>
title="站点结算批次"
rowKey="id"
columns={columns}
dataSource={batches}
loading={batchesQuery.isFetching}
emptyDescription={canView ? '该站点还没有结算批次' : '当前账号没有查看权限'}
extra={
<Typography.Text type="secondary">
{selection.site ? `${selection.site.site_name} · ${selection.site.site_code}` : ''}
</Typography.Text>
}
/>
<Modal
open={createOpen}
title="生成人工结算批次"
width={560}
maskClosable={!creating}
closable={!creating}
onCancel={() => (creating ? undefined : setCreateOpen(false))}
footer={
<Space>
<Button onClick={() => setCreateOpen(false)} disabled={creating}>
取消
</Button>
<Button type="primary" loading={creating} onClick={() => void createBatch()}>
生成批次
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="人工批次会绕过冷却期"
description={`将站点「${selection.site?.site_name ?? ''}」周期内尚未入账的分站份额立即纳入批次。生成后仍需再次确认才会释放入账。`}
/>
<Form<BatchFormValues> form={form} layout="vertical" disabled={creating}>
<Form.Item
name="period"
label="结算周期"
rules={[{ required: true, message: '请选择结算周期' }]}
>
<DatePicker.RangePicker
showTime
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
/>
</Form.Item>
<Typography.Paragraph type="secondary" className="admin-form-hint">
周期采用左闭右开:开始时间 ≤ 财务快照创建时间 &lt; 结束时间。
</Typography.Paragraph>
</Form>
</Modal>
<Modal
open={Boolean(itemsBatch)}
title={itemsBatch ? `批次明细 · ${itemsBatch.batch_no}` : '批次明细'}
width={760}
onCancel={() => setItemsBatch(undefined)}
footer={
<Button onClick={() => setItemsBatch(undefined)}>关闭</Button>
}
>
<Space size={8} wrap className="admin-settlement-detail-head">
<Tag color={itemsBatch ? batchKind(itemsBatch.batch_no).color : 'default'}>
{itemsBatch ? batchKind(itemsBatch.batch_no).label : '批次'}
</Tag>
{itemsBatch ? (
<StatusTag meta={readStatusMeta(batchStatusMap, itemsBatch.status)} />
) : null}
<Typography.Text type="secondary">
合计 {formatMoney(itemsBatch?.total_amount_cent ?? 0)}
</Typography.Text>
</Space>
<Table<AdminDistributionSettlementItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={itemsQuery.data ?? []}
loading={itemsQuery.isFetching}
pagination={false}
scroll={{ y: 360 }}
locale={{ emptyText: '该批次没有明细' }}
/>
</Modal>
</div>
)
}
@@ -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<string, string> = {
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: <UserOutlined />,
fields: [
{ key: 'id', label: '用户 ID', value: <span className="admin-mono">#{user.id}</span> },
{ key: 'phone', label: '手机号', value: <span className="admin-mono">{formatText(user.phone)}</span> },
{ key: 'nickname', label: '昵称', value: formatText(user.nickname) },
{
key: 'source',
label: '客户来源',
value: <Tag color="geekblue">{sourceLabels[user.source] ?? formatText(user.source)}</Tag>,
},
{
key: 'realname',
label: '实名状态',
value: <StatusTag meta={readStatusMeta(realnameStatusMap, user.realname_status)} />,
},
{
key: 'status',
label: '账号状态',
value: <StatusTag meta={readStatusMeta(userStatusMap, user.status)} />,
},
{ key: 'created', label: '注册时间', value: formatDateTime(user.created_at) },
{ key: 'login', label: '最近登录', value: formatDateTime(user.last_login_at) },
],
},
{
key: 'orders',
title: '交易贡献',
icon: <TeamOutlined />,
fields: [
{ key: 'order_count', label: '有效订单', value: `${user.order_count} 单` },
{
key: 'spent',
label: '累计消费',
value: <strong>{formatMoney(user.total_spent_cent)}</strong>,
},
{ key: 'last_order', label: '最近下单', value: formatDateTime(user.last_order_at) },
],
},
]
}
export function AdminDistributionUsersPage() {
const [draftFilters, setDraftFilters] = useState<AdminDistributionUserQuery>({})
const [filters, setFilters] = useState<AdminDistributionUserQuery>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selected, setSelected] = useState<AdminDistributionUser>()
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<AdminDistributionUser> = [
{
title: '客户',
key: 'customer',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-user-cell">
<Avatar
size={38}
src={row.avatar_url || undefined}
icon={<UserOutlined />}
className="admin-user-cell__avatar"
/>
<div className="admin-stack admin-stack--tiny">
<strong>{formatText(row.nickname)}</strong>
<span className="admin-mono">{maskPhone(row.phone)}</span>
<span className="admin-mono">ID #{row.id}</span>
</div>
</div>
),
},
{
title: '来源',
dataIndex: 'source',
width: 130,
render: value => (
<Tag color="geekblue">{sourceLabels[value] ?? formatText(value)}</Tag>
),
},
{
title: '实名 / 状态',
key: 'status',
width: 150,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(realnameStatusMap, row.realname_status)} />
<StatusTag meta={readStatusMeta(userStatusMap, row.status)} />
</div>
),
},
{
title: '有效订单',
dataIndex: 'order_count',
width: 110,
align: 'right',
render: value => `${value} 单`,
},
{
title: '累计消费',
dataIndex: 'total_spent_cent',
width: 140,
align: 'right',
render: value => (
<strong className="admin-amount__accent">{formatMoney(value)}</strong>
),
},
{
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) => (
<Button type="link" size="small" onClick={() => setSelected(row)}>
查看
</Button>
),
},
]
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="分销用户"
description="按站点查看注册客户、下单客户、实名状态与消费贡献。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">本站注册 {summary.totalUsers}</Tag>
<Tag color="green">成交客户 {summary.orderUsers}</Tag>
<Tag>筛选结果 {summary.filtered}</Tag>
{usersQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={() => {
void usersQuery.refetch()
void selection.refetch()
}}
loading={usersQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销站点查看权限"
description="需要 distribution:site_view 权限才能读取分销用户。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
<FilterBar
onSearch={applyFilters}
onReset={
Object.values(draftFilters).some(Boolean) || Object.values(filters).some(Boolean)
? resetFilters
: undefined
}
loading={usersQuery.isFetching}
extra={
<Typography.Text type="secondary">
{usersQuery.isPending ? '加载中' : `当前 ${rows.length} 位客户`}
</Typography.Text>
}
>
<FilterField label="关键字" width={320}>
<Input
allowClear
value={draftFilters.keyword}
placeholder="手机号 / 昵称"
onChange={event =>
setDraftFilters(current => ({ ...current, keyword: event.target.value }))
}
/>
</FilterField>
<FilterField label="客户来源" width={180}>
<Select
value={draftFilters.source ?? 'all'}
options={sourceOptions}
onChange={value => setDraftFilters(current => ({ ...current, source: value }))}
/>
</FilterField>
</FilterBar>
{usersQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="分销用户没有加载出来"
description={errorText(usersQuery.error)}
action={
<Button size="small" onClick={() => void usersQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDistributionUser>
title="站点客户"
rowKey="id"
columns={columns}
dataSource={rows}
loading={usersQuery.isFetching}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription={canView ? '该站点还没有客户' : '当前账号没有查看权限'}
/>
<DetailDrawer
open={Boolean(selected)}
onClose={() => setSelected(undefined)}
title={selected ? selected.nickname || maskPhone(selected.phone) : '分销客户'}
subtitle={
selected
? `${selection.site?.site_name ?? ''} · ${sourceLabels[selected.source] ?? ''}`
: undefined
}
tags={
selected ? (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(realnameStatusMap, selected.realname_status)} />
<StatusTag meta={readStatusMeta(userStatusMap, selected.status)} />
</Space>
) : undefined
}
sections={selected ? buildSections(selected) : []}
width={720}
/>
</div>
)
}
@@ -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<AdminDistributionWithdrawal> = [
{
title: '提现单号',
dataIndex: 'withdraw_no',
width: 200,
fixed: 'left',
render: value => <span className="admin-mono">{value}</span>,
},
{
title: '金额',
key: 'amount',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.amount_cent)}</strong>
<span>手续费 {formatMoney(row.fee_cent)}</span>
<span>到账 {formatMoney(row.actual_amount_cent)}</span>
</div>
),
},
{
title: '收款账户',
key: 'account',
width: 230,
render: (_, row) => (
<div className="admin-stack">
<span>
{row.account_type === 'alipay' ? '支付宝' : '银行卡'} · {formatText(row.account_name)}
</span>
<span className="admin-mono">{formatText(row.account_no)}</span>
{row.bank_name ? (
<span>
{row.bank_name}
{row.bank_branch ? ` · ${row.bank_branch}` : ''}
</span>
) : null}
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(withdrawalStatusMap, value)} />,
},
{
title: '备注',
key: 'remark',
width: 200,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>审核:{formatText(row.review_remark)}</span>
<span>打款:{formatText(row.payment_remark)}</span>
</div>
),
},
{
title: '时间',
key: 'time',
width: 180,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<span>申请 {formatDateTime(row.created_at)}</span>
<span>打款 {formatDateTime(row.paid_at)}</span>
</div>
),
},
]
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<WithdrawalFormValues>()
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: (
<div className="admin-stack">
<span>站点:{selection.site?.site_name ?? `#${siteId}`}</span>
<span>提现金额:{formatMoney(amountCent)}</span>
<span>收款人:{payload.account_name}</span>
<span>提交后对应金额会从可用余额转入冻结余额。</span>
</div>
),
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="站点钱包"
description="查看分站可用余额、冻结金额与结算进度,并代站点提交提现申请。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">金额单位:分</Tag>
{wallet ? <Tag color="green">{availableHint}</Tag> : null}
{walletQuery.isFetching ? <Tag color="processing">正在刷新</Tag> : null}
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={walletQuery.isFetching || withdrawalsQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销结算查看权限"
description="需要 distribution:settlement_view 权限才能读取站点钱包与提现记录。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{errorMessage ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="站点钱包没有加载出来"
description={errorMessage}
action={
<Button size="small" onClick={refresh}>
重试
</Button>
}
/>
) : null}
{canView && !siteId && !selection.isLoading ? (
<Alert
type="info"
showIcon
className="admin-table-alert"
message="请选择分销站点"
description="站点钱包、提现记录都按站点隔离。"
/>
) : null}
{wallet ? (
<div className="admin-wallet-grid">
{walletMetrics.map(metric => (
<Card
key={metric.key}
className={`admin-wallet-card${metric.accent ? ' admin-wallet-card--accent' : ''}`}
bordered={false}
>
<span className="admin-wallet-card__label">{metric.label}</span>
<strong className="admin-wallet-card__value">
{formatMoney(wallet[metric.key])}
</strong>
<span className="admin-wallet-card__hint">{metric.hint}</span>
</Card>
))}
</div>
) : null}
<Row gutter={[16, 16]} className="admin-wallet-body">
<Col xs={24} xl={9}>
<Card
className="admin-panel-card"
title={
<Space>
<WalletOutlined />
<span>申请提现</span>
</Space>
}
extra={
wallet ? (
<Typography.Text type="secondary">
可用 {formatMoney(wallet.available_balance_cent)}
</Typography.Text>
) : null
}
>
<Alert
type="info"
showIcon
className="admin-table-alert"
message="提现申请会冻结对应余额"
description="提交后生成待审核提现单;当前后台仅提供申请与记录查询,不做站点提现审核。"
/>
<Form<WithdrawalFormValues>
form={form}
layout="vertical"
disabled={!canManage || submitting || !wallet}
initialValues={{ account_type: 'alipay' }}
>
<Form.Item
name="amount_yuan"
label="提现金额(元)"
rules={[
{ required: true, message: '请输入提现金额' },
{
validator: async (_, value: string) => {
if (!value) return
const cent = parseYuanToCent(value)
if (!Number.isFinite(cent) || cent <= 0) {
throw new Error('金额格式不正确')
}
if (wallet && cent > wallet.available_balance_cent) {
throw new Error('提现金额超过可用余额')
}
},
},
]}
>
<Input
inputMode="decimal"
placeholder="例如 1280.00"
suffix="元"
maxLength={16}
/>
</Form.Item>
<Form.Item name="account_type" label="收款方式">
<Select
options={[
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行卡' },
]}
/>
</Form.Item>
<Form.Item
name="account_name"
label="收款人姓名"
rules={[{ required: true, message: '请输入收款人姓名' }]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
name="account_no"
label={accountType === 'bank' ? '银行卡号' : '支付宝账号'}
rules={[{ required: true, message: '请输入收款账号' }]}
>
<Input maxLength={128} />
</Form.Item>
{accountType === 'bank' ? (
<>
<Form.Item name="bank_name" label="开户银行">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="bank_branch" label="开户支行">
<Input maxLength={255} />
</Form.Item>
</>
) : null}
<Button
type="primary"
block
icon={<BankOutlined />}
loading={submitting}
onClick={() => void submit()}
>
提交提现申请
</Button>
{!canManage ? (
<Typography.Paragraph type="secondary" className="admin-form-hint">
当前账号只有查看权限,需要 distribution:settlement_manage 才能提交申请。
</Typography.Paragraph>
) : null}
</Form>
</Card>
</Col>
<Col xs={24} xl={15}>
<DataTable<AdminDistributionWithdrawal>
title={
<Space>
<ClockCircleOutlined />
<span>提现记录</span>
</Space>
}
extra={
<Typography.Text type="secondary">
{withdrawalsQuery.isPending ? '加载中' : `共 ${withdrawals?.total ?? 0} 笔`}
</Typography.Text>
}
rowKey="id"
columns={withdrawalColumns}
dataSource={withdrawals?.items ?? []}
loading={withdrawalsQuery.isFetching}
total={withdrawals?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription={canView ? '该站点还没有提现记录' : '当前账号没有查看权限'}
/>
</Col>
</Row>
</div>
)
}
@@ -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: <InboxOutlined />,
fields: [
{
key: 'flags',
label: '标记',
value: (
<Space size={4} wrap>
{item.is_pinned ? <Tag color="gold">置顶</Tag> : null}
{item.is_important ? <Tag color="red">重要</Tag> : null}
{item.is_popup ? <Tag color="purple">弹窗</Tag> : null}
{!item.is_pinned && !item.is_important && !item.is_popup ? <Tag>普通公告</Tag> : null}
</Space>
),
},
{
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: <div className="admin-detail__pre">{item.content}</div>,
},
],
},
]
}
export function AdminSiteAnnouncementsPage() {
const { message, modal } = App.useApp()
const [form] = Form.useForm<AdminSiteAnnouncementPayload>()
const [status, setStatus] = useState<string>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminSiteAnnouncement>()
const [submitting, setSubmitting] = useState(false)
const [detail, setDetail] = useState<AdminSiteAnnouncement>()
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<AdminSiteAnnouncement> = [
{
title: '公告',
key: 'title',
width: 300,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<button type="button" className="admin-order-link" onClick={() => setDetail(row)}>
{row.title}
</button>
<span>{row.content.slice(0, 80)}{row.content.length > 80 ? '…' : ''}</span>
<Space size={4} wrap>
<Tag>{announcementCategoryMap[row.category] ?? row.category}</Tag>
{row.is_pinned ? <Tag color="gold">置顶</Tag> : null}
{row.is_important ? <Tag color="red">重要</Tag> : null}
{row.is_popup ? <Tag color="purple">弹窗</Tag> : null}
</Space>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(announcementStatusMap, 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) => (
<Space size={4} wrap>
{row.status === 'draft' || row.status === 'archived' ? (
<Button
type="link"
size="small"
icon={<SendOutlined />}
disabled={!canManage}
onClick={() => changeStatus(row, row.status === 'archived' ? 'unarchive' : 'publish')}
>
{row.status === 'archived' ? '取消归档' : '发布'}
</Button>
) : null}
{row.status === 'published' ? (
<Button
type="link"
size="small"
icon={<StopOutlined />}
disabled={!canManage}
onClick={() => changeStatus(row, 'archive')}
>
归档
</Button>
) : null}
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => remove(row)}
>
删除
</Button>
</Space>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="站点公告"
description="维护分站自己的公告中心;公告只在本站展示,不与主站公告混用。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
{selection.site ? <Tag>{selection.site.site_name}</Tag> : null}
{listQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canManage ? (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!siteId}
onClick={openCreate}
>
新建公告
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => {
void listQuery.refetch()
void selection.refetch()
}}
loading={listQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有站点公告查看权限"
description="需要 distribution:announcement_view 权限才能读取站点公告。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
<FilterBar
onSearch={() => setPage(1)}
onReset={
status
? () => {
setStatus(undefined)
setPage(1)
}
: undefined
}
loading={listQuery.isFetching}
extra={
<Typography.Text type="secondary">
{listQuery.isPending ? '加载中' : `当前 ${rows.length} 条`}
</Typography.Text>
}
>
<FilterField label="公告状态" width={180}>
<Select
allowClear
value={status}
placeholder="全部状态"
options={announcementStatusOptions}
onChange={value => {
setStatus(value)
setPage(1)
}}
/>
</FilterField>
</FilterBar>
{listQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="站点公告没有加载出来"
description={errorText(listQuery.error)}
action={
<Button size="small" onClick={() => void listQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminSiteAnnouncement>
title="本站公告"
rowKey="id"
columns={columns}
dataSource={rows}
loading={listQuery.isFetching}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription={canView ? '该站点还没有公告' : '当前账号没有查看权限'}
/>
<DetailDrawer
open={Boolean(detail)}
onClose={() => setDetail(undefined)}
title={detail?.title ?? '站点公告'}
subtitle={
detail
? `${selection.site?.site_name ?? ''} · ${
announcementCategoryMap[detail.category] ?? detail.category
}`
: undefined
}
tags={
detail ? (
<StatusTag meta={readStatusMeta(announcementStatusMap, detail.status)} />
) : undefined
}
sections={detail ? buildSections(detail) : []}
width={760}
footer={
detail && canManage ? (
<Space>
<Button icon={<EditOutlined />} onClick={() => openEdit(detail)}>
编辑
</Button>
<Button danger icon={<DeleteOutlined />} onClick={() => remove(detail)}>
删除
</Button>
</Space>
) : undefined
}
/>
<Modal
open={editorOpen}
title={editing ? '编辑站点公告' : '新建站点公告'}
width={760}
maskClosable={!submitting}
closable={!submitting}
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
footer={
<Space>
<Button onClick={() => setEditorOpen(false)} disabled={submitting}>
取消
</Button>
<Button type="primary" loading={submitting} onClick={() => void save()}>
{editing ? '保存修改' : '创建草稿'}
</Button>
</Space>
}
>
<Form<AdminSiteAnnouncementPayload> form={form} layout="vertical" disabled={submitting}>
<Form.Item
name="title"
label="公告标题"
rules={[{ required: true, message: '请输入公告标题' }, { max: 255 }]}
>
<Input maxLength={255} showCount />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select options={announcementCategoryOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="priority" label="排序权重" extra="数值越大越靠前">
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="content"
label="公告正文"
rules={[{ required: true, message: '请输入公告正文' }]}
>
<Input.TextArea rows={8} maxLength={5000} showCount />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="is_pinned" label="置顶" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="is_important" label="重要公告" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="is_popup" label="弹窗公告" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
)
}
+467
View File
@@ -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<ApiResponse<AdminDistributionWallet>>(
`/admin/distribution/sites/${id}/wallet`
)
return data.data
}
export async function applyAdminDistributionWithdrawal(
id: number | string,
payload: ApplyAdminDistributionWithdrawalPayload
) {
const { data } = await adminClient.post<ApiResponse<AdminDistributionWithdrawal>>(
`/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<ApiResponse<Partial<AdminDistributionWithdrawalPage>>>(
`/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<ApiResponse<AdminDistributionSMSConfig>>(
`/admin/distribution/sites/${id}/sms-config`
)
return data.data
}
export async function updateAdminDistributionSMSConfig(
id: number | string,
payload: UpdateAdminDistributionSMSConfigPayload
) {
const { data } = await adminClient.put<ApiResponse<AdminDistributionSMSConfig>>(
`/admin/distribution/sites/${id}/sms-config`,
payload
)
return data.data
}
export async function testAdminDistributionSMSConfig(
id: number | string,
payload: TestAdminDistributionSMSConfigPayload
) {
const { data } = await adminClient.post<ApiResponse<{ message?: string }>>(
`/admin/distribution/sites/${id}/sms-config/test`,
payload
)
return data.data
}
export async function fetchAdminDistributionInvitationConfig(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminDistributionInvitationConfig>>(
`/admin/distribution/sites/${id}/invitation-config`
)
return data.data
}
export async function updateAdminDistributionInvitationConfig(
id: number | string,
payload: UpdateAdminDistributionInvitationConfigPayload
) {
const { data } = await adminClient.put<ApiResponse<AdminDistributionInvitationConfig>>(
`/admin/distribution/sites/${id}/invitation-config`,
payload
)
return data.data
}
export async function fetchAdminDistributionQrCodes(
id: number | string,
query: AdminDistributionQrCodeQuery
) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminDistributionQrCodePage>>>(
`/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<ApiResponse<Partial<AdminDistributionQrCodeStats>>>(
`/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<ApiResponse<AdminDistributionQrCode[]>>(
`/admin/distribution/sites/${id}/qrcodes/batch`,
{ items }
)
return data.data
}
export async function batchDeleteAdminDistributionQrCodes(
id: number | string,
ids: number[]
) {
const { data } = await adminClient.post<ApiResponse<{ deleted?: number }>>(
`/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<ApiResponse<{ items?: AdminDistributionRule[] }>>(
'/admin/distribution/rules',
{ params: compactParams(query) }
)
return data.data.items ?? []
}
export async function createAdminDistributionRule(payload: CreateAdminDistributionRulePayload) {
const { data } = await adminClient.post<ApiResponse<AdminDistributionRule>>(
'/admin/distribution/rules',
payload
)
return data.data
}
export async function updateAdminDistributionRuleStatus(
id: number,
status: 'draft' | 'active' | 'disabled'
) {
const { data } = await adminClient.put<ApiResponse<AdminDistributionRule>>(
`/admin/distribution/rules/${id}/status`,
{ status }
)
return data.data
}
export async function fetchAdminDistributionOrders(query: AdminDistributionOrderQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminDistributionOrderPage>>>(
'/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<ApiResponse<AdminDistributionOrder>>(
`/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<ApiResponse<AdminDistributionSettlementBatch>>(
'/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<ApiResponse<AdminDistributionSettlementBatch>>(
`/admin/distribution/settlement-batches/${batchId}/settle`
)
return data.data
}
export async function cancelAdminDistributionSettlementBatch(batchId: number) {
const { data } = await adminClient.post<ApiResponse<AdminDistributionSettlementBatch>>(
`/admin/distribution/settlement-batches/${batchId}/cancel`
)
return data.data
}
export async function fetchAdminDistributionUsers(
siteId: number,
query: AdminDistributionUserQuery
) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminDistributionUserPage>>>(
'/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<ApiResponse<Partial<AdminSiteAnnouncementPage>>>(
'/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<ApiResponse<AdminSiteAnnouncement>>(
'/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<ApiResponse<AdminSiteAnnouncement>>(
`/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<ApiResponse<AdminSiteAnnouncement>>(
`/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<ApiResponse<AdminSiteAnnouncement>>(
`/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<ApiResponse<AdminSiteAnnouncement>>(
`/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<ApiResponse<Partial<AdminReceivedListingPage>>>(
'/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<ApiResponse<{ items?: AdminAccountDisplay[] }>>(
'/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<ApiResponse<AdminAccountDisplay>>(
`/admin/distribution/accounts/${id}/display`,
{ display_status: displayStatus }
)
return data.data
}
export async function updateAdminAccountMarkup(id: number, shelfRatioDelta: number) {
const { data } = await adminClient.put<ApiResponse<AdminAccountDisplay>>(
`/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<ApiResponse<{ items?: AdminPaymentChannel[] }>>(
'/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<ApiResponse<AdminPaymentChannel>>(
'/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<ApiResponse<Partial<FinanceDisbursementPage>>>(
'/admin/finance/disbursements',
@@ -15,6 +15,7 @@ interface DataTableProps<T extends object> {
onRowClick?: (row: T) => void
emptyDescription?: ReactNode
scroll?: TableProps<T>['scroll']
rowSelection?: TableProps<T>['rowSelection']
className?: string
}
@@ -36,6 +37,7 @@ export function DataTable<T extends object>({
onRowClick,
emptyDescription = '没有符合条件的数据',
scroll = { x: 'max-content' },
rowSelection,
className,
}: DataTableProps<T>) {
return (
@@ -47,6 +49,7 @@ export function DataTable<T extends object>({
dataSource={dataSource}
loading={loading}
scroll={scroll}
rowSelection={rowSelection}
onRow={
onRowClick
? row => ({
@@ -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 (
<div className="admin-site-picker">
<Space size={10} wrap>
<Typography.Text type="secondary">当前站点</Typography.Text>
<Select
className="admin-site-picker__select"
value={siteId}
loading={isLoading}
disabled={disabled || sites.length === 0}
placeholder={sites.length === 0 ? '暂无可选站点' : '请选择分销站点'}
showSearch
optionFilterProp="label"
onChange={value => setSiteId(value)}
options={sites.map(item => ({
value: item.id,
label: `${item.site_name} · ${item.site_code}`,
}))}
/>
{site ? (
<Tag color={site.status === 'active' ? 'green' : 'default'}>
{site.status === 'active' ? '运营中' : '已停用'}
</Tag>
) : null}
{site?.domain ? <Typography.Text type="secondary">{site.domain}</Typography.Text> : null}
</Space>
</div>
)
}
+459
View File
@@ -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<string, string>
}
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<string, string>
}
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
}
+34
View File
@@ -99,6 +99,29 @@ const allNavGroups: NavGroup[] = [
icon: <ShareAltOutlined />,
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<string, { title: string; description: string }> = {
'/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: '查看高风险操作与变更记录。' },
+20
View File
@@ -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<ApiResponse<UploadedFile>>(
'/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) ||
+235
View File
@@ -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));
}
}
+1
View File
@@ -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';