余额告警独立页面与多渠道通知:钉钉/飞书/企业微信/Bark,支持通知频率策略

- 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道
- 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置
- 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送
- 优化告警话术:去商户ID展示、数字千分位格式化
This commit is contained in:
yml2213
2026-08-04 13:56:26 +08:00
parent 941e7ad79a
commit ca58c2bcaa
13 changed files with 1154 additions and 156 deletions
+2
View File
@@ -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 密钥" />} />
+13 -1
View File
@@ -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 = {
+3
View File
@@ -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'
+441
View File
@@ -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: 'BarkiOS', 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 }}>
/ / WebhookBarkiOS 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>
)
}
+3 -114
View File
@@ -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"
+28
View File
@@ -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
}