feat(distribution): 补齐分销长尾后台页面
This commit is contained in:
@@ -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">
|
||||
周期采用左闭右开:开始时间 ≤ 财务快照创建时间 < 结束时间。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '查看高风险操作与变更记录。' },
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user