625 lines
18 KiB
Dart
625 lines
18 KiB
Dart
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('保存'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|