diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 2575058..9993376 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -260,6 +260,7 @@ class HttpRequest extends HttpMessage { Map toJson() { return { '_class': 'HttpRequest', + '_id': requestId, 'uri': requestUrl, 'method': method.name, 'protocolVersion': protocolVersion, @@ -273,7 +274,8 @@ class HttpRequest extends HttpMessage { factory HttpRequest.fromJson(Map json) { var request = HttpRequest(HttpMethod.valueOf(json['method']), json['uri'], protocolVersion: json['protocolVersion'] ?? "HTTP/1.1"); - + + request.requestId = json['_id'] ?? request.requestId; request.headers.addAll(HttpHeaders.fromJson(json['headers'])); request.body = json['body']?.toString().codeUnits; if (json['requestTime'] != null) { diff --git a/lib/storage/favorites.dart b/lib/storage/favorites.dart index e4700b3..1ba3c26 100644 --- a/lib/storage/favorites.dart +++ b/lib/storage/favorites.dart @@ -15,10 +15,12 @@ */ import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/storage/path.dart'; +import 'package:proxypin/utils/har.dart'; /// 收藏存储 /// @author WangHongEn @@ -70,14 +72,68 @@ class FavoriteStorage { } //刷新配置 - static void flushConfig() async { + static Future flushConfig() async { var list = await favorites; - Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list))); + await Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list))); } static String toJson(Queue list) { return jsonEncode(list.map((e) => e.toJson()).toList()); } + + /// Export all favorites to a given file path + static Future exportToFile(String path) async { + var current = await favorites; + var content = toJson(current); + await File(path).writeAsString(content, flush: true); + } + + /// Export all favorites as HAR to a given file path + static Future exportToHarFile(String path, {String title = 'Favorites'}) async { + var current = await favorites; + final requests = current.map((f) => f.request).toList(growable: false); + await Har.writeFile(requests, File(path), title: title); + } + + /// Import favorites from a JSON or HAR file (merges with current list, de-duping by requestId) + static Future importFromFile(String path) async { + final file = File(path); + if (!await file.exists()) { + throw Exception('File not found'); + } + + final lower = path.toLowerCase(); + List imported; + if (lower.endsWith('.har')) { + // HAR import + final requests = await Har.readFile(file); + imported = requests.map((r) => Favorite(r)).toList(growable: false); + } else { + // JSON import (old format) + final content = await file.readAsString(); + if (content.trim().isEmpty) { + return; + } + final decoded = jsonDecode(content) as List; + imported = decoded.map((e) => Favorite.fromJson(e as Map)).toList(growable: false); + } + + final current = await favorites; + final existingIds = current.map((e) => e.request.requestId).toSet(); + + // Merge without replacing current entries; skip duplicates by requestId + for (var fav in imported.reversed) { + final rid = fav.request.requestId; + if (existingIds.contains(rid)) { + continue; + } + existingIds.add(rid); + current.addFirst(fav); + } + + await flushConfig(); + addNotifier?.call(); + } } class Favorite { diff --git a/lib/ui/component/history_cache_time.dart b/lib/ui/component/history_cache_time.dart index c41bf40..6270b63 100644 --- a/lib/ui/component/history_cache_time.dart +++ b/lib/ui/component/history_cache_time.dart @@ -24,6 +24,7 @@ class _HistoryCacheTimeState extends State { offset: const Offset(0, 35), icon: const Icon(Icons.av_timer, size: 19), initialValue: widget.configuration.historyCacheTime, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), onSelected: (val) { widget.configuration.historyCacheTime = val; widget.configuration.flushConfig(); diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index be04296..3d9779c 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -156,6 +156,7 @@ class NetworkTabState extends State with SingleTickerProvi : AppBar( title: widget.title, bottom: tabBar, + centerTitle: true, actions: [ ShareWidget( proxyServer: widget.proxyServer, request: widget.request.get(), response: widget.response.get()), diff --git a/lib/ui/desktop/left_menus/favorite.dart b/lib/ui/desktop/left_menus/favorite.dart index b7a0494..415fde9 100644 --- a/lib/ui/desktop/left_menus/favorite.dart +++ b/lib/ui/desktop/left_menus/favorite.dart @@ -20,6 +20,7 @@ import 'dart:io'; import 'package:date_format/date_format.dart'; 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'; @@ -28,6 +29,7 @@ import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/network/channel/host_port.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; +import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/storage/favorites.dart'; import 'package:proxypin/ui/component/app_dialog.dart'; import 'package:proxypin/ui/component/utils.dart'; @@ -78,12 +80,15 @@ class _FavoritesState extends State { } return ListView.separated( - itemCount: favorites.length, + itemCount: favorites.length + 1, itemBuilder: (_, index) { - var request = favorites.elementAt(index); + if (index == 0) { + return _FavoritesActions(onChanged: () => setState(() {})); + } + var request = favorites.elementAt(index - 1); return _FavoriteItem( request, - index: index, + index: index - 1, panel: widget.panel, onRemove: (Favorite favorite) { FavoriteStorage.removeFavorite(favorite); @@ -92,7 +97,8 @@ class _FavoritesState extends State { }, ); }, - separatorBuilder: (_, __) => const Divider(height: 1, thickness: 0.3), + separatorBuilder: (_, idx) => + idx == 0 ? const SizedBox(height: 4) : const Divider(height: 1, thickness: 0.3), ); } else { return const SizedBox(); @@ -287,3 +293,85 @@ class _FavoriteItemState extends State<_FavoriteItem> { widget.panel.change(request, request.response); } } + +class _FavoritesActions extends StatelessWidget { + final VoidCallback onChanged; + + const _FavoritesActions({required this.onChanged}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Text( + localizations.favorites, + style: TextStyle( + fontSize: 12.5, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82), + ), + ), + const Spacer(), + // IconButton( + // tooltip: '${localizations.export} HAR', + // padding: const EdgeInsets.symmetric(horizontal: 6), + // constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + // icon: const Icon(Icons.upload, size: 18), + // onPressed: () async { + // final path = await FilePicker.platform.saveFile(fileName: 'favorites.har'); + // if (path == null) return; + // await FavoriteStorage.exportToHarFile(path, title: localizations.favorites); + // FlutterToastr.show(localizations.exportSuccess, context); + // }, + // ), + IconButton( + tooltip: localizations.export, + padding: const EdgeInsets.symmetric(horizontal: 6), + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + icon: const Icon(Icons.upload_file, size: 18), + onPressed: () async { + final path = await FilePicker.platform.saveFile(fileName: 'favorites.json'); + if (path == null) return; + await FavoriteStorage.exportToFile(path); + if (context.mounted) CustomToast.success(localizations.exportSuccess).show(context); + onChanged(); + }, + ), + const SizedBox(width: 3), + IconButton( + tooltip: localizations.import, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + icon: const Icon(Icons.download_for_offline_outlined, size: 18), + onPressed: () async { + final result = + await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har']); + final file = result?.files.isNotEmpty == true ? result!.files.first : null; + if (file?.path == null) return; + + try { + await FavoriteStorage.importFromFile(file!.path!); + if (context.mounted) CustomToast.success(localizations.importSuccess).show(context); + onChanged(); + } catch (e) { + logger.e('Import favorites failed: $e'); + if (context.mounted) CustomToast.error('${localizations.importFailed}: $e').show(context); + } + }, + ), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.4), + ], + ); + } +} diff --git a/lib/ui/desktop/left_menus/history.dart b/lib/ui/desktop/left_menus/history.dart index f5cc5ee..f8a49a7 100644 --- a/lib/ui/desktop/left_menus/history.dart +++ b/lib/ui/desktop/left_menus/history.dart @@ -153,11 +153,25 @@ class _HistoryListState extends State<_HistoryListWidget> { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(38), + preferredSize: const Size.fromHeight(36), child: AppBar( - title: Text(localizations.historyRecord, style: const TextStyle(fontSize: 14)), + toolbarHeight: 36, + titleSpacing: 8, + centerTitle: false, + title: Text( + localizations.historyRecord, + style: TextStyle( + fontSize: 12.5, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82), + ), + ), + bottom: const PreferredSize(preferredSize: Size.fromHeight(1), child: Divider(height: 1, thickness: 0.4)), actions: [ - IconButton(onPressed: import, icon: const Icon(Icons.input, size: 18), tooltip: localizations.import), + IconButton( + onPressed: import, + icon: const Icon(Icons.input, size: 18), + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + tooltip: localizations.import), const SizedBox(width: 3), HistoryCacheTime(proxyServer.configuration, onSelected: (val) { if (val == 0) { diff --git a/lib/ui/mobile/request/favorite.dart b/lib/ui/mobile/request/favorite.dart index 648ab02..f56dda0 100644 --- a/lib/ui/mobile/request/favorite.dart +++ b/lib/ui/mobile/request/favorite.dart @@ -16,6 +16,7 @@ import 'dart:collection'; import 'dart:io'; +import 'dart:convert'; import 'package:date_format/date_format.dart'; import 'package:flutter/material.dart'; @@ -40,6 +41,7 @@ import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/utils/curl.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:file_picker/file_picker.dart'; /// 收藏列表页面 /// @author WangHongEn @@ -57,10 +59,59 @@ class MobileFavorites extends StatefulWidget { class _FavoritesState extends State { AppLocalizations get localizations => AppLocalizations.of(context)!; + Future _exportJson() async { + final favorites = await FavoriteStorage.favorites; + final json = FavoriteStorage.toJson(favorites); + final bytes = utf8.encode(json); + final path = await FilePicker.platform.saveFile(fileName: 'favorites.json', bytes: bytes); + if (path == null) return; + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + Future _materializePickedFile(PlatformFile file) async { + if (file.path != null) return file.path!; + if (file.bytes == null) return null; + final tmp = await File('${Directory.systemTemp.path}/${file.name}').create(); + await tmp.writeAsBytes(file.bytes!, flush: true); + return tmp.path; + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)), centerTitle: true), + appBar: AppBar( + title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)), + centerTitle: true, + actions: [ + IconButton( + tooltip: localizations.export, + icon: const Icon(Icons.upload_file, size: 20), + onPressed: () async { + try { + await _exportJson(); + } catch (e) { + if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context); + } + }), + IconButton( + tooltip: localizations.import, + icon: const Icon(Icons.download_for_offline_outlined, size: 20), + onPressed: () async { + final result = await FilePicker.platform + .pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har'], withData: true); + final file = result?.files.isNotEmpty == true ? result!.files.first : null; + if (file == null) return; + final path = await _materializePickedFile(file); + if (path == null) return; + try { + await FavoriteStorage.importFromFile(path); + if (context.mounted) FlutterToastr.show(localizations.importSuccess, context); + setState(() {}); + } catch (e) { + if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context); + } + }), + ]), body: FutureBuilder( future: FavoriteStorage.favorites, builder: (BuildContext context, AsyncSnapshot> snapshot) { @@ -168,7 +219,7 @@ class _FavoriteItemState extends State<_FavoriteItem> { } ///右键菜单 - menu(details) { + void menu(details) { // setState(() { // selected = true; // }); @@ -309,14 +360,14 @@ class _FavoriteItemState extends State<_FavoriteItem> { } //显示高级重发 - showCustomRepeat(HttpRequest request) { + void showCustomRepeat(HttpRequest request) { Navigator.of(context).pop(); Navigator.of(context).push(MaterialPageRoute( builder: (context) => futureWidget(SharedPreferences.getInstance(), (prefs) => MobileCustomRepeat(onRepeat: () => onRepeat(request), prefs: prefs)))); } - onRepeat(HttpRequest request) { + void onRepeat(HttpRequest request) { var httpRequest = request.copy(uri: request.requestUrl); var proxyInfo = widget.proxyServer.isRunning ? ProxyInfo.of("127.0.0.1", widget.proxyServer.port) : null; HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo); @@ -327,7 +378,7 @@ class _FavoriteItemState extends State<_FavoriteItem> { } //重命名 - rename(Favorite item) { + void rename(Favorite item) { String? name = item.name; showDialog( context: context,