Files
video-gen/video-gen-admin/src/pages/AdminMenuConfig.tsx
T
2026-06-25 17:07:32 +08:00

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;