=> [
+ {
+ 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)
+ }}
+ />
+ }
+ loading={notificationsQuery.isFetching}
+ onClick={() => void notificationsQuery.refetch()}
+ >
+ 刷新
+
+
+
+ }
+ />
+
+ {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={
+ }
+ loading={channelsQuery.isFetching || rulesQuery.isFetching}
+ onClick={() => {
+ void channelsQuery.refetch()
+ void rulesQuery.refetch()
+ }}
+ >
+ 刷新
+
+ }
+ />
+
+
+
+ 推送渠道
+
+
+
+
+ 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={
+ }
+ disabled={!canManage}
+ onClick={() => {
+ form.setFieldsValue({ name: '', type: 'webhook', config: '{}' })
+ setOpen(true)
+ }}
+ >
+ 新建渠道
+
+ }
+ 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: '维护客服群与坐席分配,支撑会话分流。',