From 80670821cddb4a54ef43f260b27d20259ef6928a Mon Sep 17 00:00:00 2001 From: jyuesong <425698907@qq.com> Date: Thu, 13 Jan 2022 14:10:29 +0800 Subject: [PATCH] add detail page --- lib/base/base_state_widget.dart | 4 +- lib/base/http/api.dart | 20 ++ lib/base/http/http.dart | 13 +- lib/base/http/url.dart | 7 + lib/base/routes.dart | 2 +- lib/base/theme.dart | 19 +- .../task/intime_log/intime_log_page.dart | 74 ++++++ lib/module/task/task_bean.dart | 2 +- .../task/task_detail/task_detail_bean.dart | 63 ++++++ .../task/task_detail/task_detail_page.dart | 36 +++ .../task_detail/task_detail_viewmodel.dart | 28 +++ lib/module/task/task_page.dart | 210 ++++++++++++++++-- lib/module/task/task_viewmodel.dart | 35 ++- lib/utils/utils.dart | 21 ++ pubspec.lock | 7 + pubspec.yaml | 1 + test/widget_test.dart | 2 - 17 files changed, 502 insertions(+), 42 deletions(-) create mode 100644 lib/module/task/intime_log/intime_log_page.dart create mode 100644 lib/module/task/task_detail/task_detail_bean.dart create mode 100644 lib/module/task/task_detail/task_detail_page.dart create mode 100644 lib/module/task/task_detail/task_detail_viewmodel.dart diff --git a/lib/base/base_state_widget.dart b/lib/base/base_state_widget.dart index bb490c8..519edc2 100644 --- a/lib/base/base_state_widget.dart +++ b/lib/base/base_state_widget.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'base_viewmodel.dart'; class BaseStateWidget extends ConsumerStatefulWidget { - final Widget Function(BuildContext context, T value, Widget? child) builder; + final Widget Function(WidgetRef context, T value, Widget? child) builder; final ProviderBase model; final Widget? child; final Function(T)? onReady; @@ -37,7 +37,7 @@ class _BaseStateWidgetState extends ConsumerState(widget.model); if (viewModel.currentState == PageState.CONTENT) { - return widget.builder(context, viewModel, widget.child); + return widget.builder(ref, viewModel, widget.child); } if (viewModel.currentState == PageState.LOADING) { diff --git a/lib/base/http/api.dart b/lib/base/http/api.dart index e03863a..14e1e95 100644 --- a/lib/base/http/api.dart +++ b/lib/base/http/api.dart @@ -1,6 +1,9 @@ +import 'dart:ffi'; + import 'package:qinglong_app/base/http/http.dart'; import 'package:qinglong_app/module/login/login_bean.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; +import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; import 'url.dart'; @@ -18,4 +21,21 @@ class Api { static Future>> crons() async { return await Http.get>(Url.TASKS, {"searchValue": ""}); } + + static Future> startTasks(List crons) async { + return await Http.put(Url.RUN_TASKS, crons); + } + + static Future> stopTasks(List crons) async { + return await Http.put(Url.RUN_TASKS, crons); + } + + static Future> inTimeLog(String cron) async { + return await Http.get(Url.INTIME_LOG(cron), null); + } + + + static Future> taskDetail(String cron) async { + return await Http.get(Url.TASK_DETAIL+cron, null); + } } diff --git a/lib/base/http/http.dart b/lib/base/http/http.dart index 57b9e68..baf1be9 100644 --- a/lib/base/http/http.dart +++ b/lib/base/http/http.dart @@ -1,12 +1,11 @@ import 'dart:convert'; -import 'dart:ffi'; +import 'dart:core'; import 'package:dio/dio.dart'; import 'package:dio_log/dio_log.dart'; import 'package:flutter/foundation.dart'; import 'package:qinglong_app/base/http/token_interceptor.dart'; import 'package:qinglong_app/base/userinfo_viewmodel.dart'; -import 'package:qinglong_app/main.dart'; import '../../json.jc.dart'; @@ -36,7 +35,7 @@ class Http { } } - static Future> get(String uri, Map json, {bool compute = true}) async { + static Future> get(String uri, Map? json, {bool compute = true}) async { _init(); var response = await _dio!.get(uri, queryParameters: json); @@ -74,7 +73,7 @@ class Http { try { if (response.data["code"] == 200) { if (response.data["data"] != null) { - if (T is Void) { + if (T == NullResponse) { return HttpResponse( success: true, code: 200, @@ -83,7 +82,7 @@ class Http { dynamic data = response.data["data"]; T t; - if (T is String) { + if (T == String) { if (data is String) { t = data as T; } else { @@ -109,7 +108,7 @@ class Http { } } else { return HttpResponse( - success: false, + success: true, code: 200, ); } @@ -173,3 +172,5 @@ class CronBean with BaseBean { void decode() async { compute(DeserializeAction.invokeJson, DeserializeAction({})); } + +class NullResponse{} \ No newline at end of file diff --git a/lib/base/http/url.dart b/lib/base/http/url.dart index f29b687..04b767d 100644 --- a/lib/base/http/url.dart +++ b/lib/base/http/url.dart @@ -1,4 +1,11 @@ class Url { static const LOGIN = "/api/user/login"; static const TASKS = "/api/crons"; + static const RUN_TASKS = "/api/crons/run"; + static const STOP_TASKS = "/api/crons/stop"; + static const TASK_DETAIL = "/api/crons/"; + + static INTIME_LOG(String cronId) { + return "/api/crons/$cronId/log"; + } } diff --git a/lib/base/routes.dart b/lib/base/routes.dart index 3346d34..e021026 100644 --- a/lib/base/routes.dart +++ b/lib/base/routes.dart @@ -8,7 +8,7 @@ class Routes { static Route? generateRoute(RouteSettings settings) { switch (settings.name) { case route_HomePage: - return CupertinoPageRoute(builder: (context) => const HomePage()); + return MaterialPageRoute(builder: (context) => const HomePage()); } return null; diff --git a/lib/base/theme.dart b/lib/base/theme.dart index 517599d..e83954f 100644 --- a/lib/base/theme.dart +++ b/lib/base/theme.dart @@ -22,14 +22,16 @@ class ThemeViewModel extends ChangeNotifier { } ThemeData darkTheme = ThemeData.dark().copyWith( - primaryColor: Color(0xffffffff), + primaryColor: const Color(0xffffffff), ); ThemeData lightTheme = ThemeData.light().copyWith( - primaryColor: Color(0xFF0F77FE), + primaryColor: const Color(0xFF0F77FE), + scaffoldBackgroundColor: Colors.white, ); abstract class ThemeColors { Color taskTitleColor(); + Color searchBarBg(); } class LightartThemeColors extends ThemeColors { @@ -37,6 +39,12 @@ class LightartThemeColors extends ThemeColors { Color taskTitleColor() { return Color(0xff333333); } + + + @override + Color searchBarBg() { + return const Color(0xffF7F7F7); + } } class DartThemeColors extends ThemeColors { @@ -44,4 +52,11 @@ class DartThemeColors extends ThemeColors { Color taskTitleColor() { return Colors.white; } + + + + @override + Color searchBarBg() { + return const Color(0xff2E312E); + } } diff --git a/lib/module/task/intime_log/intime_log_page.dart b/lib/module/task/intime_log/intime_log_page.dart new file mode 100644 index 0000000..68ad43f --- /dev/null +++ b/lib/module/task/intime_log/intime_log_page.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:qinglong_app/base/http/api.dart'; +import 'package:qinglong_app/base/http/http.dart'; + +class InTimeLogPage extends StatefulWidget { + final String cronId; + final bool needTimer; + + const InTimeLogPage(this.cronId, this.needTimer, {Key? key}) : super(key: key); + + @override + _InTimeLogPageState createState() => _InTimeLogPageState(); +} + +class _InTimeLogPageState extends State { + Timer? _timer; + + String? content; + + @override + void initState() { + super.initState(); + + if (widget.needTimer) { + _timer = Timer.periodic( + const Duration(seconds: 2), + (timer) { + getLogData(); + }, + ); + } else { + WidgetsBinding.instance?.addPostFrameCallback((timeStamp) { + getLogData(); + }); + } + } + + getLogData() async { + HttpResponse response = await Api.inTimeLog(widget.cronId); + + if (response.success) { + content = response.bean; + setState(() {}); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 10, + ), + child: content == null + ? const Center( + child: CupertinoActivityIndicator(), + ) + : CupertinoScrollbar( + child: SelectableText( + content!, + ), + ), + ); + } +} diff --git a/lib/module/task/task_bean.dart b/lib/module/task/task_bean.dart index b49a006..0fafd98 100644 --- a/lib/module/task/task_bean.dart +++ b/lib/module/task/task_bean.dart @@ -16,7 +16,7 @@ class TaskBean { int? isDisabled; String? logPath; int? isPinned; - num? lastExecutionTime; + int? lastExecutionTime; int? lastRunningTime; String? pid; diff --git a/lib/module/task/task_detail/task_detail_bean.dart b/lib/module/task/task_detail/task_detail_bean.dart new file mode 100644 index 0000000..2c33020 --- /dev/null +++ b/lib/module/task/task_detail/task_detail_bean.dart @@ -0,0 +1,63 @@ +import 'package:json_conversion_annotation/json_conversion_annotation.dart'; + +@JsonConversion() +class TaskDetailBean { + String? name; + String? command; + String? schedule; + bool? saved; + String? sId; + int? created; + int? status; + String? timestamp; + int? isSystem; + int? isDisabled; + String? logPath; + int? isPinned; + + TaskDetailBean( + {this.name, + this.command, + this.schedule, + this.saved, + this.sId, + this.created, + this.status, + this.timestamp, + this.isSystem, + this.isDisabled, + this.logPath, + this.isPinned}); + + TaskDetailBean.fromJson(Map json) { + name = json['name']; + command = json['command']; + schedule = json['schedule']; + saved = json['saved']; + sId = json['_id']; + created = json['created']; + status = json['status']; + timestamp = json['timestamp']; + isSystem = json['isSystem']; + isDisabled = json['isDisabled']; + logPath = json['log_path']; + isPinned = json['isPinned']; + } + + Map toJson() { + final Map data = new Map(); + data['name'] = this.name; + data['command'] = this.command; + data['schedule'] = this.schedule; + data['saved'] = this.saved; + data['_id'] = this.sId; + data['created'] = this.created; + data['status'] = this.status; + data['timestamp'] = this.timestamp; + data['isSystem'] = this.isSystem; + data['isDisabled'] = this.isDisabled; + data['log_path'] = this.logPath; + data['isPinned'] = this.isPinned; + return data; + } +} diff --git a/lib/module/task/task_detail/task_detail_page.dart b/lib/module/task/task_detail/task_detail_page.dart new file mode 100644 index 0000000..8dbdb98 --- /dev/null +++ b/lib/module/task/task_detail/task_detail_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qinglong_app/base/base_state_widget.dart'; +import 'package:qinglong_app/base/ql_app_bar.dart'; +import 'package:qinglong_app/module/task/task_bean.dart'; +import 'package:qinglong_app/module/task/task_detail/task_detail_viewmodel.dart'; + +class TaskDetailPage extends ConsumerStatefulWidget { + final TaskBean taskBean; + + const TaskDetailPage(this.taskBean, {Key? key}) : super(key: key); + + @override + _TaskDetailPageState createState() => _TaskDetailPageState(); +} + +class _TaskDetailPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: QlAppBar( + canBack: true, + title: widget.taskBean.name ?? "", + ), + body: BaseStateWidget( + model: taskDetailProvider, + builder: (WidgetRef context, TaskDetailViewModel value, Widget? child) { + return Container(); + }, + onReady: (model){ + model.loadDetail(widget.taskBean.sId!); + }, + ), + ); + } +} diff --git a/lib/module/task/task_detail/task_detail_viewmodel.dart b/lib/module/task/task_detail/task_detail_viewmodel.dart new file mode 100644 index 0000000..115f5d5 --- /dev/null +++ b/lib/module/task/task_detail/task_detail_viewmodel.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qinglong_app/base/base_viewmodel.dart'; +import 'package:qinglong_app/base/http/api.dart'; +import 'package:qinglong_app/base/http/http.dart'; +import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; + +var taskDetailProvider = AutoDisposeChangeNotifierProvider((ref) { + return TaskDetailViewModel(); +}); + +class TaskDetailViewModel extends BaseViewModel { + TaskDetailBean? bean; + + TaskDetailViewModel(); + + Future loadDetail(String id) async { + loading(notify: true); + + HttpResponse response = await Api.taskDetail(id); + + if (response.success) { + bean = response.bean; + success(); + } else { + failed(response.message, notify: true); + } + } +} diff --git a/lib/module/task/task_page.dart b/lib/module/task/task_page.dart index 05b35a8..3b2b07b 100644 --- a/lib/module/task/task_page.dart +++ b/lib/module/task/task_page.dart @@ -4,8 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:qinglong_app/base/base_state_widget.dart'; import 'package:qinglong_app/base/theme.dart'; +import 'package:qinglong_app/module/task/intime_log/intime_log_page.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; import 'package:qinglong_app/module/task/task_viewmodel.dart'; +import 'package:qinglong_app/utils/utils.dart'; class TaskPage extends StatefulWidget { const TaskPage({Key? key}) : super(key: key); @@ -15,27 +17,38 @@ class TaskPage extends StatefulWidget { } class _TaskPageState extends State { + String? _searchKey = null; + TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + } + @override Widget build(BuildContext context) { return BaseStateWidget( - builder: (context, model, child) { + builder: (ref, model, child) { return RefreshIndicator( onRefresh: () async { return model.loadData(false); }, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - vertical: 15, - ), + child: ListView.builder( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, itemBuilder: (context, index) { - return TaskItemCell(model.list[index]); - }, - itemCount: model.list.length, - separatorBuilder: (BuildContext context, int index) { - return const SizedBox( - height: 10, - ); + if (index == 0) { + return searchCell(ref); + } + + TaskBean item = model.list[index - 1]; + + if ((item.name == null || item.name!.contains(_searchKey ?? "")) || (item.command == null || item.command!.contains(_searchKey ?? ""))) { + return TaskItemCell(item); + } else { + return const SizedBox.shrink(); + } }, + itemCount: model.list.length + 1, ), ); }, @@ -45,6 +58,43 @@ class _TaskPageState extends State { }, ); } + + Widget searchCell(WidgetRef context) { + return Container( + color: context.watch(themeProvider).themeColor.searchBarBg(), + padding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 10, + ), + child: CupertinoSearchTextField( + onSubmitted: (value) { + setState(() { + _searchKey = value; + }); + }, + onSuffixTap: () { + _searchController.text = ""; + setState(() { + _searchKey = ""; + }); + }, + controller: _searchController, + borderRadius: BorderRadius.circular( + 30, + ), + padding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 5, + ), + prefixInsets: const EdgeInsets.only( + top: 10, + bottom: 6, + left: 15, + ), + placeholder: "搜索", + ), + ); + } } class TaskItemCell extends ConsumerWidget { @@ -63,35 +113,47 @@ class TaskItemCell extends ConsumerWidget { children: [ SlidableAction( flex: 1, - icon: CupertinoIcons.memories, + icon: bean.status == 0 ? CupertinoIcons.stop_circle : CupertinoIcons.memories, foregroundColor: Colors.white, - backgroundColor: Color(0xFF0F77FE), - onPressed: (BuildContext context) {}, + backgroundColor: const Color(0xFF0F77FE), + onPressed: (BuildContext context) { + if (bean.status == 0) { + stopCron(context, ref); + } else { + startCron(context, ref); + } + }, ), SlidableAction( flex: 1, backgroundColor: Colors.green, foregroundColor: Colors.white, icon: CupertinoIcons.clock_fill, - onPressed: (BuildContext context) {}, + onPressed: (BuildContext context) { + logCron(context, ref); + }, ), ], ), endActionPane: ActionPane( - motion: ScrollMotion(), + motion: const ScrollMotion(), extentRatio: 0.15, children: [ SlidableAction( backgroundColor: Colors.cyan, flex: 1, - onPressed: (_) {}, + onPressed: (_) { + more(context, ref); + }, foregroundColor: Colors.white, icon: CupertinoIcons.ellipsis, ), ], ), child: Container( - padding: EdgeInsets.symmetric( + color: bean.isPinned == 1 ? ref.watch(themeProvider).themeColor.searchBarBg() : Colors.transparent, + margin: const EdgeInsets.only(bottom: 7, top: 7), + padding: const EdgeInsets.symmetric( horizontal: 15, vertical: 5, ), @@ -126,7 +188,7 @@ class TaskItemCell extends ConsumerWidget { ), const Spacer(), Text( - "${bean.lastRunningTime ?? "-"}", + (bean.lastExecutionTime == null || bean.lastExecutionTime == 0) ? "-" : Utils.formatMessageTime(bean.lastExecutionTime!), maxLines: 1, style: const TextStyle( overflow: TextOverflow.ellipsis, @@ -153,4 +215,112 @@ class TaskItemCell extends ConsumerWidget { ), ); } + + startCron(BuildContext context, WidgetRef ref) { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("确认运行"), + content: Text("确认运行定时任务 ${bean.name ?? ""} 吗"), + actions: [ + CupertinoDialogAction( + child: const Text("取消"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + CupertinoDialogAction( + child: const Text("确定"), + onPressed: () { + Navigator.of(context).pop(); + ref.read(taskProvider).runCrons(bean.sId!); + }, + ), + ], + ), + ); + } + + stopCron(BuildContext context, WidgetRef ref) { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("确认停止"), + content: Text("确认停止定时任务 ${bean.name ?? ""} 吗"), + actions: [ + CupertinoDialogAction( + child: const Text("取消"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + CupertinoDialogAction( + child: const Text("确定"), + onPressed: () { + Navigator.of(context).pop(); + ref.read(taskProvider).stopCrons(bean.sId!); + }, + ), + ], + ), + ); + } + + logCron(BuildContext context, WidgetRef ref) { + showCupertinoDialog( + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: Text( + "${bean.name}运行日志", + maxLines: 1, + style: const TextStyle(overflow: TextOverflow.ellipsis), + ), + content: InTimeLogPage(bean.sId!, bean.status == 0), + actions: [ + CupertinoDialogAction( + child: const Text("知道了"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + context: context); + } + + more(BuildContext context, WidgetRef ref) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('更多操作'), + actions: [ + CupertinoActionSheetAction( + child: const Text('编辑'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: Text(bean.isDisabled! == 1 ? "启用" : "禁用"), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: const Text('删除'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: Text(bean.isPinned! == 0 ? "置顶" : "取消置顶"), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + } } diff --git a/lib/module/task/task_viewmodel.dart b/lib/module/task/task_viewmodel.dart index d2afd66..e3ca657 100644 --- a/lib/module/task/task_viewmodel.dart +++ b/lib/module/task/task_viewmodel.dart @@ -20,18 +20,37 @@ class TaskViewModel extends BaseViewModel { list.clear(); list.addAll(result.bean!); - list.sort((a, b) { - return a.isDisabled! - b.isDisabled!; - }); - - list.sort((a, b) { - return a.status! - b.status!; - }); - + sortList(); success(); } else { list.clear(); failed(result.message, notify: true); } } + + void sortList() { + list.sort((a, b) { + return a.isDisabled! - b.isDisabled!; + }); + + list.sort((a, b) { + return a.status! - b.status!; + }); + } + + Future runCrons(String cron) async { + HttpResponse result = await Api.startTasks([cron]); + if (result.success) { + list.firstWhere((element) => element.sId == cron).status = 0; + notifyListeners(); + } + } + + Future stopCrons(String cron) async { + HttpResponse result = await Api.stopTasks([cron]); + if (result.success) { + list.firstWhere((element) => element.sId == cron).status = 1; + notifyListeners(); + } + } } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index f8e7544..bb41dfe 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,7 +1,28 @@ import 'package:flutter/cupertino.dart'; +import 'package:intl/intl.dart'; class Utils { static void hideKeyBoard(BuildContext context) { FocusScope.of(context).requestFocus(FocusNode()); } + + static String formatMessageTime(int time) { + DateTime current = DateTime.now(); + DateTime chatTime = DateTime.fromMillisecondsSinceEpoch(time * 1000); + if (current.year == chatTime.year) { + if (current.day == chatTime.day) { + if (chatTime.hour <= 12) { + return DateFormat("上午 H:mm").format(chatTime); + } else { + return DateFormat("下午 H:mm").format(chatTime); + } + } else if (chatTime.day == current.day - 1) { + return DateFormat("昨天 H:mm").format(chatTime); + } else { + return DateFormat("M/d H:mm").format(chatTime); + } + } else { + return DateFormat("yyyy/M/d HH:mm").format(chatTime); + } + } } diff --git a/pubspec.lock b/pubspec.lock index d56219e..9e7be8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.17.0" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 46b993d..aa954a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: dio_log: ^2.0.2 json_conversion_annotation: ^0.0.4 logger: ^1.1.0 + intl: ^0.17.0 dev_dependencies: build_runner: ^2.0.0 diff --git a/test/widget_test.dart b/test/widget_test.dart index 9c4006f..a6993ad 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,7 +16,5 @@ import 'package:qinglong_app/module/login/login_viewmodel.dart'; void main() { - testWidgets("", (_){ - }); }