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

625 lines
18 KiB
Dart
Raw Permalink Normal View History

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