feat(admin): 新增通知中心与推送通知页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '维护客服群与坐席分配,支撑会话分流。',
|
||||
|
||||
Reference in New Issue
Block a user