v4.0.17企业微信API插件优化

This commit is contained in:
2026-09-06 09:36:49 +08:00
parent 0e711be6c6
commit 192a401227
36 changed files with 2416 additions and 103 deletions
+2
View File
@@ -50,6 +50,7 @@ import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomReportsPage from './pages/WecomReportsPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -95,6 +96,7 @@ export default function App() {
</Route>
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
<Route path="/wecom/bots" element={<WecomBotsPage />} />
<Route path="/wecom/plugins" element={<WecomApiPluginsPage />} />
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
<Route path="/wecom/reports" element={<WecomReportsPage />} />
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
@@ -128,6 +128,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '企微机器人',
children: [
{ key: '/wecom/bots', label: '智能机器人' },
{ key: '/wecom/plugins', label: 'API 插件' },
{ key: '/wecom/pushes', label: '消息推送' },
{ key: '/wecom/reports', label: '报告' },
],
@@ -186,6 +187,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/promo-codes': 'promo_codes',
'/activity-posters': 'activity_posters',
'/wecom/bots': 'wecom_bots',
'/wecom/plugins': 'wecom_bots',
'/wecom/pushes': 'wecom_bots',
'/wecom/reports': 'wecom_bots',
'wecom-group': 'wecom_bots',
@@ -0,0 +1,405 @@
import { useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_PERMISSION_GROUPS,
WECOM_PLUGIN_PERMISSION_LABELS,
resolveWecomPluginPublicUrl,
type WecomApiPluginDto,
type WecomApiPluginSecretDto,
type WecomPluginPermission,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
name: string;
apiKey?: string;
permissions: WecomPluginPermission[];
remark?: string;
enabled: boolean;
sortOrder: number;
};
export default function WecomApiPluginsPage() {
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<WecomApiPluginDto | null>(null);
const [saving, setSaving] = useState(false);
const [rotatingId, setRotatingId] = useState<string | null>(null);
const [revealed, setRevealed] = useState<WecomApiPluginSecretDto | null>(null);
const pluginUrl = resolveWecomPluginPublicUrl(window.location.hostname);
const permissionsWatch = Form.useWatch('permissions', form) as WecomPluginPermission[] | undefined;
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<WecomApiPluginDto>(
'/admin/wecom-api-plugins',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
function openCreate() {
setEditing(null);
form.resetFields();
form.setFieldsValue({
enabled: true,
permissions: [],
sortOrder: 0,
});
setModalOpen(true);
}
function openEdit(row: WecomApiPluginDto) {
setEditing(row);
form.setFieldsValue({
name: row.name,
apiKey: '',
permissions: [...row.permissions],
remark: row.remark ?? undefined,
enabled: row.enabled,
sortOrder: row.sortOrder,
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const body = {
name: values.name,
permissions: values.permissions,
remark: values.remark?.trim() || null,
enabled: values.enabled,
sortOrder: values.sortOrder ?? 0,
apiKey: values.apiKey?.trim() || undefined,
};
if (editing) {
await request(`/admin/wecom-api-plugins/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
message.success('已更新');
} else {
const created = await request<WecomApiPluginSecretDto>('/admin/wecom-api-plugins', {
method: 'POST',
body: JSON.stringify(body),
});
setRevealed(created);
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-api-plugins/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function rotateKey(id: string) {
setRotatingId(id);
try {
const row = await request<WecomApiPluginSecretDto>(`/admin/wecom-api-plugins/${id}/rotate-key`, {
method: 'POST',
body: '{}',
});
setRevealed(row);
message.success('已轮换,请立即复制新密钥');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '轮换失败');
} finally {
setRotatingId(null);
}
}
const baseColumns: ColumnsType<WecomApiPluginDto> = [
{
title: '名称',
dataIndex: 'name',
render: (name: string, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
),
},
{ title: '密钥', dataIndex: 'apiKeyMasked', width: 160 },
{
title: '权限',
dataIndex: 'permissions',
render: (perms: WecomPluginPermission[]) =>
perms.map((p) => (
<Tag key={p} style={{ marginBottom: 4 }}>
{WECOM_PLUGIN_PERMISSION_LABELS[p]}
</Tag>
)),
},
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '—' },
{
title: '启用',
dataIndex: 'enabled',
width: 72,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
key: 'actions',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Popconfirm
title="轮换后旧 Key 立即失效,确认?"
onConfirm={() => void rotateKey(row.id)}
>
<Button type="link" size="small" loading={rotatingId === row.id}>
</Button>
</Popconfirm>
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns(
'wecom-api-plugins',
baseColumns,
{ page, pageSize },
);
return (
<>
{settingsModal}
<AdminListHeader
settings={settingsButton}
description="每个实例对应企微里一只 API 插件:共用 Base URL,用不同 X-Api-Key 区分,并按勾选权限放行工具。"
actions={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="填企微「添加 API 插件」第 1 步"
description={
<div>
<div>
URL
<Typography.Text copyable>{pluginUrl}</Typography.Text>
</div>
<div>
OpenAPI Key
<Typography.Text copyable>{`${pluginUrl}/openapi.json`}</Typography.Text>
</div>
<Typography.Text type="secondary">
Service token / API keyHeader {WECOM_PLUGIN_API_KEY_HEADER}
2 GET + Query
</Typography.Text>
</div>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v as Record<string, string>);
setPage(1);
}}
>
<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>
</Space>
</Form.Item>
</Form>
<Table<WecomApiPluginDto>
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 ? '编辑 API 插件' : '新建 API 插件'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营查询、客服只读" maxLength={64} />
</Form.Item>
{!editing ? (
<Form.Item
name="apiKey"
label="API Key"
extra="留空则服务端自动生成;创建后只回显一次"
>
<Input.Password placeholder="留空自动生成" />
</Form.Item>
) : (
<Form.Item label="API Key">
<Typography.Text type="secondary">
{editing.apiKeyMasked}
</Typography.Text>
</Form.Item>
)}
<Form.Item
name="permissions"
label="工具权限"
rules={[
{
validator: (_, v: WecomPluginPermission[] | undefined) =>
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项')),
},
]}
extra={
permissionsWatch?.length ? (
<Typography.Text type="secondary"> {permissionsWatch.length} </Typography.Text>
) : null
}
>
<Checkbox.Group style={{ width: '100%' }}>
{WECOM_PLUGIN_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_PLUGIN_PERMISSION_LABELS[p]}
</Checkbox>
))}
</div>
</div>
))}
</Checkbox.Group>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} placeholder="企微侧插件名等" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="请立即复制密钥"
open={!!revealed}
onCancel={() => setRevealed(null)}
footer={[
<Button key="ok" type="primary" onClick={() => setRevealed(null)}>
</Button>,
]}
>
{revealed ? (
<div>
<p>
{revealed.name} {WECOM_PLUGIN_API_KEY_HEADER}
</p>
<Typography.Text copyable>{revealed.apiKey}</Typography.Text>
</div>
) : null}
</Modal>
</>
);
}
+7 -22
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Avatar,
@@ -26,8 +27,6 @@ import {
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES,
WECOM_PLUGIN_API_KEY_HEADER,
resolveWecomPluginPublicUrl,
type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto,
type WecomAibotRuntimeDto,
@@ -410,27 +409,13 @@ export default function WecomBotsPage() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="企微 API 插件(与上方长连接机器人独立"
message="企微 API 插件已迁入独立"
description={
<div>
<div>
URL
<Typography.Text copyable>
{resolveWecomPluginPublicUrl(window.location.hostname)}
</Typography.Text>
</div>
<div>
OpenAPI
<Typography.Text copyable>
{`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`}
</Typography.Text>
</div>
<Typography.Text type="secondary">
Service token / API keyHeader {WECOM_PLUGIN_API_KEY_HEADER}
.env WECOM_PLUGIN_API_KEY
WECOM_PLUGIN_ENABLED=true
</Typography.Text>
</div>
<span>
{' '}
<Link to="/wecom/plugins"> API </Link>
{' '}
</span>
}
/>