From bb0581c218f293713f89eefe329be3a869d56b76 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 17:04:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=AD=E5=BF=83=E4=B8=8E=E6=8E=A8=E9=80=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRouter.tsx | 12 + .../features/admin/AdminNotificationsPage.tsx | 163 +++++++++ frontend/src/features/admin/AdminPushPage.tsx | 328 ++++++++++++++++++ frontend/src/features/admin/api.ts | 90 +++++ frontend/src/features/admin/types.ts | 38 ++ frontend/src/layouts/AdminLayout.tsx | 9 +- 6 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/admin/AdminNotificationsPage.tsx create mode 100644 frontend/src/features/admin/AdminPushPage.tsx diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 14782e0..5eb7f55 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -251,6 +251,16 @@ const AdminQrCodePoolPage = lazy(() => default: module.AdminQrCodePoolPage, })) ) +const AdminNotificationsPage = lazy(() => + import('@/features/admin/AdminNotificationsPage').then(module => ({ + default: module.AdminNotificationsPage, + })) +) +const AdminPushPage = lazy(() => + import('@/features/admin/AdminPushPage').then(module => ({ + default: module.AdminPushPage, + })) +) const AdminPickupDetailPage = lazy(() => import('@/features/admin/AdminPickupDetailPage').then(module => ({ default: module.AdminPickupDetailPage, @@ -420,6 +430,8 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminNotificationsPage.tsx b/frontend/src/features/admin/AdminNotificationsPage.tsx new file mode 100644 index 0000000..7525be5 --- /dev/null +++ b/frontend/src/features/admin/AdminNotificationsPage.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, Button, Card, Segmented, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' + +import { + fetchAdminNotifications, + markAdminNotificationRead, + markAllAdminNotificationsRead, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import type { AdminNotification } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatText } from '@/shared/utils/format' + +const columns = ( + onRead: (row: AdminNotification) => void +): TableColumnsType => [ + { + title: '标题', + key: 'title', + width: 280, + render: (_, row) => ( +
+ + {row.title} + {row.is_read ? null : ( + + 未读 + + )} + + {row.type} +
+ ), + }, + { + title: '内容', + dataIndex: 'content', + render: value => formatText(value), + }, + { + title: '时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_, row) => + row.is_read ? ( + 已读 + ) : ( + + ), + }, +] + +export function AdminNotificationsPage() { + const queryClient = useQueryClient() + const [read, setRead] = useState<'all' | 'unread'>('all') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('notification:view')) + + const query = { read: read === 'unread' ? 'unread' : undefined, page, page_size: pageSize } + const notificationsQuery = useQuery({ + queryKey: ['admin-notifications', query], + queryFn: () => fetchAdminNotifications(query), + enabled: canView, + placeholderData: keepPreviousData, + }) + + const rows = notificationsQuery.data?.items ?? [] + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['admin-notifications'] }) + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 共 {notificationsQuery.data?.total ?? 0} 条 + 未读 {rows.filter(item => !item.is_read).length} + + } + extra={ + + { + setRead(value as 'all' | 'unread') + setPage(1) + }} + /> + + + + } + /> + + {notificationsQuery.isError ? ( + + ) : null} + + + + columns={columns(async row => { + await markAdminNotificationRead(row.id) + await invalidate() + })} + dataSource={rows} + rowKey="id" + loading={notificationsQuery.isFetching} + total={notificationsQuery.data?.total ?? 0} + page={notificationsQuery.data?.page ?? page} + pageSize={notificationsQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="没有通知" + /> + +
+ ) +} diff --git a/frontend/src/features/admin/AdminPushPage.tsx b/frontend/src/features/admin/AdminPushPage.tsx new file mode 100644 index 0000000..0779b39 --- /dev/null +++ b/frontend/src/features/admin/AdminPushPage.tsx @@ -0,0 +1,328 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, App, Button, Form, Input, InputNumber, Modal, Switch, Tag, Typography } from 'antd' +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' + +import { + createPushChannel, + fetchPushChannels, + fetchPushRules, + testPushChannel, + updatePushChannel, + updatePushRule, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import type { AdminPushChannel, AdminPushRule } 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 + return response?.data?.message || (error instanceof Error ? error.message : '请求失败') +} + +export function AdminPushPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm<{ name: string; type: string; config: string }>() + const [ruleForm] = Form.useForm<{ threshold: number; message_template: string }>() + const [open, setOpen] = useState(false) + const [ruleTarget, setRuleTarget] = useState() + const [submitting, setSubmitting] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('system_config:view')) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('system_config:update')) + + const channelsQuery = useQuery({ + queryKey: ['admin-push-channels'], + queryFn: fetchPushChannels, + enabled: canView, + }) + const rulesQuery = useQuery({ + queryKey: ['admin-push-rules'], + queryFn: fetchPushRules, + enabled: canView, + }) + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-push-channels'] }), + queryClient.invalidateQueries({ queryKey: ['admin-push-rules'] }), + ]) + + const create = async () => { + const values = await form.validateFields() + let config: Record + try { + config = JSON.parse(values.config || '{}') + } catch { + message.error('配置必须是合法 JSON') + return + } + setSubmitting(true) + try { + await createPushChannel({ name: values.name.trim(), type: values.type.trim(), config }) + message.success('推送渠道已创建') + setOpen(false) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 渠道 {channelsQuery.data?.length ?? 0} · 规则 {rulesQuery.data?.length ?? 0} + + } + extra={ + + } + /> + +
+ + 推送渠道 + +
+ + + columns={[ + { + title: '名称 / 类型', + key: 'name', + width: 240, + render: (_, row) => ( +
+ {row.name} + {row.type} +
+ ), + }, + { + title: '配置', + key: 'config', + render: (_, row) => ( + + {JSON.stringify(row.config ?? {})} + + ), + }, + { + title: '状态', + key: 'enabled', + width: 110, + render: (_, row) => ( + { + try { + await updatePushChannel(row.id, { enabled: checked }) + message.success(checked ? '已启用' : '已停用') + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + }} + /> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_, row) => ( + + ), + }, + ]} + dataSource={channelsQuery.data ?? []} + rowKey="id" + loading={channelsQuery.isFetching} + extra={ + + } + emptyDescription="还没有推送渠道" + /> + +
+ + 通知规则 + +
+ + + columns={[ + { + title: '事件', + dataIndex: 'event', + width: 240, + render: value => {formatText(value)}, + }, + { + title: '消息模板', + dataIndex: 'message_template', + render: value => formatText(value), + }, + { + title: '阈值', + dataIndex: 'threshold', + width: 100, + align: 'right', + }, + { + title: '状态', + key: 'enabled', + width: 110, + render: (_, row) => ( + { + await updatePushRule(row.id, { enabled: checked }) + await invalidate() + }} + /> + ), + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_, row) => ( + + ), + }, + ]} + dataSource={rulesQuery.data ?? []} + rowKey="id" + loading={rulesQuery.isFetching} + emptyDescription="还没有通知规则" + /> + + void create()} + onCancel={() => setOpen(false)} + > +
+ + + + + + + + + +
+
+ + setRuleTarget(undefined)} + onOk={async () => { + if (!ruleTarget) return + const values = await ruleForm.validateFields() + try { + await updatePushRule(ruleTarget.id, { + threshold: values.threshold, + message_template: values.message_template, + }) + message.success('规则已更新') + setRuleTarget(undefined) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + }} + > +
+ + + + + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 2bed14f..0a7b888 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -120,6 +120,9 @@ import type { AdminQrCodePayload, AdminQrCodeStats, AdminQrCodeUpdatePayload, + AdminNotificationPage, + AdminPushChannel, + AdminPushRule, ManualDisbursementPayload, OperatingExpensePage, OperatingExpensePayload, @@ -1704,5 +1707,92 @@ export async function deleteAdminQrCode(id: number) { await adminClient.delete(`/admin/chats/qrcodes/${id}`) } +export async function fetchAdminNotifications(query: { + read?: string + page?: number + page_size?: number +}) { + const { data } = await adminClient.get>>( + '/admin/notifications', + { 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 fetchAdminNotificationUnreadCount() { + const { data } = await adminClient.get>( + '/admin/notifications/unread-count' + ) + return data.data.unread_count ?? 0 +} + +export async function markAdminNotificationRead(id: number) { + await adminClient.post(`/admin/notifications/${id}/read`) +} + +export async function markAllAdminNotificationsRead() { + await adminClient.put('/admin/notifications/read-all') +} + +export async function fetchPushChannels() { + const { data } = await adminClient.get>('/admin/push-channels') + return data.data ?? [] +} + +export async function createPushChannel(payload: { + name: string + type: string + config: Record +}) { + const { data } = await adminClient.post>( + '/admin/push-channels', + payload + ) + return data.data +} + +export async function updatePushChannel( + id: number, + payload: { name?: string; config?: Record; enabled?: boolean } +) { + const { data } = await adminClient.put>( + `/admin/push-channels/${id}`, + payload + ) + return data.data +} + +export async function deletePushChannel(id: number) { + await adminClient.delete(`/admin/push-channels/${id}`) +} + +export async function testPushChannel(id: number) { + const { data } = await adminClient.post>( + `/admin/push-channels/${id}/test` + ) + return data.data +} + +export async function fetchPushRules() { + const { data } = await adminClient.get>('/admin/push-rules') + return data.data ?? [] +} + +export async function updatePushRule( + id: number, + payload: { enabled?: boolean; threshold?: number; message_template?: string } +) { + const { data } = await adminClient.put>( + `/admin/push-rules/${id}`, + payload + ) + return data.data +} + diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 9919048..18dd1bb 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -875,6 +875,44 @@ export interface AdminQrCodeUpdatePayload { clear_expires_at?: boolean } +export interface AdminNotification { + id: number + admin_user_id: number + type: string + title: string + content: string + is_read: boolean + created_at: string + updated_at: string +} + +export interface AdminNotificationPage { + items: AdminNotification[] + total: number + page: number + page_size: number +} + +export interface AdminPushChannel { + id: number + name: string + type: string + config: unknown + enabled: boolean + created_at: string + updated_at: string +} + +export interface AdminPushRule { + id: number + event: string + enabled: boolean + threshold: number + message_template: string + created_at: string + updated_at: string +} + export interface AdminAnnouncementQuery { category?: string status?: string diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 55d7caf..ffdab3d 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -134,7 +134,12 @@ const allNavGroups: NavGroup[] = [ key: 'operations', label: '运营', icon: , - children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }, { key: '/tracking-links', label: '推广追踪', permission: 'ad_tracking:manage' }], + children: [ + { key: '/announcements', label: '公告管理', permission: 'announcement:view' }, + { key: '/tracking-links', label: '推广追踪', permission: 'ad_tracking:manage' }, + { key: '/notifications', label: '通知中心', permission: 'notification:view' }, + { key: '/push', label: '推送通知', permission: 'system_config:view' }, + ], }, { key: 'support', @@ -205,6 +210,8 @@ const sectionMeta: Record = { title: '推广追踪', description: '管理推广短链、渠道成本与注册下单转化。', }, + '/notifications': { title: '通知中心', description: '查看后台系统通知并批量标记已读。' }, + '/push': { title: '推送通知', description: '维护推送渠道与通知规则。' }, '/chat-support-groups': { title: '客服群组', description: '维护客服群与坐席分配,支撑会话分流。',