Skip to main content
Glama
index.tsx37.5 kB
import React, {useEffect, useState} from 'react'; import {Button, Form, Input, InputNumber, message, Modal, Select, Tabs, Tree, Tooltip, Flex} from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, FileTextOutlined, EyeOutlined, HistoryOutlined, RollbackOutlined, ImportOutlined } from '@ant-design/icons'; import type {DataNode} from 'antd/es/tree'; import {useNavigate} from 'react-router-dom'; import IconPicker from '@/components/IconPicker'; import {MenuItem} from '@/types/menu'; import {initialMenu} from './menuData'; import CustomEventTags from './CustomEventTags'; import Preview from './Preview.tsx'; import {fetcher} from '@/components/Amis/fetcher'; interface ApiResponse { status: number; msg?: string; data?: { status: number; msg?: string; data: HistoryItem[]; }; } interface HistoryItem { menu_data: MenuItem[]; id: number; updated_at: string; created_at: string; } const MenuEditor: React.FC = () => { const navigate = useNavigate(); const [menuData, setMenuData] = useState<MenuItem[]>(initialMenu); const [history, setHistory] = useState<HistoryItem[]>([]); const [historyIndex, setHistoryIndex] = useState(-1); const [showHistory, setShowHistory] = useState(false); const [selectedKey, setSelectedKey] = useState<string | null>(null); const [form] = Form.useForm(); const [isImportModalVisible, setIsImportModalVisible] = useState(false); const [importJson, setImportJson] = useState(''); /** * 从API加载历史记录 */ const loadHistoryFromAPI = async () => { try { const response = await fetcher({ url: '/admin/menu/history', method: 'get' }) as ApiResponse; if (response.data?.status !== 0) { message.error(`获取巡检结果失败:请尝试刷新后重试。 ${response.data?.msg}`); } else { const result = response.data.data; setHistory(result); setHistoryIndex(result.length - 1); } } catch (error) { console.error('从API加载历史记录失败:', error); } }; // 组件初始化时加载历史记录 useEffect(() => { loadHistoryFromAPI(); }, []); const [editMode, setEditMode] = useState<'add' | 'edit' | null>(null); const [parentKey, setParentKey] = useState<string | null>(null); const [showIconModal, setShowIconModal] = useState(false); const [isPreview, setIsPreview] = useState(false); const [currentEventType, setCurrentEventType] = useState<'url' | 'custom'>('url'); const [currentIcon, setCurrentIcon] = useState<string | null>(null); // eventType 的变化现在直接通过 Select 的 onChange 处理 useEffect(() => { // 当表单初始化时,设置默认值 const initialEventType = form.getFieldValue('eventType'); setCurrentEventType(initialEventType || 'url'); setCurrentIcon(form.getFieldValue('icon') || null); }, [form]); // 处理图标选择 const handleIconSelect = (iconValue: string) => { setCurrentIcon(iconValue); form.setFieldValue('icon', iconValue) setShowIconModal(false); }; // 递归查找菜单项 const findMenuItem = (data: MenuItem[], key: string): MenuItem | null => { for (const item of data) { if (item.key === key) return item; if (item.children) { const found = findMenuItem(item.children, key); if (found) return found; } } return null; }; // 递归更新菜单项 const updateMenuItem = (data: MenuItem[], key: string, newItem: MenuItem): MenuItem[] => { return data.map(item => { if (item.key === key) return {...newItem}; if (item.children) { return {...item, children: updateMenuItem(item.children, key, newItem)}; } return item; }); }; // 递归删除菜单项 const deleteMenuItem = (data: MenuItem[], key: string): MenuItem[] => { return data.filter(item => { if (item.key === key) return false; if (item.children) { item.children = deleteMenuItem(item.children, key); } return true; }); }; // 递归添加菜单项 const addMenuItem = (data: MenuItem[], parentKey: string | null, newItem: MenuItem): MenuItem[] => { if (!parentKey) { return [...data, newItem]; } return data.map(item => { if (item.key === parentKey) { return {...item, children: [...(item.children || []), newItem]}; } if (item.children) { return {...item, children: addMenuItem(item.children, parentKey, newItem)}; } return item; }); }; // 处理菜单树选择 const onSelect = (selectedKeys: React.Key[]) => { // 仅设置选中项,不触发编辑模式 if (selectedKeys.length > 0) { setSelectedKey(selectedKeys[0] as string); } else { setSelectedKey(null); } }; // 拖拽排序和层级调整 const onDrop = (info: any) => { const dropKey = info.node.key; const dragKey = info.dragNode.key; const dropPos = info.node.pos.split('-'); const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); const loop = (data: MenuItem[], key: string, callback: (item: MenuItem, idx: number, arr: MenuItem[]) => void) => { for (let i = 0; i < data.length; i++) { if (data[i].key === key) { return callback(data[i], i, data); } if (data[i].children) { loop(data[i].children!, key, callback); } } }; const data = [...menuData]; let dragObj: MenuItem; loop(data, dragKey, (item, idx, arr) => { arr.splice(idx, 1); dragObj = item; }); if (!info.dropToGap) { // 拖到节点内部 loop(data, dropKey, (item) => { item.children = item.children || []; item.children.push(dragObj!); message.info(`已经将 ${dragObj!.title} 添加为 ${item.title} 的子菜单`); // 更新order值 item.children!.forEach((child, index) => { child.order = index + 1; }); }); } else if ( (info.node.children || []).length > 0 && info.node.expanded && dropPosition === 1 ) { // 拖到有子节点的节点底部 loop(data, dropKey, (item) => { item.children = item.children || []; item.children.push(dragObj!); message.info(`已经将 ${dragObj!.title} 添加为 ${item.title} 的子菜单`); // 更新order值 item.children!.forEach((child, index) => { child.order = index + 1; }); }); } else { // 拖到节点之间 let ar: MenuItem[] = data; let i: number; loop(data, dropKey, (_, idx, arr) => { ar = arr; i = idx; }); ar.splice(dropPosition === -1 ? i! : i! + 1, 0, dragObj!); message.info(`已经将 ${dragObj!.title} 添加为 ${info.node.title.props.children[1]} 的同级菜单`); // 更新order值 ar.forEach((item, index) => { item.order = index + 1; }); } setMenuData(data); saveHistory(data); }; // 新增菜单项 const handleAdd = (parentKey: string | null = null) => { setEditMode('add'); setParentKey(parentKey); setSelectedKey(null); form.resetFields(); form.setFieldsValue({ key: '', // 新增菜单时key为空,保存时再生成 title: '', icon: '', url: '', eventType: 'url', order: 1, customEvent: '', show: 'true' // 修改这里,只使用字符串值 }); }; // 编辑菜单项 const handleEdit = (key?: string) => { const editKey = key || selectedKey; if (!editKey) return; setEditMode('edit'); setParentKey(null); setSelectedKey(editKey); const item = findMenuItem(menuData, editKey); if (item) { form.resetFields(); const formValues = { key: item.key, // 添加key字段 title: item.title, icon: item.icon || '', url: item.url || '', eventType: item.eventType || 'url', customEvent: item.customEvent || '', order: item.order || 1, show: typeof item.show === 'string' ? item.show : 'true' // 修改这里,只处理字符串 }; form.setFieldsValue(formValues); setCurrentEventType(formValues.eventType); setCurrentIcon(formValues.icon || null); } }; // 删除菜单项 const handleDelete = (key?: string) => { const delKey = key || selectedKey; if (!delKey) return; Modal.confirm({ title: '确认删除该菜单项?', onOk: () => { const newData = deleteMenuItem(menuData, delKey); setMenuData(newData); saveHistory(newData); if (delKey === selectedKey) { setSelectedKey(null); form.resetFields(); } message.success('删除成功'); }, }); }; // 保存菜单项 const handleSave = () => { form.validateFields().then(values => { if (editMode === 'add') { // 生成唯一key const newKey = `menu_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newItem: MenuItem = {...values, key: newKey}; const newData = addMenuItem(menuData, parentKey, newItem); setMenuData(newData); saveHistory(newData); message.success('添加成功'); setSelectedKey(newKey); } else if (editMode === 'edit' && selectedKey) { const existingItem = findMenuItem(menuData, selectedKey); // 保留现有key不变 const newItem = {...existingItem, ...values, key: selectedKey}; const newData = updateMenuItem(menuData, selectedKey, newItem); setMenuData(newData); saveHistory(newData); } // 关闭Modal并重置表单 setEditMode(null); form.resetFields(); }); }; // 菜单树数据转换 const convertToTreeData = (data: MenuItem[]): DataNode[] => { return data.map(item => ({ key: item.key, title: ( <span> {item.icon ? <i className={`fa-solid ${item.icon}`} style={{marginRight: '4px'}}></i> : null} {item.title} <Tooltip title="编辑"> <Button size="small" type="link" icon={<EditOutlined/>} onClick={e => { e.stopPropagation(); handleEdit(item.key); }}/> </Tooltip> <Tooltip title="删除"> <Button size="small" type="link" icon={<DeleteOutlined/>} danger onClick={e => { e.stopPropagation(); handleDelete(item.key); }}/> </Tooltip> <Tooltip title="新增"> <Button size="small" type="link" icon={<PlusOutlined/>} onClick={e => { e.stopPropagation(); handleAdd(item.key); }}/> </Tooltip> </span> ), children: item.children ? convertToTreeData(item.children) : undefined, isLeaf: !item.children || item.children.length === 0 })); }; /** * 保存菜单数据到后端API * @param data 菜单数据 */ const saveMenuToAPI = async (data: MenuItem[]) => { try { const response = await fetcher({ url: '/admin/menu/save', method: 'post', data: { menu_data: data } }) as ApiResponse; if (response.data?.status === 0) { message.success('菜单保存成功'); return true; } else { message.error(response.data?.msg || '保存失败'); return false; } } catch (error) { message.error('保存菜单失败,请检查网络连接'); return false; } }; /** * 保存历史记录到本地存储 * @param data 菜单数据 */ const saveHistory = async (data: MenuItem[]) => { // 先尝试保存到后端API const saveSuccess = await saveMenuToAPI(data); if (saveSuccess) { const newHistory = [...history]; // 如果当前不是最新历史记录,则丢弃后面的记录 if (historyIndex < newHistory.length - 1) { newHistory.splice(historyIndex + 1); } newHistory.push(JSON.parse(JSON.stringify(data))); // 深拷贝 // 限制历史记录数量,避免localStorage过大 const maxHistoryCount = 50; if (newHistory.length > maxHistoryCount) { newHistory.splice(0, newHistory.length - maxHistoryCount); } setHistory(newHistory); setHistoryIndex(newHistory.length - 1); // 输出最终菜单JSON console.log("Final Menu JSON:", JSON.stringify(data, null, 2)); } }; // 恢复历史记录 const restoreHistory = (index: number) => { if (index >= 0 && index < history.length) { setMenuData(JSON.parse(JSON.stringify(history[index]))); setHistoryIndex(index); } }; /** * 删除历史记录 * @param id 历史记录ID */ const handleDeleteHistory = async (id: number) => { Modal.confirm({ title: '确认删除此历史记录?', onOk: async () => { try { const response = await fetcher({ url: `/admin/menu/history/delete/${id}`, method: 'delete' }) as ApiResponse; if (response.data?.status === 0) { message.success('历史记录删除成功'); await loadHistoryFromAPI(); // 重新加载历史记录 } else { message.error(response.msg || '删除失败'); } } catch (error) { console.error('删除历史记录失败:', error); } }, }); }; /** * @description 处理导入菜单的逻辑 */ const handleImport = () => { try { const importedMenu = JSON.parse(importJson); // 在这里可以添加对导入的JSON数据格式的校验 setMenuData(importedMenu); saveHistory(importedMenu); message.success('菜单导入成功'); setIsImportModalVisible(false); setImportJson(''); } catch (error) { message.error('JSON格式错误,请检查后重试'); } }; /** * 复制历史记录数据到剪贴板 * @param data 菜单数据 */ const handleCopyHistory = (data: MenuItem[]) => { navigator.clipboard.writeText(JSON.stringify(data, null, 2)) .then(() => message.success('菜单数据已复制到剪贴板')) .catch(() => message.error('复制失败')); }; return ( <> <div style={{ display: 'flex', height: '100vh', border: '1px solid #eee', borderRadius: 8, overflow: 'hidden' }}> {/* 左侧菜单树 */} <div style={{width: 350, borderRight: '1px solid #eee', padding: 16, overflow: 'auto'}}> <div style={{ marginBottom: 16, fontWeight: 'bold', fontSize: 18, backgroundColor: '#f5f5f5', padding: '8px 12px', borderRadius: '6px', border: '1px solid #e8e8e8', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <span>菜单编辑器</span> <div style={{display: 'flex', gap: '4px'}}> <Tooltip title="新增菜单项"> <Button type="text" size="small" icon={<PlusOutlined/>} onClick={() => handleAdd(null)} /> </Tooltip> <Tooltip title={isPreview ? "返回编辑" : "预览菜单"}> <Button type={isPreview ? "primary" : "text"} size="small" icon={<EyeOutlined/>} onClick={() => setIsPreview(!isPreview)} /> </Tooltip> <Tooltip title="导入菜单"> <Button type="text" size="small" icon={<ImportOutlined/>} onClick={() => setIsImportModalVisible(true)} /> </Tooltip> <Tooltip title="复制JSON配置"> <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => { const jsonString = JSON.stringify(menuData, null, 2); navigator.clipboard.writeText(jsonString).then(() => { message.success('JSON配置已复制到剪贴板'); }).catch(() => { message.error('复制失败,请手动复制'); }); }} /> </Tooltip> <Tooltip title="显示JSON配置"> <Button type="text" size="small" icon={<FileTextOutlined/>} onClick={() => { Modal.info({ title: '当前菜单JSON配置', content: ( <pre style={{maxHeight: '400px', overflow: 'auto'}}> {JSON.stringify(menuData, null, 2)} </pre> ), width: 800, }); }} /> </Tooltip> <Tooltip title="菜单修改历史"> <Button type={showHistory ? "primary" : "text"} size="small" icon={<HistoryOutlined/>} onClick={() => { if (!showHistory) { // 打开历史面板时重新从API加载历史记录 loadHistoryFromAPI(); } setShowHistory(!showHistory); }} /> </Tooltip> </div> </div> {isPreview ? ( <Preview menuData={menuData} navigate={navigate}/> ) : ( <Tree treeData={convertToTreeData(menuData)} defaultExpandAll showLine selectedKeys={selectedKey ? [selectedKey] : []} onSelect={onSelect} draggable onDrop={onDrop} blockNode /> )} </div> {/* 右侧使用说明面板 */} <div style={{flex: 1, padding: 32, display: isPreview ? 'none' : 'block'}}> <div style={{fontWeight: 'bold', fontSize: 18, marginBottom: 16}}> 使用说明 </div> <div style={{color: '#666', lineHeight: '1.8'}}> <h3>基本操作:</h3> <ul> <li>点击"新增根菜单"按钮可以在顶层添加菜单项</li> <li>点击菜单项后的<EditOutlined/>图标可以编辑该菜单</li> <li>点击菜单项后的<DeleteOutlined/>图标可以删除该菜单</li> <li>点击菜单项后的<PlusOutlined/>图标可以添加子菜单</li> </ul> <h3>高级功能:</h3> <ul> <li>支持拖拽排序:直接拖动菜单项可以调整顺序或层级</li> <li>支持两种菜单动作:URL跳转和自定义事件</li> <li>历史记录:点击右上角"历史记录"按钮可以查看和恢复历史版本</li> <li>预览模式:点击右上角"预览"按钮可以预览实际效果</li> </ul> <h3>菜单配置说明:</h3> <ul> <li>图标:支持 Font Awesome 图标</li> <li>URL跳转:直接填写目标URL地址</li> <li>自定义事件:可以编写 JavaScript 代码实现复杂交互</li> <li>排序号:决定同级菜单的显示顺序</li> <li> 显示表达式:控制菜单项是否显示,支持使用预定义函数的JavaScript表达式 <ul> <li><code>true</code> 或 <code>false</code>:直接控制显示</li> <li><code>contains('admin', user.role)</code>:检查用户角色是否包含指定字符串</li> <li><code>isGatewayAPISupported()==true</code>:检查集群是否支持Gateway API</li> <li><code>isIstioSupported()==true</code>:检查集群是否支持Istio</li> <li><code>isOpenKruiseSupported()==true</code>:检查集群是否支持OpenKruise</li> <li><code>isPlatformAdmin()==true</code>:检查用户是否为平台管理员</li> </ul> </li> </ul> </div> </div> {/* 历史记录面板 */} <Modal title="菜单修改历史" open={showHistory} onCancel={() => setShowHistory(false)} footer={null} width={800} > <div style={{maxHeight: '60vh', overflow: 'auto'}}> <table style={{width: '100%', borderCollapse: 'collapse'}}> <thead> <tr style={{backgroundColor: '#f0f0f0'}}> <th style={{padding: '8px', border: '1px solid #ddd'}}>序号</th> <th style={{padding: '8px', border: '1px solid #ddd'}}>时间</th> <th style={{padding: '8px', border: '1px solid #ddd'}}>操作</th> </tr> </thead> <tbody> {[...history].map((record, index) => { const actualIndex = history.length - 1 - index; // 计算实际索引 return ( <tr key={index} style={{borderBottom: '1px solid #ddd'}}> <td style={{padding: '8px', border: '1px solid #ddd'}}>{record.id}</td> <td style={{padding: '8px', border: '1px solid #ddd'}}> {new Date(record.created_at).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} </td> <td style={{padding: '0px', border: '1px solid #ddd'}}> <Flex wrap gap="small"> <Button icon={<RollbackOutlined/>} onClick={() => { restoreHistory(actualIndex); setShowHistory(false); message.success('已恢复到选定版本'); }} > 恢复 </Button> <Button danger icon={<DeleteOutlined/>} onClick={() => handleDeleteHistory(record.id)} > 删除 </Button> <Button icon={<EyeOutlined/>} onClick={() => { Modal.info({ title: '菜单预览', content: ( <div> <Tabs defaultActiveKey="1"> <Tabs.TabPane tab="菜单JSON配置" key="1"> <pre style={{ maxHeight: '400px', overflow: 'auto' }}> {JSON.stringify(record.menu_data, null, 2)} </pre> </Tabs.TabPane> <Tabs.TabPane tab="菜单预览" key="2"> <Preview menuData={record.menu_data}/> </Tabs.TabPane> </Tabs> </div> ), width: 800, }); }} > 预览 </Button> <Button icon={<CopyOutlined/>} onClick={() => handleCopyHistory(record.menu_data)} > 复制 </Button> </Flex> </td> </tr> ); })} </tbody> </table> </div> </Modal> {/* 编辑表单弹窗 */} <Modal title={editMode === 'add' ? '新增菜单项' : '编辑菜单项'} open={!!editMode} onCancel={() => { setEditMode(null); setSelectedKey(null); form.resetFields(); }} footer={[ <Tooltip key="cancel-tooltip" title="取消编辑"> <Button key="cancel" onClick={() => { setEditMode(null); setSelectedKey(null); form.resetFields(); }}> 取消 </Button> </Tooltip>, <Tooltip key="submit-tooltip" title="保存菜单项"> <Button key="submit" type="primary" onClick={handleSave}> 保存 </Button> </Tooltip> ]} > <Form form={form} layout="vertical" > <Form.Item label="菜单名称" name="title" rules={[ {required: true, whitespace: true, message: '请输入菜单名称'} ]}> <Input/> </Form.Item> <Form.Item label="图标" name="icon"> <Tooltip title="点击选择图标"> <Button type="primary" onClick={() => setShowIconModal(true)} style={{width: '100%', justifyContent: 'space-between'}} > {currentIcon ? ( <span style={{display: 'flex', alignItems: 'center'}}> <i className={`fa-solid ${currentIcon}`} style={{marginRight: '8px'}}></i> </span> ) : ( '选择图标' )} </Button> </Tooltip> </Form.Item> <Form.Item label="点击事件" name="eventType" initialValue="url"> <Select style={{zIndex: 1000000}} options={[ {label: 'URL跳转', value: 'url'}, {label: '自定义', value: 'custom'} ]} onChange={(value) => setCurrentEventType(value as 'url' | 'custom')} /> </Form.Item> {currentEventType === 'url' && ( <Form.Item label="URL" name="url"> <Input/> </Form.Item> )} {currentEventType === 'custom' && ( <Form.Item label="自定义事件代码" rules={[ {required: true, message: '请输入自定义事件代码'} ]}> <> <CustomEventTags onChange={(value) => form.setFieldsValue({customEvent: value})}/> <Form.Item noStyle name="customEvent"> <Input.TextArea rows={4} placeholder="请输入自定义事件代码"/> </Form.Item> </> </Form.Item> )} <Form.Item label="排序" name="order"> <InputNumber min={1}/> </Form.Item> {/* 修改显示控制部分 */} <Form.Item label="显示表达式" name="show" rules={[{required: true, message: '请输入显示表达式'}]} > <Input.TextArea rows={3} placeholder="请输入JavaScript表达式,例如: true 或 user.role === 'admin'" /> </Form.Item> </Form> </Modal> </div> <Modal title="导入菜单配置" open={isImportModalVisible} onOk={handleImport} onCancel={() => { setIsImportModalVisible(false); setImportJson(''); }} okText="导入" cancelText="取消" > <Input.TextArea rows={10} value={importJson} onChange={(e) => setImportJson(e.target.value)} placeholder='请在此处粘贴菜单的JSON配置' /> </Modal> <IconPicker open={showIconModal} onCancel={() => setShowIconModal(false)} onSelect={handleIconSelect} selectedIcon={currentIcon || ''} /> </> ); }; export default MenuEditor;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/weibaohui/k8m'

If you have feedback or need assistance with the MCP directory API, please join our Discord server