feat(admin): 新增通知中心与推送通知页

This commit is contained in:
yml2213
2026-10-30 17:04:02 +08:00
parent 8f0ec10e75
commit bb0581c218
6 changed files with 639 additions and 1 deletions
+12
View File
@@ -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() {
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
<Route path="chats" element={<AdminChatsPage />} />
<Route path="qrcode-pool" element={<AdminQrCodePoolPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="push" element={<AdminPushPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
@@ -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<AdminNotification> => [
{
title: '标题',
key: 'title',
width: 280,
render: (_, row) => (
<div className="admin-stack">
<Space size={6} wrap>
<strong>{row.title}</strong>
{row.is_read ? null : (
<Tag bordered={false} color="red">
未读
</Tag>
)}
</Space>
<span className="admin-mono">{row.type}</span>
</div>
),
},
{
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 ? (
<Typography.Text type="secondary">已读</Typography.Text>
) : (
<Button type="link" size="small" onClick={() => onRead(row)}>
标为已读
</Button>
),
},
]
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 (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 运营" title="通知中心" />
<Alert type="warning" showIcon message="当前账号没有通知查看权限(notification:view)" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 运营"
title="通知中心"
description="后台账号的系统通知;未读通知会同步到顶部铃铛。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {notificationsQuery.data?.total ?? 0} 条</Tag>
<Tag color="red">未读 {rows.filter(item => !item.is_read).length}</Tag>
</Space>
}
extra={
<Space>
<Segmented
value={read}
options={[
{ value: 'all', label: '全部' },
{ value: 'unread', label: '仅未读' },
]}
onChange={value => {
setRead(value as 'all' | 'unread')
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={notificationsQuery.isFetching}
onClick={() => void notificationsQuery.refetch()}
>
刷新
</Button>
<Button
onClick={async () => {
await markAllAdminNotificationsRead()
await invalidate()
}}
>
全部已读
</Button>
</Space>
}
/>
{notificationsQuery.isError ? (
<Alert type="error" showIcon className="admin-table-alert" message="通知加载失败" />
) : null}
<Card className="admin-table-card">
<DataTable<AdminNotification>
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="没有通知"
/>
</Card>
</div>
)
}
@@ -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<AdminPushRule>()
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<string, unknown>
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 (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 运营" title="推送通知" />
<Alert type="warning" showIcon message="当前账号没有系统配置权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 运营"
title="推送通知"
description="维护推送渠道(Webhook/企业微信等)与通知规则。"
meta={
<Tag color="geekblue">
渠道 {channelsQuery.data?.length ?? 0} · 规则 {rulesQuery.data?.length ?? 0}
</Tag>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={channelsQuery.isFetching || rulesQuery.isFetching}
onClick={() => {
void channelsQuery.refetch()
void rulesQuery.refetch()
}}
>
刷新
</Button>
}
/>
<div className="admin-stack" style={{ marginBottom: 12 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
推送渠道
</Typography.Title>
</div>
<DataTable<AdminPushChannel>
columns={[
{
title: '名称 / 类型',
key: 'name',
width: 240,
render: (_, row) => (
<div className="admin-stack">
<strong>{row.name}</strong>
<span className="admin-mono">{row.type}</span>
</div>
),
},
{
title: '配置',
key: 'config',
render: (_, row) => (
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{JSON.stringify(row.config ?? {})}
</Typography.Paragraph>
),
},
{
title: '状态',
key: 'enabled',
width: 110,
render: (_, row) => (
<Switch
checked={row.enabled}
disabled={!canManage}
onChange={async checked => {
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) => (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={async () => {
try {
const result = await testPushChannel(row.id)
message.success(result?.message || '测试消息已发送')
} catch (error) {
message.error(errorText(error))
}
}}
>
发送测试
</Button>
),
},
]}
dataSource={channelsQuery.data ?? []}
rowKey="id"
loading={channelsQuery.isFetching}
extra={
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={!canManage}
onClick={() => {
form.setFieldsValue({ name: '', type: 'webhook', config: '{}' })
setOpen(true)
}}
>
新建渠道
</Button>
}
emptyDescription="还没有推送渠道"
/>
<div className="admin-stack" style={{ margin: '20px 0 12px' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
通知规则
</Typography.Title>
</div>
<DataTable<AdminPushRule>
columns={[
{
title: '事件',
dataIndex: 'event',
width: 240,
render: value => <span className="admin-mono">{formatText(value)}</span>,
},
{
title: '消息模板',
dataIndex: 'message_template',
render: value => formatText(value),
},
{
title: '阈值',
dataIndex: 'threshold',
width: 100,
align: 'right',
},
{
title: '状态',
key: 'enabled',
width: 110,
render: (_, row) => (
<Switch
checked={row.enabled}
disabled={!canManage}
onChange={async checked => {
await updatePushRule(row.id, { enabled: checked })
await invalidate()
}}
/>
),
},
{
title: '操作',
key: 'actions',
width: 100,
render: (_, row) => (
<Button
type="link"
size="small"
disabled={!canManage}
onClick={() => {
setRuleTarget(row)
ruleForm.setFieldsValue({
threshold: row.threshold,
message_template: row.message_template,
})
}}
>
编辑
</Button>
),
},
]}
dataSource={rulesQuery.data ?? []}
rowKey="id"
loading={rulesQuery.isFetching}
emptyDescription="还没有通知规则"
/>
<Modal
open={open}
title="新建推送渠道"
okText="创建"
confirmLoading={submitting}
onOk={() => void create()}
onCancel={() => setOpen(false)}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true, message: '请输入类型' }]}>
<Input placeholder="webhook / wecom / feishu" maxLength={32} />
</Form.Item>
<Form.Item name="config" label="配置 JSON" rules={[{ required: true }]}>
<Input.TextArea rows={5} placeholder='{"url":"https://..."}' />
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(ruleTarget)}
title={ruleTarget ? `编辑规则 · ${ruleTarget.event}` : '编辑规则'}
okText="保存"
onCancel={() => 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))
}
}}
>
<Form form={ruleForm} layout="vertical" requiredMark={false}>
<Form.Item name="threshold" label="阈值">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="message_template" label="消息模板">
<Input.TextArea rows={4} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+90
View File
@@ -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<ApiResponse<Partial<AdminNotificationPage>>>(
'/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<ApiResponse<{ unread_count: number }>>(
'/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<ApiResponse<AdminPushChannel[]>>('/admin/push-channels')
return data.data ?? []
}
export async function createPushChannel(payload: {
name: string
type: string
config: Record<string, unknown>
}) {
const { data } = await adminClient.post<ApiResponse<AdminPushChannel>>(
'/admin/push-channels',
payload
)
return data.data
}
export async function updatePushChannel(
id: number,
payload: { name?: string; config?: Record<string, unknown>; enabled?: boolean }
) {
const { data } = await adminClient.put<ApiResponse<AdminPushChannel>>(
`/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<ApiResponse<{ sent: boolean; message?: string }>>(
`/admin/push-channels/${id}/test`
)
return data.data
}
export async function fetchPushRules() {
const { data } = await adminClient.get<ApiResponse<AdminPushRule[]>>('/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<ApiResponse<AdminPushRule>>(
`/admin/push-rules/${id}`,
payload
)
return data.data
}
+38
View File
@@ -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
+8 -1
View File
@@ -134,7 +134,12 @@ const allNavGroups: NavGroup[] = [
key: 'operations',
label: '运营',
icon: <BellOutlined />,
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<string, { title: string; description: string }> = {
title: '推广追踪',
description: '管理推广短链、渠道成本与注册下单转化。',
},
'/notifications': { title: '通知中心', description: '查看后台系统通知并批量标记已读。' },
'/push': { title: '推送通知', description: '维护推送渠道与通知规则。' },
'/chat-support-groups': {
title: '客服群组',
description: '维护客服群与坐席分配,支撑会话分流。',