feat: 消息推送自定义模板与独立设置页

后端:
- 新增通知模板表与实体、DTO、Repository、NotificationTemplateService
- 支持 {{variable}} 模板语法,提供模板类型与变量接口
- TelegramNotificationService 改为通过模板渲染发送(ORDER_SUCCESS/ORDER_FAILED/ORDER_FILTERED/CRYPTO_TAIL_SUCCESS/REDEEM_SUCCESS/REDEEM_NO_RETURN)
- 模板 CRUD、重置默认、测试发送接口
- 补全订单过滤相关 i18n key(zh/en/zh-TW)

前端:
- 消息推送设置抽离为独立页 /system-settings/notification
- 系统设置概览改为入口卡片,侧栏增加「消息推送设置」菜单
- NotificationSettingsPage: 机器人配置 + 模板配置双卡片布局(与概览一致)
- 模板配置支持选择类型、编辑内容、变量面板(点击复制、悬停说明)、保存/重置/测试
- 多语言 key 补全(notificationSettings.templates.*、templateTypes.*)

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-02 21:31:45 +08:00
parent 83bc209489
commit e7af4d4821
19 changed files with 2087 additions and 338 deletions
+2
View File
@@ -29,6 +29,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
import FilteredOrdersList from './pages/FilteredOrdersList'
import SystemSettings from './pages/SystemSettings'
import NotificationSettingsPage from './pages/NotificationSettingsPage'
import ApiHealthStatus from './pages/ApiHealthStatus'
import RpcNodeSettings from './pages/RpcNodeSettings'
import Announcements from './pages/Announcements'
@@ -268,6 +269,7 @@ function App() {
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
<Route path="/system-settings/notification" element={<ProtectedRoute><NotificationSettingsPage /></ProtectedRoute>} />
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
+5
View File
@@ -216,6 +216,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
key: '/system-settings/api-health',
icon: <CheckCircleOutlined />,
label: t('menu.apiHealth') || 'API健康'
},
{
key: '/system-settings/notification',
icon: <NotificationOutlined />,
label: t('menu.notifications') || '消息推送设置'
}
]
},
+36 -1
View File
@@ -1167,7 +1167,42 @@
"getChatIdsFailed": "Failed to get Chat IDs",
"getChatIdsNoToken": "Please enter Bot Token first",
"getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry",
"getChatIdsButton": "Get Chat ID"
"getChatIdsButton": "Get Chat ID",
"botConfig": "Bot Configuration",
"templateConfig": "Template Configuration",
"templates": {
"title": "Message Template Configuration",
"templateType": "Template Type",
"templateContent": "Template Content",
"isDefault": "Default Template",
"isCustom": "Custom Template",
"resetToDefault": "Reset to Default",
"resetConfirm": "Are you sure you want to reset to default? Your custom content will be lost.",
"resetSuccess": "Reset successfully",
"resetFailed": "Reset failed",
"saveSuccess": "Saved successfully",
"saveFailed": "Save failed",
"testSuccess": "Test message sent successfully, please check Telegram",
"testFailed": "Failed to send test message",
"variables": "Available Variables",
"clickToCopy": "Click to copy",
"copied": "Copied",
"commonVariables": "Common Variables",
"orderVariables": "Order Variables",
"copyTradingVariables": "Copy Trading Variables",
"redeemVariables": "Redeem Variables",
"errorVariables": "Error Variables",
"filterVariables": "Filter Variables",
"strategyVariables": "Strategy Variables"
},
"templateTypes": {
"ORDER_SUCCESS": "Order Success",
"ORDER_FAILED": "Order Failed",
"ORDER_FILTERED": "Order Filtered",
"CRYPTO_TAIL_SUCCESS": "Crypto Spread Strategy Success",
"REDEEM_SUCCESS": "Position Redeem Success",
"REDEEM_NO_RETURN": "Position Settled (No Return)"
}
},
"telegramConfig": {
"title": "Telegram Configuration Guide",
+36 -1
View File
@@ -1167,7 +1167,42 @@
"getChatIdsFailed": "获取 Chat IDs 失败",
"getChatIdsNoToken": "请先填写 Bot Token",
"getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试",
"getChatIdsButton": "获取 Chat ID"
"getChatIdsButton": "获取 Chat ID",
"botConfig": "机器人配置",
"templateConfig": "模板配置",
"templates": {
"title": "消息模板配置",
"templateType": "模板类型",
"templateContent": "模板内容",
"isDefault": "默认模板",
"isCustom": "自定义模板",
"resetToDefault": "重置为默认",
"resetConfirm": "确定要重置为默认模板吗?您的自定义内容将丢失。",
"resetSuccess": "重置成功",
"resetFailed": "重置失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"testSuccess": "测试消息发送成功,请检查 Telegram",
"testFailed": "测试消息发送失败",
"variables": "可用变量",
"clickToCopy": "点击复制",
"copied": "已复制",
"commonVariables": "通用变量",
"orderVariables": "订单变量",
"copyTradingVariables": "跟单变量",
"redeemVariables": "赎回变量",
"errorVariables": "错误变量",
"filterVariables": "过滤变量",
"strategyVariables": "策略变量"
},
"templateTypes": {
"ORDER_SUCCESS": "订单成功通知",
"ORDER_FAILED": "订单失败通知",
"ORDER_FILTERED": "订单过滤通知",
"CRYPTO_TAIL_SUCCESS": "加密价差策略成功通知",
"REDEEM_SUCCESS": "仓位赎回成功通知",
"REDEEM_NO_RETURN": "仓位结算(无收益)通知"
}
},
"telegramConfig": {
"title": "Telegram 配置说明",
+36 -1
View File
@@ -1167,7 +1167,42 @@
"getChatIdsFailed": "獲取 Chat IDs 失敗",
"getChatIdsNoToken": "請先填寫 Bot Token",
"getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試",
"getChatIdsButton": "獲取 Chat ID"
"getChatIdsButton": "獲取 Chat ID",
"botConfig": "機器人配置",
"templateConfig": "模板配置",
"templates": {
"title": "消息模板配置",
"templateType": "模板類型",
"templateContent": "模板內容",
"isDefault": "默認模板",
"isCustom": "自定義模板",
"resetToDefault": "重置為默認",
"resetConfirm": "確定要重置為默認模板嗎?您的自定義內容將丟失。",
"resetSuccess": "重置成功",
"resetFailed": "重置失敗",
"saveSuccess": "保存成功",
"saveFailed": "保存失敗",
"testSuccess": "測試消息發送成功,請檢查 Telegram",
"testFailed": "測試消息發送失敗",
"variables": "可用變量",
"clickToCopy": "點擊複製",
"copied": "已複製",
"commonVariables": "通用變量",
"orderVariables": "訂單變量",
"copyTradingVariables": "跟單變量",
"redeemVariables": "贖回變量",
"errorVariables": "錯誤變量",
"filterVariables": "過濾變量",
"strategyVariables": "策略變量"
},
"templateTypes": {
"ORDER_SUCCESS": "訂單成功通知",
"ORDER_FAILED": "訂單失敗通知",
"ORDER_FILTERED": "訂單過濾通知",
"CRYPTO_TAIL_SUCCESS": "加密價差策略成功通知",
"REDEEM_SUCCESS": "倉位贖回成功通知",
"REDEEM_NO_RETURN": "倉位結算(無收益)通知"
}
},
"telegramConfig": {
"title": "Telegram 配置說明",
@@ -0,0 +1,708 @@
import React, { useEffect, useState, useCallback } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, Switch, Tooltip, Row, Col, Menu } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined, CopyOutlined, ReloadOutlined, CheckOutlined, RobotOutlined, FormOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse, TemplateVariable } from '../types'
import { useMediaQuery } from 'react-responsive'
import { TelegramConfigForm } from '../components/notifications'
import TextArea from 'antd/es/input/TextArea'
const { Title, Text, Paragraph } = Typography
const templateTypeMenuStyle: React.CSSProperties = {
border: 'none',
background: 'transparent',
}
const variableChipStyle: React.CSSProperties = {
display: 'inline-block',
cursor: 'pointer',
marginBottom: 8,
marginRight: 8,
borderRadius: 16,
padding: '6px 12px',
fontSize: 13,
transition: 'all 0.2s',
border: '1px solid #d9d9d9',
background: '#fafafa',
}
const variableChipHoverStyle: React.CSSProperties = {
borderColor: '#1890ff',
background: '#e6f7ff',
color: '#1890ff',
}
/**
* 变量分类标签映射
*/
const CATEGORY_LABELS: Record<string, string> = {
common: 'notificationSettings.templates.commonVariables',
order: 'notificationSettings.templates.orderVariables',
copy_trading: 'notificationSettings.templates.copyTradingVariables',
redeem: 'notificationSettings.templates.redeemVariables',
error: 'notificationSettings.templates.errorVariables',
filter: 'notificationSettings.templates.filterVariables',
strategy: 'notificationSettings.templates.strategyVariables'
}
const NotificationSettingsPage: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
// 机器人配置相关状态
const [configs, setConfigs] = useState<NotificationConfig[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null)
const [form] = Form.useForm()
const [testLoading, setTestLoading] = useState(false)
// 模板配置相关状态
const [templateTypes, setTemplateTypes] = useState<TemplateTypeInfo[]>([])
const [templates, setTemplates] = useState<NotificationTemplate[]>([])
const [selectedTemplateType, setSelectedTemplateType] = useState<string>('ORDER_SUCCESS')
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplate | null>(null)
const [templateVariables, setTemplateVariables] = useState<TemplateVariablesResponse | null>(null)
const [templateContent, setTemplateContent] = useState('')
const [templateLoading, setTemplateLoading] = useState(false)
const [testTemplateLoading, setTestTemplateLoading] = useState(false)
// 加载机器人配置
useEffect(() => {
fetchConfigs()
}, [])
// 加载模板类型
useEffect(() => {
fetchTemplateTypes()
}, [])
// 加载模板数据
useEffect(() => {
fetchTemplates()
}, [])
// 当选中的模板类型改变时,加载模板详情和变量
useEffect(() => {
if (selectedTemplateType) {
fetchTemplateDetail(selectedTemplateType)
fetchTemplateVariables(selectedTemplateType)
}
}, [selectedTemplateType])
const fetchConfigs = async () => {
setLoading(true)
try {
const response = await apiService.notifications.list({ type: 'telegram' })
if (response.data.code === 0 && response.data.data) {
setConfigs(response.data.data)
} else {
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.fetchFailed'))
} finally {
setLoading(false)
}
}
const fetchTemplateTypes = async () => {
try {
const response = await apiService.notifications.getTemplateTypes()
if (response.data.code === 0 && response.data.data) {
setTemplateTypes(response.data.data)
}
} catch (error) {
console.error('获取模板类型失败:', error)
}
}
const fetchTemplates = async () => {
setTemplateLoading(true)
try {
const response = await apiService.notifications.getTemplates()
if (response.data.code === 0 && response.data.data) {
setTemplates(response.data.data)
}
} catch (error) {
console.error('获取模板列表失败:', error)
} finally {
setTemplateLoading(false)
}
}
const fetchTemplateDetail = async (templateType: string) => {
try {
const response = await apiService.notifications.getTemplateDetail({ templateType })
if (response.data.code === 0 && response.data.data) {
setCurrentTemplate(response.data.data)
setTemplateContent(response.data.data.templateContent)
}
} catch (error) {
console.error('获取模板详情失败:', error)
}
}
const fetchTemplateVariables = async (templateType: string) => {
try {
const response = await apiService.notifications.getTemplateVariables({ templateType })
if (response.data.code === 0 && response.data.data) {
setTemplateVariables(response.data.data)
}
} catch (error) {
console.error('获取模板变量失败:', error)
}
}
// 机器人配置相关方法
const handleCreate = () => {
setEditingConfig(null)
form.resetFields()
form.setFieldsValue({
type: 'telegram',
enabled: true,
config: {
botToken: '',
chatIds: []
}
})
setModalVisible(true)
}
const handleEdit = (config: NotificationConfig) => {
setEditingConfig(config)
let botToken = ''
let chatIds = ''
if (config.config) {
if ('data' in config.config && config.config.data) {
const data = config.config.data as any
botToken = data.botToken || ''
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.join(',')
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds
}
}
} else {
if ('botToken' in config.config) {
botToken = (config.config as any).botToken || ''
}
if ('chatIds' in config.config) {
const ids = (config.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.join(',')
} else if (typeof ids === 'string') {
chatIds = ids
}
}
}
}
form.setFieldsValue({
type: config.type,
name: config.name,
enabled: config.enabled,
config: {
botToken: botToken,
chatIds: chatIds
}
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
try {
const response = await apiService.notifications.delete({ id })
if (response.data.code === 0) {
message.success(t('notificationSettings.deleteSuccess'))
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.deleteFailed'))
}
}
const handleUpdateEnabled = async (id: number, enabled: boolean) => {
try {
const response = await apiService.notifications.updateEnabled({ id, enabled })
if (response.data.code === 0) {
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.updateStatusFailed'))
}
}
const handleTest = async () => {
setTestLoading(true)
try {
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.testFailed'))
} finally {
setTestLoading(false)
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const chatIds = typeof values.config.chatIds === 'string'
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
: values.config.chatIds || []
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
type: values.type,
name: values.name,
enabled: values.enabled,
config: {
botToken: values.config.botToken,
chatIds: chatIds
}
}
if (editingConfig?.id) {
const updateData = {
...configData,
id: editingConfig.id
} as NotificationConfigUpdateRequest
const response = await apiService.notifications.update(updateData)
if (response.data.code === 0) {
message.success(t('notificationSettings.updateSuccess'))
setModalVisible(false)
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateFailed'))
}
} else {
const response = await apiService.notifications.create(configData)
if (response.data.code === 0) {
message.success(t('notificationSettings.createSuccess'))
setModalVisible(false)
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.createFailed'))
}
}
} catch (error: any) {
if (error.errorFields) {
return
}
message.error(error.message || t('message.error'))
}
}
const getConfigFormComponent = (type: string) => {
switch (type?.toLowerCase()) {
case 'telegram':
return <TelegramConfigForm form={form} />
default:
return null
}
}
// 模板配置相关方法
const handleTemplateTypeChange = (type: string) => {
setSelectedTemplateType(type)
}
const handleTemplateContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setTemplateContent(e.target.value)
}
const handleSaveTemplate = async () => {
try {
const response = await apiService.notifications.updateTemplate({
templateType: selectedTemplateType,
templateContent: templateContent
})
if (response.data.code === 0) {
message.success(t('notificationSettings.templates.saveSuccess'))
fetchTemplates()
fetchTemplateDetail(selectedTemplateType)
} else {
message.error(response.data.msg || t('notificationSettings.templates.saveFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.saveFailed'))
}
}
const handleResetTemplate = async () => {
try {
const response = await apiService.notifications.resetTemplate({
templateType: selectedTemplateType
})
if (response.data.code === 0) {
message.success(t('notificationSettings.templates.resetSuccess'))
fetchTemplates()
fetchTemplateDetail(selectedTemplateType)
} else {
message.error(response.data.msg || t('notificationSettings.templates.resetFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.resetFailed'))
}
}
const handleTestTemplate = async () => {
setTestTemplateLoading(true)
try {
const response = await apiService.notifications.testTemplate({
templateType: selectedTemplateType,
templateContent: templateContent
})
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.templates.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.templates.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.testFailed'))
} finally {
setTestTemplateLoading(false)
}
}
const handleCopyVariable = useCallback((variable: string) => {
navigator.clipboard.writeText(`{{${variable}}}`)
message.success(t('notificationSettings.templates.copied'))
}, [t])
const [variableHoverKey, setVariableHoverKey] = useState<string | null>(null)
const renderVariableItem = (variable: TemplateVariable) => {
const isHover = variableHoverKey === variable.key
return (
<Tooltip key={variable.key} title={variable.description || `{{${variable.key}}}`}>
<span
role="button"
tabIndex={0}
style={{ ...variableChipStyle, ...(isHover ? variableChipHoverStyle : {}) }}
onClick={() => handleCopyVariable(variable.key)}
onMouseEnter={() => setVariableHoverKey(variable.key)}
onMouseLeave={() => setVariableHoverKey(null)}
onKeyDown={(e) => e.key === 'Enter' && handleCopyVariable(variable.key)}
>
<CopyOutlined style={{ marginRight: 6, fontSize: 12 }} />
{variable.label}
</span>
</Tooltip>
)
}
const renderVariablesPanel = () => {
if (!templateVariables) return null
return (
<Card
size="small"
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
{t('notificationSettings.templates.variables')}
</span>
}
style={{ height: '100%', borderRadius: 8 }}
bodyStyle={{ paddingTop: 12 }}
>
{templateVariables.categories.map(category => {
const categoryVariables = templateVariables.variables.filter(v => v.category === category.key)
if (categoryVariables.length === 0) return null
return (
<div key={category.key} style={{ marginBottom: 20 }}>
<Text strong style={{ marginBottom: 10, display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
{t(CATEGORY_LABELS[category.key] || category.label)}
</Text>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{categoryVariables.sort((a, b) => a.sortOrder - b.sortOrder).map(renderVariableItem)}
</div>
</div>
)
})}
<Paragraph type="secondary" style={{ marginTop: 16, marginBottom: 0, fontSize: 12 }}>
{t('notificationSettings.templates.clickToCopy')}
</Paragraph>
</Card>
)
}
// 机器人配置表格列
const configColumns = [
{
title: t('notificationSettings.configName'),
dataIndex: 'name',
key: 'name',
},
{
title: t('notificationSettings.type'),
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
},
{
title: t('notificationSettings.status'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'default'}>
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
</Tag>
)
},
{
title: t('notificationSettings.chatIds'),
key: 'chatIds',
render: (_: any, record: NotificationConfig) => {
let chatIds: string[] = []
if (record.config) {
if ('data' in record.config && record.config.data) {
const data = (record.config as any).data
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.filter((id: any) => id && String(id).trim())
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
}
}
} else if ('chatIds' in record.config) {
const ids = (record.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.filter((id: any) => id && String(id).trim())
} else if (typeof ids === 'string') {
chatIds = (ids as string).split(',').map((id: string) => id.trim()).filter((id: string) => id)
}
}
}
return chatIds.length > 0 ? (
<Text type="secondary" style={{ fontSize: '12px' }}>
{chatIds.join(', ')}
</Text>
) : (
<Text type="danger" style={{ fontSize: '12px' }}>{t('notificationSettings.chatIdsNotConfigured')}</Text>
)
}
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 120 : 200,
render: (_: any, record: NotificationConfig) => (
<Space size="small" wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
{t('notificationSettings.edit')}
</Button>
<Switch
checked={record.enabled}
size="small"
onChange={(checked) => handleUpdateEnabled(record.id!, checked)}
/>
<Button
type="link"
size="small"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTest}
>
{t('notificationSettings.test')}
</Button>
<Popconfirm
title={t('notificationSettings.deleteConfirm')}
onConfirm={() => handleDelete(record.id!)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
{t('notificationSettings.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const templateTypeMenuItems = templateTypes.map(type => ({
key: type.type,
icon: <FormOutlined />,
label: (
<div>
<div style={{ fontWeight: 500 }}>{t(`notificationSettings.templateTypes.${type.type}`)}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginTop: 2 }}>{type.description}</div>
</div>
),
}))
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Title level={2} style={{ margin: 0 }}>{t('notificationSettings.title')}</Title>
</div>
{/* 机器人配置 */}
<Card
title={
<Space>
<RobotOutlined />
<span>{t('notificationSettings.botConfig')}</span>
</Space>
}
style={{ marginBottom: '16px' }}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
{t('notificationSettings.addConfig')}
</Button>
}
>
<Table
columns={configColumns}
dataSource={configs}
loading={loading}
rowKey="id"
pagination={false}
scroll={{ x: isMobile ? 600 : 'auto' }}
/>
</Card>
{/* 模板配置 */}
<Card
title={
<Space>
<FormOutlined />
<span>{t('notificationSettings.templateConfig')}</span>
</Space>
}
loading={templateLoading}
style={{ marginBottom: '16px' }}
>
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={6}>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ display: 'block', marginBottom: 12, fontSize: 14 }}>
{t('notificationSettings.templates.templateType')}
</Text>
<Menu
mode="inline"
selectedKeys={[selectedTemplateType]}
style={{ ...templateTypeMenuStyle, minHeight: 320 }}
items={templateTypeMenuItems}
onClick={({ key }) => handleTemplateTypeChange(key)}
/>
</div>
</Col>
<Col xs={24} sm={24} md={10}>
<Card size="small" bordered style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<Space wrap size="small">
<Text strong style={{ fontSize: 14 }}>{t('notificationSettings.templates.templateContent')}</Text>
{currentTemplate && (
<Tag color={currentTemplate.isDefault ? 'green' : 'blue'}>
{currentTemplate.isDefault ? t('notificationSettings.templates.isDefault') : t('notificationSettings.templates.isCustom')}
</Tag>
)}
</Space>
<Space wrap size="small">
<Popconfirm
title={t('notificationSettings.templates.resetConfirm')}
onConfirm={handleResetTemplate}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button size={isMobile ? 'small' : 'middle'} icon={<ReloadOutlined />}>
{t('notificationSettings.templates.resetToDefault')}
</Button>
</Popconfirm>
<Button size={isMobile ? 'small' : 'middle'} type="primary" icon={<CheckOutlined />} onClick={handleSaveTemplate}>
{t('common.save')}
</Button>
<Button size={isMobile ? 'small' : 'middle'} icon={<SendOutlined />} loading={testTemplateLoading} onClick={handleTestTemplate}>
{t('notificationSettings.test')}
</Button>
</Space>
</div>
</Card>
<TextArea
value={templateContent}
onChange={handleTemplateContentChange}
rows={14}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Col>
<Col xs={24} sm={24} md={8}>
{renderVariablesPanel()}
</Col>
</Row>
</Card>
<Modal
title={editingConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={isMobile ? '90%' : 600}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Form
form={form}
layout="vertical"
>
<Form.Item
name="type"
label={t('notificationSettings.type')}
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
>
<Input disabled value="telegram" />
</Form.Item>
<Form.Item
name="name"
label={t('notificationSettings.configName')}
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
>
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
</Form.Item>
<Form.Item
name="enabled"
label={t('notificationSettings.enabled')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate={(prevValues, currentValues) => {
return prevValues.type !== currentValues.type ||
prevValues.config !== currentValues.config
}}>
{() => {
const currentType = form.getFieldValue('type') || 'telegram'
return getConfigFormComponent(currentType)
}}
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default NotificationSettingsPage
+20 -316
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select, Table, Tag, Popconfirm, Modal } from 'antd'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined, RightOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SystemConfig, BuilderApiKeyUpdateRequest, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
import { TelegramConfigForm } from '../components/notifications'
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
import SystemUpdate from './SystemUpdate'
const { Title, Text, Paragraph } = Typography
@@ -33,20 +33,13 @@ interface ProxyCheckResponse {
const SystemSettings: React.FC = () => {
const { t, i18n: i18nInstance } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
// 第一部分:多语言
const [languageForm] = Form.useForm()
const [currentLang, setCurrentLang] = useState<string>('auto')
// 第二部分:消息推送设
const [notificationConfigs, setNotificationConfigs] = useState<NotificationConfig[]>([])
const [notificationLoading, setNotificationLoading] = useState(false)
const [notificationModalVisible, setNotificationModalVisible] = useState(false)
const [editingNotificationConfig, setEditingNotificationConfig] = useState<NotificationConfig | null>(null)
const [notificationForm] = Form.useForm()
const [testLoading, setTestLoading] = useState(false)
// 第三部分:Relayer配置
// 第二部分:Relayer配
const [relayerForm] = Form.useForm()
const [autoRedeemForm] = Form.useForm()
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null)
@@ -67,7 +60,6 @@ const SystemSettings: React.FC = () => {
languageForm.setFieldsValue({ language: savedLanguage })
// 加载其他配置
fetchNotificationConfigs()
fetchSystemConfig()
fetchProxyConfig()
}, [])
@@ -103,246 +95,7 @@ const SystemSettings: React.FC = () => {
}
}
// ==================== 第二部分:消息推送设置 ====================
const fetchNotificationConfigs = async () => {
setNotificationLoading(true)
try {
const response = await apiService.notifications.list({ type: 'telegram' })
if (response.data.code === 0 && response.data.data) {
setNotificationConfigs(response.data.data)
} else {
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.fetchFailed'))
} finally {
setNotificationLoading(false)
}
}
const handleNotificationCreate = () => {
setEditingNotificationConfig(null)
notificationForm.resetFields()
notificationForm.setFieldsValue({
type: 'telegram',
enabled: true,
config: {
botToken: '',
chatIds: []
}
})
setNotificationModalVisible(true)
}
const handleNotificationEdit = (config: NotificationConfig) => {
setEditingNotificationConfig(config)
let botToken = ''
let chatIds = ''
if (config.config) {
if ('data' in config.config && config.config.data) {
const data = config.config.data as any
botToken = data.botToken || ''
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.join(',')
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds
}
}
} else {
if ('botToken' in config.config) {
botToken = (config.config as any).botToken || ''
}
if ('chatIds' in config.config) {
const ids = (config.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.join(',')
} else if (typeof ids === 'string') {
chatIds = ids
}
}
}
}
notificationForm.setFieldsValue({
type: config.type,
name: config.name,
enabled: config.enabled,
config: {
botToken: botToken,
chatIds: chatIds
}
})
setNotificationModalVisible(true)
}
const handleNotificationDelete = async (id: number) => {
try {
const response = await apiService.notifications.delete({ id })
if (response.data.code === 0) {
message.success(t('notificationSettings.deleteSuccess'))
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.deleteFailed'))
}
}
const handleNotificationUpdateEnabled = async (id: number, enabled: boolean) => {
try {
const response = await apiService.notifications.updateEnabled({ id, enabled })
if (response.data.code === 0) {
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.updateStatusFailed'))
}
}
const handleNotificationTest = async () => {
setTestLoading(true)
try {
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.testFailed'))
} finally {
setTestLoading(false)
}
}
const handleNotificationSubmit = async () => {
try {
const values = await notificationForm.validateFields()
const chatIds = typeof values.config.chatIds === 'string'
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
: values.config.chatIds || []
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
type: values.type,
name: values.name,
enabled: values.enabled,
config: {
botToken: values.config.botToken,
chatIds: chatIds
}
}
if (editingNotificationConfig?.id) {
const updateData = {
...configData,
id: editingNotificationConfig.id
} as NotificationConfigUpdateRequest
const response = await apiService.notifications.update(updateData)
if (response.data.code === 0) {
message.success(t('notificationSettings.updateSuccess'))
setNotificationModalVisible(false)
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateFailed'))
}
} else {
const response = await apiService.notifications.create(configData)
if (response.data.code === 0) {
message.success(t('notificationSettings.createSuccess'))
setNotificationModalVisible(false)
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.createFailed'))
}
}
} catch (error: any) {
if (error.errorFields) {
return
}
message.error(error.message || t('message.error'))
}
}
const notificationColumns = [
{
title: t('notificationSettings.configName'),
dataIndex: 'name',
key: 'name',
},
{
title: t('notificationSettings.type'),
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
},
{
title: t('notificationSettings.status'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'default'}>
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
</Tag>
)
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 120 : 200,
render: (_: any, record: NotificationConfig) => (
<Space size="small" wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleNotificationEdit(record)}
>
{t('notificationSettings.edit')}
</Button>
<Switch
checked={record.enabled}
size="small"
onChange={(checked) => handleNotificationUpdateEnabled(record.id!, checked)}
/>
<Button
type="link"
size="small"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleNotificationTest}
>
{t('notificationSettings.test')}
</Button>
<Popconfirm
title={t('notificationSettings.deleteConfirm')}
onConfirm={() => handleNotificationDelete(record.id!)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
{t('notificationSettings.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
// ==================== 第三部分:Relayer配置 ====================
// ==================== 第二部分:Relayer配置 ====================
const fetchSystemConfig = async () => {
try {
const response = await apiService.systemConfig.get()
@@ -551,7 +304,7 @@ const SystemSettings: React.FC = () => {
</Form>
</Card>
{/* 第二部分:消息推送设置 */}
{/* 第二部分:消息推送设置(独立页面入口) */}
<Card
title={
<Space>
@@ -563,73 +316,24 @@ const SystemSettings: React.FC = () => {
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleNotificationCreate}
icon={<RightOutlined />}
onClick={() => navigate('/system-settings/notification')}
>
{t('notificationSettings.addConfig')}
{t('notificationSettings.title')}
</Button>
}
>
<Table
columns={notificationColumns}
dataSource={notificationConfigs}
loading={notificationLoading}
rowKey="id"
pagination={false}
scroll={{ x: isMobile ? 600 : 'auto' }}
/>
<Modal
title={editingNotificationConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
open={notificationModalVisible}
onOk={handleNotificationSubmit}
onCancel={() => setNotificationModalVisible(false)}
width={isMobile ? '90%' : 600}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
{t('notificationSettings.botConfig')}{t('notificationSettings.templateConfig')}
</Paragraph>
<Button
type="link"
icon={<RightOutlined />}
onClick={() => navigate('/system-settings/notification')}
style={{ padding: 0 }}
>
<Form
form={notificationForm}
layout="vertical"
>
<Form.Item
name="type"
label={t('notificationSettings.type')}
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
>
<Input disabled value="telegram" />
</Form.Item>
<Form.Item
name="name"
label={t('notificationSettings.configName')}
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
>
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
</Form.Item>
<Form.Item
name="enabled"
label={t('notificationSettings.enabled')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate={(prevValues, currentValues) => {
return prevValues.type !== currentValues.type ||
prevValues.config !== currentValues.config
}}>
{() => {
const currentType = notificationForm.getFieldValue('type') || 'telegram'
if (currentType === 'telegram') {
return <TelegramConfigForm form={notificationForm} />
}
return null
}}
</Form.Item>
</Form>
</Modal>
{t('notificationSettings.title')}
</Button>
</Card>
{/* 第三部分:Relayer配置 */}
+46 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosInstance, AxiosError } from 'axios'
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse } from '../types'
import { getToken, setToken, removeToken } from '../utils'
import { wsManager } from './websocket'
import i18n from '../i18n/config'
@@ -686,7 +686,51 @@ export const apiService = {
* Telegram Chat IDs
*/
getTelegramChatIds: (data: { botToken: string }) =>
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data)
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data),
// ==================== 模板相关 API ====================
/**
*
*/
getTemplateTypes: () =>
apiClient.post<ApiResponse<TemplateTypeInfo[]>>('/system/notifications/templates/types', {}),
/**
*
*/
getTemplates: () =>
apiClient.post<ApiResponse<NotificationTemplate[]>>('/system/notifications/templates/list', {}),
/**
*
*/
getTemplateDetail: (data: { templateType: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/detail', data),
/**
*
*/
getTemplateVariables: (data: { templateType: string }) =>
apiClient.post<ApiResponse<TemplateVariablesResponse>>('/system/notifications/templates/variables', data),
/**
*
*/
updateTemplate: (data: { templateType: string; templateContent: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/update', data),
/**
*
*/
resetTemplate: (data: { templateType: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/reset', data),
/**
*
*/
testTemplate: (data: { templateType: string; templateContent?: string }) =>
apiClient.post<ApiResponse<boolean>>('/system/notifications/templates/test', data)
},
/**
+53
View File
@@ -1258,3 +1258,56 @@ export interface ManualOrderDetails {
/** 总金额 */
totalAmount: string
}
// ==================== 消息模板相关类型 ====================
/**
*
*/
export interface NotificationTemplate {
id?: number
templateType: string // 模板类型
templateContent: string // 模板内容
isDefault: boolean // 是否使用默认模板
createdAt?: number
updatedAt?: number
}
/**
*
*/
export interface TemplateTypeInfo {
type: string // 模板类型
name: string // 类型名称
description: string // 类型描述
}
/**
*
*/
export interface TemplateVariable {
key: string // 变量名
label: string // 显示名称
description: string // 变量说明
category: string // 分类
sortOrder: number // 排序顺序
}
/**
*
*/
export interface TemplateVariableCategory {
key: string // 分类 key
label: string // 分类名称
sortOrder: number // 排序顺序
}
/**
*
*/
export interface TemplateVariablesResponse {
templateType: string // 模板类型
templateTypeName: string // 模板类型名称
categories: TemplateVariableCategory[] // 分类列表
variables: TemplateVariable[] // 变量列表
}