ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
122
lib/screens/admin/admin_hub_page.dart
Normal file
122
lib/screens/admin/admin_hub_page.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../utils/permissions.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
|
||||
/// 管理中心页(底部导航第 4 个 tab):各管理模块入口。
|
||||
///
|
||||
/// 模块可见性由 [SessionPermissions] 统一驱动,与 [router.dart] 的路由可达性
|
||||
/// 保持一致:
|
||||
/// - 用户管理:所有已登录账号可见(数据范围由后端按会话权限收敛);
|
||||
/// - 渠道商管理:管理员 + 渠道商可见,代理商不可见;
|
||||
/// - 其余模块(设备/激活码/公司介绍/滚动字幕)对所有账号可见。
|
||||
class AdminHubPage extends ConsumerWidget {
|
||||
const AdminHubPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = AppLocalizations.of(context);
|
||||
// 注:按需求「设备分组」整合进设备管理功能(设备详情/设备列表内可改分组),
|
||||
// 「应用管理」移除以简化管理页。故网格不再单列这两项。
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final user = ref.watch(authProvider).user;
|
||||
// 菜单可见性与路由可达性由同一套 SessionPermissions 谓词驱动:
|
||||
// - 用户管理:所有已登录账号(各角色看自己范围内用户);
|
||||
// - 渠道商管理:管理员 + 渠道商(代理商不可见,路由层亦会拦截其深链)。
|
||||
final showUserManagement = SessionPermissions.canViewUserManagement(user);
|
||||
final showChannelManagement =
|
||||
SessionPermissions.canViewChannelManagement(user);
|
||||
|
||||
final items = [
|
||||
_MenuItem(
|
||||
title: t.deviceManagement,
|
||||
icon: Icons.devices_outlined,
|
||||
color: scheme.primary,
|
||||
route: '/admin/device',
|
||||
),
|
||||
_MenuItem(
|
||||
title: t.activationCode,
|
||||
icon: Icons.vpn_key_outlined,
|
||||
color: Colors.amber,
|
||||
route: '/admin/activation',
|
||||
),
|
||||
if (showUserManagement)
|
||||
_MenuItem(
|
||||
title: t.userManagement,
|
||||
icon: Icons.people_outlined,
|
||||
color: scheme.outline,
|
||||
route: '/admin/sys/user',
|
||||
),
|
||||
_MenuItem(
|
||||
title: t.companyIntro,
|
||||
icon: Icons.business_outlined,
|
||||
color: Colors.teal,
|
||||
route: '/admin/intro/store-list',
|
||||
),
|
||||
_MenuItem(
|
||||
title: t.marqueeManagement,
|
||||
icon: Icons.subtitles_outlined,
|
||||
color: Colors.deepPurple,
|
||||
route: '/admin/marquee',
|
||||
),
|
||||
if (showChannelManagement)
|
||||
_MenuItem(
|
||||
title: t.channelManagement,
|
||||
icon: Icons.account_tree_outlined,
|
||||
color: Colors.blue,
|
||||
route: '/admin/channel',
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.admin)),
|
||||
body: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => context.push(item.route),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, size: 40, color: item.color),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem {
|
||||
const _MenuItem({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.route,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String route;
|
||||
}
|
||||
358
lib/screens/admin/app/app_version_page.dart
Normal file
358
lib/screens/admin/app/app_version_page.dart
Normal file
@@ -0,0 +1,358 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/app_version.dart';
|
||||
import '../../../services/app_service.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
|
||||
/// 应用版本管理页。
|
||||
class AppVersionPage extends ConsumerStatefulWidget {
|
||||
const AppVersionPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AppVersionPage> createState() => _AppVersionPageState();
|
||||
}
|
||||
|
||||
class _AppVersionPageState extends ConsumerState<AppVersionPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
List<AppVersion> _items = [];
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
String? _error;
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200 &&
|
||||
!_loading &&
|
||||
_hasMore) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await AppService().getAppVersionList(page: 1, limit: 20);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = list;
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page = 1;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = _friendlyError(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final list = await AppService().getAppVersionList(page: _page + 1, limit: 20);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items.addAll(list);
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page++;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
String _friendlyError(Object e) {
|
||||
if (e is ApiException) return e.message;
|
||||
final msg = e.toString();
|
||||
if (msg.contains('404') ||
|
||||
msg.contains('DioException') ||
|
||||
msg.contains('SocketException')) {
|
||||
return '应用版本接口暂不可用,请联系管理员确认权限或接口地址';
|
||||
}
|
||||
return '加载失败:$msg';
|
||||
}
|
||||
|
||||
void _showForm({AppVersion? editing}) {
|
||||
final isEdit = editing != null;
|
||||
final appNameCtrl = TextEditingController(text: editing?.appName ?? '');
|
||||
final packageCtrl = TextEditingController(text: editing?.packageName ?? '');
|
||||
final versionNameCtrl = TextEditingController(text: editing?.versionName ?? '');
|
||||
final versionCodeCtrl =
|
||||
TextEditingController(text: editing?.versionCode.toString() ?? '');
|
||||
final apkUrlCtrl = TextEditingController(text: editing?.apkUrl ?? '');
|
||||
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
|
||||
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
|
||||
int statusValue = editing?.status ?? 0;
|
||||
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEdit ? '编辑版本' : '新增版本',
|
||||
style: Theme.of(ctx).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: appNameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '应用名称', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: packageCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '包名', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: versionNameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '版本名', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: versionCodeCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '版本号', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: apkUrlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'APK下载地址', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: orgIdCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '组织ID', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: groupIdCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分组ID', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: statusValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '状态', border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0, child: Text('正常')),
|
||||
DropdownMenuItem(value: 1, child: Text('禁用')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => statusValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final data = <String, dynamic>{
|
||||
if (isEdit) 'id': editing!.id,
|
||||
'appName': appNameCtrl.text,
|
||||
'packageName': packageCtrl.text,
|
||||
'versionName': versionNameCtrl.text,
|
||||
'versionCode': int.tryParse(versionCodeCtrl.text) ?? 0,
|
||||
'apkUrl': apkUrlCtrl.text,
|
||||
'orgId': orgIdCtrl.text,
|
||||
'groupId': groupIdCtrl.text,
|
||||
'status': statusValue,
|
||||
};
|
||||
try {
|
||||
if (isEdit) {
|
||||
await AppService().updateAppVersion(data);
|
||||
} else {
|
||||
await AppService().saveAppVersion(data);
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('保存成功')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ctx.mounted) return;
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('确定要删除此版本吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await AppService().deleteAppVersion([int.tryParse(id) ?? 0]);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('应用版本管理')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索应用名称',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (v) => setState(() => _keyword = v),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? Center(child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
|
||||
],
|
||||
))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: _items.isEmpty && !_loading
|
||||
? const Center(child: Text('暂无数据'))
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = _items[i];
|
||||
if (_keyword.isNotEmpty &&
|
||||
!item.appName.toLowerCase().contains(_keyword.toLowerCase())) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(item.appName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text('包名: ${item.packageName}'),
|
||||
Text('版本: ${item.versionName} (${item.versionCode})'),
|
||||
if (item.createTime != null)
|
||||
Text('创建: ${Formatters.formatDateTime(item.createTime!)}'),
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
label: Text(item.status == 0 ? '正常' : '禁用'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _showForm(editing: item);
|
||||
if (v == 'delete') _delete(item.id);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
346
lib/screens/admin/app/tv_app_page.dart
Normal file
346
lib/screens/admin/app/tv_app_page.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/tv_app.dart';
|
||||
import '../../../services/app_service.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
|
||||
/// TV端应用管理页。
|
||||
class TvAppPage extends ConsumerStatefulWidget {
|
||||
const TvAppPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TvAppPage> createState() => _TvAppPageState();
|
||||
}
|
||||
|
||||
class _TvAppPageState extends ConsumerState<TvAppPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
List<TvApp> _items = [];
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
String? _error;
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200 &&
|
||||
!_loading &&
|
||||
_hasMore) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
|
||||
final list = await AppService().getTvAppList(page: 1, limit: 20, appName: kw);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = list;
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page = 1;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is ApiException ? e.message : '加载失败';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
|
||||
final list = await AppService().getTvAppList(page: _page + 1, limit: 20, appName: kw);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items.addAll(list);
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page++;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
void _showForm({TvApp? editing}) {
|
||||
final isEdit = editing != null;
|
||||
final nameCtrl = TextEditingController(text: editing?.appName ?? '');
|
||||
final pkgCtrl = TextEditingController(text: editing?.packageName ?? '');
|
||||
final catCtrl = TextEditingController(text: editing?.appCategory ?? '');
|
||||
final iconCtrl = TextEditingController(text: editing?.iconUrl ?? '');
|
||||
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
|
||||
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
|
||||
int statusValue = editing?.status ?? 0;
|
||||
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEdit ? '编辑应用' : '新增应用',
|
||||
style: Theme.of(ctx).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '应用名称', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: pkgCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '包名', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: catCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分类', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: iconCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '图标地址', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: orgIdCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '组织ID', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: groupIdCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分组ID', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: statusValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '状态', border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0, child: Text('正常')),
|
||||
DropdownMenuItem(value: 1, child: Text('禁用')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => statusValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final data = <String, dynamic>{
|
||||
if (isEdit) 'id': editing!.id,
|
||||
'appName': nameCtrl.text,
|
||||
'packageName': pkgCtrl.text,
|
||||
'appCategory': catCtrl.text,
|
||||
'iconUrl': iconCtrl.text,
|
||||
'orgId': orgIdCtrl.text,
|
||||
'groupId': groupIdCtrl.text,
|
||||
'status': statusValue,
|
||||
};
|
||||
try {
|
||||
await AppService().saveTvApp(data);
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('保存成功')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ctx.mounted) return;
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('确定要删除此应用吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await AppService().deleteTvApp([int.tryParse(id) ?? 0]);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('TV端应用管理')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索应用名称',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
_refresh();
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (v) {
|
||||
setState(() => _keyword = v);
|
||||
_refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? Center(child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
|
||||
],
|
||||
))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: _items.isEmpty && !_loading
|
||||
? const Center(child: Text('暂无数据'))
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = _items[i];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: item.iconUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
item.iconUrl,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.apps, size: 40),
|
||||
),
|
||||
)
|
||||
: const CircleAvatar(child: Icon(Icons.apps)),
|
||||
title: Text(item.appName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text('包名: ${item.packageName}'),
|
||||
if (item.appCategory.isNotEmpty)
|
||||
Text('分类: ${item.appCategory}'),
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
label: Text(item.status == 0 ? '正常' : '禁用'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _showForm(editing: item);
|
||||
if (v == 'delete') _delete(item.id);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
894
lib/screens/admin/channel/channel_management_page.dart
Normal file
894
lib/screens/admin/channel/channel_management_page.dart
Normal file
@@ -0,0 +1,894 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/org_node.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/org_service.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
import '../../../utils/i10n.dart';
|
||||
import 'channel_version_manage_dialog.dart';
|
||||
|
||||
/// 渠道商管理页:渠道 → 代理/子代理 树形 CRUD。
|
||||
///
|
||||
/// 功能结构、布局与交互逻辑对齐 Web 后台 `org.vue`(screen-front
|
||||
/// `views/modules/org/org.vue`):
|
||||
/// - 列表:渠道 + 代理树形展示,默认展开顶层渠道;
|
||||
/// - 搜索:按名称模糊匹配;
|
||||
/// - 筛选:类型(全部/渠道/代理)+ 状态(全部/启用/禁用),与 Web 一致;
|
||||
/// - 详情:点按行打开底部详情卡,展示全部字段;
|
||||
/// - 新增/编辑/新增子级/删除:底部表单或确认弹窗;
|
||||
/// - 删除守卫:存在子集时禁止删除(Web "存在子集时不允许删除");
|
||||
/// - 移动端适配:点按区域放大、`Dismissible` 左滑删除手势。
|
||||
///
|
||||
/// 数据来自 [OrgService.getChannelTree](组合 /sys/org/channels 与
|
||||
/// /sys/org/channelAgentTree),增删改复用既有组织接口
|
||||
/// [OrgService.createOrg] / [updateOrg] / [deleteOrg]。
|
||||
class ChannelManagementPage extends ConsumerStatefulWidget {
|
||||
const ChannelManagementPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChannelManagementPage> createState() =>
|
||||
_ChannelManagementPageState();
|
||||
}
|
||||
|
||||
class _ChannelManagementPageState
|
||||
extends ConsumerState<ChannelManagementPage> {
|
||||
List<OrgNode> _tree = [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
final _searchController = TextEditingController();
|
||||
String _keyword = '';
|
||||
int? _typeFilter;
|
||||
String? _statusFilter;
|
||||
final Set<String> _expanded = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final tree = await OrgService().getChannelTree();
|
||||
if (!mounted) return;
|
||||
// 默认展开顶层渠道,便于一眼看到其下代理。
|
||||
final autoExpand = tree.map((n) => n.id).toSet();
|
||||
setState(() {
|
||||
_tree = tree;
|
||||
_loading = false;
|
||||
_expanded.addAll(autoExpand);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = formatApiError(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算当前应展示的扁平行(含层级 depth 与父级名称)。
|
||||
///
|
||||
/// - 无筛选(关键词 / 类型 / 状态都为空):按 [_expanded] 折叠/展开;
|
||||
/// - 有筛选:忽略展开态,递归展开全部并仅显示命中节点(父未命中但子命中时
|
||||
/// 子仍可见),确保筛选结果不被折叠隐藏。
|
||||
List<_ChannelRow> _computeRows() {
|
||||
final rows = <_ChannelRow>[];
|
||||
final kw = _keyword.trim().toLowerCase();
|
||||
final filterActive =
|
||||
kw.isNotEmpty || _typeFilter != null || _statusFilter != null;
|
||||
bool pass(OrgNode n) {
|
||||
if (kw.isNotEmpty && !n.name.toLowerCase().contains(kw)) return false;
|
||||
if (_typeFilter != null && n.type != _typeFilter) return false;
|
||||
if (_statusFilter != null &&
|
||||
n.status.toUpperCase() != _statusFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void walk(List<OrgNode> nodes, int depth, String? parentName) {
|
||||
for (final n in nodes) {
|
||||
if (filterActive ? pass(n) : true) {
|
||||
rows.add(
|
||||
_ChannelRow(node: n, depth: depth, parentName: parentName),
|
||||
);
|
||||
}
|
||||
final childrenVisible =
|
||||
n.children.isNotEmpty && (filterActive || _expanded.contains(n.id));
|
||||
if (childrenVisible) walk(n.children, depth + 1, n.name);
|
||||
}
|
||||
}
|
||||
|
||||
walk(_tree, 0, null);
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _toggleExpand(OrgNode node) {
|
||||
if (node.children.isEmpty) return;
|
||||
setState(() {
|
||||
if (_expanded.contains(node.id)) {
|
||||
_expanded.remove(node.id);
|
||||
} else {
|
||||
_expanded.add(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 渠道/代理可新增子级(与 Web [canShowAddChildForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
|
||||
bool _canAddChild(OrgNode n) => n.type == 1 || n.type == 2;
|
||||
|
||||
/// 可下钻版本管理(与 Web [canShowVersionForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
|
||||
bool _canShowVersion(OrgNode n) => n.type == 1 || n.type == 2;
|
||||
|
||||
Color _typeColor(BuildContext context, int type) {
|
||||
if (type == 1) return Colors.green;
|
||||
if (type == 2) return Colors.orange;
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
|
||||
/// 删除节点(含确认弹窗)。存在子集时直接拦截并提示,与 Web 一致。
|
||||
///
|
||||
/// 返回是否删除成功;[skipConfirm] 用于滑动手势等已显式触发的场景。
|
||||
Future<bool> _delete(OrgNode node, {bool skipConfirm = false}) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
if (node.children.isNotEmpty) {
|
||||
_snack(t.channelCannotDeleteWithChildren);
|
||||
return false;
|
||||
}
|
||||
if (!skipConfirm) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.confirmDelete),
|
||||
content: Text(t.deleteConfirmContent(node.name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return false;
|
||||
}
|
||||
try {
|
||||
await OrgService().deleteOrg(node.id);
|
||||
if (!mounted) return false;
|
||||
_load();
|
||||
_snack(t.deleted);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!mounted) return false;
|
||||
_snack(e is ApiException ? e.message : t.deleteFailed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部详情卡:展示节点全部字段 + 操作入口(新增子级/编辑/删除)。
|
||||
void _showDetail(OrgNode node, String? parentName) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final hasChildren = node.children.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (sheetCtx, scrollCtrl) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
node.name,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_DetailChip(label: node.typeLabel, color: _typeColor(ctx, node.type)),
|
||||
const SizedBox(width: 8),
|
||||
_DetailChip(
|
||||
label: node.isDisabled ? t.channelDisabled : t.channelEnabled,
|
||||
color: node.isDisabled ? Colors.red : Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollCtrl,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_DetailRow(t.channelOrgCode, node.orgCodeLabel),
|
||||
_DetailRow(t.channelSort, node.orderNo.toString()),
|
||||
_DetailRow(t.channelParent, parentName ?? '-'),
|
||||
_DetailRow(
|
||||
t.channelContactPhone,
|
||||
node.contactPhone.isEmpty ? '-' : node.contactPhone,
|
||||
),
|
||||
_DetailRow(
|
||||
t.channelAddress,
|
||||
node.address.isEmpty ? '-' : node.address,
|
||||
),
|
||||
if (node.createdAt != null)
|
||||
_DetailRow(
|
||||
t.createTime,
|
||||
Formatters.formatDateTime(node.createdAt!),
|
||||
),
|
||||
if (node.expireTime != null)
|
||||
_DetailRow(
|
||||
t.channelExpireTime,
|
||||
Formatters.formatDate(node.expireTime!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: _canShowVersion(node)
|
||||
? SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
showChannelVersionManageDialog(context, node: node);
|
||||
},
|
||||
icon: const Icon(Icons.system_update_alt),
|
||||
label: Text(t.channelVersionManage),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_canAddChild(node))
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showForm(parent: node);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(t.addSubChannel),
|
||||
),
|
||||
),
|
||||
if (_canAddChild(node)) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_showForm(editing: node);
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
label: Text(t.edit),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(ctx).colorScheme.errorContainer,
|
||||
foregroundColor:
|
||||
Theme.of(ctx).colorScheme.onErrorContainer,
|
||||
),
|
||||
onPressed: hasChildren
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(ctx);
|
||||
_delete(node);
|
||||
},
|
||||
icon: const Icon(Icons.delete),
|
||||
label: Text(t.delete),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final rows = _computeRows();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(t.channelManagement),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(168),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.searchChannelHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (v) => setState(() => _keyword = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
SegmentedButton<int?>(
|
||||
segments: [
|
||||
ButtonSegment(value: null, label: Text(t.all)),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
label: Text(t.channelTypeChannel),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 2,
|
||||
label: Text(t.channelTypeAgent),
|
||||
),
|
||||
],
|
||||
selected: {_typeFilter},
|
||||
onSelectionChanged: (set) =>
|
||||
setState(() => _typeFilter = set.first),
|
||||
),
|
||||
SegmentedButton<String?>(
|
||||
segments: [
|
||||
ButtonSegment(value: null, label: Text(t.all)),
|
||||
ButtonSegment(
|
||||
value: 'ACTIVE',
|
||||
label: Text(t.channelEnabled),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 'DISABLED',
|
||||
label: Text(t.channelDisabled),
|
||||
),
|
||||
],
|
||||
selected: {_statusFilter},
|
||||
onSelectionChanged: (set) =>
|
||||
setState(() => _statusFilter = set.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(onPressed: _load, child: Text(t.retry)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _loading && _tree.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: rows.isEmpty
|
||||
? Center(child: Text(t.noData))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final row = rows[i];
|
||||
return Dismissible(
|
||||
key: Key(row.node.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.channelSwipeToDelete,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (_) => _delete(
|
||||
row.node,
|
||||
skipConfirm: true,
|
||||
),
|
||||
child: _ChannelRowTile(
|
||||
row: row,
|
||||
isExpanded: _expanded.contains(row.node.id),
|
||||
onToggle: _toggleExpand,
|
||||
onDetail: _showDetail,
|
||||
onEdit: (n) => _showForm(editing: n),
|
||||
onAddChild: (n) => _showForm(parent: n),
|
||||
onVersionManage: (n) =>
|
||||
showChannelVersionManageDialog(
|
||||
context,
|
||||
node: n,
|
||||
),
|
||||
onDelete: _delete,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showForm(),
|
||||
tooltip: t.addChannel,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 新增/编辑/新增子级 的底部表单。
|
||||
void _showForm({OrgNode? editing, OrgNode? parent}) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final isEdit = editing != null;
|
||||
final isChild = parent != null && !isEdit;
|
||||
|
||||
// 类型:编辑沿用原类型;新增子级默认代理(SERVICE_PROVIDER);
|
||||
// 新增顶层渠道默认渠道(CHANNEL)。
|
||||
int typeValue = editing?.type ?? (isChild ? 2 : 1);
|
||||
|
||||
final nameCtrl = TextEditingController(text: editing?.name ?? '');
|
||||
final orderCtrl = TextEditingController(
|
||||
text: editing?.orderNo.toString() ?? '0',
|
||||
);
|
||||
final phoneCtrl =
|
||||
TextEditingController(text: editing?.contactPhone ?? '');
|
||||
final addressCtrl = TextEditingController(text: editing?.address ?? '');
|
||||
int statusValue = editing?.isDisabled == true ? 1 : 0;
|
||||
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isEdit
|
||||
? t.editChannel
|
||||
: isChild
|
||||
? t.addSubChannel
|
||||
: t.addChannel,
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isChild)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'${t.channelParent}:${parent!.name}',
|
||||
style: Theme.of(ctx)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme.of(ctx).hintColor),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: typeValue,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelType,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 1, child: Text('渠道')),
|
||||
DropdownMenuItem(value: 2, child: Text('代理')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => typeValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: orderCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelSort,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: statusValue,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelStatus,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(value: 0, child: Text(t.channelEnabled)),
|
||||
DropdownMenuItem(value: 1, child: Text(t.channelDisabled)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => statusValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelContactPhone,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: addressCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.channelAddress,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameCtrl.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(t.channelNameRequired)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final typeString =
|
||||
typeValue == 1 ? 'CHANNEL' : 'SERVICE_PROVIDER';
|
||||
final statusString =
|
||||
statusValue == 0 ? 'ACTIVE' : 'DISABLED';
|
||||
final data = <String, dynamic>{
|
||||
'name': name,
|
||||
'type': typeString,
|
||||
'orderNo':
|
||||
int.tryParse(orderCtrl.text.trim()) ?? 0,
|
||||
'status': statusString,
|
||||
'contactPhone': phoneCtrl.text.trim(),
|
||||
'address': addressCtrl.text.trim(),
|
||||
if (isEdit) 'id': editing?.id,
|
||||
if (isChild) 'parentId': parent?.id,
|
||||
};
|
||||
try {
|
||||
if (isEdit) {
|
||||
await OrgService().updateOrg(data);
|
||||
} else {
|
||||
await OrgService().createOrg(data);
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.saved)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ctx.mounted) return;
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is ApiException ? e.message : t.saveFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(t.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 渠道树的一行(节点 + 层级深度 + 父级名称)。
|
||||
class _ChannelRow {
|
||||
const _ChannelRow({
|
||||
required this.node,
|
||||
required this.depth,
|
||||
this.parentName,
|
||||
});
|
||||
final OrgNode node;
|
||||
final int depth;
|
||||
final String? parentName;
|
||||
}
|
||||
|
||||
/// 渠道行卡片:层级缩进 + 展开按钮 + 类型/状态徽章 + 点按查看详情 + 操作菜单。
|
||||
class _ChannelRowTile extends StatelessWidget {
|
||||
const _ChannelRowTile({
|
||||
required this.row,
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
required this.onDetail,
|
||||
required this.onEdit,
|
||||
required this.onAddChild,
|
||||
required this.onVersionManage,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final _ChannelRow row;
|
||||
final bool isExpanded;
|
||||
final void Function(OrgNode) onToggle;
|
||||
final void Function(OrgNode, String?) onDetail;
|
||||
final void Function(OrgNode) onEdit;
|
||||
final void Function(OrgNode) onAddChild;
|
||||
final void Function(OrgNode) onVersionManage;
|
||||
final Future<bool> Function(OrgNode, {bool skipConfirm}) onDelete;
|
||||
|
||||
Color _typeColor(BuildContext context, int type) {
|
||||
if (type == 1) return Colors.green;
|
||||
if (type == 2) return Colors.orange;
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final node = row.node;
|
||||
final hasChildren = node.children.isNotEmpty;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (hasChildren)
|
||||
IconButton(
|
||||
icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => onToggle(node),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
// 点按行主体打开详情(清晰的大点按区域,触控友好)。
|
||||
onTap: () => onDetail(node, row.parentName),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
node.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: Text(node.typeLabel),
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor:
|
||||
_typeColor(context, node.type).withOpacity(0.15),
|
||||
labelStyle: TextStyle(
|
||||
color: _typeColor(context, node.type),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: node.isDisabled
|
||||
? Colors.red.withOpacity(0.15)
|
||||
: Colors.green.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
node.isDisabled
|
||||
? t.channelDisabled
|
||||
: t.channelEnabled,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: node.isDisabled ? Colors.red : Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${t.channelOrgCode}:${node.orgCodeLabel} · '
|
||||
'${t.channelSort}:${node.orderNo}'
|
||||
'${node.createdAt != null ? ' · ${t.createTime}:${Formatters.formatDateTime(node.createdAt!)}' : ''}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'add') onAddChild(node);
|
||||
if (v == 'edit') onEdit(node);
|
||||
if (v == 'version') onVersionManage(node);
|
||||
if (v == 'delete') onDelete(node);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (node.type == 1 || node.type == 2)
|
||||
PopupMenuItem(value: 'add', child: Text(t.addSubChannel)),
|
||||
PopupMenuItem(value: 'edit', child: Text(t.edit)),
|
||||
if (node.type == 1 || node.type == 2)
|
||||
PopupMenuItem(
|
||||
value: 'version',
|
||||
child: Text(t.channelVersionManage),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
enabled: !hasChildren,
|
||||
child: Text(
|
||||
hasChildren ? '${t.delete}(有子集)' : t.delete,
|
||||
style: TextStyle(
|
||||
color: hasChildren
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情卡:字段行。
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow(this.label, this.value);
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情卡:类型/状态徽章。
|
||||
class _DetailChip extends StatelessWidget {
|
||||
const _DetailChip({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
@@ -0,0 +1,435 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/org_node.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/app_service.dart';
|
||||
import '../../../services/upload_service.dart';
|
||||
import '../../../utils/i10n.dart';
|
||||
|
||||
/// 渠道 / 代理节点的 App 版本管理对话框。
|
||||
///
|
||||
/// 对齐 Web 后台 `app-version-manage-dialog.vue`:每个组织(scope=ORG)
|
||||
/// 至多维护一条版本配置,进入时按 [OrgNode.id] 拉取当前版本——
|
||||
/// 有则进入「编辑」流程(含删除按钮),无则进入「新增」流程。
|
||||
///
|
||||
/// 字段与 Web 完全一致:版本名 / 版本号 / 下载地址 / 强制更新 / 弹窗提示;
|
||||
/// 下载地址支持手动录入,或点击「上传文件」按钮直传 APK(对齐 Web 后台的 el-upload,
|
||||
/// 经 /theme/upload/credential 取 STS 凭证直传 COS),上传成功后自动回填 URL。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// showChannelVersionManageDialog(context, node: node);
|
||||
/// ```
|
||||
Future<void> showChannelVersionManageDialog(
|
||||
BuildContext context, {
|
||||
required OrgNode node,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _ChannelVersionManageSheet(node: node),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChannelVersionManageSheet extends StatefulWidget {
|
||||
const _ChannelVersionManageSheet({required this.node});
|
||||
|
||||
final OrgNode node;
|
||||
|
||||
@override
|
||||
State<_ChannelVersionManageSheet> createState() =>
|
||||
_ChannelVersionManageSheetState();
|
||||
}
|
||||
|
||||
class _ChannelVersionManageSheetState
|
||||
extends State<_ChannelVersionManageSheet> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _codeCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
|
||||
/// 当前版本 ID(非空表示编辑态)。
|
||||
String? _versionId;
|
||||
bool _forceUpdate = false;
|
||||
bool _showPopup = true;
|
||||
|
||||
bool _loading = true;
|
||||
bool _submitting = false;
|
||||
|
||||
/// APK 上传中标记,上传期间禁用 URL 手动输入并禁用上传按钮。
|
||||
bool _uploading = false;
|
||||
|
||||
/// APK 上传进度(0~100),仅在 [_uploading] 为 true 时展示。
|
||||
int _uploadProgress = 0;
|
||||
|
||||
AppLocalizations get t => AppLocalizations.of(context);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrent();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_codeCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 进入时拉取该组织的当前版本配置,有则回填表单(编辑态),无则空表单(新增态)。
|
||||
Future<void> _loadCurrent() async {
|
||||
try {
|
||||
final data = await AppService().searchAppVersion(orgId: widget.node.id);
|
||||
if (!mounted) return;
|
||||
if (data != null) {
|
||||
_versionId = data['id']?.toString();
|
||||
_nameCtrl.text = data['versionName']?.toString() ?? '';
|
||||
final rawCode = data['versionCode'] ?? data['version_code'];
|
||||
_codeCtrl.text = rawCode?.toString() ?? '';
|
||||
_urlCtrl.text = (data['downloadUrl'] ?? data['download_url'] ?? data['apkUrl'])
|
||||
?.toString() ??
|
||||
'';
|
||||
_forceUpdate = _toBool(data['forceUpdate'] ?? data['force_update']);
|
||||
_showPopup = _toBool(data['showPopup'] ?? data['show_popup'], fallback: true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_errText(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _toBool(dynamic v, {bool fallback = false}) {
|
||||
if (v == null) return fallback;
|
||||
final n = int.tryParse(v.toString()) ?? 0;
|
||||
return n == 1;
|
||||
}
|
||||
|
||||
String _errText(Object? e) =>
|
||||
e is ApiException ? e.message : (t.loadFailed);
|
||||
|
||||
String get _title {
|
||||
final name = widget.node.name.trim();
|
||||
final action = _versionId != null ? t.editVersion : t.addVersion;
|
||||
return name.isNotEmpty ? '$action - $name' : action;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
final codeText = _codeCtrl.text.trim();
|
||||
final url = _urlCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
_snack(t.versionNameRequired);
|
||||
return;
|
||||
}
|
||||
final code = int.tryParse(codeText);
|
||||
if (code == null || code <= 0) {
|
||||
_snack(t.versionCodeRequired);
|
||||
return;
|
||||
}
|
||||
if (url.isEmpty) {
|
||||
_snack(t.versionUrlRequired);
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
_snack(t.versionUrlInvalid);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
final payload = <String, dynamic>{
|
||||
'scope': 'ORG',
|
||||
'orgId': widget.node.id,
|
||||
'versionName': name,
|
||||
'versionCode': code,
|
||||
'downloadUrl': url,
|
||||
'forceUpdate': _forceUpdate ? 1 : 0,
|
||||
'showPopup': _showPopup ? 1 : 0,
|
||||
if (_versionId != null) 'id': _versionId,
|
||||
};
|
||||
try {
|
||||
if (_versionId != null) {
|
||||
await AppService().updateAppVersion(payload);
|
||||
} else {
|
||||
await AppService().saveAppVersion(payload);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.saved)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_errText(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final id = _versionId;
|
||||
if (id == null) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.confirmDelete),
|
||||
content: Text(t.versionDeleteConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await AppService().deleteAppVersion([int.parse(id)]);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.versionDeleted)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_errText(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
/// 选择 .apk 文件并直传腾讯云 COS,成功后把返回 URL 填回下载地址输入框。
|
||||
///
|
||||
/// 对齐 Web 后台 `app-version-manage-dialog.vue` 的「上传文件」按钮:
|
||||
/// 点击 → 系统文件选择器(仅 .apk)→ 选完即传 → 回填 downloadUrl。
|
||||
Future<void> _pickAndUpload() async {
|
||||
// 1. 打开文件选择器,仅允许 .apk;用户取消返回 null。
|
||||
final picked = await UploadService.pickApkFile();
|
||||
if (!mounted) return;
|
||||
if (picked == null) return;
|
||||
|
||||
// 2. 校验扩展名(选择器已限制,这里兜底)。
|
||||
if (!picked.name.toLowerCase().endsWith('.apk')) {
|
||||
_snack(t.pleaseSelectApk);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 进入上传态,统一在 finally 复位,避免 async 后 setState 崩溃。
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final url = await UploadService().uploadApkByOriginalName(
|
||||
picked,
|
||||
onProgress: (p) {
|
||||
if (mounted) setState(() => _uploadProgress = p);
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
// 4. 成功:回填下载地址并提示。
|
||||
_urlCtrl.text = url;
|
||||
_snack(t.uploadSuccess);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack('${t.uploadFailed}:${_errText(e)}');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_uploading = false;
|
||||
_uploadProgress = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = _versionId != null;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
_title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Divider(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _loading
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.versionName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _codeCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.versionCode,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _urlCtrl,
|
||||
keyboardType: TextInputType.url,
|
||||
enabled: !_uploading,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.downloadUrlHint,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: TextButton.icon(
|
||||
onPressed: _uploading ? null : _pickAndUpload,
|
||||
icon: const Icon(Icons.upload),
|
||||
label: _uploading
|
||||
? Text('${t.uploading} $_uploadProgress%')
|
||||
: Text(t.uploadFile),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SwitchRow(
|
||||
label: t.forceUpdate,
|
||||
value: _forceUpdate,
|
||||
onChanged: (v) => setState(() => _forceUpdate = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SwitchRow(
|
||||
label: t.showPopup,
|
||||
value: _showPopup,
|
||||
onChanged: (v) => setState(() => _showPopup = v),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
if (isEdit)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _delete,
|
||||
icon: const Icon(Icons.delete),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
),
|
||||
label: Text(t.delete),
|
||||
),
|
||||
),
|
||||
if (isEdit) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () => Navigator.pop(context),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(isEdit ? t.save : t.add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简易「是/否」开关行(对齐 Web 的 radio 否/是)。
|
||||
class _SwitchRow extends StatelessWidget {
|
||||
const _SwitchRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 84,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SegmentedButton<bool>(
|
||||
segments: [
|
||||
ButtonSegment(value: false, label: Text(t.forceUpdateNo)),
|
||||
ButtonSegment(value: true, label: Text(t.forceUpdateYes)),
|
||||
],
|
||||
selected: {value},
|
||||
onSelectionChanged: (set) => onChanged(set.first),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal file
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../models/store_info.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/org_service.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
import '../../../utils/i10n.dart';
|
||||
|
||||
/// 公司介绍 - 门店列表入口页。
|
||||
///
|
||||
/// 以门店列表形式展示各门店,支持按关键字搜索门店;点击门店进入该门店的
|
||||
/// 公司介绍内容页(按内容类型分类呈现)。
|
||||
class IntroStoreListPage extends ConsumerStatefulWidget {
|
||||
const IntroStoreListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<IntroStoreListPage> createState() => _IntroStoreListPageState();
|
||||
}
|
||||
|
||||
class _IntroStoreListPageState extends ConsumerState<IntroStoreListPage> {
|
||||
final _orgService = OrgService();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
List<StoreInfo> _allStores = [];
|
||||
List<StoreInfo> _stores = [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final items = await _orgService.getMyStores();
|
||||
if (!mounted) return;
|
||||
_allStores = items;
|
||||
_applyFilter();
|
||||
setState(() => _loading = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = formatApiError(e, fallback: '门店加载失败,请稍后重试');
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyFilter() {
|
||||
final keyword = _keyword.trim().toLowerCase();
|
||||
if (keyword.isEmpty) {
|
||||
_stores = List.of(_allStores);
|
||||
} else {
|
||||
_stores = _allStores
|
||||
.where((s) => s.name.toLowerCase().contains(keyword))
|
||||
.toList();
|
||||
}
|
||||
// 与门店管理页保持一致:按创建时间倒序(最新的排最前面)。
|
||||
_stores.sort((a, b) {
|
||||
if (a.createTime != null && b.createTime != null) {
|
||||
return b.createTime!.compareTo(a.createTime!);
|
||||
}
|
||||
if (a.createTime == null && b.createTime != null) return 1;
|
||||
if (a.createTime != null && b.createTime == null) return -1;
|
||||
return b.orgId.compareTo(a.orgId);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearch() {
|
||||
setState(() {
|
||||
_keyword = _searchController.text;
|
||||
_applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
void _onStoreTap(StoreInfo store) {
|
||||
context.push('/admin/intro/store-content/${store.orgId}', extra: store);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.introCategoryTitle)),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.searchStoreHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_keyword = '';
|
||||
_applyFilter();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _onSearch(),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final t = AppLocalizations.of(context);
|
||||
if (_loading && _stores.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null && _stores.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${t.loadFailed}:$_error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadData,
|
||||
child: Text(t.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_stores.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
const SizedBox(height: 200),
|
||||
Center(child: Text(t.noData)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _stores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final store = _stores[index];
|
||||
return _StoreListTile(
|
||||
store: store,
|
||||
onTap: () => _onStoreTap(store),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店列表项:复用门店管理页的视觉风格。
|
||||
class _StoreListTile extends StatelessWidget {
|
||||
const _StoreListTile({required this.store, required this.onTap});
|
||||
|
||||
final StoreInfo store;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = AppLocalizations.of(context);
|
||||
final isDisabled = store.status == 1;
|
||||
final statusColor = isDisabled ? Colors.grey : Colors.green;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
store.name,
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: statusColor.withOpacity(0.6)),
|
||||
),
|
||||
child: Text(
|
||||
isDisabled ? t.statusDisabled : t.statusNormal,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (store.address.isNotEmpty)
|
||||
_infoLine(context, Icons.location_on, store.address),
|
||||
if (store.contactPhone.isNotEmpty)
|
||||
_infoLine(context, Icons.phone, store.contactPhone),
|
||||
if (store.expireTime != null)
|
||||
_infoLine(
|
||||
context,
|
||||
Icons.event,
|
||||
'${t.expireTime}:${Formatters.formatDateTime(store.expireTime!)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoLine(BuildContext context, IconData icon, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
391
lib/screens/admin/media/media_list_page.dart
Normal file
391
lib/screens/admin/media/media_list_page.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/media_asset.dart';
|
||||
import '../../../services/media_service.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
|
||||
/// 媒资管理页。
|
||||
class MediaListPage extends ConsumerStatefulWidget {
|
||||
const MediaListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MediaListPage> createState() => _MediaListPageState();
|
||||
}
|
||||
|
||||
class _MediaListPageState extends ConsumerState<MediaListPage> {
|
||||
final _scrollController = ScrollController();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
List<MediaAsset> _items = [];
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
String? _error;
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200 &&
|
||||
!_loading &&
|
||||
_hasMore) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await MediaService().getMediaAssetList(page: 1, limit: 20);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = list;
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page = 1;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is ApiException ? e.message : '加载失败';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final list = await MediaService().getMediaAssetList(page: _page + 1, limit: 20);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items.addAll(list);
|
||||
_loading = false;
|
||||
_hasMore = list.length >= 20;
|
||||
_page++;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
void _showForm({MediaAsset? editing}) {
|
||||
final isEdit = editing != null;
|
||||
final nameCtrl = TextEditingController(text: editing?.name ?? '');
|
||||
final codeCtrl = TextEditingController(text: editing?.code ?? '');
|
||||
final coverCtrl = TextEditingController(text: editing?.coverUrl ?? '');
|
||||
final playCtrl = TextEditingController(text: editing?.playUrl ?? '');
|
||||
final durationCtrl =
|
||||
TextEditingController(text: editing?.duration.toString() ?? '');
|
||||
String typeValue = editing?.type.isNotEmpty == true ? editing!.type : 'video';
|
||||
int statusValue = editing?.status ?? 0;
|
||||
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEdit ? '编辑媒资' : '新增媒资',
|
||||
style: Theme.of(ctx).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '名称', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: codeCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '编码', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
value: typeValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '类型', border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'video', child: Text('影视')),
|
||||
DropdownMenuItem(value: 'audio', child: Text('音乐')),
|
||||
DropdownMenuItem(value: 'image', child: Text('图片')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => typeValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: coverCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '封面地址', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: playCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '播放地址', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: durationCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '时长(秒)', border: OutlineInputBorder())),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: statusValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '状态', border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0, child: Text('正常')),
|
||||
DropdownMenuItem(value: 1, child: Text('禁用')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setSheet(() => statusValue = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final data = <String, dynamic>{
|
||||
if (isEdit) 'id': editing!.id,
|
||||
'name': nameCtrl.text,
|
||||
'code': codeCtrl.text,
|
||||
'type': typeValue,
|
||||
'coverUrl': coverCtrl.text,
|
||||
'playUrl': playCtrl.text,
|
||||
'duration': int.tryParse(durationCtrl.text) ?? 0,
|
||||
'status': statusValue,
|
||||
};
|
||||
try {
|
||||
await MediaService().saveMediaAsset(data);
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('保存成功')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ctx.mounted) return;
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('确定要删除此媒资吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await MediaService().deleteMediaAsset([int.tryParse(id) ?? 0]);
|
||||
_refresh();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'video':
|
||||
return '影视';
|
||||
case 'audio':
|
||||
return '音乐';
|
||||
case 'image':
|
||||
return '图片';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
if (h > 0) return '${h}h${m.toString().padLeft(2, '0')}m';
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _keyword.isEmpty
|
||||
? _items
|
||||
: _items
|
||||
.where((e) => e.name.toLowerCase().contains(_keyword.toLowerCase()))
|
||||
.toList();
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('媒资管理')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索媒资名称',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (v) => setState(() => _keyword = v),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? Center(child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
|
||||
],
|
||||
))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: filtered.isEmpty && !_loading
|
||||
? const Center(child: Text('暂无数据'))
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: filtered.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i >= filtered.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = filtered[i];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: item.coverUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.network(
|
||||
item.coverUrl,
|
||||
width: 50,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(
|
||||
width: 50,
|
||||
height: 70,
|
||||
color: Colors.grey.shade300,
|
||||
child: const Icon(Icons.video_library, size: 24),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 50,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(Icons.video_library, size: 24),
|
||||
),
|
||||
title: Text(item.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text('编码: ${item.code}'),
|
||||
Text('类型: ${_typeLabel(item.type)}'),
|
||||
if (item.duration > 0)
|
||||
Text('时长: ${_formatDuration(item.duration)}'),
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
label: Text(item.status == 0 ? '正常' : '禁用'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _showForm(editing: item);
|
||||
if (v == 'delete') _delete(item.id);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
368
lib/screens/admin/media/media_rank_page.dart
Normal file
368
lib/screens/admin/media/media_rank_page.dart
Normal file
@@ -0,0 +1,368 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/media_asset.dart';
|
||||
import '../../../services/media_service.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
|
||||
/// 媒资排行榜 Top10 管理页。
|
||||
class MediaRankPage extends ConsumerStatefulWidget {
|
||||
const MediaRankPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MediaRankPage> createState() => _MediaRankPageState();
|
||||
}
|
||||
|
||||
class _MediaRankPageState extends ConsumerState<MediaRankPage> {
|
||||
List<MediaRankItem> _items = [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await MediaService().getMediaAssetRankTop10();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = list;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is ApiException ? e.message : '加载失败';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _moveUp(String code) async {
|
||||
try {
|
||||
await MediaService().moveUpMediaAssetRank(code);
|
||||
_loadData();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _moveDown(String code) async {
|
||||
try {
|
||||
await MediaService().moveDownMediaAssetRank(code);
|
||||
_loadData();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _remove(String code) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认移除'),
|
||||
content: const Text('确定要从排行榜移除此媒资吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('移除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await MediaService().removeMediaAssetRank(code);
|
||||
_loadData();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('移除成功')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '移除失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAll() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认清空'),
|
||||
content: const Text('确定要清空整个排行榜吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('清空')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await MediaService().clearMediaAssetRank();
|
||||
_loadData();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddDialog() {
|
||||
final searchCtrl = TextEditingController();
|
||||
List<MediaAsset> allMedia = [];
|
||||
bool searching = false;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetCtx) {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setSheet) {
|
||||
Future<void> doSearch() async {
|
||||
setSheet(() => searching = true);
|
||||
try {
|
||||
allMedia = await MediaService().getMediaAssetList(page: 1, limit: 50);
|
||||
} catch (_) {
|
||||
allMedia = [];
|
||||
} finally {
|
||||
setSheet(() => searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
if (allMedia.isEmpty && !searching) {
|
||||
doSearch();
|
||||
}
|
||||
|
||||
final kw = searchCtrl.text.trim().toLowerCase();
|
||||
final filtered = kw.isEmpty
|
||||
? allMedia
|
||||
: allMedia.where((m) => m.name.toLowerCase().contains(kw)).toList();
|
||||
final existingCodes = _items.map((e) => e.code).toSet();
|
||||
final available = filtered.where((m) => !existingCodes.contains(m.code)).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(ctx).size.height * 0.7,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('选择媒资', style: Theme.of(ctx).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
controller: searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索媒资名称',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setSheet(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: searching
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: available.isEmpty
|
||||
? const Center(child: Text('无可选媒资'))
|
||||
: ListView.builder(
|
||||
itemCount: available.length,
|
||||
itemBuilder: (_, index) {
|
||||
final media = available[index];
|
||||
return ListTile(
|
||||
leading: media.coverUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.network(
|
||||
media.coverUrl,
|
||||
width: 40,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 56,
|
||||
child: Icon(Icons.video_library)),
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 40,
|
||||
height: 56,
|
||||
child: Icon(Icons.video_library),
|
||||
),
|
||||
title: Text(media.name),
|
||||
subtitle: Text(media.code),
|
||||
trailing: FilledButton.tonal(
|
||||
onPressed: () => _addToRank(ctx, media.code, media.name),
|
||||
child: const Text('添加'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToRank(BuildContext sheetCtx, String code, String name) async {
|
||||
try {
|
||||
await MediaService().setMediaAssetRank([code]);
|
||||
if (!mounted) return;
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_loadData();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已添加「$name」到排行榜')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e is ApiException ? e.message : '添加失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('媒资排行榜 Top10'),
|
||||
actions: [
|
||||
if (_items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: _clearAll,
|
||||
tooltip: '清空排行榜',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(onPressed: _loadData, child: const Text('重试')),
|
||||
],
|
||||
))
|
||||
: _items.isEmpty
|
||||
? const Center(child: Text('排行榜为空,点击+添加'))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = _items[i];
|
||||
final rank = i + 1;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: rank <= 3
|
||||
? [Colors.amber, Colors.grey, Colors.brown][rank - 1]
|
||||
: Theme.of(ctx).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: rank <= 3 ? Colors.white : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (item.coverUrl.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.network(
|
||||
item.coverUrl,
|
||||
width: 36,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const SizedBox(
|
||||
width: 36,
|
||||
height: 48,
|
||||
child: Icon(Icons.video_library, size: 20)),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 36,
|
||||
height: 48,
|
||||
child: Icon(Icons.video_library, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(item.mediaName.isNotEmpty
|
||||
? item.mediaName
|
||||
: item.code),
|
||||
subtitle: Text(item.code),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: i > 0 ? () => _moveUp(item.code) : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_downward),
|
||||
onPressed: i < _items.length - 1
|
||||
? () => _moveDown(item.code)
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _remove(item.code),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
404
lib/screens/admin/sys/config_page.dart
Normal file
404
lib/screens/admin/sys/config_page.dart
Normal file
@@ -0,0 +1,404 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/sys_config.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/sys_service.dart';
|
||||
|
||||
/// 参数配置页。
|
||||
class ConfigPage extends ConsumerStatefulWidget {
|
||||
const ConfigPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConfigPage> createState() => _ConfigPageState();
|
||||
}
|
||||
|
||||
class _ConfigPageState extends ConsumerState<ConfigPage> {
|
||||
final SystemService _service = SystemService();
|
||||
|
||||
final List<SysConfig> _items = [];
|
||||
bool _loading = false;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchKeyword = '';
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final int _limit = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_items.clear();
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await _service.getConfigList(page: _page, limit: _limit);
|
||||
_items.addAll(list);
|
||||
_hasMore = list.length >= _limit;
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
_page++;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadData(reset: true);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_searchKeyword = value;
|
||||
// 本地过滤,因为 getConfigList 不支持搜索参数
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<SysConfig> get _filteredItems {
|
||||
if (_searchKeyword.isEmpty) return _items;
|
||||
return _items
|
||||
.where((c) => c.paramKey.toLowerCase().contains(
|
||||
_searchKeyword.toLowerCase(),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _showForm({SysConfig? config}) async {
|
||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _ConfigForm(config: config),
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
try {
|
||||
await _service.saveConfig(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> _deleteConfig(SysConfig config) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除参数「${config.paramKey}」吗?'),
|
||||
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.deleteConfig([int.tryParse(config.id) ?? 0]);
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _filteredItems;
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('参数配置')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索参数键名',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchKeyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody(filtered)),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showForm(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(List<SysConfig> items) {
|
||||
if (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (items.isEmpty && !_loading) {
|
||||
return const Center(child: Text('暂无数据'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == items.length) {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore && _searchKeyword.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: Text('没有更多数据')),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final config = items[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(config.paramKey),
|
||||
subtitle: Text(
|
||||
'值:${config.paramValue.isEmpty ? "(空)" : config.paramValue}'
|
||||
'${config.remark.isNotEmpty ? "\n备注:${config.remark}" : ""}',
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_showForm(config: config);
|
||||
} else if (value == 'delete') {
|
||||
_deleteConfig(config);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 参数配置表单。
|
||||
class _ConfigForm extends StatefulWidget {
|
||||
const _ConfigForm({this.config});
|
||||
|
||||
final SysConfig? config;
|
||||
|
||||
@override
|
||||
State<_ConfigForm> createState() => _ConfigFormState();
|
||||
}
|
||||
|
||||
class _ConfigFormState extends State<_ConfigForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _paramKeyCtrl;
|
||||
late TextEditingController _paramValueCtrl;
|
||||
late TextEditingController _remarkCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_paramKeyCtrl = TextEditingController(text: widget.config?.paramKey ?? '');
|
||||
_paramValueCtrl =
|
||||
TextEditingController(text: widget.config?.paramValue ?? '');
|
||||
_remarkCtrl = TextEditingController(text: widget.config?.remark ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_paramKeyCtrl.dispose();
|
||||
_paramValueCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final data = <String, dynamic>{
|
||||
'paramKey': _paramKeyCtrl.text.trim(),
|
||||
'paramValue': _paramValueCtrl.text.trim(),
|
||||
'remark': _remarkCtrl.text.trim(),
|
||||
};
|
||||
if (widget.config != null) {
|
||||
data['id'] = widget.config!.id;
|
||||
}
|
||||
Navigator.pop(context, data);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.config != 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: _paramKeyCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '参数键名',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入参数键名' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _paramValueCtrl,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '参数值',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入参数值' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '备注',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/screens/admin/sys/log_page.dart
Normal file
261
lib/screens/admin/sys/log_page.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/sys_log.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/sys_service.dart';
|
||||
|
||||
/// 系统日志页(TabBar:系统日志 / 审计日志 / JST调用日志)。
|
||||
class LogPage extends ConsumerStatefulWidget {
|
||||
const LogPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LogPage> createState() => _LogPageState();
|
||||
}
|
||||
|
||||
class _LogPageState extends ConsumerState<LogPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('系统日志'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: '系统日志'),
|
||||
Tab(text: '审计日志'),
|
||||
Tab(text: 'JST调用日志'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: const [
|
||||
_LogTab(title: '系统日志', logType: _LogType.system),
|
||||
_LogTab(title: '审计日志', logType: _LogType.audit),
|
||||
_LogTab(title: 'JST调用日志', logType: _LogType.jst),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志类型枚举。
|
||||
enum _LogType { system, audit, jst }
|
||||
|
||||
/// 日志列表 Tab。
|
||||
class _LogTab extends StatefulWidget {
|
||||
const _LogTab({
|
||||
required this.title,
|
||||
required this.logType,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final _LogType logType;
|
||||
|
||||
@override
|
||||
State<_LogTab> createState() => _LogTabState();
|
||||
}
|
||||
|
||||
class _LogTabState extends State<_LogTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final SystemService _service = SystemService();
|
||||
|
||||
final List<SysLog> _items = [];
|
||||
bool _loading = false;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final int _limit = 20;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SysLog>> _fetch({int page = 1, int limit = 20}) {
|
||||
switch (widget.logType) {
|
||||
case _LogType.system:
|
||||
return _service.getLogList(page: page, limit: limit);
|
||||
case _LogType.audit:
|
||||
return _service.getAuditLogList(page: page, limit: limit);
|
||||
case _LogType.jst:
|
||||
return _service.getApiJstCallLogList(page: page, limit: limit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_items.clear();
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await _fetch(page: _page, limit: _limit);
|
||||
_items.addAll(list);
|
||||
_hasMore = list.length >= _limit;
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
_page++;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadData(reset: true);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
|
||||
'${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:'
|
||||
'${dt.minute.toString().padLeft(2, '0')}:'
|
||||
'${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
if (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_loading) {
|
||||
return const Center(child: Text('暂无数据'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: Text('没有更多数据')),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final log = _items[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.operation.isEmpty
|
||||
? '(无操作描述)'
|
||||
: log.operation,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${log.time}ms',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('用户:${log.username}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'方法:${log.method}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('IP:${log.ip}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'时间:${_formatDate(log.createDate)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
511
lib/screens/admin/sys/menu_page.dart
Normal file
511
lib/screens/admin/sys/menu_page.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
340
lib/screens/admin/sys/role_page.dart
Normal file
340
lib/screens/admin/sys/role_page.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/sys_role.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/sys_service.dart';
|
||||
|
||||
/// 角色管理页。
|
||||
class RolePage extends ConsumerStatefulWidget {
|
||||
const RolePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RolePage> createState() => _RolePageState();
|
||||
}
|
||||
|
||||
class _RolePageState extends ConsumerState<RolePage> {
|
||||
final SystemService _service = SystemService();
|
||||
|
||||
final List<SysRole> _items = [];
|
||||
bool _loading = false;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final int _limit = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_items.clear();
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await _service.getRoleList(page: _page, limit: _limit);
|
||||
_items.addAll(list);
|
||||
_hasMore = list.length >= _limit;
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
_page++;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadData(reset: true);
|
||||
}
|
||||
|
||||
Future<void> _showForm({SysRole? role}) async {
|
||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _RoleForm(role: role),
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
try {
|
||||
await _service.saveRole(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> _deleteRole(SysRole role) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除角色「${role.roleName}」吗?'),
|
||||
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.deleteRole([int.tryParse(role.roleId) ?? 0]);
|
||||
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_loading) {
|
||||
return const Center(child: Text('暂无数据'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: Text('没有更多数据')),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final role = _items[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(role.roleName),
|
||||
subtitle: Text(role.remark.isEmpty ? '无备注' : role.remark),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_showForm(role: role);
|
||||
} else if (value == 'delete') {
|
||||
_deleteRole(role);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色表单。
|
||||
class _RoleForm extends StatefulWidget {
|
||||
const _RoleForm({this.role});
|
||||
|
||||
final SysRole? role;
|
||||
|
||||
@override
|
||||
State<_RoleForm> createState() => _RoleFormState();
|
||||
}
|
||||
|
||||
class _RoleFormState extends State<_RoleForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _roleNameCtrl;
|
||||
late TextEditingController _remarkCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_roleNameCtrl = TextEditingController(text: widget.role?.roleName ?? '');
|
||||
_remarkCtrl = TextEditingController(text: widget.role?.remark ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_roleNameCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final data = <String, dynamic>{
|
||||
'roleName': _roleNameCtrl.text.trim(),
|
||||
'remark': _remarkCtrl.text.trim(),
|
||||
};
|
||||
if (widget.role != null) {
|
||||
data['roleId'] = widget.role!.roleId;
|
||||
}
|
||||
Navigator.pop(context, data);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.role != 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: _roleNameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '角色名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入角色名称' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '备注',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
624
lib/screens/admin/sys/schedule_page.dart
Normal file
624
lib/screens/admin/sys/schedule_page.dart
Normal file
@@ -0,0 +1,624 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/schedule.dart';
|
||||
import '../../../models/sys_log.dart';
|
||||
import '../../../services/api_client.dart';
|
||||
import '../../../services/sys_service.dart';
|
||||
|
||||
/// 定时任务管理页。
|
||||
class SchedulePage extends ConsumerStatefulWidget {
|
||||
const SchedulePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SchedulePage> createState() => _SchedulePageState();
|
||||
}
|
||||
|
||||
class _SchedulePageState extends ConsumerState<SchedulePage> {
|
||||
final SystemService _service = SystemService();
|
||||
|
||||
final List<Schedule> _items = [];
|
||||
bool _loading = false;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final int _limit = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_items.clear();
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await _service.getScheduleList(page: _page, limit: _limit);
|
||||
_items.addAll(list);
|
||||
_hasMore = list.length >= _limit;
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
_page++;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadData(reset: true);
|
||||
}
|
||||
|
||||
Future<void> _showForm({Schedule? schedule}) async {
|
||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _ScheduleForm(schedule: schedule),
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
try {
|
||||
await _service.saveSchedule(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> _deleteSchedule(Schedule schedule) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除任务「${schedule.beanName}.${schedule.methodName}」吗?'),
|
||||
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.deleteSchedule([int.tryParse(schedule.jobId) ?? 0]);
|
||||
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> _pauseSchedule(Schedule schedule) async {
|
||||
try {
|
||||
await _service.pauseSchedule(schedule.jobId);
|
||||
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> _resumeSchedule(Schedule schedule) async {
|
||||
try {
|
||||
await _service.resumeSchedule(schedule.jobId);
|
||||
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> _runSchedule(Schedule schedule) async {
|
||||
try {
|
||||
await _service.runSchedule(schedule.jobId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('任务已触发执行')),
|
||||
);
|
||||
}
|
||||
} 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_loading) {
|
||||
return const Center(child: Text('暂无数据'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: Text('没有更多数据')),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final schedule = _items[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text('${schedule.beanName}.${schedule.methodName}'),
|
||||
subtitle: Text(
|
||||
'Cron:${schedule.cronExpression} | ${schedule.status == 0 ? '正常' : '暂停'}'
|
||||
'${schedule.remark.isNotEmpty ? "\n${schedule.remark}" : ""}',
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
_showForm(schedule: schedule);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteSchedule(schedule);
|
||||
break;
|
||||
case 'pause':
|
||||
_pauseSchedule(schedule);
|
||||
break;
|
||||
case 'resume':
|
||||
_resumeSchedule(schedule);
|
||||
break;
|
||||
case 'run':
|
||||
_runSchedule(schedule);
|
||||
break;
|
||||
case 'log':
|
||||
_showScheduleLog(schedule);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
const PopupMenuItem(value: 'pause', child: Text('暂停')),
|
||||
const PopupMenuItem(value: 'resume', child: Text('恢复')),
|
||||
const PopupMenuItem(value: 'run', child: Text('立即运行')),
|
||||
const PopupMenuItem(value: 'log', child: Text('日志')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showScheduleLog(Schedule schedule) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _ScheduleLogPage(jobId: schedule.jobId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 定时任务日志页。
|
||||
class _ScheduleLogPage extends StatefulWidget {
|
||||
const _ScheduleLogPage({required this.jobId});
|
||||
|
||||
final String jobId;
|
||||
|
||||
@override
|
||||
State<_ScheduleLogPage> createState() => _ScheduleLogPageState();
|
||||
}
|
||||
|
||||
class _ScheduleLogPageState extends State<_ScheduleLogPage> {
|
||||
final SystemService _service = SystemService();
|
||||
|
||||
final List<SysLog> _items = [];
|
||||
bool _loading = false;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final int _limit = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_items.clear();
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list =
|
||||
await _service.getScheduleLogList(page: _page, limit: _limit);
|
||||
_items.addAll(list);
|
||||
_hasMore = list.length >= _limit;
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
_page++;
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadData(reset: true);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
|
||||
'${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:'
|
||||
'${dt.minute.toString().padLeft(2, '0')}:'
|
||||
'${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('任务日志')),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty && !_loading) {
|
||||
return const Center(child: Text('暂无日志'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (!_hasMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: Text('没有更多数据')),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final log = _items[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
log.operation.isEmpty ? '(无操作描述)' : log.operation,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('用户:${log.username}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'方法:${log.method}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('耗时:${log.time}ms'),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'时间:${_formatDate(log.createDate)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 定时任务表单。
|
||||
class _ScheduleForm extends StatefulWidget {
|
||||
const _ScheduleForm({this.schedule});
|
||||
|
||||
final Schedule? schedule;
|
||||
|
||||
@override
|
||||
State<_ScheduleForm> createState() => _ScheduleFormState();
|
||||
}
|
||||
|
||||
class _ScheduleFormState extends State<_ScheduleForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _beanName;
|
||||
late final TextEditingController _methodName;
|
||||
late final TextEditingController _params;
|
||||
late final TextEditingController _cronExpression;
|
||||
late final TextEditingController _remark;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final s = widget.schedule;
|
||||
_beanName = TextEditingController(text: s?.beanName ?? '');
|
||||
_methodName = TextEditingController(text: s?.methodName ?? '');
|
||||
_params = TextEditingController(text: s?.params ?? '');
|
||||
_cronExpression = TextEditingController(text: s?.cronExpression ?? '');
|
||||
_remark = TextEditingController(text: s?.remark ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_beanName.dispose();
|
||||
_methodName.dispose();
|
||||
_params.dispose();
|
||||
_cronExpression.dispose();
|
||||
_remark.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.schedule != null;
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEdit ? '编辑任务' : '新增任务',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _beanName,
|
||||
decoration: const InputDecoration(labelText: 'Bean名称'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入Bean名称' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _methodName,
|
||||
decoration: const InputDecoration(labelText: '方法名称'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入方法名称' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _params,
|
||||
decoration: const InputDecoration(labelText: '参数'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _cronExpression,
|
||||
decoration: const InputDecoration(labelText: 'Cron表达式'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '请输入Cron表达式' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _remark,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
final data = <String, dynamic>{
|
||||
if (isEdit) 'jobId': widget.schedule!.jobId,
|
||||
'beanName': _beanName.text.trim(),
|
||||
'methodName': _methodName.text.trim(),
|
||||
'params': _params.text.trim(),
|
||||
'cronExpression': _cronExpression.text.trim(),
|
||||
'remark': _remark.text.trim(),
|
||||
};
|
||||
Navigator.pop(context, data);
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1191
lib/screens/admin/sys/user_page.dart
Normal file
1191
lib/screens/admin/sys/user_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
140
lib/screens/alerts/alerts_page.dart
Normal file
140
lib/screens/alerts/alerts_page.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/alert.dart';
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/devices_provider.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
|
||||
/// 告警列表页(底部导航第 2 个 tab)。
|
||||
class AlertsPage extends ConsumerWidget {
|
||||
const AlertsPage({super.key});
|
||||
|
||||
Color _levelColor(BuildContext context, AlertLevel level) {
|
||||
final semantic = Theme.of(context).extension<AppSemanticColors>();
|
||||
switch (level) {
|
||||
case AlertLevel.critical:
|
||||
return Colors.red;
|
||||
case AlertLevel.warning:
|
||||
return Colors.orange;
|
||||
case AlertLevel.info:
|
||||
return semantic?.info ?? Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _levelIcon(AlertLevel level) {
|
||||
switch (level) {
|
||||
case AlertLevel.critical:
|
||||
return Icons.error;
|
||||
case AlertLevel.warning:
|
||||
return Icons.warning_amber;
|
||||
case AlertLevel.info:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final alertsAsync = ref.watch(alertsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('告警中心')),
|
||||
body: alertsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('告警列表加载失败'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.refresh(alertsProvider),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (alerts) {
|
||||
if (alerts.isEmpty) {
|
||||
return const Center(child: Text('暂无告警'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(alertsProvider.future),
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: alerts.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildAlertCard(context, alerts[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertCard(BuildContext context, Alert alert) {
|
||||
final color = _levelColor(context, alert.level);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_levelIcon(alert.level), color: color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
alert.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
_buildLevelTag(alert, color),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
alert.message,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
alert.deviceName,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Formatters.formatDateTime(alert.occurredAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelTag(Alert alert, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
alert.acknowledged ? '${alert.level.label}·已确认' : alert.level.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
567
lib/screens/device/device_activation_page.dart
Normal file
567
lib/screens/device/device_activation_page.dart
Normal file
@@ -0,0 +1,567 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/store_info.dart';
|
||||
import '../../services/device_service.dart';
|
||||
import '../../services/org_service.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
|
||||
/// 激活码管理页:门店选择、激活码信息、生成/重置。
|
||||
class DeviceActivationPage extends ConsumerStatefulWidget {
|
||||
const DeviceActivationPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceActivationPage> createState() =>
|
||||
_DeviceActivationPageState();
|
||||
}
|
||||
|
||||
class _DeviceActivationPageState extends ConsumerState<DeviceActivationPage> {
|
||||
final OrgService _orgService = OrgService();
|
||||
final DeviceService _deviceService = DeviceService();
|
||||
|
||||
AppLocalizations get t => AppLocalizations.of(context);
|
||||
|
||||
List<StoreInfo> _stores = [];
|
||||
String? _selectedStoreId;
|
||||
bool _loadingStores = true;
|
||||
|
||||
Map<String, dynamic>? _codeInfo;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStores();
|
||||
}
|
||||
|
||||
Future<void> _loadStores() async {
|
||||
try {
|
||||
final stores = await _orgService.getMyStores();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stores = stores;
|
||||
_loadingStores = false;
|
||||
if (stores.isNotEmpty) {
|
||||
_selectedStoreId = stores.first.orgId;
|
||||
}
|
||||
});
|
||||
if (_selectedStoreId != null) {
|
||||
_loadCodeInfo();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingStores = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context).storeListLoadFailed(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCodeInfo() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final info = await _deviceService.getActivationCodeInfo(
|
||||
_selectedStoreId!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_codeInfo = info;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showGenerateSheet() {
|
||||
if (_selectedStoreId == null) return;
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _GenerateCodeSheet(
|
||||
deviceService: _deviceService,
|
||||
orgId: _selectedStoreId!,
|
||||
onSaved: () {
|
||||
Navigator.pop(ctx);
|
||||
_loadCodeInfo();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetCode() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
final t = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.resetActivationCode),
|
||||
content: Text(t.resetActivationCodeConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.orange),
|
||||
child: Text(t.reset),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _deviceService.resetActivationCode(_selectedStoreId!);
|
||||
if (!mounted) return;
|
||||
// 结果以页面中央弹窗展示(替代左下方 SnackBar)。
|
||||
_showResultDialog(
|
||||
isSuccess: true,
|
||||
title: t.activationCodeResetSuccess,
|
||||
);
|
||||
_loadCodeInfo();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showResultDialog(
|
||||
isSuccess: false,
|
||||
title: t.activationCodeResetFailed(formatApiError(e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 居中弹窗展示激活码重置结果(成功/失败)。
|
||||
///
|
||||
/// 替代原左下角 SnackBar,保证用户清晰看到操作反馈。
|
||||
void _showResultDialog({
|
||||
required bool isSuccess,
|
||||
required String title,
|
||||
}) {
|
||||
final t = AppLocalizations.of(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: Icon(
|
||||
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
|
||||
color: isSuccess ? Colors.green : Colors.red,
|
||||
size: 42,
|
||||
),
|
||||
title: Center(child: Text(title, textAlign: TextAlign.center)),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(t.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(t.activationCodeManagement),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildStoreSelector(),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoreSelector() {
|
||||
if (_loadingStores) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_stores.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(t.noStoreAvailable),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedStoreId,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.selectStore,
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.store),
|
||||
),
|
||||
items: _stores
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.orgId,
|
||||
child: Text(s.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedStoreId = v);
|
||||
_loadCodeInfo();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_selectedStoreId == null) {
|
||||
return Center(child: Text(t.pleaseSelectStoreFirst));
|
||||
}
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(t.activationCodeLoadFailed(_error!)),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadCodeInfo,
|
||||
child: Text(t.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_codeInfo == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.vpn_key_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.noActivationCode),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _showGenerateSheet,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(t.generateActivationCode),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadCodeInfo,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
_buildCodeCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoCard(),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _showGenerateSheet,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(t.generateActivationCode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _resetCode,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.orange,
|
||||
),
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: Text(t.resetActivationCode),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeCard() {
|
||||
final code = _codeInfo!['code']?.toString() ?? '';
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.vpn_key,
|
||||
color: theme.colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(t.currentActivationCode,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (code.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
t.activationCodeNoCodeHint,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.4)),
|
||||
),
|
||||
child: SelectableText(
|
||||
code,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (code.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: code));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.activationCodeCopied)),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: Text(t.copy),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
final theme = Theme.of(context);
|
||||
final maxCount = _codeInfo!['maxActivateCount'];
|
||||
final activatedCount = _codeInfo!['activatedCount'];
|
||||
final createTime = _codeInfo!['createTime'];
|
||||
|
||||
String _formatTime(dynamic t) {
|
||||
if (t == null) return '—';
|
||||
final text = t.toString().trim();
|
||||
if (text.isEmpty) return '—';
|
||||
final dt = DateTime.tryParse(text) ??
|
||||
DateTime.tryParse(text.replaceFirst(' ', 'T'));
|
||||
return dt != null ? Formatters.formatDateTime(dt) : text;
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_infoRow(
|
||||
theme,
|
||||
t.maxActivateCount,
|
||||
maxCount?.toString() ?? '—',
|
||||
),
|
||||
_infoRow(
|
||||
theme,
|
||||
t.activatedCount,
|
||||
activatedCount?.toString() ?? '—',
|
||||
),
|
||||
_infoRow(theme, t.createTime, _formatTime(createTime)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成激活码表单。
|
||||
class _GenerateCodeSheet extends StatefulWidget {
|
||||
const _GenerateCodeSheet({
|
||||
required this.deviceService,
|
||||
required this.orgId,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final DeviceService deviceService;
|
||||
final String orgId;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<_GenerateCodeSheet> createState() => _GenerateCodeSheetState();
|
||||
}
|
||||
|
||||
class _GenerateCodeSheetState extends State<_GenerateCodeSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _countController = TextEditingController(text: '1');
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final t = AppLocalizations.of(context);
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final count = int.parse(_countController.text.trim());
|
||||
await widget.deviceService.generateActivationCode(
|
||||
widget.orgId,
|
||||
count,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.activationCodeGenerated)),
|
||||
);
|
||||
widget.onSaved();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.generateFailedWithError(e.toString()))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
t.generateActivationCode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.generateActivationCodeHint,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _countController,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.maxActivationCountLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.looks),
|
||||
helperText: t.maxActivationCountHint,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
validator: (v) {
|
||||
final count = int.tryParse(v ?? '');
|
||||
if (count == null || count <= 0) {
|
||||
return t.positiveIntegerRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(t.generate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
488
lib/screens/device/device_group_page.dart
Normal file
488
lib/screens/device/device_group_page.dart
Normal file
@@ -0,0 +1,488 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/org_node.dart';
|
||||
import '../../models/store_info.dart';
|
||||
import '../../services/device_service.dart';
|
||||
import '../../services/org_service.dart';
|
||||
|
||||
/// 设备分组页:门店选择、分组列表、新增/编辑/删除。
|
||||
class DeviceGroupPage extends ConsumerStatefulWidget {
|
||||
const DeviceGroupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceGroupPage> createState() => _DeviceGroupPageState();
|
||||
}
|
||||
|
||||
class _DeviceGroupPageState extends ConsumerState<DeviceGroupPage> {
|
||||
final OrgService _orgService = OrgService();
|
||||
final DeviceService _deviceService = DeviceService();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
List<StoreInfo> _stores = [];
|
||||
String? _selectedStoreId;
|
||||
bool _loadingStores = true;
|
||||
|
||||
List<OrgNode> _items = [];
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadStores();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStores() async {
|
||||
try {
|
||||
final stores = await _orgService.getMyStores();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stores = stores;
|
||||
_loadingStores = false;
|
||||
if (stores.isNotEmpty) {
|
||||
_selectedStoreId = stores.first.orgId;
|
||||
}
|
||||
});
|
||||
if (_selectedStoreId != null) {
|
||||
_loadData();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingStores = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('门店列表加载失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
});
|
||||
try {
|
||||
final items = await _deviceService.getDeviceGroupList(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
storeId: _selectedStoreId!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_hasMore = items.length >= 20;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore || _selectedStoreId == null) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final items = await _deviceService.getDeviceGroupList(
|
||||
page: _page + 1,
|
||||
limit: 20,
|
||||
storeId: _selectedStoreId!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_page++;
|
||||
_items.addAll(items);
|
||||
_hasMore = items.length >= 20;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载更多失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteGroup(OrgNode group) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除分组'),
|
||||
content: Text('确定要删除分组「${group.name}」吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _deviceService.deleteDeviceGroups([group.id]);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('分组已删除')),
|
||||
);
|
||||
_loadData();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('删除失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddEditSheet({OrgNode? group}) {
|
||||
if (_selectedStoreId == null) return;
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _GroupFormSheet(
|
||||
deviceService: _deviceService,
|
||||
storeId: _selectedStoreId!,
|
||||
group: group,
|
||||
onSaved: () {
|
||||
Navigator.pop(ctx);
|
||||
_loadData();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('设备分组'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildStoreSelector(),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _selectedStoreId == null
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
onPressed: () => _showAddEditSheet(),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoreSelector() {
|
||||
if (_loadingStores) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_stores.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('暂无可选门店'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedStoreId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择门店',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.store),
|
||||
),
|
||||
items: _stores
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.orgId,
|
||||
child: Text(s.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedStoreId = v);
|
||||
_loadData();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_selectedStoreId == null) {
|
||||
return const Center(child: Text('请先选择门店'));
|
||||
}
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('加载失败:$_error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadData,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 200),
|
||||
Center(child: Text('暂无分组数据')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) {
|
||||
return _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
final group = _items[index];
|
||||
return _GroupListTile(
|
||||
group: group,
|
||||
onEdit: () => _showAddEditSheet(group: group),
|
||||
onDelete: () => _deleteGroup(group),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组列表项。
|
||||
class _GroupListTile extends StatelessWidget {
|
||||
const _GroupListTile({
|
||||
required this.group,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final OrgNode group;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.folder_outlined),
|
||||
),
|
||||
title: Text(group.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (group.address.isNotEmpty)
|
||||
_line(Icons.location_on, group.address),
|
||||
if (group.contactPhone.isNotEmpty)
|
||||
_line(Icons.phone, group.contactPhone),
|
||||
_line(Icons.tag, 'ID: ${group.id}'),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
onEdit();
|
||||
} else if (value == 'delete') {
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(IconData icon, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增/编辑分组表单。
|
||||
class _GroupFormSheet extends StatefulWidget {
|
||||
const _GroupFormSheet({
|
||||
required this.deviceService,
|
||||
required this.storeId,
|
||||
this.group,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final DeviceService deviceService;
|
||||
final String storeId;
|
||||
final OrgNode? group;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<_GroupFormSheet> createState() => _GroupFormSheetState();
|
||||
}
|
||||
|
||||
class _GroupFormSheetState extends State<_GroupFormSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.group != null) {
|
||||
_nameController.text = widget.group!.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final data = <String, dynamic>{
|
||||
'name': _nameController.text.trim(),
|
||||
'storeId': widget.storeId,
|
||||
if (widget.group != null) 'id': widget.group!.id,
|
||||
};
|
||||
if (widget.group != null) {
|
||||
await widget.deviceService.updateDeviceGroup(data);
|
||||
} else {
|
||||
await widget.deviceService.saveDeviceGroup(data);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.group != null ? '分组已更新' : '分组已创建'),
|
||||
),
|
||||
);
|
||||
widget.onSaved();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存失败:$e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.group != null;
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isEdit ? '编辑分组' : '新增分组',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分组名称',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.folder),
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? '请输入分组名称' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(isEdit ? '保存' : '创建'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1023
lib/screens/device/device_scan_page.dart
Normal file
1023
lib/screens/device/device_scan_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
554
lib/screens/device/store_config_page.dart
Normal file
554
lib/screens/device/store_config_page.dart
Normal file
@@ -0,0 +1,554 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/store_info.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../services/device_service.dart';
|
||||
import '../../services/org_service.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
|
||||
/// 门店配置页:门店选择、配置表单、保存/重置密码。
|
||||
class StoreConfigPage extends ConsumerStatefulWidget {
|
||||
const StoreConfigPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StoreConfigPage> createState() => _StoreConfigPageState();
|
||||
}
|
||||
|
||||
class _StoreConfigPageState extends ConsumerState<StoreConfigPage> {
|
||||
final OrgService _orgService = OrgService();
|
||||
final DeviceService _deviceService = DeviceService();
|
||||
|
||||
List<StoreInfo> _stores = [];
|
||||
String? _selectedStoreId;
|
||||
bool _loadingStores = true;
|
||||
|
||||
Map<String, dynamic>? _config;
|
||||
bool _loading = false;
|
||||
bool _saving = false;
|
||||
bool _disabled = false;
|
||||
String? _error;
|
||||
|
||||
// 表单控制器
|
||||
final _defaultVolumeController = TextEditingController();
|
||||
final _heartbeatIntervalController = TextEditingController();
|
||||
final _storePasswordController = TextEditingController();
|
||||
final _maxVolumeController = TextEditingController();
|
||||
final _offlineThresholdController = TextEditingController();
|
||||
final _mediaVolumeController = TextEditingController();
|
||||
final _expireTimeController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStores();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_defaultVolumeController.dispose();
|
||||
_heartbeatIntervalController.dispose();
|
||||
_storePasswordController.dispose();
|
||||
_maxVolumeController.dispose();
|
||||
_offlineThresholdController.dispose();
|
||||
_mediaVolumeController.dispose();
|
||||
_expireTimeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadStores() async {
|
||||
try {
|
||||
final stores = await _orgService.getMyStores();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stores = stores;
|
||||
_loadingStores = false;
|
||||
if (stores.isNotEmpty) {
|
||||
_selectedStoreId = stores.first.orgId;
|
||||
}
|
||||
});
|
||||
if (_selectedStoreId != null) {
|
||||
_loadConfig();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingStores = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('门店列表加载失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final config = await _deviceService.getStoreConfig(_selectedStoreId!);
|
||||
if (!mounted) return;
|
||||
_populateControllers(config);
|
||||
setState(() {
|
||||
_config = config;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final friendly = formatApiError(e,
|
||||
fallback: '门店配置加载失败,请稍后重试');
|
||||
setState(() {
|
||||
_error = '$friendly\n\n若持续出现「未知异常」,请联系后端排查 /device/storeConfig/info/{orgId} 接口。';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _populateControllers(Map<String, dynamic> config) {
|
||||
// 字段名以 Web 后台 StoreConfig 模型为准(systemDefaultVolume / password),
|
||||
// 同时兼容旧移动端字段名(defaultVolume / storePassword),避免回显为空。
|
||||
_defaultVolumeController.text =
|
||||
_toString(config['systemDefaultVolume'] ?? config['defaultVolume']);
|
||||
_heartbeatIntervalController.text =
|
||||
_toString(config['heartbeatInterval']);
|
||||
_storePasswordController.text =
|
||||
_toString(config['password'] ?? config['storePassword']);
|
||||
_maxVolumeController.text = _toString(config['maxVolume']);
|
||||
_offlineThresholdController.text =
|
||||
_toString(config['offlineThreshold']);
|
||||
_mediaVolumeController.text =
|
||||
_toString(config['mediaVolume'] ?? config['media_volume']);
|
||||
_expireTimeController.text = _toString(config['expireTime']);
|
||||
_disabled = _parseDisabled(config['disabled']);
|
||||
}
|
||||
|
||||
/// 解析禁用状态:true / 非零 num / 字符串 '1' 视为禁用=true,其余 false。
|
||||
static bool _parseDisabled(dynamic value) {
|
||||
if (value == true) return true;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) return value.trim() == '1';
|
||||
return false;
|
||||
}
|
||||
|
||||
static String _toString(dynamic value) {
|
||||
if (value == null) return '';
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
// 字段名以 Web 后台 StoreConfig 模型为准(见 screen-front src/api/device.ts):
|
||||
// systemDefaultVolume / maxVolume / heartbeatInterval / password / expireTime。
|
||||
// 仅发送非空字段,避免把后端既有值覆盖为空;密码留空则不传(与 Web 一致,
|
||||
// 防止误清空)。offlineThreshold 为旧移动端遗留字段,后端忽略无害,保留发送。
|
||||
final data = <String, dynamic>{'orgId': _selectedStoreId};
|
||||
final sysDef = _defaultVolumeController.text.trim();
|
||||
final vSysDef = int.tryParse(sysDef);
|
||||
if (sysDef.isNotEmpty && vSysDef != null) {
|
||||
data['systemDefaultVolume'] = vSysDef;
|
||||
}
|
||||
final max = _maxVolumeController.text.trim();
|
||||
final vMax = int.tryParse(max);
|
||||
if (max.isNotEmpty && vMax != null) data['maxVolume'] = vMax;
|
||||
final hb = _heartbeatIntervalController.text.trim();
|
||||
final vHb = int.tryParse(hb);
|
||||
if (hb.isNotEmpty && vHb != null) data['heartbeatInterval'] = vHb;
|
||||
final off = _offlineThresholdController.text.trim();
|
||||
final vOff = int.tryParse(off);
|
||||
if (off.isNotEmpty && vOff != null) data['offlineThreshold'] = vOff;
|
||||
final pwd = _storePasswordController.text.trim();
|
||||
if (pwd.isNotEmpty) data['password'] = pwd;
|
||||
final exp = _expireTimeController.text.trim();
|
||||
if (exp.isNotEmpty) data['expireTime'] = exp;
|
||||
final media = _mediaVolumeController.text.trim();
|
||||
final vMedia = int.tryParse(media);
|
||||
if (media.isNotEmpty && vMedia != null) data['mediaVolume'] = vMedia;
|
||||
// disabled 始终发送当前开关状态(布尔值),确保与 Web 后台一致。
|
||||
data['disabled'] = _disabled;
|
||||
await _deviceService.createOrUpdateStoreConfig(data);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('配置已保存')),
|
||||
);
|
||||
_loadConfig();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存失败:${formatApiError(e)}')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetPassword() async {
|
||||
if (_selectedStoreId == null) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('重置密码'),
|
||||
content: const Text('确定要重置门店配置密码吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.orange),
|
||||
child: const Text('重置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _deviceService.resetStoreConfigPassword(_selectedStoreId!);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('密码已重置')),
|
||||
);
|
||||
_loadConfig();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('重置失败:${formatApiError(e)}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('门店配置'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildStoreSelector(),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoreSelector() {
|
||||
if (_loadingStores) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_stores.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('暂无可选门店'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedStoreId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择门店',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.store),
|
||||
),
|
||||
items: _stores
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.orgId,
|
||||
child: Text(s.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedStoreId = v);
|
||||
_loadConfig();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_selectedStoreId == null) {
|
||||
return const Center(child: Text('请先选择门店'));
|
||||
}
|
||||
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: _loadConfig,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_config == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
const Text('暂无配置信息'),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建配置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadConfig,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
_buildConfigForm(),
|
||||
const SizedBox(height: 16),
|
||||
_buildExtraInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildActions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfigForm() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'配置项',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _defaultVolumeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '系统默认音量',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.volume_up),
|
||||
helperText: '设备系统默认音量 (0-100)',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _mediaVolumeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '媒体区域音量',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.volume_down),
|
||||
helperText: '媒体播放区域音量 (0-100)',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _maxVolumeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '最大音量',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.volume_up_sharp),
|
||||
helperText: '设备允许的最大音量',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _heartbeatIntervalController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '心跳间隔 (秒)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.favorite),
|
||||
helperText: '设备心跳上报间隔',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _offlineThresholdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '离线阈值 (秒)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.timer_off),
|
||||
helperText: '超过此时间无心跳则判定离线',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _storePasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '门店密码',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
helperText: '设备绑定门店时的验证密码',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _expireTimeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '到期时间',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.event),
|
||||
helperText: '留空则不修改;格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.calendar_month),
|
||||
tooltip: '选择日期',
|
||||
onPressed: _saving ? null : _pickExpireDate,
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.datetime,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 84, child: Text('禁用')),
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(value: false, label: Text('否')),
|
||||
ButtonSegment(value: true, label: Text('是')),
|
||||
],
|
||||
selected: {_disabled},
|
||||
onSelectionChanged: (set) => setState(() => _disabled = set.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickExpireDate() async {
|
||||
final current = _expireTimeController.text.trim();
|
||||
final initial = DateTime.tryParse(current) ??
|
||||
DateTime.tryParse(current.replaceFirst(' ', 'T')) ??
|
||||
DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() =>
|
||||
_expireTimeController.text = picked.toIso8601String().substring(0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildExtraInfo() {
|
||||
final theme = Theme.of(context);
|
||||
final createTime = _config?['createTime'];
|
||||
final updateTime = _config?['updateTime'];
|
||||
|
||||
String _fmt(dynamic t) {
|
||||
if (t == null) return '—';
|
||||
final text = t.toString().trim();
|
||||
if (text.isEmpty) return '—';
|
||||
final dt = DateTime.tryParse(text) ??
|
||||
DateTime.tryParse(text.replaceFirst(' ', 'T'));
|
||||
return dt != null ? Formatters.formatDateTime(dt) : text;
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'其他信息',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_infoRow(theme, '创建时间', _fmt(createTime)),
|
||||
_infoRow(theme, '更新时间', _fmt(updateTime)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save),
|
||||
label: const Text('保存配置'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _resetPassword,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.orange,
|
||||
),
|
||||
icon: const Icon(Icons.password),
|
||||
label: const Text('重置密码'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
618
lib/screens/home/device_detail_page.dart
Normal file
618
lib/screens/home/device_detail_page.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/device.dart';
|
||||
import '../../models/org_node.dart';
|
||||
import '../../providers/devices_provider.dart';
|
||||
import '../../providers/metrics_visibility_provider.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../services/device_service.dart';
|
||||
import '../../services/org_service.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../app/theme.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import '../../widgets/device_command_sheet.dart';
|
||||
import '../../widgets/metric_card.dart';
|
||||
import '../../widgets/status_chip.dart';
|
||||
|
||||
/// 设备详情页:指标卡(CPU/内存/磁盘/网络)、状态与运行时长。
|
||||
class DeviceDetailPage extends ConsumerWidget {
|
||||
const DeviceDetailPage({super.key, required this.deviceId});
|
||||
|
||||
/// 设备 ID。
|
||||
final String deviceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final deviceAsync = ref.watch(deviceDetailProvider(deviceId));
|
||||
final showMetrics = ref.watch(showRuntimeMetricsProvider);
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: Text(t.deviceDetails)),
|
||||
body: deviceAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
ref.refresh(deviceDetailProvider(deviceId)),
|
||||
child: Text(t.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (device) => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(deviceDetailProvider(deviceId).future),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildHeader(context, ref, device),
|
||||
const SizedBox(height: 16),
|
||||
_buildBasicInfo(context, device),
|
||||
const SizedBox(height: 16),
|
||||
_buildConfigParams(context, device),
|
||||
const SizedBox(height: 16),
|
||||
_buildNetworkInfo(context, device),
|
||||
const SizedBox(height: 16),
|
||||
_buildRuntimeData(context, device),
|
||||
if (showMetrics) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildMetricsGrid(context, device),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStickyActions(context, ref, device),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, WidgetRef ref, Device device) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.macLabel,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (device.model.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'${AppLocalizations.of(context).modelLabel}:${device.model}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(status: device.status),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部常驻操作栏(发送指令 / 编辑分组),滚动时保持可见。
|
||||
Widget _buildStickyActions(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Device device,
|
||||
) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface,
|
||||
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
||||
boxShadow: AppShadows.sm,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showCommandSheet(context, device),
|
||||
child: Text(t.sendCommand),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showEditSheet(context, ref, device),
|
||||
child: Text(t.editGroup),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 信息行标签宽度随窗口尺寸自适应(窄屏 96 / 中屏 128 / 宽屏 160)。
|
||||
double _labelWidth(BuildContext context) {
|
||||
final w = context.responsive.windowSize;
|
||||
return w == AppWindowSize.compact
|
||||
? 96
|
||||
: w == AppWindowSize.medium
|
||||
? 128
|
||||
: 160;
|
||||
}
|
||||
|
||||
/// 修改设备分组 / 房号。
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Device device) {
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => DeviceGroupRoomSheet(
|
||||
device: device,
|
||||
onSaved: (groupId, roomNo) async {
|
||||
Navigator.pop(ctx);
|
||||
try {
|
||||
await DeviceService().updateDevice({
|
||||
'id': device.id,
|
||||
// 后端 /device/info/update 要求 mac 非空,修改分组/房号时须回传原 MAC。
|
||||
'mac': device.mac,
|
||||
'groupId': groupId,
|
||||
'roomId': roomNo,
|
||||
});
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context).saveSuccess)),
|
||||
);
|
||||
ref.refresh(deviceDetailProvider(deviceId));
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.message)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppLocalizations.of(context).saveFailed}:${formatApiError(e)}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCommandSheet(BuildContext context, Device device) { showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => DeviceCommandSheet(
|
||||
device: device,
|
||||
deviceService: DeviceService(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 带标题的分区块卡片。
|
||||
Widget _sectionCard(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<Widget> rows,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
...rows,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 基本信息:设备名称 / 设备 ID / 型号 / 状态。
|
||||
Widget _buildBasicInfo(BuildContext context, Device device) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return _sectionCard(context, t.basicInfo, [
|
||||
_infoRow(context, theme, t.deviceName,
|
||||
device.macLabel.isEmpty ? t.none : device.macLabel),
|
||||
_infoRow(context, theme, t.deviceId,
|
||||
device.id.isEmpty ? t.none : device.id),
|
||||
_infoRow(context, theme, t.modelLabel,
|
||||
device.model.isEmpty ? t.none : device.model),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelWidth(context),
|
||||
child: Text(
|
||||
t.status,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: StatusChip(status: device.status)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 配置参数:所属门店 / 分组 / 房号 / 序列号 / UID / 创建时间。
|
||||
Widget _buildConfigParams(BuildContext context, Device device) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return _sectionCard(context, t.configParams, [
|
||||
_infoRow(context, theme, t.belongStore,
|
||||
device.storeName.isEmpty ? t.none : device.storeName),
|
||||
_infoRow(context, theme, t.belongGroup,
|
||||
device.groupId.isEmpty ? t.notGrouped : device.groupId),
|
||||
_infoRow(context, theme, t.roomNo,
|
||||
device.roomNo.isEmpty ? t.none : device.roomNo),
|
||||
_infoRow(context, theme, t.deviceSnLabel,
|
||||
device.deviceSn.isEmpty ? t.none : device.deviceSn),
|
||||
_infoRow(context, theme, t.deviceUidLabel,
|
||||
device.deviceUid.isEmpty ? t.none : device.deviceUid),
|
||||
_infoRow(context, theme, t.createTime,
|
||||
device.createTime != null
|
||||
? Formatters.formatDateTime(device.createTime!)
|
||||
: t.none),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 网络信息:IP 地址 / MAC 地址。
|
||||
Widget _buildNetworkInfo(BuildContext context, Device device) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return _sectionCard(context, t.networkInfo, [
|
||||
_infoRow(context, theme, t.ipAddress,
|
||||
device.ip.isEmpty ? t.none : device.ip),
|
||||
_infoRow(context, theme, t.macAddress,
|
||||
device.mac.isEmpty ? t.none : device.mac),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 运行时数据:在线时长 / 最后活跃时间。
|
||||
Widget _buildRuntimeData(BuildContext context, Device device) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return _sectionCard(context, t.runtimeData, [
|
||||
_infoRow(context, theme, t.uptime,
|
||||
device.uptimeSeconds > 0
|
||||
? t.formatUptime(device.uptimeSeconds)
|
||||
: t.none),
|
||||
_infoRow(context, theme, t.lastActiveTime,
|
||||
device.lastSyncTime != null
|
||||
? Formatters.formatDateTime(device.lastSyncTime!)
|
||||
: t.none),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _infoRow(BuildContext context, ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelWidth(context),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricsGrid(BuildContext context, Device device) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final metrics = device.metrics;
|
||||
// 真实后端不提供运行指标:无指标数据时显示 “—”(仅 mock 模式有值)。
|
||||
final hasMetrics = device.hasMetrics;
|
||||
final none = t.none;
|
||||
return GridView.count(
|
||||
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.15,
|
||||
children: [
|
||||
MetricCard(
|
||||
icon: Icons.memory,
|
||||
title: t.cpuUsage,
|
||||
value: hasMetrics ? Formatters.formatPercent(metrics.cpuUsage) : none,
|
||||
progress: hasMetrics ? metrics.cpuUsage : null,
|
||||
accentColor:
|
||||
hasMetrics && metrics.cpuUsage > 0.8 ? Colors.red : null,
|
||||
),
|
||||
MetricCard(
|
||||
icon: Icons.storage,
|
||||
title: t.memoryUsage,
|
||||
value:
|
||||
hasMetrics ? Formatters.formatPercent(metrics.memoryUsage) : none,
|
||||
progress: hasMetrics ? metrics.memoryUsage : null,
|
||||
accentColor:
|
||||
hasMetrics && metrics.memoryUsage > 0.85 ? Colors.red : null,
|
||||
),
|
||||
MetricCard(
|
||||
icon: Icons.sd_storage,
|
||||
title: t.diskUsage,
|
||||
value:
|
||||
hasMetrics ? Formatters.formatPercent(metrics.diskUsage) : none,
|
||||
progress: hasMetrics ? metrics.diskUsage : null,
|
||||
accentColor:
|
||||
hasMetrics && metrics.diskUsage > 0.8 ? Colors.orange : null,
|
||||
),
|
||||
MetricCard(
|
||||
icon: Icons.network_check,
|
||||
title: t.networkRxTx,
|
||||
value: hasMetrics
|
||||
? '${metrics.networkRxKbps.toStringAsFixed(1)} / '
|
||||
'${metrics.networkTxKbps.toStringAsFixed(1)} KB/s'
|
||||
: none,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改设备分组 / 房号底部表单。
|
||||
///
|
||||
/// 分组改为「分组名称」下拉选择(与 Web 端一致):进入时按设备所属门店调用
|
||||
/// `/sys/store/deviceGroups/{storeId}` 拉取该门店下专属分组,下拉显示 [OrgNode.name],
|
||||
/// 提交 [OrgNode.id]。房号仍保留文本输入。
|
||||
class DeviceGroupRoomSheet extends StatefulWidget {
|
||||
const DeviceGroupRoomSheet({
|
||||
super.key,
|
||||
required this.device,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final Device device;
|
||||
final Future<void> Function(String groupId, String roomNo) onSaved;
|
||||
|
||||
@override
|
||||
State<DeviceGroupRoomSheet> createState() => _DeviceGroupRoomSheetState();
|
||||
}
|
||||
|
||||
class _DeviceGroupRoomSheetState extends State<DeviceGroupRoomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _roomNoCtrl;
|
||||
bool _saving = false;
|
||||
|
||||
/// 当前门店下的设备分组列表。
|
||||
List<OrgNode> _groups = const [];
|
||||
bool _groupsLoading = false;
|
||||
String? _groupsError;
|
||||
|
||||
/// 选中的分组 ID(null 表示「未分组」)。
|
||||
String? _selectedGroupId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_roomNoCtrl = TextEditingController(text: widget.device.roomNo);
|
||||
_selectedGroupId =
|
||||
widget.device.groupId.isEmpty ? null : widget.device.groupId;
|
||||
_loadGroups();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_roomNoCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 拉取当前门店下的设备分组列表。
|
||||
///
|
||||
/// 优先使用 `/device/group/list?storeId=xxx`,该接口支持按门店筛选且已在
|
||||
/// [DeviceService] 中实现;若调用失败则降级为读取「当前分组」一项,避免阻塞
|
||||
/// 用户修改房号或保存现有分组。
|
||||
Future<void> _loadGroups() async {
|
||||
if (widget.device.storeId.isEmpty) {
|
||||
if (mounted) setState(() => _groupsLoading = false);
|
||||
return;
|
||||
}
|
||||
if (mounted) setState(() => _groupsLoading = true);
|
||||
try {
|
||||
_groups = await DeviceService().getDeviceGroupList(
|
||||
storeId: widget.device.storeId,
|
||||
);
|
||||
_groupsError = null;
|
||||
} on ApiException catch (e) {
|
||||
_groupsError = e.message;
|
||||
_groups = const [];
|
||||
} catch (e) {
|
||||
_groupsError = formatApiError(e, fallback: '分组加载失败,请检查网络');
|
||||
_groups = const [];
|
||||
} finally {
|
||||
if (mounted) setState(() => _groupsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造下拉项:首项「未分组」,随后各分组(显示名称,值为 ID)。
|
||||
/// 若当前分组不在列表中(如后端未返回或跨门店),追加一项以便正确回显。
|
||||
List<DropdownMenuItem<String>> _buildGroupItems(AppLocalizations t) {
|
||||
final items = <DropdownMenuItem<String>>[
|
||||
DropdownMenuItem<String>(
|
||||
value: '',
|
||||
child: Text(t.notGrouped),
|
||||
),
|
||||
];
|
||||
final seen = <String>{''};
|
||||
for (final group in _groups) {
|
||||
if (seen.contains(group.id)) continue;
|
||||
seen.add(group.id);
|
||||
items.add(
|
||||
DropdownMenuItem<String>(
|
||||
value: group.id,
|
||||
child: Text(group.name.isNotEmpty ? group.name : group.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
final current = widget.device.groupId;
|
||||
if (current.isNotEmpty && !seen.contains(current)) {
|
||||
items.add(
|
||||
DropdownMenuItem<String>(
|
||||
value: current,
|
||||
child: Text(current),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onSaved(
|
||||
_selectedGroupId ?? '',
|
||||
_roomNoCtrl.text.trim(),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${t.editGroup} / ${t.roomNo}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 所属分组:名称下拉选择(提交分组 ID)。
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedGroupId ?? '',
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.belongGroup,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.dvr),
|
||||
),
|
||||
items: _buildGroupItems(t),
|
||||
onChanged: _groupsLoading
|
||||
? null
|
||||
: (value) => setState(() => _selectedGroupId = value),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_groupsLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(t.groupListLoading),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_groupsError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
'${t.loadFailed}:$_groupsError',
|
||||
style: const TextStyle(color: Colors.redAccent, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _roomNoCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.roomNo,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.meeting_room),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _submit,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(t.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
lib/screens/home/device_list_page.dart
Normal file
492
lib/screens/home/device_list_page.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/device.dart';
|
||||
import '../../models/org_node.dart';
|
||||
import '../../models/store_info.dart';
|
||||
import '../../providers/devices_provider.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../services/org_service.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../utils/session_org_scope.dart';
|
||||
import '../../widgets/device_list_tile.dart';
|
||||
import '../../widgets/org_tree_filter.dart';
|
||||
|
||||
/// 设备列表页(底部导航第 2 个 tab),支持关键字搜索与组织树筛选。
|
||||
class DeviceListPage extends ConsumerStatefulWidget {
|
||||
const DeviceListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceListPage> createState() => _DeviceListPageState();
|
||||
}
|
||||
|
||||
class _DeviceListPageState extends ConsumerState<DeviceListPage>
|
||||
with WidgetsBindingObserver {
|
||||
final _searchController = TextEditingController();
|
||||
String _keyword = '';
|
||||
|
||||
final _modelController = TextEditingController();
|
||||
String _modelKeyword = '';
|
||||
|
||||
/// 门店名称搜索关键字:按所属门店名称过滤设备,与门店管理页的「搜索门店名称」对齐。
|
||||
final _storeNameController = TextEditingController();
|
||||
String _storeNameKeyword = '';
|
||||
|
||||
/// 可选门店列表(异步加载后填充,用于受限账号默认范围及门店名称反查)。
|
||||
List<StoreInfo> _stores = [];
|
||||
|
||||
/// 设备列表(页面态,替代 devicesByStoreProvider 的 watch)。
|
||||
List<Device> _devices = [];
|
||||
bool _loadingDevices = false;
|
||||
Object? _deviceError;
|
||||
|
||||
/// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。
|
||||
Set<String> _selectedOrgIds = {};
|
||||
|
||||
/// 组织树(异步加载,失败则置空,树筛选不可用)。
|
||||
List<OrgNode>? _orgTree;
|
||||
|
||||
/// 已发起删除、等待后端确认期间临时隐藏的设备 ID,避免行先回弹再消失。
|
||||
final Set<String> _pendingDeleteIds = {};
|
||||
|
||||
/// 用于跳过首次启动时的 resumed 事件,避免与 initState 初始加载重复请求。
|
||||
bool _initialResumeHandled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_searchController.dispose();
|
||||
_modelController.dispose();
|
||||
_storeNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 初始化:加载门店列表(受限账号默认首店)、组织树,随后拉取设备。
|
||||
Future<void> _initData() async {
|
||||
await _loadStores();
|
||||
if (!mounted) return;
|
||||
await _loadOrgTree();
|
||||
if (!mounted) return;
|
||||
await _loadDevices();
|
||||
}
|
||||
|
||||
/// 应用从后台切回前台时刷新设备/门店列表,以同步 Web 端等外部变更。
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state != AppLifecycleState.resumed) return;
|
||||
if (!_initialResumeHandled) {
|
||||
_initialResumeHandled = true;
|
||||
return;
|
||||
}
|
||||
_loadStores();
|
||||
_loadDevices();
|
||||
}
|
||||
|
||||
Future<void> _loadStores() async {
|
||||
// 静默加载门店列表,失败不阻断设备列表。
|
||||
try {
|
||||
_stores = await OrgService().getMyStores();
|
||||
} catch (e) {
|
||||
_stores = [];
|
||||
} finally {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步加载组织树(静默失败:仅影响组织树筛选,不影响设备列表本身)。
|
||||
Future<void> _loadOrgTree() async {
|
||||
try {
|
||||
_orgTree = await OrgService().getOrgTree();
|
||||
} catch (_) {
|
||||
_orgTree = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按组织树筛选 / 受限账号 / 全部门店拉取设备,并叠加本地型号/关键词过滤。
|
||||
Future<void> _loadDevices() async {
|
||||
setState(() {
|
||||
_loadingDevices = true;
|
||||
_deviceError = null;
|
||||
});
|
||||
try {
|
||||
final deviceService = ref.read(deviceServiceProvider);
|
||||
List<Device> devices;
|
||||
|
||||
if (_selectedOrgIds.isEmpty) {
|
||||
// 未选组织树:
|
||||
if (SessionOrgScope.instance.enabled) {
|
||||
// 受限账号:不能有「全部门店」视图(否则会拉取跨组织设备)。
|
||||
// 改为拉取其组织范围内【全部门店】的设备(而非仅第一家),
|
||||
// 既见全本组织设备、又不越权。门店列表本就按范围收敛,取其 orgId 集合即可。
|
||||
final storeIds = _stores
|
||||
.map((s) => s.orgId)
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
if (storeIds.isEmpty) {
|
||||
// 异常兜底:连一家门店都没有时,不调用全量接口,避免越权拉取跨组织设备。
|
||||
devices = const [];
|
||||
} else {
|
||||
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
|
||||
}
|
||||
} else {
|
||||
// 管理员:全量设备。
|
||||
devices = await deviceService.fetchDevices();
|
||||
}
|
||||
} else {
|
||||
// 组织树筛选:仅收集每个选中节点的【门店(type=3)后代】orgId。
|
||||
final storeIds = <String>{};
|
||||
for (final id in _selectedOrgIds) {
|
||||
storeIds.addAll(
|
||||
OrgService.collectDescendantStoreIds(_orgTree ?? [], id),
|
||||
);
|
||||
}
|
||||
if (storeIds.isEmpty) {
|
||||
// 选中的节点没有任何门店后代(如设备分组 / 无下属门店的空代理):
|
||||
// 直接返回空结果——绝不回退为「用节点自身 id 充当 storeOrgId」,
|
||||
// 也绝不退化为「全量设备」(fetchDevicesByOrgSet 空集会退化为跨组织全量)。
|
||||
devices = const [];
|
||||
} else {
|
||||
devices = await deviceService.fetchDevicesByOrgSet(storeIds);
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
_devices = devices;
|
||||
setState(() => _loadingDevices = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_deviceError = e;
|
||||
setState(() => _loadingDevices = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开组织树筛选抽屉,回填选中集合并重新拉取设备。
|
||||
///
|
||||
/// 返回 null(取消)时保持原筛选不变;返回非空集合(含空集合)时覆盖 [_selectedOrgIds]。
|
||||
Future<void> _openOrgFilter() async {
|
||||
final result = await showOrgTreeFilterSheet(
|
||||
context,
|
||||
initialSelected: _selectedOrgIds,
|
||||
);
|
||||
if (result == null) return;
|
||||
setState(() => _selectedOrgIds = result);
|
||||
await _loadDevices();
|
||||
}
|
||||
|
||||
/// 按门店名称搜索:保存关键字到 [_storeNameKeyword] 并触发本地过滤。
|
||||
/// 搜索结果由 [_filter] 在构建时实时应用,并在下拉刷新/生命周期刷新后保持。
|
||||
void _onStoreSearch() {
|
||||
setState(() {
|
||||
_storeNameKeyword = _storeNameController.text.trim();
|
||||
});
|
||||
}
|
||||
|
||||
/// 取设备所属门店的展示名称(优先 [Device.storeName],其次按 storeId 在 [_stores] 中反查)。
|
||||
String _deviceStoreName(Device device) {
|
||||
if (device.storeName.isNotEmpty) return device.storeName;
|
||||
for (final s in _stores) {
|
||||
if (s.orgId == device.storeId) return s.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 组织树筛选按钮文案:空选显示「全部门店/全部组织」;
|
||||
/// 有选显示首个选中节点名 + 超量「(+N)」。
|
||||
String _orgFilterLabel() {
|
||||
if (_selectedOrgIds.isEmpty) return '全部门店/全部组织';
|
||||
if (_orgTree == null) return '已选 ${_selectedOrgIds.length} 项';
|
||||
final byId = <String, OrgNode>{};
|
||||
void index(List<OrgNode> ns) {
|
||||
for (final n in ns) {
|
||||
byId[n.id] = n;
|
||||
index(n.children);
|
||||
}
|
||||
}
|
||||
|
||||
index(_orgTree!);
|
||||
final names = <String>[];
|
||||
for (final id in _selectedOrgIds) {
|
||||
final n = byId[id];
|
||||
if (n != null) names.add(n.name);
|
||||
}
|
||||
if (names.isEmpty) return '已选 ${_selectedOrgIds.length} 项';
|
||||
if (names.length == 1) return names.first;
|
||||
return '${names.first} (+${names.length - 1})';
|
||||
}
|
||||
|
||||
List<Device> _filter(List<Device> devices) {
|
||||
final keyword = _keyword.trim().toLowerCase();
|
||||
final model = _modelKeyword.trim().toLowerCase();
|
||||
final storeName = _storeNameKeyword.trim().toLowerCase();
|
||||
// 过滤掉已发起删除、正在等待后端确认的设备(用与行 key 一致的唯一标识)。
|
||||
final visible = devices.where((d) => !_pendingDeleteIds.contains(_rowKey(d)));
|
||||
if (keyword.isEmpty && model.isEmpty && storeName.isEmpty) {
|
||||
return visible.toList();
|
||||
}
|
||||
return visible.where((device) {
|
||||
final matchesKeyword = keyword.isEmpty ||
|
||||
device.mac.toLowerCase().contains(keyword) ||
|
||||
device.name.toLowerCase().contains(keyword) ||
|
||||
device.roomNo.toLowerCase().contains(keyword) ||
|
||||
device.ip.contains(keyword);
|
||||
final matchesModel = model.isEmpty ||
|
||||
device.model.toLowerCase().contains(model) ||
|
||||
device.deviceSn.toLowerCase().contains(model);
|
||||
final matchesStoreName = storeName.isEmpty ||
|
||||
_deviceStoreName(device).toLowerCase().contains(storeName);
|
||||
return matchesKeyword && matchesModel && matchesStoreName;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 行唯一键:优先用设备 id,缺失时回退 mac,避免空 id 导致 ValueKey('') 重复触发断言。
|
||||
String _rowKey(Device d) => '${d.id.isNotEmpty ? d.id : ''}__${d.mac}';
|
||||
|
||||
/// 滑动后的删除确认:弹出对话框,确认才调用后端删除,取消保留。
|
||||
Future<bool> _confirmDeleteDevice(Device device) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final name = device.macRoomLabel;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.confirmDelete),
|
||||
content: Text(t.deleteConfirmContent(name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return false;
|
||||
// 先本地隐藏,避免删除过程中行回弹再消失造成闪烁。
|
||||
final key = _rowKey(device);
|
||||
_pendingDeleteIds.add(key);
|
||||
if (mounted) setState(() {});
|
||||
try {
|
||||
await ref.read(deviceServiceProvider).deleteDevice(device.id);
|
||||
if (!mounted) return true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.deleted)),
|
||||
);
|
||||
await _loadDevices();
|
||||
_pendingDeleteIds.remove(key);
|
||||
if (mounted) setState(() {});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_pendingDeleteIds.remove(key);
|
||||
if (!mounted) return false;
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(t.deviceList),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/device/scan?mode=bind&activate=1'),
|
||||
icon: const Icon(Icons.add_a_photo),
|
||||
label: Text(t.scanActivateDevice),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 顶部筛选区:与门店管理页保持一致。
|
||||
// 第一行:门店名称搜索 + 独立组织筛选图标;第二行:设备关键字/型号搜索。
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
// 门店名称搜索 + 组织筛选图标(独立,不与其它筛选项混排)。
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _storeNameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.searchStoreHint,
|
||||
prefixIcon: const Icon(Icons.search_outlined),
|
||||
suffixIcon: _storeNameController.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear_outlined),
|
||||
onPressed: () {
|
||||
_storeNameController.clear();
|
||||
setState(() => _storeNameKeyword = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _onStoreSearch(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.account_tree_outlined),
|
||||
tooltip: '组织筛选',
|
||||
onPressed: _openOrgFilter,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 设备关键字 / 型号搜索。
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.searchDeviceHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (value) => setState(() => _keyword = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _modelController,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.searchModelHint,
|
||||
prefixIcon: const Icon(Icons.memory),
|
||||
suffixIcon: _modelKeyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_modelController.clear();
|
||||
setState(() => _modelKeyword = '');
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (value) =>
|
||||
setState(() => _modelKeyword = value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_selectedOrgIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(_orgFilterLabel()),
|
||||
deleteIcon: const Icon(Icons.close, size: 18),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
_selectedOrgIds = {};
|
||||
});
|
||||
_loadDevices();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _buildDeviceList()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据页面态([_devices]/[_loadingDevices]/[_deviceError])渲染设备列表。
|
||||
Widget _buildDeviceList() {
|
||||
final t = AppLocalizations.of(context);
|
||||
if (_loadingDevices) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_deviceError != null) {
|
||||
// 登录态失效:拦截器已清空令牌并触发路由重定向到登录页,
|
||||
// 此处不再展示底层错误,直接显示加载占位(路由会接管跳转)。
|
||||
if (isUnauthorizedError(_deviceError!)) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(t.deviceListLoadFailed(formatApiError(_deviceError!))),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadDevices,
|
||||
child: Text(t.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final filtered = _filter(_devices);
|
||||
if (filtered.isEmpty) {
|
||||
return Center(child: Text(t.noMatchedDevices));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadDevices,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = filtered[index];
|
||||
// 向左滑动(end side)揭示右侧红色「删除」按钮;点击按钮才弹确认框。
|
||||
// 不用 Dismissible:后者松手过阈值即自动弹窗,无法满足「先露按钮再点击」的交互。
|
||||
return Slidable(
|
||||
key: ValueKey(_rowKey(device)),
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
// 不自动 dismiss,删除以「确认弹窗 + 后端成功」为准。
|
||||
dismissible: null,
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => _confirmDeleteDevice(device),
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.delete_outline,
|
||||
label: t.delete,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: DeviceListTile(
|
||||
device: device,
|
||||
onTap: () => context.push('/device/${device.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/screens/home/home_page.dart
Normal file
261
lib/screens/home/home_page.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/device.dart';
|
||||
import '../../providers/dashboard_provider.dart';
|
||||
import '../../providers/devices_provider.dart';
|
||||
import '../../app/theme.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/app_update.dart';
|
||||
import '../../utils/permissions.dart';
|
||||
import '../../widgets/cloud_card.dart';
|
||||
|
||||
/// 首页:仪表盘概览(底部导航第 1 个 tab)。
|
||||
///
|
||||
/// 展示设备统计卡片与各模块快捷入口。
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载仪表盘统计(设备/门店总数)。
|
||||
Future.microtask(
|
||||
() => ref.read(dashboardStatsProvider.notifier).load(),
|
||||
);
|
||||
// 启动后静默检测新版本(无更新/失败均不打扰,仅会话内首次有更新时弹窗)。
|
||||
final orgId = ref.read(authProvider).user?.orgId;
|
||||
final ctx = context;
|
||||
Future.microtask(() {
|
||||
if (!ctx.mounted) return;
|
||||
checkAndPromptUpdate(
|
||||
ctx,
|
||||
silentIfNoUpdate: true,
|
||||
orgId: orgId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicesAsync = ref.watch(devicesProvider);
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.appTitle)),
|
||||
body: devicesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => _buildDashboard(context, null),
|
||||
data: (devices) => _buildDashboard(context, devices),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashboard(BuildContext context, List<Device>? devices) {
|
||||
final stats = ref.watch(dashboardStatsProvider);
|
||||
final totalCount = stats.deviceTotal;
|
||||
final storeTotal = stats.storeTotal;
|
||||
// 在线/离线统计改为后端真实聚合值(按 status 拉 totalCount),
|
||||
// 不再用首屏列表客户端统计,避免与设备总数(12 万级)严重不符。
|
||||
final onlineCount = stats.onlineCount;
|
||||
final offlineCount = stats.offlineCount;
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
// 统计卡配色纳入橙白语义体系(仅改色,图标图形与 size 冻结不动)。
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final semantic = Theme.of(context).extension<AppSemanticColors>();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final _ = await ref.refresh(devicesProvider.future);
|
||||
await ref.read(dashboardStatsProvider.notifier).load();
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildBrandBanner(context),
|
||||
const SizedBox(height: 16),
|
||||
// 统计卡片
|
||||
GridView.count(
|
||||
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.4,
|
||||
children: [
|
||||
_StatCard(
|
||||
title: t.totalDevices,
|
||||
value: '$totalCount',
|
||||
icon: Icons.devices,
|
||||
color: scheme.primary,
|
||||
),
|
||||
_StatCard(
|
||||
title: t.totalStores,
|
||||
value: '$storeTotal',
|
||||
icon: Icons.store,
|
||||
color: scheme.tertiary,
|
||||
),
|
||||
_StatCard(
|
||||
title: t.onlineDevices,
|
||||
value: '$onlineCount',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: semantic?.success ?? scheme.tertiary,
|
||||
),
|
||||
_StatCard(
|
||||
title: t.offlineDevices,
|
||||
value: '$offlineCount',
|
||||
icon: Icons.highlight_off,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 快捷操作
|
||||
Text(
|
||||
t.quickActions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
label: t.deviceManagement,
|
||||
icon: Icons.devices,
|
||||
onTap: () => context.go('/device'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
label: t.storeManagement,
|
||||
icon: Icons.store,
|
||||
onTap: () => context.go('/store'),
|
||||
),
|
||||
),
|
||||
if (SessionPermissions.canViewUserManagement(
|
||||
ref.read(authProvider).user))
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
label: t.userManagement,
|
||||
icon: Icons.people,
|
||||
onTap: () => context.push('/admin/sys/user'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 品牌云卡横幅:首页招牌区块(橙→金渐变)。
|
||||
Widget _buildBrandBanner(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return CloudCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud, size: 36),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.appHeroTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'终端管控·内容运营·门店管理',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
const _StatCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 28),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(title, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickAction extends StatelessWidget {
|
||||
const _QuickAction({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
509
lib/screens/login/login_page.dart
Normal file
509
lib/screens/login/login_page.dart
Normal file
@@ -0,0 +1,509 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../app/theme.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../utils/constants.dart';
|
||||
import '../../utils/credential_storage.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../widgets/cloud_card.dart';
|
||||
|
||||
/// 登录页。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _accountController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _captchaController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
/// 是否记住密码。
|
||||
bool _rememberPassword = false;
|
||||
|
||||
/// 当前验证码图片数据。
|
||||
Uint8List? _captchaBytes;
|
||||
|
||||
/// 当前验证码 UUID,登录时必须原样回传。
|
||||
String _captchaUuid = '';
|
||||
|
||||
/// 是否正在加载验证码。
|
||||
bool _captchaLoading = false;
|
||||
|
||||
/// 验证码加载/刷新错误提示。
|
||||
String? _captchaError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_safeLoadSavedCredentials();
|
||||
if (AppConfig.enableCaptcha) {
|
||||
_safeRefreshCaptcha();
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
|
||||
///
|
||||
/// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。
|
||||
void _safeLoadSavedCredentials() {
|
||||
try {
|
||||
_loadSavedCredentials();
|
||||
} catch (_) {
|
||||
// 读取本地凭据失败时使用空表单,不阻塞登录。
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。
|
||||
void _safeRefreshCaptcha() {
|
||||
try {
|
||||
_refreshCaptcha();
|
||||
} catch (_) {
|
||||
// 极端情况下同步抛错,确保页面仍可渲染。
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_captchaError = '验证码加载失败,请重试';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
|
||||
void _loadSavedCredentials() {
|
||||
final cs = CredentialStorage.instance;
|
||||
if (cs.rememberPassword) {
|
||||
_rememberPassword = true;
|
||||
_accountController.text = cs.savedAccount ?? '';
|
||||
_passwordController.text = cs.savedPassword ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountController.dispose();
|
||||
_passwordController.dispose();
|
||||
_captchaController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshCaptcha() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_captchaLoading = true;
|
||||
_captchaError = null;
|
||||
});
|
||||
try {
|
||||
final captcha = await AuthService().fetchCaptcha();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_captchaUuid = captcha.uuid;
|
||||
_captchaBytes = Uint8List.fromList(captcha.imageBytes);
|
||||
_captchaLoading = false;
|
||||
});
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_captchaError = e.message;
|
||||
_captchaLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_captchaError = '验证码加载失败,点击重试';
|
||||
_captchaLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) {
|
||||
return;
|
||||
}
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
account: _accountController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
captcha: _captchaController.text.trim(),
|
||||
uuid: _captchaUuid,
|
||||
rememberPassword: _rememberPassword,
|
||||
);
|
||||
if (!success && mounted && AppConfig.enableCaptcha) {
|
||||
// 登录失败后刷新验证码,并清空输入。
|
||||
_captchaController.clear();
|
||||
await _refreshCaptcha();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final authState = ref.watch(authProvider);
|
||||
// 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme,
|
||||
// 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final inputFillColor = scheme.surfaceContainerHighest;
|
||||
final inputBorderColor = scheme.outline;
|
||||
final inputTextColor = scheme.onSurface;
|
||||
final inputLabelColor = scheme.onSurfaceVariant;
|
||||
final cardColor = scheme.surfaceContainerLow;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: scheme.surface,
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
// 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。
|
||||
gradient: scheme.brightness == Brightness.light
|
||||
? AppBrand.cloudGradient
|
||||
: null,
|
||||
color: scheme.brightness == Brightness.light ? null : scheme.surface,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Card(
|
||||
color: cardColor,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 112,
|
||||
height: 112,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CloudCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud, size: 36),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppConstants.appName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.appSubtitle,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _accountController,
|
||||
style: TextStyle(color: inputTextColor),
|
||||
decoration: InputDecoration(
|
||||
labelText: '账号',
|
||||
labelStyle: TextStyle(color: inputLabelColor),
|
||||
prefixIconColor: inputLabelColor,
|
||||
filled: true,
|
||||
fillColor: inputFillColor,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: inputBorderColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: inputBorderColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: scheme.primary),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? '请输入账号'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
style: TextStyle(color: inputTextColor),
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
labelStyle: TextStyle(color: inputLabelColor),
|
||||
prefixIconColor: inputLabelColor,
|
||||
suffixIconColor: inputLabelColor,
|
||||
filled: true,
|
||||
fillColor: inputFillColor,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: inputBorderColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: inputBorderColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: scheme.primary),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
color: inputLabelColor,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.isEmpty) ? '请输入密码' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (AppConfig.enableCaptcha) ...[
|
||||
const SizedBox(height: 16),
|
||||
// 验证码区域
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: TextFormField(
|
||||
controller: _captchaController,
|
||||
style: TextStyle(color: inputTextColor),
|
||||
decoration: InputDecoration(
|
||||
labelText: '验证码',
|
||||
labelStyle:
|
||||
TextStyle(color: inputLabelColor),
|
||||
prefixIconColor: inputLabelColor,
|
||||
filled: true,
|
||||
fillColor: inputFillColor,
|
||||
border: OutlineInputBorder(
|
||||
borderSide:
|
||||
BorderSide(color: inputBorderColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide:
|
||||
BorderSide(color: inputBorderColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide:
|
||||
BorderSide(color: scheme.primary),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.security_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? '请输入验证码'
|
||||
: null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: InkWell(
|
||||
onTap: _captchaLoading
|
||||
? null
|
||||
: _refreshCaptcha,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: inputFillColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: inputBorderColor,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _buildCaptchaImage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_captchaError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_captchaError!,
|
||||
style: TextStyle(
|
||||
color: scheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (authState.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
authState.errorMessage!,
|
||||
style: TextStyle(
|
||||
color: scheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
// 记住密码
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _rememberPassword,
|
||||
onChanged: (v) => setState(
|
||||
() => _rememberPassword = v ?? false,
|
||||
),
|
||||
fillColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? scheme.primary
|
||||
: inputBorderColor,
|
||||
),
|
||||
checkColor: scheme.onPrimary,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(
|
||||
() => _rememberPassword = !_rememberPassword,
|
||||
),
|
||||
child: Text(
|
||||
'记住密码',
|
||||
style: TextStyle(color: inputTextColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: authState.isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
disabledBackgroundColor:
|
||||
scheme.onSurface.withOpacity(0.12),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: authState.isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text('登 录'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 仅 mock 模式显示演示账号;真实模式下提示无意义
|
||||
if (AppConstants.useMock)
|
||||
Text(
|
||||
'演示账号:${AppConstants.mockAccount} / '
|
||||
'${AppConstants.mockPassword}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'v6-fix68',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant.withOpacity(0.5),
|
||||
fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptchaImage() {
|
||||
if (_captchaLoading) {
|
||||
return const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
final bytes = _captchaBytes;
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 20,
|
||||
color: Color(0xFFB0B0B0),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'点击刷新',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFFB0B0B0),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.refresh,
|
||||
size: 20,
|
||||
color: Color(0xFFB0B0B0),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'点击刷新',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFFB0B0B0),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
430
lib/screens/profile/settings_page.dart
Normal file
430
lib/screens/profile/settings_page.dart
Normal file
@@ -0,0 +1,430 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../providers/theme_provider.dart';
|
||||
import '../../providers/display_provider.dart';
|
||||
import '../../services/api_client.dart';
|
||||
import '../../services/sys_service.dart';
|
||||
import '../../utils/constants.dart';
|
||||
import '../../utils/i10n.dart';
|
||||
import '../../utils/app_update.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
/// 设置页(底部导航第 3 个 tab):主题切换 / 语言占位 / 关于 / 退出登录。
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
Future<void> _showChangePasswordDialog(BuildContext context, WidgetRef ref) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final oldCtrl = TextEditingController();
|
||||
final newCtrl = TextEditingController();
|
||||
final confirmCtrl = TextEditingController();
|
||||
bool saving = false;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setState) => AlertDialog(
|
||||
title: Text(t.passwordDialogTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: oldCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.oldPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: newCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.newPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: confirmCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.confirmNewPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving ? null : () => Navigator.pop(ctx),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final oldPwd = oldCtrl.text.trim();
|
||||
final newPwd = newCtrl.text.trim();
|
||||
final confirmPwd = confirmCtrl.text.trim();
|
||||
if (oldPwd.isEmpty || newPwd.isEmpty) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(t.pleaseFillPassword)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPwd != confirmPwd) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(t.passwordsDoNotMatch)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => saving = true);
|
||||
try {
|
||||
await SystemService().updatePassword(oldPwd, newPwd);
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.passwordChangeSuccess)),
|
||||
);
|
||||
// 修改成功后安全登出,要求用新密码重新登录。
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (context.mounted) {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
setState(() => saving = false);
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(e.message)),
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => saving = false);
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(content: Text(t.passwordChangeFailed(formatApiError(e)))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(t.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
oldCtrl.dispose();
|
||||
newCtrl.dispose();
|
||||
confirmCtrl.dispose();
|
||||
}
|
||||
|
||||
String _themeModeLabel(ThemeMode mode, AppLocalizations t) {
|
||||
switch (mode) {
|
||||
case ThemeMode.dark:
|
||||
return t.themeDark;
|
||||
case ThemeMode.light:
|
||||
return t.themeLight;
|
||||
case ThemeMode.system:
|
||||
return t.themeSystem;
|
||||
}
|
||||
}
|
||||
|
||||
String _orientationLabel(DisplayOrientation mode, AppLocalizations t) {
|
||||
switch (mode) {
|
||||
case DisplayOrientation.system:
|
||||
return t.orientationSystem;
|
||||
case DisplayOrientation.portrait:
|
||||
return t.orientationPortrait;
|
||||
case DisplayOrientation.landscape:
|
||||
return t.orientationLandscape;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final appLocale = ref.watch(localeProvider);
|
||||
final displayOrientation = ref.watch(displayOrientationProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.settings)),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (authState.user != null)
|
||||
ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||
title: Text(authState.user!.displayName),
|
||||
subtitle: Text(
|
||||
authState.user!.role.isEmpty
|
||||
? '${t.account}:${authState.user!.account}'
|
||||
: authState.user!.role,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 主题切换:深色 / 浅色 / 跟随系统
|
||||
_AppSettingsTile(
|
||||
leading: const Icon(Icons.dark_mode_outlined),
|
||||
title: t.themeMode,
|
||||
subtitle: _themeModeLabel(themeMode, t),
|
||||
control: SegmentedButton<ThemeMode>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: const Icon(Icons.dark_mode),
|
||||
label: Text(t.themeDark),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: const Icon(Icons.light_mode),
|
||||
label: Text(t.themeLight),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: const Icon(Icons.settings_suggest),
|
||||
label: Text(t.themeSystem),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (selection) => ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setThemeMode(selection.first),
|
||||
),
|
||||
),
|
||||
// 语言切换:简体中文 / English
|
||||
_AppSettingsTile(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
title: t.language,
|
||||
subtitle: appLocale.label,
|
||||
control: SegmentedButton<AppLocale>(
|
||||
segments: const [
|
||||
ButtonSegment(value: AppLocale.zh, label: Text('中文')),
|
||||
ButtonSegment(value: AppLocale.en, label: Text('EN')),
|
||||
],
|
||||
selected: {appLocale},
|
||||
onSelectionChanged: (selection) {
|
||||
try {
|
||||
ref.read(localeProvider.notifier).setLocale(selection.first);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('语言切换失败:$e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 显示方向:跟随系统 / 竖屏 / 横屏(手动切换屏幕方向,布局自动适配)。
|
||||
_AppSettingsTile(
|
||||
leading: const Icon(Icons.screen_rotation_outlined),
|
||||
title: t.orientationMode,
|
||||
subtitle: _orientationLabel(displayOrientation, t),
|
||||
control: SegmentedButton<DisplayOrientation>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: DisplayOrientation.system,
|
||||
icon: const Icon(Icons.screen_rotation),
|
||||
label: Text(t.orientationSystem),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: DisplayOrientation.portrait,
|
||||
icon: const Icon(Icons.stay_current_portrait),
|
||||
label: Text(t.orientationPortrait),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: DisplayOrientation.landscape,
|
||||
icon: const Icon(Icons.stay_current_landscape),
|
||||
label: Text(t.orientationLandscape),
|
||||
),
|
||||
],
|
||||
selected: {displayOrientation},
|
||||
onSelectionChanged: (selection) => ref
|
||||
.read(displayOrientationProvider.notifier)
|
||||
.set(selection.first),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 修改密码
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: Text(t.changePassword),
|
||||
onTap: () => _showChangePasswordDialog(context, ref),
|
||||
),
|
||||
const Divider(),
|
||||
// 检查更新
|
||||
ListTile(
|
||||
leading: const Icon(Icons.system_update_alt_outlined),
|
||||
title: Text(t.checkUpdate),
|
||||
subtitle: _VersionSubtitle(t: t),
|
||||
onTap: () => checkAndPromptUpdate(
|
||||
context,
|
||||
silentIfNoUpdate: false,
|
||||
orgId: ref.read(authProvider).user?.orgId,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 关于
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(t.about),
|
||||
subtitle: _VersionSubtitle(t: t, includeName: true),
|
||||
onTap: () async {
|
||||
final pkg = await currentPackageInfo;
|
||||
if (!context.mounted) return;
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: AppConstants.appName,
|
||||
applicationVersion: '${pkg.version} (${pkg.buildNumber})',
|
||||
applicationIcon: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
applicationLegalese: 'Device/system operation admin mobile demo',
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// 退出登录
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.logout,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
t.logout,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.logout),
|
||||
content: const Text('确定要退出当前账号吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && context.mounted) {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置项:图标 + 标题 + 副标题在上,控件整行铺满在下。
|
||||
///
|
||||
/// 用于 [SegmentedButton] 等较宽的控件,避免塞进 [ListTile.trailing]
|
||||
/// 在窄屏溢出。
|
||||
class _AppSettingsTile extends StatelessWidget {
|
||||
const _AppSettingsTile({
|
||||
required this.title,
|
||||
required this.control,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget control;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: leading != null ? 32 : 0),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: double.infinity, child: control),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 App 版本副标题:异步读取 [PackageInfo] 展示版本名与构建号。
|
||||
///
|
||||
/// [includeName]=true 时前缀应用名(用于「关于」项)。
|
||||
class _VersionSubtitle extends StatelessWidget {
|
||||
const _VersionSubtitle({required this.t, this.includeName = false});
|
||||
|
||||
final AppLocalizations t;
|
||||
final bool includeName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<PackageInfo>(
|
||||
future: currentPackageInfo,
|
||||
builder: (ctx, snap) {
|
||||
final info = snap.data;
|
||||
if (includeName) {
|
||||
// 「关于」项:副标题只展示应用名,版本详情交由「关于」弹窗承载,
|
||||
// 避免与应用名 + 版本号 + 构建号叠加成冗余且暴露内部构建号的文案。
|
||||
return const Text(AppConstants.appName);
|
||||
}
|
||||
// 「检查更新」项:展示当前已安装版本名(不暴露内部构建号)。
|
||||
final version = info != null ? info.version : t.loading;
|
||||
return Text('${t.currentVersion} $version');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
554
lib/screens/store/org_tree_page.dart
Normal file
554
lib/screens/store/org_tree_page.dart
Normal file
@@ -0,0 +1,554 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/org_node.dart';
|
||||
import '../../services/org_service.dart';
|
||||
import '../../utils/session_org_scope.dart';
|
||||
|
||||
/// 组织架构树页:递归树展示、颜色编码、新增/删除/详情。
|
||||
class OrgTreePage extends ConsumerStatefulWidget {
|
||||
const OrgTreePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OrgTreePage> createState() => _OrgTreePageState();
|
||||
}
|
||||
|
||||
class _OrgTreePageState extends ConsumerState<OrgTreePage> {
|
||||
final OrgService _orgService = OrgService();
|
||||
|
||||
List<OrgNode> _tree = [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
OrgNode? _selectedNode;
|
||||
|
||||
/// 受限账号(带 orgId 的渠道/代理商等)仅有只读查看权限,隐藏新增/删除等写操作入口。
|
||||
bool get _canEdit => !SessionOrgScope.instance.enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTree();
|
||||
}
|
||||
|
||||
Future<void> _loadTree() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final tree = await _orgService.getOrgTree();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tree = tree;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteNode(OrgNode node) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除组织'),
|
||||
content: Text('确定要删除「${node.name}」吗?子节点也会一并删除。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _orgService.deleteOrg(node.id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('组织已删除')),
|
||||
);
|
||||
_loadTree();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('删除失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddSheet({OrgNode? parent}) {
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _OrgFormSheet(
|
||||
orgService: _orgService,
|
||||
parentId: parent?.id,
|
||||
parentName: parent?.name,
|
||||
onSaved: () {
|
||||
Navigator.pop(ctx);
|
||||
_loadTree();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDetailSheet(OrgNode node) {
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _OrgDetailSheet(
|
||||
node: node,
|
||||
canEdit: _canEdit,
|
||||
onAddChild: () {
|
||||
Navigator.pop(ctx);
|
||||
_showAddSheet(parent: node);
|
||||
},
|
||||
onDelete: () {
|
||||
Navigator.pop(ctx);
|
||||
_deleteNode(node);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canEdit = _canEdit;
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('组织架构')),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: canEdit
|
||||
? FloatingActionButton(
|
||||
onPressed: () => _showAddSheet(parent: _selectedNode),
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final canEdit = _canEdit;
|
||||
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: _loadTree,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_tree.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.account_tree_outlined,
|
||||
size: 64, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
const Text('暂无组织数据'),
|
||||
const SizedBox(height: 8),
|
||||
if (canEdit) const Text('点击 + 添加组织'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadTree,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: _tree.map((node) => _buildTreeNode(node, 0)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTreeNode(OrgNode node, int depth) {
|
||||
final hasChildren = node.children.isNotEmpty;
|
||||
final typeInfo = _orgTypeInfo(node.type);
|
||||
final canEdit = _canEdit;
|
||||
|
||||
if (hasChildren) {
|
||||
return ExpansionTile(
|
||||
key: ValueKey('org-${node.id}'),
|
||||
leading: _typeDot(typeInfo.color),
|
||||
title: _nodeTitle(node, typeInfo),
|
||||
subtitle: Text('${node.children.length} 个子节点',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
children: [
|
||||
...node.children.map((child) => _buildTreeNode(child, depth + 1)),
|
||||
if (canEdit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 4, bottom: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showAddSheet(parent: node),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('添加子节点'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey('org-${node.id}'),
|
||||
leading: _typeDot(typeInfo.color),
|
||||
title: _nodeTitle(node, typeInfo),
|
||||
onLongPress: () => _selectNode(node),
|
||||
onTap: () => _showDetailSheet(node),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'add') {
|
||||
_showAddSheet(parent: node);
|
||||
} else if (value == 'detail') {
|
||||
_showDetailSheet(node);
|
||||
} else if (value == 'delete') {
|
||||
_deleteNode(node);
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
if (canEdit)
|
||||
const PopupMenuItem(value: 'add', child: Text('添加子节点')),
|
||||
const PopupMenuItem(value: 'detail', child: Text('查看详情')),
|
||||
if (canEdit)
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectNode(OrgNode node) {
|
||||
setState(() => _selectedNode = node);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已选择「${node.name}」为父节点,点击 + 添加子组织')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nodeTitle(OrgNode node, _TypeInfo info) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
node.name,
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: info.color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: info.color.withOpacity(0.6)),
|
||||
),
|
||||
child: Text(
|
||||
info.label,
|
||||
style: TextStyle(
|
||||
color: info.color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeDot(Color color) {
|
||||
return Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_TypeInfo _orgTypeInfo(int type) {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return const _TypeInfo('平台', Colors.purple);
|
||||
case 1:
|
||||
return const _TypeInfo('渠道', Color(0xFF0B6397)); // 语义信息蓝
|
||||
case 2:
|
||||
return const _TypeInfo('代理商', Colors.orange);
|
||||
case 3:
|
||||
return const _TypeInfo('门店', Colors.green);
|
||||
case 4:
|
||||
return const _TypeInfo('设备分组', Colors.grey);
|
||||
default:
|
||||
return const _TypeInfo('未知', Colors.grey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeInfo {
|
||||
const _TypeInfo(this.label, this.color);
|
||||
final String label;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// 组织详情底部弹窗。
|
||||
class _OrgDetailSheet extends StatelessWidget {
|
||||
const _OrgDetailSheet({
|
||||
required this.node,
|
||||
required this.onAddChild,
|
||||
required this.onDelete,
|
||||
this.canEdit = true,
|
||||
});
|
||||
|
||||
final OrgNode node;
|
||||
final VoidCallback onAddChild;
|
||||
final VoidCallback onDelete;
|
||||
final bool canEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
node.name,
|
||||
style: theme.textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_row(theme, '节点ID', node.id),
|
||||
_row(theme, '父级ID', node.parentId),
|
||||
_row(theme, '地址', node.address),
|
||||
_row(theme, '联系电话', node.contactPhone),
|
||||
_row(theme, '激活码', node.activationCode),
|
||||
const SizedBox(height: 20),
|
||||
if (canEdit)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: onAddChild,
|
||||
child: const Text('添加子节点'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onDelete,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value.isEmpty ? '—' : value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增组织表单。
|
||||
class _OrgFormSheet extends StatefulWidget {
|
||||
const _OrgFormSheet({
|
||||
required this.orgService,
|
||||
this.parentId,
|
||||
this.parentName,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final OrgService orgService;
|
||||
final String? parentId;
|
||||
final String? parentName;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
State<_OrgFormSheet> createState() => _OrgFormSheetState();
|
||||
}
|
||||
|
||||
class _OrgFormSheetState extends State<_OrgFormSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
int _type = 1;
|
||||
bool _saving = false;
|
||||
|
||||
static const _typeOptions = [
|
||||
_TypeOption(0, '平台'),
|
||||
_TypeOption(1, '渠道'),
|
||||
_TypeOption(2, '代理商'),
|
||||
_TypeOption(3, '门店'),
|
||||
_TypeOption(4, '设备分组'),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final data = <String, dynamic>{
|
||||
'name': _nameController.text.trim(),
|
||||
'type': _type,
|
||||
'parentId': widget.parentId ?? '',
|
||||
};
|
||||
await widget.orgService.saveOrUpdateOrg(data);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('组织已创建')),
|
||||
);
|
||||
widget.onSaved();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存失败:$e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'新增组织',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (widget.parentName != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'父级组织:${widget.parentName}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '组织名称',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.label),
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? '请输入组织名称' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _type,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '组织类型',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.category),
|
||||
),
|
||||
items: _typeOptions
|
||||
.map((opt) => DropdownMenuItem(
|
||||
value: opt.type,
|
||||
child: Text(opt.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _type = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('创建'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeOption {
|
||||
const _TypeOption(this.type, this.label);
|
||||
final int type;
|
||||
final String label;
|
||||
}
|
||||
16
lib/screens/store/store_detail_page.dart
Normal file
16
lib/screens/store/store_detail_page.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 门店详情页(桩页面)。
|
||||
class StoreDetailPage extends StatelessWidget {
|
||||
const StoreDetailPage({super.key, required this.storeId});
|
||||
|
||||
final String storeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('门店详情')),
|
||||
body: Center(child: Text('门店 $storeId\n功能开发中')),
|
||||
);
|
||||
}
|
||||
}
|
||||
1771
lib/screens/store/store_list_page.dart
Normal file
1771
lib/screens/store/store_list_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user