612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
Avatar,
|
||
Button,
|
||
Checkbox,
|
||
Descriptions,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
WECOM_PUSH_CONDITION_GROUPS,
|
||
WECOM_PUSH_CONDITION_LABELS,
|
||
WECOM_TEMPLATE_EVENT_KEYS,
|
||
WECOM_TEMPLATE_EVENT_LABELS,
|
||
WECOM_TEMPLATE_PLACEHOLDERS,
|
||
type WecomMessagePushDto,
|
||
type WecomPushCondition,
|
||
type WecomPushTemplateDto,
|
||
type WecomTemplateEventKey,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
import OssUpload from '../components/OssUpload';
|
||
|
||
type FormValues = {
|
||
name: string;
|
||
avatarUrl?: string;
|
||
webhookUrl: string;
|
||
enabled: boolean;
|
||
mentionWecomUserId?: string;
|
||
pushConditions: WecomPushCondition[];
|
||
sortOrder: number;
|
||
};
|
||
|
||
function WecomPushConditionPicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value?: WecomPushCondition[];
|
||
onChange?: (next: WecomPushCondition[]) => void;
|
||
}) {
|
||
const selected = new Set(value ?? []);
|
||
|
||
function toggle(cond: WecomPushCondition, checked: boolean) {
|
||
const next = new Set(selected);
|
||
if (checked) next.add(cond);
|
||
else next.delete(cond);
|
||
onChange?.([...next]);
|
||
}
|
||
|
||
return (
|
||
<div style={{ width: '100%' }}>
|
||
{WECOM_PUSH_CONDITION_GROUPS.map((group) => (
|
||
<div key={group.key} style={{ marginBottom: 12 }}>
|
||
<Typography.Text strong>{group.label}</Typography.Text>
|
||
<div style={{ marginTop: 8 }}>
|
||
{group.conditions.map((c) => (
|
||
<div key={c}>
|
||
<Checkbox
|
||
checked={selected.has(c)}
|
||
onChange={(e) => toggle(c, e.target.checked)}
|
||
>
|
||
{WECOM_PUSH_CONDITION_LABELS[c]}
|
||
</Checkbox>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PushRoutesTab() {
|
||
const [filterForm] = Form.useForm();
|
||
const [form] = Form.useForm<FormValues>();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<WecomMessagePushDto | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [detail, setDetail] = useState<WecomMessagePushDto | null>(null);
|
||
const [testingId, setTestingId] = useState<string | null>(null);
|
||
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||
useAdminList<WecomMessagePushDto>(
|
||
'/admin/wecom-message-pushes',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.name) qs.set('name', filters.name);
|
||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
|
||
const conditionsWatch = Form.useWatch('pushConditions', form) as WecomPushCondition[] | undefined;
|
||
|
||
function openCreate() {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({
|
||
enabled: true,
|
||
pushConditions: ['alert.ops'],
|
||
sortOrder: 0,
|
||
});
|
||
setModalOpen(true);
|
||
}
|
||
|
||
function openEdit(row: WecomMessagePushDto) {
|
||
setEditing(row);
|
||
form.setFieldsValue({
|
||
name: row.name,
|
||
avatarUrl: row.avatarUrl ?? undefined,
|
||
webhookUrl: row.webhookUrl,
|
||
enabled: row.enabled,
|
||
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
|
||
pushConditions: row.pushConditions,
|
||
sortOrder: row.sortOrder,
|
||
});
|
||
setModalOpen(true);
|
||
}
|
||
|
||
async function save() {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const body = {
|
||
name: values.name,
|
||
avatarUrl: values.avatarUrl || null,
|
||
webhookUrl: values.webhookUrl,
|
||
enabled: values.enabled,
|
||
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
|
||
pushConditions: values.pushConditions,
|
||
sortOrder: values.sortOrder ?? 0,
|
||
};
|
||
if (editing) {
|
||
await request(`/admin/wecom-message-pushes/${editing.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(body),
|
||
});
|
||
message.success('已更新');
|
||
} else {
|
||
await request('/admin/wecom-message-pushes', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
message.success('已创建');
|
||
}
|
||
setModalOpen(false);
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function remove(id: string) {
|
||
try {
|
||
await request(`/admin/wecom-message-pushes/${id}`, { method: 'DELETE' });
|
||
message.success('已删除');
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '删除失败');
|
||
}
|
||
}
|
||
|
||
async function testPush(id: string) {
|
||
setTestingId(id);
|
||
try {
|
||
const res = await request<{ ok: boolean; message: string }>(
|
||
`/admin/wecom-message-pushes/${id}/test`,
|
||
{ method: 'POST', body: '{}' },
|
||
);
|
||
message.success(res.message || '已发送测试消息');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '测试失败');
|
||
} finally {
|
||
setTestingId(null);
|
||
}
|
||
}
|
||
|
||
const columns: ColumnsType<WecomMessagePushDto> = [
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
render: (name: string, row) => (
|
||
<Space>
|
||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||
<span>{name}</span>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: 'Webhook',
|
||
dataIndex: 'webhookUrlMasked',
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: '推送条件',
|
||
dataIndex: 'pushConditions',
|
||
render: (conds: WecomPushCondition[]) =>
|
||
conds.map((c) => (
|
||
<Tag key={c} style={{ marginBottom: 4 }}>
|
||
{WECOM_PUSH_CONDITION_LABELS[c]}
|
||
</Tag>
|
||
)),
|
||
},
|
||
{
|
||
title: '启用',
|
||
dataIndex: 'enabled',
|
||
width: 72,
|
||
render: (v: boolean) => (v ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
|
||
},
|
||
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 220,
|
||
render: (_, row) => (
|
||
<Space wrap>
|
||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
loading={testingId === row.id}
|
||
onClick={() => void testPush(row.id)}
|
||
>
|
||
测试
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
void request<WecomMessagePushDto>(`/admin/wecom-message-pushes/${row.id}`).then(setDetail);
|
||
}}
|
||
>
|
||
详情
|
||
</Button>
|
||
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<Typography.Paragraph type="secondary">
|
||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||
</Typography.Paragraph>
|
||
|
||
<Form
|
||
form={filterForm}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v) => setFilters(v as Record<string, string>)}
|
||
>
|
||
<Form.Item name="name" label="名称">
|
||
<Input allowClear placeholder="搜索" />
|
||
</Form.Item>
|
||
<Form.Item name="enabled" label="启用">
|
||
<Select
|
||
allowClear
|
||
placeholder="全部"
|
||
style={{ width: 100 }}
|
||
options={[
|
||
{ value: 'true', label: '启用' },
|
||
{ value: 'false', label: '停用' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
filterForm.resetFields();
|
||
setFilters({});
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
<Button type="primary" onClick={openCreate}>
|
||
新建推送
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table<WecomMessagePushDto>
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title={editing ? '编辑消息推送' : '新建消息推送'}
|
||
open={modalOpen}
|
||
onCancel={() => setModalOpen(false)}
|
||
onOk={() => void save()}
|
||
confirmLoading={saving}
|
||
width={640}
|
||
destroyOnClose
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||
<Input placeholder="如:业务待办通知群" />
|
||
</Form.Item>
|
||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="webhookUrl"
|
||
label="Webhook URL"
|
||
rules={[{ required: true, message: '请填写 Webhook URL' }]}
|
||
>
|
||
<Input.Password placeholder="企微群机器人 Webhook" />
|
||
</Form.Item>
|
||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="mentionWecomUserId"
|
||
label="企微 userid(可选)"
|
||
extra="markdown 推送用 <@userid> @ 群成员;任务派发等场景生效"
|
||
>
|
||
<Input placeholder="如 woDacESQAAD..." allowClear />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="pushConditions"
|
||
label="推送条件"
|
||
rules={[
|
||
{
|
||
validator: (_, v: WecomPushCondition[] | undefined) =>
|
||
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项推送条件')),
|
||
},
|
||
]}
|
||
>
|
||
<WecomPushConditionPicker />
|
||
</Form.Item>
|
||
{conditionsWatch?.length ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||
已选 {conditionsWatch.length} 项:
|
||
{conditionsWatch.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
|
||
</Typography.Text>
|
||
) : null}
|
||
<Form.Item name="sortOrder" label="排序">
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="消息推送详情"
|
||
open={!!detail}
|
||
onCancel={() => setDetail(null)}
|
||
footer={null}
|
||
width={560}
|
||
>
|
||
{detail ? (
|
||
<Descriptions column={1} bordered size="small">
|
||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||
<Descriptions.Item label="Webhook">{detail.webhookUrlMasked}</Descriptions.Item>
|
||
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
|
||
<Descriptions.Item label="userid">{detail.mentionWecomUserId || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="推送条件">
|
||
{detail.pushConditions.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
|
||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||
</Descriptions>
|
||
) : null}
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TemplatesTab() {
|
||
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
|
||
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState(false);
|
||
const [preview, setPreview] = useState('');
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
|
||
setTemplates(list);
|
||
setEventKey((prev) => {
|
||
const current = list.find((t) => t.eventKey === prev) ?? list[0];
|
||
if (current) {
|
||
form.setFieldsValue({
|
||
title: current.title,
|
||
body: current.body,
|
||
handleLabel: current.handleLabel,
|
||
});
|
||
return current.eventKey;
|
||
}
|
||
return prev;
|
||
});
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载模板失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [form]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
function selectEvent(key: WecomTemplateEventKey) {
|
||
setEventKey(key);
|
||
const row = templates.find((t) => t.eventKey === key);
|
||
if (row) {
|
||
form.setFieldsValue({
|
||
title: row.title,
|
||
body: row.body,
|
||
handleLabel: row.handleLabel,
|
||
});
|
||
setPreview('');
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const updated = await request<WecomPushTemplateDto>(
|
||
`/admin/wecom-push-templates/${eventKey}`,
|
||
{
|
||
method: 'PUT',
|
||
body: JSON.stringify(values),
|
||
},
|
||
);
|
||
message.success('模板已保存');
|
||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function reset() {
|
||
setSaving(true);
|
||
try {
|
||
const updated = await request<WecomPushTemplateDto>(
|
||
`/admin/wecom-push-templates/${eventKey}/reset`,
|
||
{ method: 'POST', body: '{}' },
|
||
);
|
||
message.success('已恢复默认文案');
|
||
form.setFieldsValue({
|
||
title: updated.title,
|
||
body: updated.body,
|
||
handleLabel: updated.handleLabel,
|
||
});
|
||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||
setPreview('');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '恢复失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function testSend() {
|
||
setTesting(true);
|
||
try {
|
||
const res = await request<{ ok: boolean; message: string; preview: string }>(
|
||
`/admin/wecom-push-templates/${eventKey}/test`,
|
||
{ method: 'POST', body: '{}' },
|
||
);
|
||
setPreview(res.preview || '');
|
||
message.success(res.message || '已发送');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '测试失败');
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
}
|
||
|
||
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
|
||
|
||
return (
|
||
<>
|
||
<Typography.Paragraph type="secondary">
|
||
每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'}
|
||
;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。
|
||
</Typography.Paragraph>
|
||
|
||
<Space align="start" style={{ width: '100%' }} size={24} wrap>
|
||
<div style={{ minWidth: 200 }}>
|
||
<Typography.Text strong>事件</Typography.Text>
|
||
<div style={{ marginTop: 8 }}>
|
||
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
|
||
<div key={k} style={{ marginBottom: 4 }}>
|
||
<Button
|
||
type={k === eventKey ? 'primary' : 'text'}
|
||
size="small"
|
||
onClick={() => selectEvent(k)}
|
||
block
|
||
style={{ textAlign: 'left' }}
|
||
>
|
||
{WECOM_TEMPLATE_EVENT_LABELS[k]}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 360 }}>
|
||
<Form form={form} layout="vertical" disabled={loading}>
|
||
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="body"
|
||
label="正文(企微 markdown)"
|
||
rules={[{ required: true }]}
|
||
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
|
||
>
|
||
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
|
||
</Form.Item>
|
||
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
|
||
<Input placeholder="去处理" />
|
||
</Form.Item>
|
||
<Space wrap>
|
||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||
保存
|
||
</Button>
|
||
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
|
||
<Button loading={saving}>恢复默认</Button>
|
||
</Popconfirm>
|
||
<Button loading={testing} onClick={() => void testSend()}>
|
||
用示例数据测试推送
|
||
</Button>
|
||
</Space>
|
||
</Form>
|
||
|
||
{preview ? (
|
||
<div style={{ marginTop: 16 }}>
|
||
<Typography.Text strong>预览</Typography.Text>
|
||
<pre
|
||
style={{
|
||
marginTop: 8,
|
||
padding: 12,
|
||
background: '#f5f5f5',
|
||
whiteSpace: 'pre-wrap',
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
{preview}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Space>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function WecomMessagePushesPage() {
|
||
return (
|
||
<div>
|
||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||
<Tabs
|
||
items={[
|
||
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
|
||
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
|
||
]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|