Files
dukang/apps/admin-web/src/pages/WecomBotsPage.tsx
T
jacy 2b7ef65cce
CI / verify (pull_request) Has been cancelled
feat(admin): LLM config, knowledge base, and WeCom AI binding
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:31:22 +08:00

513 lines
16 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_PERMISSIONS,
WECOM_BOT_PERMISSION_LABELS,
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES,
type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto,
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 ListRes = {
items: WecomBotDto[];
total: number;
page: number;
pageSize: number;
runtime?: {
masterEnabled: boolean;
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
};
};
type FormValues = {
name: string;
role: WecomBotRole;
botId: string;
secret?: string;
avatarUrl?: string;
welcome?: string;
permissions: WecomBotPermission[];
aiEnabled: boolean;
llmConfigId?: string | null;
knowledgeBaseId?: string | null;
enabled: boolean;
sortOrder: number;
};
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 [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomBotDto | null>(null);
const [runtime, setRuntime] = useState<ListRes['runtime']>();
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
const roleWatch = Form.useWatch('role', form);
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],
);
useEffect(() => {
// useAdminList returns items; runtime comes from same API — fetch once for banner
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
.then((res) => setRuntime(res.runtime))
.catch(() => {});
}, [data]);
useEffect(() => {
void request<{
llmConfigs: LlmApiConfigOptionDto[];
knowledgeBases: KnowledgeBaseOptionDto[];
}>('/admin/wecom-bots/ai-options')
.then((res) => {
setLlmOptions(res.llmConfigs);
setKbOptions(res.knowledgeBases);
})
.catch(() => {
setLlmOptions([]);
setKbOptions([]);
});
}, []);
function openCreate() {
setEditing(null);
form.setFieldsValue({
name: '',
role: 'CUSTOMER_SERVICE',
botId: '',
secret: '',
avatarUrl: '',
welcome: '',
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
enabled: true,
sortOrder: 0,
});
setModalOpen(true);
}
function openEdit(row: WecomBotDto) {
setEditing(row);
form.setFieldsValue({
name: row.name,
role: row.role,
botId: row.botId,
secret: '',
avatarUrl: row.avatarUrl || '',
welcome: row.welcome || '',
permissions: row.permissions,
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId,
knowledgeBaseId: row.knowledgeBaseId,
enabled: row.enabled,
sortOrder: row.sortOrder,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
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: values.permissions,
aiEnabled: values.aiEnabled,
llmConfigId: values.llmConfigId || null,
knowledgeBaseId: values.knowledgeBaseId || null,
enabled: values.enabled,
sortOrder: values.sortOrder,
};
if (editing) {
await request(`/admin/wecom-bots/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
...payload,
secret: values.secret?.trim() || undefined,
}),
});
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('已创建');
}
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-bots/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function reloadConnections() {
try {
const st = await request<{
masterEnabled: boolean;
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
}>('/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={() => 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={() => setModalOpen(false)}
onOk={() => void submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<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] }))}
onChange={(role: WecomBotRole) => {
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
}}
/>
</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={[{ required: true, message: '请至少选择一项权限' }]}
extra={
roleWatch
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
: undefined
}
>
<Checkbox.Group
options={WECOM_BOT_PERMISSIONS.map((p) => ({
value: p,
label: WECOM_BOT_PERMISSION_LABELS[p],
}))}
/>
</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>
</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.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>
);
}