ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View 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;
}

View 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),
),
);
}
}

View 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),
),
);
}
}

View 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),
),
);
}
}

View 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),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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))),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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),
),
);
}
}

View 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),
),
);
}
}

View 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('保存'),
),
],
),
],
),
),
),
);
}
}

View 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,
),
],
),
),
);
},
),
);
}
}

View 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('保存'),
),
],
),
],
),
),
),
);
}
}

View 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('保存'),
),
],
),
],
),
),
),
);
}
}

View 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('保存'),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff