v4.0.17企业微信API插件优化
This commit is contained in:
@@ -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 key,Header 名 {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 key,Header 名 {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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user