Files
CloudHome-Ops/lib/screens/admin/sys/menu_page.dart

512 lines
14 KiB
Dart
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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_menu.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 菜单管理页(树形展示)。
class MenuPage extends ConsumerStatefulWidget {
const MenuPage({super.key});
@override
ConsumerState<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends ConsumerState<MenuPage> {
final SystemService _service = SystemService();
List<SysMenu> _items = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
_items = await _service.getMenuList();
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
await _loadData();
}
/// 构建菜单树:parentId 为空或 "0" 的为根节点。
List<_MenuNode> _buildTree(List<SysMenu> menus) {
final nodeMap = <String, _MenuNode>{};
for (final menu in menus) {
nodeMap[menu.menuId] = _MenuNode(menu: menu);
}
final roots = <_MenuNode>[];
for (final menu in menus) {
final node = nodeMap[menu.menuId]!;
final parentId = menu.parentId.isEmpty || menu.parentId == '0'
? null
: menu.parentId;
if (parentId == null || !nodeMap.containsKey(parentId)) {
roots.add(node);
} else {
nodeMap[parentId]!.children.add(node);
}
}
// 排序
void sortNodes(List<_MenuNode> nodes) {
nodes.sort((a, b) => a.menu.orderNum.compareTo(b.menu.orderNum));
for (final node in nodes) {
sortNodes(node.children);
}
}
sortNodes(roots);
return roots;
}
Future<void> _showForm({SysMenu? menu}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _MenuForm(menu: menu, allMenus: _items),
);
if (result == null) return;
try {
await _service.saveMenu(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteMenu(SysMenu menu) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除菜单「${menu.name}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteMenu(menu.menuId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
String _typeLabel(int type) {
switch (type) {
case 0:
return '目录';
case 1:
return '菜单';
case 2:
return '按钮';
default:
return '未知';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('菜单管理')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty) {
return const Center(child: Text('暂无数据'));
}
final tree = _buildTree(_items);
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: tree.length,
itemBuilder: (context, index) {
return _buildTreeNode(tree[index]);
},
),
);
}
Widget _buildTreeNode(_MenuNode node) {
final menu = node.menu;
final hasChildren = node.children.isNotEmpty;
if (hasChildren) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ExpansionTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
children: node.children.map((child) => _buildTreeNode(child)).toList(),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
}
IconData _iconData(String icon) {
switch (icon) {
case 'home':
return Icons.home;
case 'settings':
return Icons.settings;
case 'person':
return Icons.person;
case 'people':
return Icons.people;
case 'menu':
return Icons.menu;
case 'list':
return Icons.list;
case 'dashboard':
return Icons.dashboard;
case 'devices':
return Icons.devices;
case 'notifications':
return Icons.notifications;
default:
return Icons.folder;
}
}
}
/// 菜单树节点辅助类。
class _MenuNode {
_MenuNode({required this.menu});
final SysMenu menu;
final List<_MenuNode> children = [];
}
/// 菜单表单。
class _MenuForm extends StatefulWidget {
const _MenuForm({this.menu, this.allMenus = const []});
final SysMenu? menu;
final List<SysMenu> allMenus;
@override
State<_MenuForm> createState() => _MenuFormState();
}
class _MenuFormState extends State<_MenuForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _urlCtrl;
late TextEditingController _iconCtrl;
late TextEditingController _orderNumCtrl;
late TextEditingController _permsCtrl;
late String _parentId;
late int _type;
late bool _show;
@override
void initState() {
super.initState();
final m = widget.menu;
_nameCtrl = TextEditingController(text: m?.name ?? '');
_urlCtrl = TextEditingController(text: m?.url ?? '');
_iconCtrl = TextEditingController(text: m?.icon ?? '');
_orderNumCtrl = TextEditingController(text: (m?.orderNum ?? 0).toString());
_permsCtrl = TextEditingController(text: m?.perms ?? '');
_parentId = m?.parentId ?? '0';
_type = m?.type ?? 0;
_show = (m?.show ?? 0) == 0;
}
@override
void dispose() {
_nameCtrl.dispose();
_urlCtrl.dispose();
_iconCtrl.dispose();
_orderNumCtrl.dispose();
_permsCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'name': _nameCtrl.text.trim(),
'parentId': _parentId,
'type': _type,
'url': _urlCtrl.text.trim(),
'icon': _iconCtrl.text.trim(),
'orderNum': int.tryParse(_orderNumCtrl.text.trim()) ?? 0,
'perms': _permsCtrl.text.trim(),
'show': _show ? 0 : 1,
};
if (widget.menu != null) {
data['menuId'] = widget.menu!.menuId;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.menu != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑菜单' : '新增菜单',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: '菜单名称',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入菜单名称' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _parentId,
decoration: const InputDecoration(
labelText: '父级菜单',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: '0', child: Text('一级菜单')),
...widget.allMenus.map((m) => DropdownMenuItem(
value: m.menuId,
child: Text(m.name),
)),
],
onChanged: (v) => setState(() => _parentId = v ?? '0'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: const Text('目录'),
value: 0,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 0),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('菜单'),
value: 1,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 1),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('按钮'),
value: 2,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 2),
contentPadding: EdgeInsets.zero,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: '路由地址',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _iconCtrl,
decoration: const InputDecoration(
labelText: '图标',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _orderNumCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '排序号',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _permsCtrl,
decoration: const InputDecoration(
labelText: '权限标识',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('是否显示'),
value: _show,
onChanged: (v) => setState(() => _show = v),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}