Files
dukang/apps/admin-web/src/pages/WecomApiPluginsPage.tsx
T

406 lines
12 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 { 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>
</>
);
}