add export functionality for domain HAR files (#725)

This commit is contained in:
wanghongenpin
2026-03-22 01:43:24 +08:00
parent 8ab517bb38
commit c706174894
9 changed files with 111 additions and 3 deletions

View File

@@ -168,6 +168,7 @@
"editRequest": "Edit and Request",
"reSendRequest": "The request has been resent",
"viewExport": "View Export",
"exportDomainHar": "Export This Domain HAR",
"timeDesc": "Descending by time",
"timeAsc": "Ascending by time",

View File

@@ -1068,6 +1068,12 @@ abstract class AppLocalizations {
/// **'View Export'**
String get viewExport;
/// No description provided for @exportDomainHar.
///
/// In en, this message translates to:
/// **'Export This Domain HAR'**
String get exportDomainHar;
/// No description provided for @timeDesc.
///
/// In en, this message translates to:

View File

@@ -499,6 +499,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get viewExport => 'View Export';
@override
String get exportDomainHar => 'Export This Domain HAR';
@override
String get timeDesc => 'Descending by time';

View File

@@ -498,6 +498,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get viewExport => '视图导出';
@override
String get exportDomainHar => '导出该域名 HAR';
@override
String get timeDesc => '按时间降序';
@@ -1562,6 +1565,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get viewExport => '檢視匯出';
@override
String get exportDomainHar => '匯出該網域名稱 HAR';
@override
String get timeDesc => '按時間降序';

View File

@@ -169,6 +169,7 @@
"editRequest": "编辑请求",
"reSendRequest": "已重新发送请求",
"viewExport": "视图导出",
"exportDomainHar": "导出该域名 HAR",
"timeDesc": "按时间降序",
"timeAsc": "按时间升序",

View File

@@ -161,6 +161,7 @@
"editRequest": "編輯請求",
"reSendRequest": "已重新傳送請求",
"viewExport": "檢視匯出",
"exportDomainHar": "匯出該網域名稱 HAR",
"timeDesc": "按時間降序",
"timeAsc": "按時間升序",
"search": "搜尋",

View File

@@ -78,7 +78,7 @@ class HistoryStorage {
return _histories.source;
}
addListener(ListenerListEvent<HistoryItem> listener) async {
void addListener(ListenerListEvent<HistoryItem> listener) {
_histories.addListener(listener);
}

View File

@@ -17,6 +17,7 @@
import 'dart:collection';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_desktop_context_menu/flutter_desktop_context_menu.dart';
@@ -34,7 +35,9 @@ import 'package:proxypin/ui/component/transition.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/content/panel.dart';
import 'package:proxypin/ui/desktop/request/request.dart';
import 'package:proxypin/utils/har.dart';
import 'package:proxypin/utils/keyword_highlight.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/utils/listenable_list.dart';
import '../../component/model/search_model.dart';
@@ -79,6 +82,8 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
bool sortDesc = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
void changeState() {
if (!changing) {
changing = true;
@@ -198,6 +203,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
proxyServer: widget.proxyServer,
trailing: appIcon(request),
onDelete: deleteHost,
onExportHar: exportDomainHar,
onRequestRemove: (req) {
widget.onRemove?.call([req]);
changeState();
@@ -281,8 +287,41 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
return container.expand((list) => list.body.map((it) => it.request)).toList();
}
Future<void> exportDomainHar(String domain) async {
var requests = containerMap[domain]?.body.map((it) => it.request).toList() ?? [];
if (requests.isEmpty) {
if (mounted) FlutterToastr.show(localizations.emptyData, context);
return;
}
var fileName = _domainHarFileName(domain);
var path = await FilePicker.platform.saveFile(fileName: fileName);
if (path == null) {
return;
}
try {
var file = await File(path).create(recursive: true);
await Har.writeFile(requests, file, title: fileName);
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
} catch (e) {
if (mounted) FlutterToastr.show('${localizations.exportFailed} $e', context);
}
}
String _domainHarFileName(String domain) {
var uri = Uri.tryParse(domain);
var host = (uri?.host.isNotEmpty == true) ? uri!.host : domain;
var suffix = uri?.hasPort == true ? '_${uri!.port}' : '';
var safeDomain = '$host$suffix'.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
if (safeDomain.isEmpty) {
safeDomain = 'domain';
}
return 'ProxyPin_${safeDomain}_${DateTime.now().dateFormat()}.har';
}
///排序
sort(bool desc) {
void sort(bool desc) {
sortDesc = desc;
containerMap.forEach((key, request) {
var reversed = request.body.toList().reversed;
@@ -310,10 +349,16 @@ class DomainRequests extends StatefulWidget {
//移除回调
final Function(String host)? onDelete;
final Function(String host)? onExportHar;
final Function(HttpRequest request)? onRequestRemove;
DomainRequests(this.domain,
{this.selected = false, this.onDelete, required this.proxyServer, this.onRequestRemove, this.trailing})
{this.selected = false,
this.onDelete,
this.onExportHar,
required this.proxyServer,
this.onRequestRemove,
this.trailing})
: super(key: GlobalKey<_DomainRequestsState>());
///添加请求
@@ -368,6 +413,7 @@ class DomainRequests extends StatefulWidget {
trailing: trailing,
selected: selected ?? state.currentState?.selected == true,
onDelete: onDelete,
onExportHar: onExportHar,
onRequestRemove: onRequestRemove,
proxyServer: proxyServer);
if (body != null) {
@@ -479,6 +525,8 @@ class _DomainRequestsState extends State<DomainRequests> {
submenu: hostFilterMenu(),
),
MenuItem.separator(),
MenuItem(label: localizations.exportDomainHar, onClick: (_) => exportDomainHar()),
MenuItem.separator(),
MenuItem(label: localizations.repeatDomainRequests, onClick: (_) => repeatDomainRequests()),
MenuItem.separator(),
MenuItem(label: localizations.delete, onClick: (_) => _delete()),
@@ -502,6 +550,10 @@ class _DomainRequestsState extends State<DomainRequests> {
}
}
void exportDomainHar() {
widget.onExportHar?.call(widget.domain);
}
Menu hostFilterMenu() {
return Menu(items: [
MenuItem(

View File

@@ -15,8 +15,10 @@
*/
import 'dart:collection';
import 'dart:convert';
import 'package:date_format/date_format.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:proxypin/l10n/app_localizations.dart';
@@ -29,6 +31,8 @@ import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/ui/component/widgets.dart';
import 'package:proxypin/ui/mobile/request/request_sequence.dart';
import 'package:proxypin/utils/har.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/utils/listenable_list.dart';
///域名列表
@@ -297,6 +301,12 @@ class DomainListState extends State<DomainList> with AutomaticKeepAliveClientMix
repeatDomainRequests(hostAndPort);
}),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(
text: localizations.exportDomainHar,
onPressed: () {
exportDomainHar(hostAndPort);
}),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(
text: localizations.delete,
onPressed: () {
@@ -345,4 +355,32 @@ class DomainListState extends State<DomainList> with AutomaticKeepAliveClientMix
}
}
}
Future<void> exportDomainHar(HostAndPort hostAndPort) async {
var requests = containerMap[hostAndPort] ?? [];
if (requests.isEmpty) {
if (mounted) FlutterToastr.show(localizations.emptyData, context);
return;
}
var fileName = _domainHarFileName(hostAndPort);
var json = await Har.writeJson(requests, title: fileName);
var bytes = utf8.encode(json);
var path = await FilePicker.platform.saveFile(fileName: fileName, bytes: bytes);
if (path == null) {
return;
}
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
}
String _domainHarFileName(HostAndPort hostAndPort) {
var suffix = (hostAndPort.port == 80 || hostAndPort.port == 443) ? '' : '_${hostAndPort.port}';
var safeDomain = '${hostAndPort.host}$suffix'.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
if (safeDomain.isEmpty) {
safeDomain = 'domain';
}
return 'ProxyPin_${safeDomain}_${DateTime.now().dateFormat()}.har';
}
}