余额告警独立页面与多渠道通知:钉钉/飞书/企业微信/Bark,支持通知频率策略
- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道 - 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置 - 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送 - 优化告警话术:去商户ID展示、数字千分位格式化
This commit is contained in:
@@ -10,6 +10,7 @@ import ApiDebugger from './pages/ApiDebugger'
|
||||
import Delivery from './pages/Delivery'
|
||||
import MerchantCenter from './pages/MerchantCenter'
|
||||
import MerchantRecharge from './pages/MerchantRecharge'
|
||||
import AlertSettings from './pages/AlertSettings'
|
||||
import PlatformMerchants from './pages/PlatformMerchants'
|
||||
import type { ReactNode } from 'react'
|
||||
function PrivateRoute({ children }: { children: ReactNode }) {
|
||||
@@ -43,6 +44,7 @@ function AppRoutes() {
|
||||
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
|
||||
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
|
||||
<Route path="merchant-recharge" element={<MerchantRecharge />} />
|
||||
<Route path="merchant-alert-settings" element={<AlertSettings />} />
|
||||
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
|
||||
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
|
||||
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
DashboardStats,
|
||||
AlertChannelInput,
|
||||
AlertNotifyChannel,
|
||||
ApiClient,
|
||||
ApiCredential,
|
||||
CallbackCredential,
|
||||
@@ -108,8 +110,18 @@ export const merchantApi = {
|
||||
request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication),
|
||||
alertConfig: () =>
|
||||
request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
saveAlertConfig: (data: LowBalanceAlertConfig) =>
|
||||
saveAlertConfig: (data: { enabled: boolean; threshold_points: number; notify_interval_minutes: number; max_notifications: number }) =>
|
||||
request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig),
|
||||
alertChannels: () =>
|
||||
request.get('/merchant/alert-channels').then((r) => r.data.data as AlertNotifyChannel[]),
|
||||
createAlertChannel: (data: AlertChannelInput) =>
|
||||
request.post('/merchant/alert-channels', data).then((r) => r.data.data as AlertNotifyChannel),
|
||||
updateAlertChannel: (id: number, data: AlertChannelInput) =>
|
||||
request.put(`/merchant/alert-channels/${id}`, data).then((r) => r.data.data as AlertNotifyChannel),
|
||||
deleteAlertChannel: (id: number) =>
|
||||
request.delete(`/merchant/alert-channels/${id}`).then((r) => r.data.data),
|
||||
testAlertChannel: (id: number) =>
|
||||
request.post(`/merchant/alert-channels/${id}/test`).then((r) => r.data.data),
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
|
||||
@@ -85,6 +85,7 @@ const adminSections: SidebarSection[] = [
|
||||
children: [
|
||||
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
|
||||
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
|
||||
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,6 +127,7 @@ const merchantSections: SidebarSection[] = [
|
||||
children: [
|
||||
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
|
||||
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
|
||||
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,6 +153,7 @@ function getSelectedKey(pathname: string, search: string) {
|
||||
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
|
||||
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
|
||||
if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge'
|
||||
if (pathname.startsWith('/merchant-alert-settings')) return 'merchant-alert-settings'
|
||||
if (pathname.startsWith('/merchant-members')) return 'merchant-members'
|
||||
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
|
||||
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
BellOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { merchantApi } from '../api'
|
||||
import { formatDateTime } from '../utils/time'
|
||||
import type { AlertChannelType, AlertNotifyChannel, LowBalanceAlertConfig, WalletAccount } from '../types'
|
||||
|
||||
const channelOptions: { value: AlertChannelType; label: string; placeholder: string }[] = [
|
||||
{ value: 'dingtalk', label: '钉钉机器人', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
|
||||
{ value: 'feishu', label: '飞书机器人', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
|
||||
{ value: 'wecom', label: '企业微信机器人', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
|
||||
{ value: 'bark', label: 'Bark(iOS)', placeholder: '' },
|
||||
{ value: 'webhook', label: '通用 Webhook', placeholder: 'https://your-server.com/notify' },
|
||||
]
|
||||
|
||||
const channelConfigMap: Record<string, { label: string; color: string; icon: string }> = {
|
||||
dingtalk: { label: '钉钉机器人', color: '#0284c7', icon: '✉️' },
|
||||
feishu: { label: '飞书机器人', color: '#0ea5e9', icon: '🚀' },
|
||||
wecom: { label: '企业微信机器人', color: '#16a34a', icon: '💼' },
|
||||
bark: { label: 'Bark (iOS)', color: '#7c3aed', icon: '📱' },
|
||||
webhook: { label: '通用 Webhook', color: '#475569', icon: '🔗' },
|
||||
}
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
function configSummary(record: AlertNotifyChannel) {
|
||||
const cfg = record.config || {}
|
||||
if (record.channel_type === 'bark') {
|
||||
return `${cfg.server || 'https://api.day.app'}/${cfg.key || '-'}`
|
||||
}
|
||||
return cfg.webhook_url || '-'
|
||||
}
|
||||
|
||||
export default function AlertSettings() {
|
||||
const [channels, setChannels] = useState<AlertNotifyChannel[]>([])
|
||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||
const [alertConfig, setAlertConfig] = useState<LowBalanceAlertConfig | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [alertForm] = Form.useForm()
|
||||
const [channelForm] = Form.useForm()
|
||||
const [channelOpen, setChannelOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<AlertNotifyChannel | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [configData, channelData] = await Promise.all([
|
||||
merchantApi.alertConfig(),
|
||||
merchantApi.alertChannels(),
|
||||
])
|
||||
setChannels(channelData || [])
|
||||
setAlertConfig(configData)
|
||||
alertForm.setFieldsValue({
|
||||
enabled: configData.enabled,
|
||||
threshold_points: configData.threshold_points,
|
||||
notify_interval_minutes: configData.notify_interval_minutes || 60,
|
||||
max_notifications: configData.max_notifications ?? 5,
|
||||
})
|
||||
merchantApi.wallet().then(setWallet).catch(() => setWallet(null))
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [alertForm])
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
try {
|
||||
const values = await alertForm.validateFields()
|
||||
await merchantApi.saveAlertConfig({
|
||||
enabled: values.enabled,
|
||||
threshold_points: Number(values.threshold_points),
|
||||
notify_interval_minutes: Number(values.notify_interval_minutes),
|
||||
max_notifications: Number(values.max_notifications),
|
||||
})
|
||||
message.success('告警阈值设置已保存')
|
||||
loadAll()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingChannel(null)
|
||||
channelForm.resetFields()
|
||||
channelForm.setFieldsValue({ channel_type: 'dingtalk', enabled: true })
|
||||
setChannelOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: AlertNotifyChannel) => {
|
||||
setEditingChannel(record)
|
||||
channelForm.resetFields()
|
||||
channelForm.setFieldsValue({
|
||||
channel_type: record.channel_type,
|
||||
name: record.name,
|
||||
webhook_url: record.config?.webhook_url,
|
||||
server: record.config?.server,
|
||||
key: record.config?.key,
|
||||
enabled: record.enabled,
|
||||
})
|
||||
setChannelOpen(true)
|
||||
}
|
||||
|
||||
const submitChannel = async () => {
|
||||
try {
|
||||
const values = await channelForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
channel_type: values.channel_type,
|
||||
name: values.name,
|
||||
webhook_url: values.webhook_url,
|
||||
server: values.server,
|
||||
key: values.key,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
if (editingChannel) {
|
||||
await merchantApi.updateAlertChannel(editingChannel.id, payload)
|
||||
message.success('通知渠道已更新')
|
||||
} else {
|
||||
await merchantApi.createAlertChannel(payload)
|
||||
message.success('通知渠道已添加')
|
||||
}
|
||||
setChannelOpen(false)
|
||||
loadAll()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChannel = async (record: AlertNotifyChannel) => {
|
||||
try {
|
||||
await merchantApi.deleteAlertChannel(record.id)
|
||||
message.success('通知渠道已删除')
|
||||
loadAll()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const testChannel = async (record: AlertNotifyChannel) => {
|
||||
try {
|
||||
await merchantApi.testAlertChannel(record.id)
|
||||
message.success('测试消息已发送,请检查接收端')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AlertNotifyChannel> = [
|
||||
{
|
||||
title: '渠道类型',
|
||||
dataIndex: 'channel_type',
|
||||
width: 160,
|
||||
render: (v: string) => {
|
||||
const item = channelConfigMap[v] || { label: v, color: '#475569', icon: '🔔' }
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
padding: '2px 10px',
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${item.color}30`,
|
||||
background: `${item.color}10`,
|
||||
color: item.color,
|
||||
fontSize: 12.5,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '渠道名称',
|
||||
dataIndex: 'name',
|
||||
width: 180,
|
||||
render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{v || '默认接收组'}</span>,
|
||||
},
|
||||
{
|
||||
title: '通知地址 / 密钥配置',
|
||||
dataIndex: 'config',
|
||||
width: 420,
|
||||
ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const text = configSummary(record)
|
||||
return text !== '-' ? (
|
||||
<Text code copyable={{ text }} ellipsis style={{ maxWidth: '100%' }}>
|
||||
{text}
|
||||
</Text>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 110,
|
||||
render: (v: boolean) =>
|
||||
v ? (
|
||||
<span className="status-tag status-tag--green">
|
||||
<span className="status-dot"></span>
|
||||
已启用
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-tag status-tag--gray">
|
||||
<span className="status-dot"></span>
|
||||
已停用
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => testChannel(record)}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除该通知渠道?"
|
||||
onConfirm={() => deleteChannel(record)}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const channelType = Form.useWatch('channel_type', channelForm)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="余额告警设置"
|
||||
subtitle="设置低余额预警阈值,配置钉钉 / 飞书 / 企业微信 / Bark 等通知渠道"
|
||||
breadcrumbs={[{ title: '积分管理' }, { title: '余额告警' }]}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadAll}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||
title={
|
||||
<Space size={8}>
|
||||
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>预警阈值设置</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={alertForm} layout="vertical" style={{ padding: '8px 4px 0' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>当前账户积分余额</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
|
||||
{wallet === null ? '-' : wallet.available_balance.toLocaleString('zh-CN')}
|
||||
<span style={{ fontSize: 13, color: '#64748b', fontWeight: 500, marginLeft: 4 }}>积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item name="enabled" label="启用告警" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="threshold_points"
|
||||
label="低于多少积分提醒"
|
||||
rules={[{ required: true, message: '请填写预警阈值' }]}
|
||||
style={{ marginBottom: 0, minWidth: 240 }}
|
||||
>
|
||||
<InputNumber<number> min={0} step={100000} addonAfter="积分" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="notify_interval_minutes"
|
||||
label="通知间隔"
|
||||
rules={[{ required: true, message: '请填写通知间隔' }]}
|
||||
style={{ marginBottom: 0, minWidth: 180 }}
|
||||
>
|
||||
<InputNumber<number> min={1} precision={0} addonAfter="分钟" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="max_notifications"
|
||||
label="最大通知次数"
|
||||
rules={[{ required: true, message: '请填写最大通知次数' }]}
|
||||
tooltip="余额低于阈值期间最多通知几次,0 表示不限"
|
||||
style={{ marginBottom: 0, minWidth: 160 }}
|
||||
>
|
||||
<InputNumber<number> min={0} precision={0} addonAfter="次" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ alignSelf: 'flex-end', marginBottom: 0 }}>
|
||||
<Button type="primary" onClick={handleSaveConfig}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed #e2e8f0' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
提示:余额低于设定阈值时开始告警,之后按"通知间隔"重复提醒,达到"最大通知次数"后停止;余额恢复到阈值以上后自动重置计数,重新开始新一轮告警。
|
||||
</Text>
|
||||
{alertConfig?.last_notified_at && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最近通知:{formatDateTime(alertConfig.last_notified_at)} · 本轮已通知 {alertConfig.notification_count ?? 0} 次
|
||||
{alertConfig.max_notifications > 0 && ` / 最多 ${alertConfig.max_notifications} 次`}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>通知渠道列表({channels.length})</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={channels}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={false}
|
||||
/>
|
||||
<div style={{ marginTop: 12, padding: '4px 0' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
支持钉钉 / 飞书 / 企业微信群机器人 Webhook、Bark(iOS 推送)与自定义 Webhook。添加后建议点击“测试”验证连通性。
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={editingChannel ? '编辑通知渠道' : '添加通知渠道'}
|
||||
open={channelOpen}
|
||||
onOk={submitChannel}
|
||||
onCancel={() => setChannelOpen(false)}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={channelForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="channel_type" label="渠道类型" rules={[{ required: true }]}>
|
||||
<Select options={channelOptions.map(({ value, label }) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称(可选)" rules={[{ max: 64 }]}>
|
||||
<Input placeholder="如:财务群 / 老板手机" maxLength={64} />
|
||||
</Form.Item>
|
||||
|
||||
{channelType === 'bark' ? (
|
||||
<>
|
||||
<Form.Item name="server" label="Bark 服务器地址(可选)">
|
||||
<Input placeholder="https://api.day.app(默认官方服务器)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="key" label="Bark 设备 Key" rules={[{ required: true, message: '请填写 Bark 设备 Key' }]}>
|
||||
<Input placeholder="安装 Bark 后生成的设备 Key" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="webhook_url"
|
||||
label="Webhook URL"
|
||||
rules={[{ required: true, message: '请填写 Webhook URL' }]}
|
||||
>
|
||||
<Input placeholder={channelOptions.find((item) => item.value === channelType)?.placeholder} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="enabled" label="启用状态" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
BellOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
EyeOutlined,
|
||||
@@ -31,17 +29,15 @@ import { PageHeader } from '../components/PageHeader'
|
||||
import ImageUploader from '../components/ImageUploader'
|
||||
import { merchantApi, platformApi } from '../api'
|
||||
import { useAuth } from '../store/auth'
|
||||
import type { LowBalanceAlertConfig, PageResult, RechargeApplication, WalletAccount } from '../types'
|
||||
import type { PageResult, RechargeApplication } from '../types'
|
||||
import { formatDateTime } from '../utils/time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export default function MerchantRecharge() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||
const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ list: [], total: 0, page: 1, size: 10 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [alertForm] = Form.useForm()
|
||||
const [filterForm] = Form.useForm()
|
||||
const [createForm] = Form.useForm()
|
||||
const [reviewForm] = Form.useForm()
|
||||
@@ -54,8 +50,6 @@ export default function MerchantRecharge() {
|
||||
|
||||
const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({})
|
||||
|
||||
const currentPointsBalance = wallet?.available_balance
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (!filterParams.no) {
|
||||
return applications.list
|
||||
@@ -77,45 +71,9 @@ export default function MerchantRecharge() {
|
||||
}
|
||||
}, [applications.page, applications.size, filterParams, isAdmin])
|
||||
|
||||
const loadAlertConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await merchantApi.alertConfig()
|
||||
alertForm.setFieldsValue(data)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '告警配置加载失败')
|
||||
}
|
||||
}, [alertForm])
|
||||
|
||||
const loadWallet = useCallback(async () => {
|
||||
try {
|
||||
setWallet(await merchantApi.wallet())
|
||||
} catch {
|
||||
// 钱包不可用时静默降级
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadApplications()
|
||||
loadAlertConfig()
|
||||
loadWallet()
|
||||
}, [loadAlertConfig, loadApplications, loadWallet])
|
||||
|
||||
const handleSaveAlertConfig = async () => {
|
||||
try {
|
||||
const values = await alertForm.validateFields()
|
||||
const newConfig: LowBalanceAlertConfig = {
|
||||
enabled: values.enabled,
|
||||
threshold_points: values.threshold_points,
|
||||
webhook_url: values.webhook_url,
|
||||
}
|
||||
await merchantApi.saveAlertConfig(newConfig)
|
||||
message.success('低余额告警设置已更新')
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [loadApplications])
|
||||
|
||||
const handleFilterSubmit = (values: { no?: string; status?: string }) => {
|
||||
const params = {
|
||||
@@ -176,7 +134,6 @@ export default function MerchantRecharge() {
|
||||
setReviewItem(null)
|
||||
reviewForm.resetFields()
|
||||
loadApplications()
|
||||
loadWallet()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '审核失败')
|
||||
} finally {
|
||||
@@ -335,7 +292,7 @@ export default function MerchantRecharge() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="积分购买与充值"
|
||||
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录及配置低余额 Webhook 自动化告警"
|
||||
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录"
|
||||
breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadApplications()}>
|
||||
@@ -345,74 +302,6 @@ export default function MerchantRecharge() {
|
||||
/>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
{/* 告警与通知配置面板 */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||
title={
|
||||
<Space size={8}>
|
||||
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>余额告警与 Webhook 通知设置</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={alertForm}
|
||||
layout="vertical"
|
||||
style={{ padding: '4px 8px 0' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: '0 0 200px' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>当前积分余额</Text>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
|
||||
{currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} <span style={{ fontSize: 13, color: '#64748b', fontWeight: 500 }}>积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label="启用告警"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="threshold_points"
|
||||
label="低于多少积分提醒"
|
||||
rules={[{ required: true, message: '请填写预警阈值' }]}
|
||||
style={{ marginBottom: 12, minWidth: 220 }}
|
||||
>
|
||||
<InputNumber<number>
|
||||
min={0}
|
||||
step={100000}
|
||||
addonAfter="积分"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="webhook_url"
|
||||
label="告警 Webhook (钉钉/飞书机器人)"
|
||||
rules={[{ required: true, message: '请填写 Webhook URL' }]}
|
||||
style={{ marginBottom: 12, flex: 1, minWidth: 320 }}
|
||||
>
|
||||
<Input placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." allowClear />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ alignSelf: 'flex-end', marginBottom: 12 }}>
|
||||
<Button type="primary" onClick={handleSaveAlertConfig}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
提示:告警机器人安全设置需包含关键词「余额」。当额度低于阈值时(充值入账/调账后触发),平台将以 JSON POST 发送通知至上述 URL。
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 申请记录表格 Card */}
|
||||
<Card
|
||||
size="small"
|
||||
|
||||
@@ -273,4 +273,32 @@ export interface LowBalanceAlertConfig {
|
||||
enabled: boolean
|
||||
threshold_points: number
|
||||
webhook_url: string
|
||||
notify_interval_minutes: number
|
||||
max_notifications: number
|
||||
last_notified_at?: string
|
||||
notification_count: number
|
||||
}
|
||||
|
||||
export type AlertChannelType = 'dingtalk' | 'feishu' | 'wecom' | 'bark' | 'webhook'
|
||||
|
||||
export interface AlertNotifyChannel {
|
||||
id: number
|
||||
merchant_id: number
|
||||
channel_type: AlertChannelType
|
||||
name: string
|
||||
config: {
|
||||
webhook_url?: string
|
||||
server?: string
|
||||
key?: string
|
||||
}
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AlertChannelInput {
|
||||
channel_type: AlertChannelType
|
||||
name?: string
|
||||
webhook_url?: string
|
||||
server?: string
|
||||
key?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user