Files
dukang/apps/admin-web/src/pages/WecomBotsPage.tsx
T
2026-08-04 21:38:49 +08:00

622 lines
20 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 { useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_BOT_PERMISSION_GROUPS,
WECOM_BOT_PERMISSION_LABELS,
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES,
type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto,
type WecomAibotRuntimeDto,
type WecomBotDto,
type WecomBotPermission,
type WecomBotRole,
} 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;
role: WecomBotRole;
botId: string;
secret?: string;
avatarUrl?: string;
welcome?: string;
permissions: WecomBotPermission[];
reviewSuperAdminWecomUserIds?: string;
aiEnabled: boolean;
llmConfigId?: string | null;
knowledgeBaseId?: string | null;
enabled: boolean;
sortOrder: number;
};
function parseUserIdLines(text?: string): string[] {
if (!text?.trim()) return [];
return [...new Set(text.split(/[,\s\n]+/).map((s) => s.trim()).filter(Boolean))];
}
function formatUserIdLines(ids?: string[]): string {
return (ids ?? []).join('\n');
}
function mapDtoToForm(row: WecomBotDto): FormValues {
return {
name: row.name,
role: row.role,
botId: row.botId,
secret: '',
avatarUrl: row.avatarUrl || '',
welcome: row.welcome || '',
permissions: [...row.permissions],
reviewSuperAdminWecomUserIds: formatUserIdLines(row.reviewSuperAdminWecomUserIds),
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId,
knowledgeBaseId: row.knowledgeBaseId,
enabled: row.enabled,
sortOrder: row.sortOrder,
};
}
function samePermissionSet(a: WecomBotPermission[], b: WecomBotPermission[]): boolean {
if (a.length !== b.length) return false;
const setB = new Set(b);
return a.every((p) => setB.has(p));
}
const CREATE_DEFAULTS: FormValues = {
name: '',
role: 'CUSTOMER_SERVICE',
botId: '',
secret: '',
avatarUrl: '',
welcome: '',
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
reviewSuperAdminWecomUserIds: '',
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
enabled: true,
sortOrder: 0,
};
export default function WecomBotsPage() {
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<WecomBotDto | null>(null);
const [formInitial, setFormInitial] = useState<FormValues | null>(null);
const [formReady, setFormReady] = useState(false);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomBotDto | null>(null);
const [runtime, setRuntime] = useState<WecomAibotRuntimeDto>();
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
const roleWatch = Form.useWatch('role', form);
const permissionsWatch = Form.useWatch('permissions', form) as WecomBotPermission[] | undefined;
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
'/admin/wecom-bots',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.role) qs.set('role', filters.role);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const loadRuntime = () =>
request<WecomAibotRuntimeDto>('/admin/wecom-bots/runtime')
.then(setRuntime)
.catch(() => {});
useEffect(() => {
void loadRuntime();
}, [data]);
useEffect(() => {
void request<{
llmConfigs: LlmApiConfigOptionDto[];
knowledgeBases: KnowledgeBaseOptionDto[];
}>('/admin/wecom-bots/ai-options')
.then((res) => {
setLlmOptions(res.llmConfigs);
setKbOptions(res.knowledgeBases);
})
.catch(() => {
setLlmOptions([]);
setKbOptions([]);
});
}, []);
useEffect(() => {
if (!modalOpen || !formReady || !formInitial) return;
form.resetFields();
form.setFieldsValue(formInitial);
}, [modalOpen, formReady, formInitial, form]);
function closeModal() {
setModalOpen(false);
setFormReady(false);
setFormInitial(null);
setEditing(null);
form.resetFields();
}
function openCreate() {
setEditing(null);
setFormInitial({ ...CREATE_DEFAULTS });
setFormReady(true);
setModalOpen(true);
}
async function openEdit(row: WecomBotDto) {
setEditing(row);
setFormInitial(null);
setFormReady(false);
setModalOpen(true);
try {
const detail = await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`);
setEditing(detail);
setFormInitial(mapDtoToForm(detail));
} catch (e) {
message.error(e instanceof Error ? e.message : '加载机器人详情失败');
setFormInitial(mapDtoToForm(row));
} finally {
setFormReady(true);
}
}
async function submit() {
const values = await form.validateFields();
const permissions = (form.getFieldValue('permissions') ?? values.permissions) as WecomBotPermission[];
if (!permissions?.length) {
message.error('请至少选择一项权限');
return;
}
setSaving(true);
try {
const payload = {
name: values.name.trim(),
role: values.role,
botId: values.botId.trim(),
avatarUrl: values.avatarUrl?.trim() || null,
welcome: values.welcome?.trim() || null,
permissions,
reviewSuperAdminWecomUserIds: parseUserIdLines(values.reviewSuperAdminWecomUserIds),
aiEnabled: values.aiEnabled,
llmConfigId: values.llmConfigId || null,
knowledgeBaseId: values.knowledgeBaseId || null,
enabled: values.enabled,
sortOrder: values.sortOrder,
};
if (editing) {
const saved = await request<WecomBotDto>(`/admin/wecom-bots/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
...payload,
secret: values.secret?.trim() || undefined,
}),
});
if (!samePermissionSet(permissions, saved.permissions)) {
message.warning('权限与保存结果不一致,已用服务端数据刷新表单');
setFormInitial(mapDtoToForm(saved));
setEditing(saved);
reload();
return;
}
message.success('已更新');
} else {
if (!values.secret?.trim()) {
message.error('请填写 Secret');
return;
}
await request('/admin/wecom-bots', {
method: 'POST',
body: JSON.stringify({
...payload,
secret: values.secret.trim(),
}),
});
message.success('已创建');
}
closeModal();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function reloadConnections() {
try {
const st = await request<WecomAibotRuntimeDto>('/admin/wecom-bots/reload', {
method: 'POST',
body: '{}',
});
message.success('已重载长连接');
setRuntime(st);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '重载失败');
}
}
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [
{
title: '头像',
dataIndex: 'avatarUrl',
width: 64,
render: (url: string | null, row) => (
<Avatar src={url || undefined} shape="square" size={40}>
{row.name.slice(0, 1)}
</Avatar>
),
},
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
},
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) =>
perms.map((p) => (
<Tag key={p} style={{ marginBottom: 2 }}>
{WECOM_BOT_PERMISSION_LABELS[p] || p}
</Tag>
)),
},
{
title: 'AI',
width: 100,
render: (_, row) =>
row.aiEnabled ? (
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
) : (
<Tag></Tag>
),
},
{
title: '启用',
dataIndex: 'enabled',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
},
{
title: '连接',
width: 80,
render: (_, row) => {
const rt = runtimeMap.get(row.id);
if (!runtime?.masterEnabled) return <Tag>总开关关</Tag>;
if (!row.enabled) return <Tag>未启用</Tag>;
return (
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
);
},
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
fixed: 'right',
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
编辑
</Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
}}
>
详情
</Button>
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
企微机器人
</Typography.Title>
<Typography.Text type="secondary">
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 功能开关」。
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
</Typography.Text>
</div>
<Space>
<Button onClick={() => void reloadConnections()}>重载连接</Button>
<Button type="primary" onClick={openCreate}>
创建机器人
</Button>
</Space>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select
allowClear
style={{ width: 160 }}
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</Form.Item>
<Form.Item name="enabled" label="启用">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑企微机器人' : '创建企微机器人'}
open={modalOpen}
onCancel={closeModal}
onOk={() => void submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
{formReady && formInitial ? (
<Form
key={editing ? `edit-${editing.id}` : 'create'}
form={form}
layout="vertical"
preserve={false}
initialValues={formInitial}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:客服机器人" maxLength={64} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</Form.Item>
<Form.Item>
<Button
size="small"
onClick={() => {
const role = form.getFieldValue('role') as WecomBotRole | undefined;
if (!role) return;
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
}}
>
应用当前角色默认权限
</Button>
</Form.Item>
<Form.Item name="avatarUrl" label="头像">
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
<Input placeholder="企业微信后台长连接 BotID" />
</Form.Item>
<Form.Item
name="secret"
label="Secret"
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
</Form.Item>
<Form.Item
name="permissions"
label="权限(按模块)"
rules={[
{
validator: (_, v: WecomBotPermission[] | undefined) =>
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少选择一项权限')),
},
]}
extra={
<>
{roleWatch
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
: null}
{permissionsWatch?.length ? (
<Typography.Text type="secondary"> · 已选 {permissionsWatch.length} </Typography.Text>
) : null}
</>
}
>
<Checkbox.Group style={{ width: '100%' }}>
{WECOM_BOT_PERMISSION_GROUPS.map((group) => (
<div key={group.key} style={{ marginBottom: 12 }}>
<Typography.Text strong>{group.label}</Typography.Text>
<div style={{ marginTop: 4 }}>
{group.permissions.map((p) => (
<Checkbox key={p} value={p} style={{ marginInlineStart: 0, marginRight: 12 }}>
{WECOM_BOT_PERMISSION_LABELS[p]}
</Checkbox>
))}
</div>
</div>
))}
</Checkbox.Group>
</Form.Item>
{(roleWatch === 'TECH_SUPPORT' || permissionsWatch?.includes('support_ticket.review')) && (
<Form.Item
name="reviewSuperAdminWecomUserIds"
label="审批白名单(企微 userid"
extra="填写成员企微 userid(如 woDacESQAAD…),不是姓名/昵称;每行或逗号分隔。发「状态」或在服务端日志 inbound user= 可核对"
>
<Input.TextArea rows={3} placeholder="zhangsan&#10;lisi" />
</Form.Item>
)}
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
</Form.Item>
<Form.Item
name="aiEnabled"
label="启用 AI 问答"
valuePropName="checked"
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
>
<Switch />
</Form.Item>
<Form.Item name="llmConfigId" label="语言模型">
<Select
allowClear
placeholder="选择已生效的配置"
options={llmOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.modelName}`,
}))}
/>
</Form.Item>
<Form.Item name="knowledgeBaseId" label="知识库">
<Select
allowClear
placeholder="可选,供 AI 检索"
options={kbOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.documentCount} 篇)`,
}))}
/>
</Form.Item>
<Space size="large">
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber style={{ width: 120 }} />
</Form.Item>
</Space>
</Form>
) : (
<Typography.Text type="secondary">加载中…</Typography.Text>
)}
</Modal>
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="头像">
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
{detail.name.slice(0, 1)}
</Avatar>
</Descriptions.Item>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="角色">
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
</Descriptions.Item>
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
<Descriptions.Item label="Secret">
{detail.secretConfigured ? '已配置' : '未配置'}
</Descriptions.Item>
<Descriptions.Item label="权限">
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
</Descriptions.Item>
<Descriptions.Item label="审批白名单">
{detail.reviewSuperAdminWecomUserIds?.length
? detail.reviewSuperAdminWecomUserIds.join('、')
: '—'}
</Descriptions.Item>
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</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>
)}
</Drawer>
</div>
);
}