From e309fddd18a475e3bee6b82e4596069851fed4c1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 17:12:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRouter.tsx | 6 + .../features/admin/AdminInvitationsPage.tsx | 472 ++++++++++++++++++ frontend/src/features/admin/api.ts | 82 +++ frontend/src/features/admin/types.ts | 71 +++ frontend/src/layouts/AdminLayout.tsx | 5 + 5 files changed, 636 insertions(+) create mode 100644 frontend/src/features/admin/AdminInvitationsPage.tsx diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 5eb7f55..a5329ce 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -261,6 +261,11 @@ const AdminPushPage = lazy(() => default: module.AdminPushPage, })) ) +const AdminInvitationsPage = lazy(() => + import('@/features/admin/AdminInvitationsPage').then(module => ({ + default: module.AdminInvitationsPage, + })) +) const AdminPickupDetailPage = lazy(() => import('@/features/admin/AdminPickupDetailPage').then(module => ({ default: module.AdminPickupDetailPage, @@ -432,6 +437,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminInvitationsPage.tsx b/frontend/src/features/admin/AdminInvitationsPage.tsx new file mode 100644 index 0000000..add44a1 --- /dev/null +++ b/frontend/src/features/admin/AdminInvitationsPage.tsx @@ -0,0 +1,472 @@ +import { useEffect, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Form, + Input, + InputNumber, + Segmented, + Space, + Statistic, + Switch, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { ReloadOutlined } from '@ant-design/icons' + +import { + fetchAdminInvitationConfig, + fetchAdminInvitationRelations, + fetchAdminInviteRebates, + fetchAdminInviteRebateSummary, + updateAdminInvitationConfig, +} 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 type { + AdminInvitationRelation, + AdminInviteRebate, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const statusMeta: Record = { + pending: { label: '待实名', color: 'gold' }, + active: { label: '已生效', color: 'green' }, +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + return response?.data?.message || (error instanceof Error ? error.message : '请求失败') +} + +const relationColumns: TableColumnsType = [ + { + title: '邀请人', + key: 'inviter', + width: 200, + render: (_, row) => ( +
+ {formatText(row.inviter_nickname)} + {formatText(row.inviter_phone)} +
+ ), + }, + { + title: '被邀请人', + key: 'invitee', + width: 200, + render: (_, row) => ( +
+ {formatText(row.invitee_nickname)} + {formatText(row.invitee_phone)} +
+ ), + }, + { + title: '邀请码', + dataIndex: 'invite_code', + width: 140, + render: value => {formatText(value)}, + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => { + const meta = statusMeta[value] ?? { label: value || '—', color: 'default' } + return ( + + {meta.label} + + ) + }, + }, + { + title: '实名时间', + dataIndex: 'verified_at', + width: 170, + render: value => (value ? formatDateTime(value) : '—'), + }, + { + title: '绑定时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +const rebateColumns: TableColumnsType = [ + { + title: '订单', + dataIndex: 'order_no', + width: 200, + render: value => {formatText(value)}, + }, + { + title: '被邀请人', + key: 'invitee', + width: 180, + render: (_, row) => ( +
+ {formatText(row.invitee_nickname)} + {formatText(row.invitee_masked_phone)} +
+ ), + }, + { + title: '角色 / 周期', + key: 'role', + width: 160, + render: (_, row) => ( + + {row.trade_role === 'buyer' ? '买家' : '卖家'} + {formatText(row.cycle_stage)} + + ), + }, + { + title: '基数', + dataIndex: 'single_side_basis_cent', + width: 130, + align: 'right', + render: value => formatMoney(value), + }, + { + title: '比例', + dataIndex: 'rate_applied', + width: 90, + align: 'right', + render: value => `${(Number(value) * 100).toFixed(1)}%`, + }, + { + title: '返利', + dataIndex: 'rebate_amount_cent', + width: 130, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '结算时间', + dataIndex: 'settled_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function AdminInvitationsPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [configForm] = Form.useForm<{ + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + }>() + const [tab, setTab] = useState<'relations' | 'rebates'>('relations') + const [relFilters, setRelFilters] = useState({ phone: '', invite_code: '' }) + const [relApplied, setRelApplied] = useState({ phone: '', invite_code: '' }) + const [relPage, setRelPage] = useState(1) + const [rebateFilters, setRebateFilters] = useState({ order_no: '', inviter_phone: '', invitee_phone: '' }) + const [rebateApplied, setRebateApplied] = useState(rebateFilters) + const [rebatePage, setRebatePage] = useState(1) + const [pageSize] = useState(20) + const [saving, setSaving] = useState(false) + + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || + permissions?.includes('distribution:site_view') || + permissions?.includes('distribution:settlement_view') + ) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('distribution:site_manage')) + + const configQuery = useQuery({ + queryKey: ['admin-invitation-config'], + queryFn: () => fetchAdminInvitationConfig(), + enabled: canView, + }) + const relationsQuery = useQuery({ + queryKey: ['admin-invitation-relations', relApplied, relPage], + queryFn: () => + fetchAdminInvitationRelations({ + phone: relApplied.phone.trim() || undefined, + invite_code: relApplied.invite_code.trim() || undefined, + page: relPage, + page_size: pageSize, + }), + enabled: canView && tab === 'relations', + placeholderData: keepPreviousData, + }) + const rebatesQuery = useQuery({ + queryKey: ['admin-invite-rebates', rebateApplied, rebatePage], + queryFn: () => + fetchAdminInviteRebates({ + order_no: rebateApplied.order_no.trim() || undefined, + inviter_phone: rebateApplied.inviter_phone.trim() || undefined, + invitee_phone: rebateApplied.invitee_phone.trim() || undefined, + page: rebatePage, + page_size: pageSize, + }), + enabled: canView && tab === 'rebates', + placeholderData: keepPreviousData, + }) + const summaryQuery = useQuery({ + queryKey: ['admin-invite-rebate-summary', rebateApplied], + queryFn: () => + fetchAdminInviteRebateSummary({ + order_no: rebateApplied.order_no.trim() || undefined, + inviter_phone: rebateApplied.inviter_phone.trim() || undefined, + invitee_phone: rebateApplied.invitee_phone.trim() || undefined, + }), + enabled: canView && tab === 'rebates', + }) + + useEffect(() => { + if (!configQuery.data) return + configForm.setFieldsValue({ + first_month_rate: Number((configQuery.data.first_month_rate * 100).toFixed(2)), + second_month_rate: Number((configQuery.data.second_month_rate * 100).toFixed(2)), + subsequent_rate: Number((configQuery.data.subsequent_rate * 100).toFixed(2)), + enabled: configQuery.data.enabled, + }) + }, [configQuery.data, configForm]) + + if (!canView) { + return ( +
+ + +
+ ) + } + + const summary = summaryQuery.data + + return ( +
+ + 返利总额 {formatMoney(summary?.total_rebate_cent ?? 0)} + 明细 {summary?.ledger_count ?? 0} 条 + + } + extra={ + + } + /> + + +
+ + + + + + + + + + + + + + + +
+ + 最近更新:{formatDateTime(configQuery.data?.updated_at)} + +
+ + + setTab(value as 'relations' | 'rebates')} + /> + + + {tab === 'relations' ? ( + <> + { + setRelPage(1) + setRelApplied(relFilters) + }} + onReset={() => { + setRelFilters({ phone: '', invite_code: '' }) + setRelApplied({ phone: '', invite_code: '' }) + setRelPage(1) + }} + > + + + setRelFilters(current => ({ ...current, phone: event.target.value })) + } + /> + + + + setRelFilters(current => ({ ...current, invite_code: event.target.value })) + } + /> + + + + + columns={relationColumns} + dataSource={relationsQuery.data?.items ?? []} + rowKey="id" + loading={relationsQuery.isFetching} + total={relationsQuery.data?.total ?? 0} + page={relationsQuery.data?.page ?? relPage} + pageSize={relationsQuery.data?.page_size ?? pageSize} + onPageChange={nextPage => setRelPage(nextPage)} + emptyDescription="还没有邀请关系" + /> + + ) : ( + <> +
+
+ 返利总额 + {formatMoney(summary?.total_rebate_cent ?? 0)} + 明细 {summary?.ledger_count ?? 0} 条 +
+
+ 邀请人 / 被邀请人 + + {summary?.inviter_count ?? 0} / {summary?.invitee_count ?? 0} + + 去重统计 +
+
+ 订单 / 提号返利 + {formatMoney(summary?.order_rebate_cent ?? 0)} + 提号 {formatMoney(summary?.pickup_rebate_cent ?? 0)} +
+
+ 首月 / 次月 / 后续 + {formatMoney(summary?.first_month_cent ?? 0)} + + 次月 {formatMoney(summary?.second_month_cent ?? 0)} · 后续{' '} + {formatMoney(summary?.subsequent_cent ?? 0)} + +
+
+ + { + setRebatePage(1) + setRebateApplied(rebateFilters) + }} + onReset={() => { + const empty = { order_no: '', inviter_phone: '', invitee_phone: '' } + setRebateFilters(empty) + setRebateApplied(empty) + setRebatePage(1) + }} + > + + + setRebateFilters(current => ({ ...current, order_no: event.target.value })) + } + /> + + + + setRebateFilters(current => ({ ...current, inviter_phone: event.target.value })) + } + /> + + + + setRebateFilters(current => ({ ...current, invitee_phone: event.target.value })) + } + /> + + + + + columns={rebateColumns} + dataSource={rebatesQuery.data?.items ?? []} + rowKey="id" + loading={rebatesQuery.isFetching} + total={rebatesQuery.data?.total ?? 0} + page={rebatesQuery.data?.page ?? rebatePage} + pageSize={rebatesQuery.data?.page_size ?? pageSize} + onPageChange={nextPage => setRebatePage(nextPage)} + emptyDescription="还没有返利流水" + /> + + )} + + +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 0a7b888..b20d9b7 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -123,6 +123,10 @@ import type { AdminNotificationPage, AdminPushChannel, AdminPushRule, + AdminInvitationConfig, + AdminInvitationRelationPage, + AdminInviteRebatePage, + AdminInviteRebateSummary, ManualDisbursementPayload, OperatingExpensePage, OperatingExpensePayload, @@ -1796,3 +1800,81 @@ export async function updatePushRule( + +export async function fetchAdminInvitationConfig(siteId?: number) { + const { data } = await adminClient.get>( + '/admin/invitations/configs', + { params: siteId ? { site_id: siteId } : undefined } + ) + return data.data +} + +export async function updateAdminInvitationConfig(payload: { + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + site_id?: number +}) { + const { data } = await adminClient.put>( + '/admin/invitations/configs', + payload + ) + return data.data +} + +export async function fetchAdminInvitationRelations(query: { + phone?: string + invite_code?: string + status?: string + page?: number + page_size?: number +}) { + const { data } = await adminClient.get>>( + '/admin/invitations', + { 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 fetchAdminInviteRebates(query: { + order_no?: string + inviter_phone?: string + invitee_phone?: string + trade_role?: string + start_date?: string + end_date?: string + page?: number + page_size?: number +}) { + const { data } = await adminClient.get>>( + '/admin/invitations/rebates', + { 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 fetchAdminInviteRebateSummary(query: { + order_no?: string + inviter_phone?: string + invitee_phone?: string + trade_role?: string + start_date?: string + end_date?: string +}) { + const { data } = await adminClient.get>( + '/admin/invitations/rebates/summary', + { params: compactParams(query) } + ) + return data.data +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 18dd1bb..ce867d4 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -913,6 +913,77 @@ export interface AdminPushRule { updated_at: string } +export interface AdminInvitationConfig { + distribution_site_id: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + updated_at: string +} + +export interface AdminInvitationRelation { + id: number + inviter_id: number + inviter_phone: string + inviter_nickname: string + invitee_id: number + invitee_phone: string + invitee_nickname: string + distribution_site_id?: number | null + invite_code: string + status: string + verified_at?: string | null + created_at: string +} + +export interface AdminInvitationRelationPage { + items: AdminInvitationRelation[] + total: number + page: number + page_size: number +} + +export interface AdminInviteRebate { + id: number + order_id: number + order_no: string + invitee_masked_phone: string + invitee_nickname: string + trade_role: string + single_side_basis_cent: number + rate_applied: number + cycle_stage: string + rebate_amount_cent: number + settled_at: string +} + +export interface AdminInviteRebatePage { + items: AdminInviteRebate[] + total: number + page: number + page_size: number +} + +export interface AdminInviteRebateSummary { + total_rebate_cent: number + ledger_count: number + inviter_count: number + invitee_count: number + avg_rebate_cent: number + order_rebate_cent: number + order_count: number + pickup_rebate_cent: number + pickup_count: number + buyer_rebate_cent: number + buyer_count: number + seller_rebate_cent: number + seller_count: number + first_month_cent: number + second_month_cent: number + subsequent_cent: number +} + export interface AdminAnnouncementQuery { category?: string status?: string diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index ffdab3d..7c8d9f4 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -139,6 +139,7 @@ const allNavGroups: NavGroup[] = [ { key: '/tracking-links', label: '推广追踪', permission: 'ad_tracking:manage' }, { key: '/notifications', label: '通知中心', permission: 'notification:view' }, { key: '/push', label: '推送通知', permission: 'system_config:view' }, + { key: '/invitations', label: '邀请管理', permission: 'distribution:site_view' }, ], }, { @@ -212,6 +213,10 @@ const sectionMeta: Record = { }, '/notifications': { title: '通知中心', description: '查看后台系统通知并批量标记已读。' }, '/push': { title: '推送通知', description: '维护推送渠道与通知规则。' }, + '/invitations': { + title: '邀请管理', + description: '维护邀请返利比例,查看邀请关系与返利流水。', + }, '/chat-support-groups': { title: '客服群组', description: '维护客服群与坐席分配,支撑会话分流。',