Files
dukang/apps/admin-web/src/pages/WecomMessagePushesPage.tsx
T
jacy 26334ed072
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新2
2026-08-20 20:09:05 +08:00

612 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}