import 'dart:convert'; import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/components/manager/request_map_manager.dart'; import 'package:proxypin/ui/component/app_dialog.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/desktop/setting/request_map/map_local.dart'; import 'package:proxypin/ui/desktop/setting/request_map/map_scipt.dart'; import 'package:proxypin/utils/lang.dart'; import '../../../../network/util/logger.dart'; bool _refresh = false; /// 刷新配置 Future _refreshConfig({bool force = false}) async { if (force) { _refresh = false; await RequestMapManager.instance.then((manager) => manager.flushConfig()); await DesktopMultiWindow.invokeMethod(0, "refreshRequestMap"); return; } if (_refresh) { return; } _refresh = true; Future.delayed(const Duration(milliseconds: 1000), () async { _refresh = false; await RequestMapManager.instance.then((manager) => manager.flushConfig()); await DesktopMultiWindow.invokeMethod(0, "refreshRequestMap"); }); } class RequestMapPage extends StatefulWidget { final int? windowId; const RequestMapPage({super.key, this.windowId}); @override State createState() => _RequestMapPageState(); } class _RequestMapPageState extends State { AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); HardwareKeyboard.instance.addHandler(onKeyEvent); } @override void dispose() { HardwareKeyboard.instance.removeHandler(onKeyEvent); super.dispose(); } bool onKeyEvent(KeyEvent event) { if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape) && Navigator.canPop(context)) { Navigator.maybePop(context); return true; } if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) && event.logicalKey == LogicalKeyboardKey.keyW) { HardwareKeyboard.instance.removeHandler(onKeyEvent); if (_refresh) { _refreshConfig(force: true).whenComplete(() => WindowController.fromWindowId(widget.windowId!).close()); return true; } WindowController.fromWindowId(widget.windowId!).close(); return true; } return false; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(localizations.requestMap, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), toolbarHeight: 36, centerTitle: true), body: Padding( padding: const EdgeInsets.only(left: 15, right: 10), child: futureWidget( RequestMapManager.instance, loading: true, (data) => Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ Row(children: [ SizedBox( width: 350, child: ListTile( title: Text("${localizations.enable} ${localizations.requestMap}"), subtitle: Text(localizations.requestMapDescribe, style: const TextStyle(fontSize: 12)), trailing: SwitchWidget( value: data.enabled, scale: 0.8, onChanged: (value) { data.enabled = value; _refreshConfig(); }))), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ const SizedBox(width: 10), TextButton.icon( icon: const Icon(Icons.add, size: 18), onPressed: showEdit, label: Text(localizations.add)), const SizedBox(width: 10), TextButton.icon( icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, label: Text(localizations.import), ), const SizedBox(width: 10), ], )), const SizedBox(width: 15) ]), const SizedBox(height: 5), RequestMapList(list: data.rules, windowId: widget.windowId), ])))); } //导入js Future import() async { String? path; if (Platform.isMacOS) { path = await DesktopMultiWindow.invokeMethod(0, "pickFiles", { "allowedExtensions": ['json'] }); WindowController.fromWindowId(widget.windowId!).show(); } else { FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']); path = result?.files.single.path; } if (path == null) { return; } try { var json = jsonDecode(await File(path).readAsString()); var manager = (await RequestMapManager.instance); if (json is List) { for (var item in json) { var mapRule = RequestMapRule.fromJson(item); var requestMapItem = RequestMapItem.fromJson(item['item']); await manager.addRule(mapRule, requestMapItem); } } if (mounted) { CustomToast.success(localizations.importSuccess).show(context); } setState(() {}); } catch (e, t) { logger.e('[RequestMap] import fail $path', error: e, stackTrace: t); if (mounted) { CustomToast.error("${localizations.importFailed} $e").show(context); } } } /// 添加脚本 Future showEdit() async { showDialog(barrierDismissible: false, context: context, builder: (_) => RequestMapEdit(windowId: widget.windowId)) .then((value) { if (value != null) { setState(() {}); } }); } } /// 脚本列表 class RequestMapList extends StatefulWidget { final int? windowId; final List list; const RequestMapList({super.key, required this.list, required this.windowId}); @override State createState() => _RequestMapListState(); } class _RequestMapListState extends State { Set selected = {}; bool isPressed = false; Offset? lastPressPosition; AppLocalizations get localizations => AppLocalizations.of(context)!; @override Widget build(BuildContext context) { return GestureDetector( onSecondaryTap: () { if (lastPressPosition == null) { return; } showGlobalMenu(lastPressPosition!); }, onTapDown: (details) { if (selected.isEmpty) { return; } if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { return; } setState(() { selected.clear(); }); }, child: Listener( onPointerUp: (event) => isPressed = false, onPointerDown: (event) { lastPressPosition = event.localPosition; if (event.buttons == kPrimaryMouseButton) { isPressed = true; } }, child: Container( padding: const EdgeInsets.only(top: 10), height: 530, decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: Column(children: [ Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Container(width: 130, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), const VerticalDivider(), const Expanded(child: Text("URL")), SizedBox(width: 100, child: Text(localizations.action, textAlign: TextAlign.center)), ], ), const Divider(thickness: 0.5), Column(children: rows(widget.list)) ]))))); } List rows(List list) { var primaryColor = Theme.of(context).colorScheme.primary; bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); return List.generate(list.length, (index) { return InkWell( highlightColor: Colors.transparent, splashColor: Colors.transparent, hoverColor: primaryColor.withOpacity(0.3), onSecondaryTapDown: (details) => showMenus(details, index), onDoubleTap: () => showEdit(index), onHover: (hover) { if (isPressed && !selected.contains(index)) { setState(() { selected.add(index); }); } }, onTap: () { if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { setState(() { selected.contains(index) ? selected.remove(index) : selected.add(index); }); return; } if (selected.isEmpty) { return; } setState(() { selected.clear(); }); }, child: Container( color: selected.contains(index) ? primaryColor.withOpacity(0.6) : index.isEven ? Colors.grey.withOpacity(0.1) : null, height: 30, padding: const EdgeInsets.all(5), child: Row( children: [ SizedBox(width: 130, child: Text(list[index].name ?? '', style: const TextStyle(fontSize: 13))), SizedBox( width: 40, child: Transform.scale( scale: 0.6, child: SwitchWidget( value: list[index].enabled, onChanged: (val) { list[index].enabled = val; _refreshConfig(); }))), const SizedBox(width: 20), Expanded( child: Text(list[index].url, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), SizedBox( width: 100, child: Text(isEN ? list[index].type.name.camelCaseToSpaced() : list[index].type.label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13))), ], ))); }); } void showGlobalMenu(Offset offset) { showContextMenu(context, offset, items: [ PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit()), PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.toList())), const PopupMenuDivider(), PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => enableStatus(true)), PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => enableStatus(false)), const PopupMenuDivider(), PopupMenuItem(height: 35, child: Text(localizations.deleteSelect), onTap: () => remove(selected.toList())), ]); } //点击菜单 void showMenus(TapDownDetails details, int index) { if (selected.length > 1) { showGlobalMenu(details.globalPosition); return; } setState(() { selected.add(index); }); showContextMenu(context, details.globalPosition, items: [ PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export([index])), PopupMenuItem( height: 35, child: widget.list[index].enabled ? Text(localizations.disabled) : Text(localizations.enable), onTap: () { widget.list[index].enabled = !widget.list[index].enabled; _refreshConfig(); }), const PopupMenuDivider(), PopupMenuItem( height: 35, child: Text(localizations.delete), onTap: () async { var manager = await RequestMapManager.instance; await manager.deleteRule(index); _refreshConfig(); }), ]).then((value) { if (mounted) { setState(() { selected.remove(index); }); } }); } Future showEdit([int? index]) async { final item = index == null ? null : await (await RequestMapManager.instance).getMapItem(widget.list[index]); if (!mounted) { return; } showDialog( barrierDismissible: false, context: context, builder: (_) => RequestMapEdit(windowId: widget.windowId, rule: index == null ? null : widget.list[index], item: item)) .then((value) { if (value != null) { setState(() {}); } }); } //导出 Future export(List indexes) async { if (indexes.isEmpty) return; //文件名称 String fileName = 'request_map.json'; String? path; if (Platform.isMacOS) { path = await DesktopMultiWindow.invokeMethod(0, "saveFile", {"fileName": fileName}); if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show(); } else { path = await FilePicker.platform.saveFile(fileName: fileName); } if (path == null) { return; } var manager = await RequestMapManager.instance; List json = []; for (var idx in indexes) { var item = widget.list[idx]; var map = item.toJson(); map.remove("itemPath"); map['item'] = (await manager.getMapItem(item))?.toJson(); json.add(map); } await File(path).writeAsBytes(utf8.encode(jsonEncode(json))); if (mounted) FlutterToastr.show(localizations.exportSuccess, context); } void enableStatus(bool enable) { for (var idx in selected) { widget.list[idx].enabled = enable; } setState(() {}); _refreshConfig(); } Future remove(List indexes) async { if (indexes.isEmpty) return; showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { var manager = await RequestMapManager.instance; for (var idx in indexes) { await manager.deleteRule(idx); } setState(() { selected.clear(); }); _refreshConfig(force: true); if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); }); } } ///请求重写规则添加对话框 class RequestMapEdit extends StatefulWidget { final RequestMapRule? rule; final RequestMapItem? item; final int? windowId; final String? url; final String? title; const RequestMapEdit({super.key, this.rule, this.windowId, this.item, this.url, this.title}); @override State createState() { return _RequestMapEditState(); } } class _RequestMapEditState extends State { final mapLocalKey = GlobalKey(); final mapScriptKey = GlobalKey(); late RequestMapRule rule; late RequestMapType mapType; late TextEditingController nameInput; late TextEditingController urlInput; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); rule = widget.rule ?? RequestMapRule(url: widget.url ?? '', type: RequestMapType.local); mapType = rule.type; nameInput = TextEditingController(text: rule.name ?? widget.title); urlInput = TextEditingController(text: rule.url); } @override void dispose() { urlInput.dispose(); nameInput.dispose(); super.dispose(); } @override Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); return AlertDialog( scrollable: true, titlePadding: const EdgeInsets.only(top: 10, left: 20), actionsPadding: const EdgeInsets.only(right: 15, bottom: 15), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), title: Row(children: [ Text(localizations.requestRewriteRule, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), ]), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), content: Container( width: 550, constraints: const BoxConstraints(minHeight: 200, maxHeight: 530), child: Form( key: formKey, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ SizedBox(width: 55, child: Text('${localizations.enable}:')), SwitchWidget(value: rule.enabled, onChanged: (val) => rule.enabled = val, scale: 0.8) ]), const SizedBox(height: 5), textField('${localizations.name}:', nameInput, localizations.pleaseEnter), const SizedBox(height: 5), textField('URL:', urlInput, 'https://www.example.com/api/*', required: true), const SizedBox(height: 5), Row(children: [ SizedBox(width: 60, child: Text('${localizations.action}:')), SizedBox( width: 150, height: 33, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, value: mapType, decoration: InputDecoration( errorStyle: const TextStyle(height: 0, fontSize: 0), contentPadding: const EdgeInsets.only(left: 7, right: 7), focusedBorder: focusedBorder(), border: const OutlineInputBorder()), items: RequestMapType.values .map((e) => DropdownMenuItem( value: e, child: Text(isEN ? e.name : e.label, style: const TextStyle(fontSize: 13)))) .toList(), onChanged: onChangeType, )), const SizedBox(width: 10), ]), const SizedBox(height: 10), mapRule(), ]))), actions: [ ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()), FilledButton( child: Text(localizations.save), onPressed: () async { if (!(formKey.currentState as FormState).validate()) { FlutterToastr.show(localizations.cannotBeEmpty, context, position: FlutterToastr.center); return; } (formKey.currentState as FormState).save(); rule.name = nameInput.text; rule.url = urlInput.text; rule.type = mapType; RequestMapItem item; if (mapType == RequestMapType.local) { item = mapLocalKey.currentState!.getRequestMapItem(); } else { String? scriptCode = mapScriptKey.currentState?.getScriptCode(); item = widget.item ?? RequestMapItem(); item.script = scriptCode; } var requestMapManager = await RequestMapManager.instance; var index = requestMapManager.rules.indexOf(rule); if (index >= 0) { await requestMapManager.updateRule(rule, item); } else { await requestMapManager.addRule(rule, item); } DesktopMultiWindow.invokeMethod(0, "refreshRequestMap"); if (mounted) { Navigator.of(this.context).pop(rule); } }) ]); } void onChangeType(RequestMapType? val) async { if (mapType == val) return; mapType = val!; setState(() {}); } Widget mapRule() { if (mapType == RequestMapType.script) { return DesktopMapScript(key: mapScriptKey, script: widget.item?.script); } return DesktopMapLocal(key: mapLocalKey, item: widget.item, windowId: widget.windowId); } Widget textField(String label, TextEditingController controller, String hint, {bool required = false, FormFieldSetter? onSaved}) { return Row(children: [ SizedBox(width: 60, child: Text(label)), Expanded( child: TextFormField( controller: controller, style: const TextStyle(fontSize: 14), validator: (val) => val?.isNotEmpty == true || !required ? null : "", onSaved: onSaved, decoration: InputDecoration( hintText: hint, constraints: const BoxConstraints(minHeight: 38), hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), errorStyle: const TextStyle(height: 0, fontSize: 0), focusedBorder: focusedBorder(), isDense: true, border: const OutlineInputBorder()), )) ]); } InputBorder focusedBorder() { return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); } }