336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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 channels = Array.isArray(channelsQuery.data) ? channelsQuery.data : []
|
|
const rules = Array.isArray(rulesQuery.data) ? rulesQuery.data : []
|
|
|
|
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">
|
|
渠道 {channels.length} · 规则 {rules.length}
|
|
</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={channels}
|
|
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 => {
|
|
try {
|
|
await updatePushRule(row.id, { enabled: checked })
|
|
await invalidate()
|
|
} catch (error) {
|
|
message.error(errorText(error))
|
|
}
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
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={rules}
|
|
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>
|
|
)
|
|
}
|