288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
|
} from 'antd';
|
|
import {
|
|
MenuOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
|
HomeOutlined, PlayCircleOutlined, WalletOutlined, RobotOutlined,
|
|
SettingOutlined, BellOutlined, UserOutlined, AppstoreOutlined,
|
|
FileTextOutlined, StarOutlined, HeartOutlined, CameraOutlined,
|
|
DashboardOutlined, CalculatorOutlined, DollarOutlined, GiftOutlined,
|
|
ThunderboltOutlined, FireOutlined, CloudOutlined, SmileOutlined,
|
|
TrophyOutlined, RocketOutlined, BulbOutlined, CodeOutlined,
|
|
PictureOutlined, VideoCameraOutlined, AudioOutlined,
|
|
MailOutlined, PhoneOutlined, GlobalOutlined, ShoppingCartOutlined,
|
|
TeamOutlined, BarChartOutlined, PieChartOutlined, LineChartOutlined,
|
|
SecurityScanOutlined, ApiOutlined, DatabaseOutlined, CloudServerOutlined,
|
|
} from '@ant-design/icons';
|
|
import { getMenuConfigs, saveMenuConfig, deleteMenuConfig } from '../api';
|
|
|
|
const ICON_MAP: Record<string, React.ReactNode> = {
|
|
HomeOutlined: <HomeOutlined />, PlayCircleOutlined: <PlayCircleOutlined />,
|
|
WalletOutlined: <WalletOutlined />, RobotOutlined: <RobotOutlined />,
|
|
SettingOutlined: <SettingOutlined />, BellOutlined: <BellOutlined />,
|
|
UserOutlined: <UserOutlined />, AppstoreOutlined: <AppstoreOutlined />,
|
|
FileTextOutlined: <FileTextOutlined />, StarOutlined: <StarOutlined />,
|
|
HeartOutlined: <HeartOutlined />, CameraOutlined: <CameraOutlined />,
|
|
DashboardOutlined: <DashboardOutlined />, CalculatorOutlined: <CalculatorOutlined />,
|
|
DollarOutlined: <DollarOutlined />, GiftOutlined: <GiftOutlined />,
|
|
ThunderboltOutlined: <ThunderboltOutlined />, FireOutlined: <FireOutlined />,
|
|
CloudOutlined: <CloudOutlined />, SmileOutlined: <SmileOutlined />,
|
|
TrophyOutlined: <TrophyOutlined />, RocketOutlined: <RocketOutlined />,
|
|
BulbOutlined: <BulbOutlined />, CodeOutlined: <CodeOutlined />,
|
|
PictureOutlined: <PictureOutlined />, VideoCameraOutlined: <VideoCameraOutlined />,
|
|
AudioOutlined: <AudioOutlined />, MailOutlined: <MailOutlined />,
|
|
PhoneOutlined: <PhoneOutlined />, GlobalOutlined: <GlobalOutlined />,
|
|
ShoppingCartOutlined: <ShoppingCartOutlined />, TeamOutlined: <TeamOutlined />,
|
|
BarChartOutlined: <BarChartOutlined />, PieChartOutlined: <PieChartOutlined />,
|
|
LineChartOutlined: <LineChartOutlined />, SecurityScanOutlined: <SecurityScanOutlined />,
|
|
ApiOutlined: <ApiOutlined />, DatabaseOutlined: <DatabaseOutlined />,
|
|
CloudServerOutlined: <CloudServerOutlined />, MenuOutlined: <MenuOutlined />,
|
|
};
|
|
|
|
const ICON_OPTIONS = Object.keys(ICON_MAP).map(key => ({
|
|
value: key,
|
|
label: <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>{ICON_MAP[key]} {key.replace('Outlined', '')}</span>,
|
|
}));
|
|
|
|
const TYPE_COLORS: Record<string, string> = { page: 'blue', group: 'purple' };
|
|
const TYPE_LABELS: Record<string, string> = { page: '页面', group: '分组' };
|
|
|
|
const AdminMenuConfig: React.FC = () => {
|
|
const [menus, setMenus] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<string>('frontend');
|
|
const [modal, setModal] = useState<{ open: boolean; menu: any | null }>({ open: false, menu: null });
|
|
const [form] = Form.useForm();
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getMenuConfigs();
|
|
setMenus(data);
|
|
} catch { /* auth error handled by client */ }
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
const payload = {
|
|
label: values.label,
|
|
path: values.path || '',
|
|
icon: values.icon || '',
|
|
sort_order: values.sortOrder ?? 0,
|
|
is_active: values.isActive ?? true,
|
|
parent_id: values.parentId || null,
|
|
menu_type: values.menuType || 'page',
|
|
menu_target: values.menuTarget || 'frontend',
|
|
is_default: values.isDefault ?? false,
|
|
};
|
|
if (modal.menu?.id) {
|
|
await saveMenuConfig({ ...payload, id: modal.menu.id });
|
|
} else {
|
|
await saveMenuConfig(payload);
|
|
}
|
|
message.success(modal.menu?.id ? '菜单已更新' : '菜单已添加');
|
|
setModal({ open: false, menu: null });
|
|
form.resetFields();
|
|
load();
|
|
} catch { /* validation */ }
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await deleteMenuConfig(id);
|
|
message.success('菜单已删除');
|
|
load();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '删除失败');
|
|
}
|
|
};
|
|
|
|
const openEdit = (menu?: any) => {
|
|
setModal({ open: true, menu: menu || null });
|
|
if (menu) {
|
|
form.setFieldsValue({
|
|
label: menu.label,
|
|
path: menu.path,
|
|
icon: menu.icon,
|
|
sortOrder: menu.sortOrder ?? 0,
|
|
isActive: menu.isActive ?? true,
|
|
parentId: menu.parentId ?? '',
|
|
menuType: menu.menuType ?? 'page',
|
|
menuTarget: menu.menuTarget ?? 'frontend',
|
|
isDefault: menu.isDefault ?? false,
|
|
});
|
|
} else {
|
|
form.resetFields();
|
|
form.setFieldsValue({ icon: 'HomeOutlined', sortOrder: menus.length, isActive: true, menuType: 'page', menuTarget: activeTab, isDefault: false });
|
|
}
|
|
};
|
|
|
|
// Filter menus by active tab
|
|
const filteredMenus = menus.filter(m => {
|
|
const target = m.menuTarget ?? 'frontend';
|
|
return target === activeTab || target === 'both';
|
|
});
|
|
|
|
// Build tree: groups first, then pages under groups
|
|
const groups = filteredMenus.filter(m => m.menuType === 'group');
|
|
const parentOptions = [
|
|
{ value: '', label: '顶级菜单' },
|
|
...groups.map(g => ({ value: g.id, label: g.label })),
|
|
];
|
|
|
|
// Build flat display with indentation
|
|
const displayMenus: any[] = [];
|
|
const topLevel = filteredMenus.filter(m => !m.parentId);
|
|
const childMap: Record<string, any[]> = {};
|
|
filteredMenus.filter(m => m.parentId).forEach(m => {
|
|
const pid = m.parentId;
|
|
if (!childMap[pid]) childMap[pid] = [];
|
|
childMap[pid].push(m);
|
|
});
|
|
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
|
|
displayMenus.push({ ...m, _depth: 0 });
|
|
(childMap[m.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(c => {
|
|
displayMenus.push({ ...c, _depth: 1 });
|
|
});
|
|
});
|
|
|
|
const columns = [
|
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
|
{
|
|
title: '菜单名称', key: 'label', width: 180,
|
|
render: (_: any, r: any) => (
|
|
<span style={{ paddingLeft: r._depth * 20, fontWeight: r._depth === 0 ? 600 : 400 }}>
|
|
{r._depth === 1 && <span style={{ color: '#cbd5e1', marginRight: 4 }}>└</span>}
|
|
{r.label}
|
|
</span>
|
|
),
|
|
},
|
|
{ title: '路由路径', dataIndex: 'path', width: 160, render: (v: string) => v || <Typography.Text type="secondary">-</Typography.Text> },
|
|
{
|
|
title: '图标', dataIndex: 'icon', width: 100,
|
|
render: (v: string) => v && ICON_MAP[v] ? (
|
|
<span style={{ fontSize: 16, color: '#6366f1' }}>{ICON_MAP[v]}</span>
|
|
) : '-',
|
|
},
|
|
{
|
|
title: '类型', dataIndex: 'menuType', width: 80,
|
|
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'isActive', width: 70,
|
|
render: (v: boolean) => (
|
|
<span style={{ color: v ? '#22c55e' : '#94a3b8' }}>{v ? '启用' : '停用'}</span>
|
|
),
|
|
},
|
|
...(activeTab === 'frontend' ? [{
|
|
title: '默认显示', dataIndex: 'isDefault', width: 80,
|
|
render: (v: boolean) => v ? <Tag color="green">默认</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
|
}] : []),
|
|
{
|
|
title: '操作', key: 'action', width: 150,
|
|
render: (_: any, r: any) => (
|
|
<Space size={4}>
|
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
|
<Popconfirm title="确定删除该菜单?" onConfirm={() => handleDelete(r.id)}>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
<Space>
|
|
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
|
<Typography.Text strong style={{ fontSize: 16 }}>菜单配置</Typography.Text>
|
|
</Space>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
|
添加菜单
|
|
</Button>
|
|
</div>
|
|
<Tabs
|
|
activeKey={activeTab}
|
|
onChange={setActiveTab}
|
|
items={[
|
|
{ key: 'frontend', label: '前台菜单' },
|
|
{ key: 'admin', label: '后台菜单' },
|
|
]}
|
|
/>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={displayMenus}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={false}
|
|
scroll={{ x: 900 }}
|
|
/>
|
|
</Card>
|
|
|
|
<Modal
|
|
title={<Space><MenuOutlined />{modal.menu?.id ? '编辑菜单' : '添加菜单'}</Space>}
|
|
open={modal.open}
|
|
onOk={handleSave}
|
|
onCancel={() => { setModal({ open: false, menu: null }); form.resetFields(); }}
|
|
okText="确认" cancelText="取消" width={520}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="label" label="菜单名称" rules={[{ required: true, message: '请输入菜单名称' }]}>
|
|
<Input placeholder="例如:我的项目" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="path" label="路由路径" tooltip="分组类型可留空">
|
|
<Input placeholder="例如:/projects(分组可留空)" size="large" />
|
|
</Form.Item>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="menuType" label="菜单类型" style={{ flex: 1 }} rules={[{ required: true }]}>
|
|
<Select size="large" options={[
|
|
{ value: 'page', label: '页面' },
|
|
{ value: 'group', label: '分组' },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item name="menuTarget" label="适用端" style={{ flex: 1 }} rules={[{ required: true }]}>
|
|
<Select size="large" options={[
|
|
{ value: 'frontend', label: '前台' },
|
|
{ value: 'admin', label: '后台' },
|
|
{ value: 'both', label: '两者' },
|
|
]} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Form.Item name="icon" label="图标" style={{ flex: 1 }} rules={[{ required: true }]}>
|
|
<Select size="large" options={ICON_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item name="sortOrder" label="排序" style={{ flex: 1 }}>
|
|
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" placeholder="默认0" />
|
|
</Form.Item>
|
|
</div>
|
|
<Form.Item name="parentId" label="上级菜单">
|
|
<Select size="large" options={parentOptions} allowClear placeholder="顶级菜单" />
|
|
</Form.Item>
|
|
<Form.Item name="isActive" label="启用" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.menuTarget !== cur.menuTarget}>
|
|
{({ getFieldValue }) =>
|
|
getFieldValue('menuTarget') !== 'admin' ? (
|
|
<Form.Item name="isDefault" label="新用户默认显示" valuePropName="checked" tooltip="开启后,新注册用户默认显示此菜单">
|
|
<Switch />
|
|
</Form.Item>
|
|
) : null
|
|
}
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminMenuConfig;
|