feat: 完善 Telegram 推送通知功能
- 推送模板优化: - 优先使用账户名称而不是钱包地址 - 使用市场标题而不是16进制ID - 添加可点击的市场链接(支持 slug 和 conditionId) - 添加市场方向(outcome)显示 - 数量和价格从订单详情API获取实际值 - 失败通知只显示后端返回的msg,不显示完整堆栈 - 多语言支持: - 后端推送消息支持多语言(使用前端最后请求的语言) - 添加所有 ErrorCode 的多语言资源文件(中文简体、繁体、英文) - 通知消息文本全部使用多语言资源 - 功能改进: - 从订单详情获取实际的 side、price、size、outcome - 支持买入/卖出方向的多语言显示 - 错误信息优化,只显示后端返回的错误消息
This commit is contained in:
@@ -18,12 +18,14 @@ import {
|
||||
GithubOutlined,
|
||||
TwitterOutlined,
|
||||
GlobalOutlined,
|
||||
CheckCircleOutlined
|
||||
CheckCircleOutlined,
|
||||
NotificationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import { removeToken } from '../utils'
|
||||
import { wsManager } from '../services/websocket'
|
||||
import Logo from './Logo'
|
||||
|
||||
const { Header, Content, Sider } = AntLayout
|
||||
|
||||
@@ -133,6 +135,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
key: '/system-settings/proxy',
|
||||
icon: <LinkOutlined />,
|
||||
label: t('menu.proxy')
|
||||
},
|
||||
{
|
||||
key: '/system-settings/notifications',
|
||||
icon: <NotificationOutlined />,
|
||||
label: t('menu.notifications')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -198,9 +205,10 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
|
||||
PolyHermes
|
||||
</div>
|
||||
<Logo
|
||||
size="normal"
|
||||
darkMode={true}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<a
|
||||
href="https://github.com/WrBug/PolyHermes"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react'
|
||||
|
||||
interface LogoProps {
|
||||
/**
|
||||
* Logo 尺寸
|
||||
* @default 'normal'
|
||||
*/
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
/**
|
||||
* 是否只显示图标(不显示文字)
|
||||
* @default false
|
||||
*/
|
||||
iconOnly?: boolean
|
||||
/**
|
||||
* 是否使用深色模式(用于深色背景)
|
||||
* @default false
|
||||
*/
|
||||
darkMode?: boolean
|
||||
/**
|
||||
* 自定义样式类名
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* 自定义样式
|
||||
*/
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* PolyHermes Logo 组件
|
||||
*
|
||||
* 设计理念:
|
||||
* - Hermes(赫尔墨斯)是希腊神话中的信使神,代表快速传递和连接
|
||||
* - 结合交易元素(箭头、连接线)体现跟单交易的核心功能
|
||||
* - 现代简洁的设计风格,适配深色和浅色背景
|
||||
*/
|
||||
const Logo: React.FC<LogoProps> = ({
|
||||
size = 'normal',
|
||||
iconOnly = false,
|
||||
darkMode = false,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
// 根据尺寸确定图标大小
|
||||
const iconSizes = {
|
||||
small: 24,
|
||||
normal: 32,
|
||||
large: 48
|
||||
}
|
||||
|
||||
// 根据尺寸确定文字大小
|
||||
const textSizes = {
|
||||
small: 14,
|
||||
normal: 18,
|
||||
large: 24
|
||||
}
|
||||
|
||||
const iconSize = iconSizes[size]
|
||||
const textSize = textSizes[size]
|
||||
|
||||
// 根据深色模式选择颜色
|
||||
const gradientColors = darkMode
|
||||
? { start: '#69c0ff', end: '#b37feb' } // 深色背景使用较亮的颜色
|
||||
: { start: '#1890ff', end: '#722ed1' } // 浅色背景使用标准颜色
|
||||
|
||||
const textColor = darkMode ? '#fff' : 'inherit'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`polyhermes-logo ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{/* Logo 图标 */}
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{/* 渐变定义 */}
|
||||
<defs>
|
||||
<linearGradient id={`logoGradient-${darkMode ? 'dark' : 'light'}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={gradientColors.start} />
|
||||
<stop offset="100%" stopColor={gradientColors.end} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 主图标设计:双箭头连接,代表跟单交易 */}
|
||||
{/* 左侧箭头(指向中心) */}
|
||||
<path
|
||||
d="M 16 32 L 8 24 L 8 40 Z"
|
||||
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
/>
|
||||
|
||||
{/* 中心连接线(代表跟单连接) */}
|
||||
<line
|
||||
x1="20"
|
||||
y1="32"
|
||||
x2="44"
|
||||
y2="32"
|
||||
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 右侧箭头(指向中心) */}
|
||||
<path
|
||||
d="M 48 32 L 56 24 L 56 40 Z"
|
||||
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
/>
|
||||
|
||||
{/* 中心圆点(代表交易节点/数据同步点) */}
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="5"
|
||||
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
/>
|
||||
|
||||
{/* 装饰性数据流弧线(代表实时数据同步) */}
|
||||
<path
|
||||
d="M 20 20 Q 32 14 44 20"
|
||||
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
opacity="0.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 20 44 Q 32 50 44 44"
|
||||
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
opacity="0.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Logo 文字 */}
|
||||
{!iconOnly && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${textSize}px`,
|
||||
fontWeight: 'bold',
|
||||
background: darkMode
|
||||
? 'linear-gradient(135deg, #69c0ff 0%, #b37feb 100%)'
|
||||
: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
letterSpacing: '0.5px',
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
PolyHermes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Form, Input, Alert } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DiscordConfigFormProps {
|
||||
form: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord 配置表单组件
|
||||
*/
|
||||
const DiscordConfigForm: React.FC<DiscordConfigFormProps> = ({ form }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
message={t('discordConfig.title')}
|
||||
description={
|
||||
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||
<p style={{ margin: '4px 0' }}>{t('discordConfig.step1')}</p>
|
||||
<p style={{ margin: '4px 0' }}>{t('discordConfig.step2')}</p>
|
||||
<p style={{ margin: '4px 0' }}>{t('discordConfig.step3')}</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
label={t('notificationSettings.chatIds')}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name={['config', 'webhookUrl']}
|
||||
rules={[{ required: true, message: t('discordConfig.webhookUrlRequired') }]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('discordConfig.webhookUrlPlaceholder')}
|
||||
addonBefore={t('discordConfig.webhookUrl')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscordConfigForm
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Form, Input, Alert } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SlackConfigFormProps {
|
||||
form: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack 配置表单组件
|
||||
*/
|
||||
const SlackConfigForm: React.FC<SlackConfigFormProps> = ({ form }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
message={t('slackConfig.title')}
|
||||
description={
|
||||
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||
<p style={{ margin: '4px 0' }}>{t('slackConfig.step1')}</p>
|
||||
<p style={{ margin: '4px 0' }}>{t('slackConfig.step2')}</p>
|
||||
<p style={{ margin: '4px 0' }}>{t('slackConfig.step3')}</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
label={t('notificationSettings.chatIds')}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name={['config', 'webhookUrl']}
|
||||
rules={[{ required: true, message: t('slackConfig.webhookUrlRequired') }]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('slackConfig.webhookUrlPlaceholder')}
|
||||
addonBefore={t('slackConfig.webhookUrl')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlackConfigForm
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Alert, Button, Space, message } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../../services/api'
|
||||
|
||||
interface TelegramConfigFormProps {
|
||||
form: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram 配置表单组件
|
||||
*/
|
||||
const TelegramConfigForm: React.FC<TelegramConfigFormProps> = ({ form }) => {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* 自动获取 Chat IDs
|
||||
*/
|
||||
const handleGetChatIds = async () => {
|
||||
const botToken = form.getFieldValue(['config', 'botToken'])
|
||||
if (!botToken || botToken.trim() === '') {
|
||||
message.warning(t('notificationSettings.getChatIdsNoToken'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.notifications.getTelegramChatIds({ botToken: botToken.trim() })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const chatIds = response.data.data
|
||||
if (chatIds.length > 0) {
|
||||
// 获取现有的 Chat IDs
|
||||
const existingChatIds = form.getFieldValue(['config', 'chatIds']) || ''
|
||||
const existingArray = typeof existingChatIds === 'string'
|
||||
? existingChatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
|
||||
: Array.isArray(existingChatIds) ? existingChatIds : []
|
||||
|
||||
// 合并并去重
|
||||
const allChatIds = [...new Set([...existingArray, ...chatIds])]
|
||||
form.setFieldsValue({
|
||||
config: {
|
||||
...form.getFieldValue('config'),
|
||||
chatIds: allChatIds.join(',')
|
||||
}
|
||||
})
|
||||
message.success(t('notificationSettings.getChatIdsSuccess', { count: chatIds.length }))
|
||||
} else {
|
||||
message.warning(t('notificationSettings.getChatIdsNoMessage'))
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || t('notificationSettings.getChatIdsFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('notificationSettings.getChatIdsFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
message={t('telegramConfig.title')}
|
||||
description={
|
||||
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `1. ${t('telegramConfig.step1')}` }} />
|
||||
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `2. ${t('telegramConfig.step2')}` }} />
|
||||
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `3. ${t('telegramConfig.step3')}` }} />
|
||||
<p style={{ margin: '4px 0' }}>{t('telegramConfig.step4')}</p>
|
||||
<p style={{ margin: '4px 0' }}>{t('telegramConfig.step5')}</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
label={t('notificationSettings.chatIds')}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name={['config', 'botToken']}
|
||||
rules={[{ required: true, message: t('telegramConfig.botTokenRequired') }]}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={t('telegramConfig.botTokenPlaceholder')}
|
||||
addonBefore={t('telegramConfig.botToken')}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleGetChatIds}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{t('notificationSettings.getChatIdsButton')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['config', 'chatIds']}
|
||||
rules={[{ required: true, message: t('notificationSettings.chatIdsRequired') }]}
|
||||
extra={t('notificationSettings.chatIdsExtra')}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('notificationSettings.chatIdsPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TelegramConfigForm
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as TelegramConfigForm } from './TelegramConfigForm'
|
||||
export { default as DiscordConfigForm } from './DiscordConfigForm'
|
||||
export { default as SlackConfigForm } from './SlackConfigForm'
|
||||
|
||||
Reference in New Issue
Block a user