From aa49bb6226ca8b787bd1c3155c8b422dd14ff634 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 25 Oct 2025 01:49:22 +0800 Subject: [PATCH] Desktop support report server --- lib/network/bin/server.dart | 2 + lib/network/components/interceptor.dart | 4 + .../manager/report_server_manager.dart | 164 +++++++++ .../components/report_server_interceptor.dart | 113 ++++++ lib/network/handle/http_proxy_handle.dart | 11 + lib/ui/desktop/request/list.dart | 20 +- lib/ui/desktop/request/report_servers.dart | 333 ++++++++++++++++++ lib/ui/desktop/setting/hosts.dart | 4 +- lib/ui/desktop/setting/request_block.dart | 8 +- lib/ui/desktop/toolbar/toolbar.dart | 4 +- 10 files changed, 653 insertions(+), 10 deletions(-) create mode 100644 lib/network/components/manager/report_server_manager.dart create mode 100644 lib/network/components/report_server_interceptor.dart create mode 100644 lib/ui/desktop/request/report_servers.dart diff --git a/lib/network/bin/server.dart b/lib/network/bin/server.dart index 53518aa..004aced 100644 --- a/lib/network/bin/server.dart +++ b/lib/network/bin/server.dart @@ -20,6 +20,7 @@ import 'dart:io'; import 'package:proxypin/network/bin/configuration.dart'; import 'package:proxypin/network/components/hosts.dart'; import 'package:proxypin/network/components/interceptor.dart'; +import 'package:proxypin/network/components/report_server_interceptor.dart'; import 'package:proxypin/network/components/request_block.dart'; import 'package:proxypin/network/components/request_rewrite.dart'; import 'package:proxypin/network/components/script.dart'; @@ -85,6 +86,7 @@ class ProxyServer { RequestRewriteInterceptor.instance, ScriptInterceptor(), RequestBlockInterceptor(), + ReportServerInterceptor() ]; interceptors.sort((a, b) => a.priority.compareTo(b.priority)); diff --git a/lib/network/components/interceptor.dart b/lib/network/components/interceptor.dart index 53b112b..672a842 100644 --- a/lib/network/components/interceptor.dart +++ b/lib/network/components/interceptor.dart @@ -25,4 +25,8 @@ abstract class Interceptor { Future onResponse(HttpRequest request, HttpResponse response) async { return response; } + + Future onError(HttpRequest? request, dynamic error, StackTrace? stackTrace) async { + return; + } } diff --git a/lib/network/components/manager/report_server_manager.dart b/lib/network/components/manager/report_server_manager.dart new file mode 100644 index 0000000..d927c41 --- /dev/null +++ b/lib/network/components/manager/report_server_manager.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +import '../../../storage/path.dart'; +import '../../util/logger.dart'; + +class ReportServerManager { + static ReportServerManager? _instance; + + List _list = []; + + ///单例 + static Future get instance async { + if (_instance == null) { + _instance = ReportServerManager._internal(); + await _instance!.loadConfig(); + } + return _instance!; + } + + // Private constructor + ReportServerManager._internal(); + + /// Get configured report servers + List get servers => _list; + + Future matchServer(String url) async { + final list = servers; + for (var server in list) { + if (server.match(url)) { + return server; + } + } + return null; + } + + Future add(ReportServer server) async { + _list.add(server); + await _flush(); + } + + Future removeAt(int index) async { + final list = servers; + list.removeAt(index); + await _flush(); + } + + Future update(int index, ReportServer server) async { + final list = servers; + server.updateUrlReg(); + list[index] = server; + await _flush(); + } + + Future toggleEnabled(int index, bool enabled) async { + final list = servers; + list[index] = list[index].copyWith(enabled: enabled); + await _flush(); + } + + Future loadConfig() async { + var list = []; + final file = await Paths.getPath("report_servers.json"); + if (await file.exists()) { + final content = await file.readAsString(); + if (content.trim().isNotEmpty) { + try { + final decoded = jsonDecode(content) as List; + list = decoded.map((e) => ReportServer.fromJson(e as Map)).toList(); + } catch (e, t) { + logger.e('上报服务器配置解析失败', error: e, stackTrace: t); + } + } + } + + _list = list; + } + + Future _flush() async { + final file = await Paths.getPath("report_servers.json"); + final list = servers; + await file.writeAsString(jsonEncode(list.map((e) => e.toJson()).toList())); + } +} + +class ReportServer { + final String name; + + final String matchUrl; + + /// 服务器URL + final String serverUrl; + + /// 是否启用 + final bool enabled; + + /// 压缩方式:none/gzip,默认 none + final String? compression; + + /// 额外请求头(可选) + final Map? headers; + + RegExp _urlReg; + + ReportServer({ + required this.name, + required this.matchUrl, + required this.serverUrl, + this.enabled = true, + this.compression, + this.headers, + }) : _urlReg = RegExp(matchUrl.replaceAll("*", ".*").replaceFirst('?', '\\?')); + + bool match(String url) { + if (enabled) { + return _urlReg.hasMatch(url); + } + return false; + } + + void updateUrlReg() { + _urlReg = RegExp(matchUrl.replaceAll("*", ".*").replaceFirst('?', '\\?')); + } + + ReportServer copyWith({ + String? name, + String? serverUrl, + bool? enabled, + String? matchUrl, + String? matchType, + String? compression, + Map? headers, + }) { + return ReportServer( + name: name ?? this.name, + matchUrl: matchUrl ?? this.matchUrl, + serverUrl: serverUrl ?? this.serverUrl, + enabled: enabled ?? this.enabled, + compression: compression ?? this.compression, + headers: headers ?? this.headers, + ); + } + + factory ReportServer.fromJson(Map json) { + final headers = json['headers']; + return ReportServer( + name: json['name'] ?? '', + matchUrl: json['matchUrl'] ?? '', + serverUrl: json['serverUrl'] ?? '', + enabled: json['enabled'] ?? true, + compression: (json['compression'] ?? 'none') as String, + headers: headers == null ? null : Map.from(headers as Map), + ); + } + + Map toJson() { + return { + 'name': name, + 'matchUrl': matchUrl, + 'serverUrl': serverUrl, + 'enabled': enabled, + 'compression': compression, + }; + } +} diff --git a/lib/network/components/report_server_interceptor.dart b/lib/network/components/report_server_interceptor.dart new file mode 100644 index 0000000..dddef33 --- /dev/null +++ b/lib/network/components/report_server_interceptor.dart @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Hongen Wang All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:proxypin/network/util/compress.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/utils/har.dart'; + +import '../http/http.dart'; +import 'interceptor.dart'; +import 'manager/report_server_manager.dart'; + +/// Hosts interceptor +/// @author wanghongen +class ReportServerInterceptor extends Interceptor { + Future get reportServerManager async => await ReportServerManager.instance; + + static HttpClient httpClient = HttpClient(); + + @override + int get priority => 1000; + + @override + Future onResponse(HttpRequest request, HttpResponse response) async { + // Fire-and-forget reporting; don't block the proxy pipeline + unawaited(reportServer(request, response)); + return response; + } + + @override + Future onError(HttpRequest? request, error, StackTrace? stackTrace) async { + if (request != null) { + unawaited(reportServer(request, null, error: error, stackTrace: stackTrace)); + } + return; + } + + Future reportServer(HttpRequest request, HttpResponse? response, + {dynamic error, StackTrace? stackTrace}) async { + String requestUrl = request.requestUrl; + var manager = await reportServerManager; + var server = await manager.matchServer(requestUrl); + if (server == null) { + return; + } + + try { + logger.i("reportServer start: $requestUrl -> ${server.name} (${server.serverUrl})"); + + // Prepare server URL (ensure scheme) + var serverUrl = (server.serverUrl).trim(); + if (serverUrl.isEmpty) { + logger.w('reportServer skipped: serverUrl empty for ${server.name}'); + return; + } + if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) { + serverUrl = 'http://$serverUrl'; + } + + final uri = Uri.parse(serverUrl); + + var payload = Har.toHar(request); + + List body = utf8.encode(jsonEncode(payload)); + // Apply compression if configured + final compression = server.compression?.toLowerCase(); + if (compression == 'gzip') { + try { + body = gzipEncode(body); + } catch (e) { + logger.w('reportServer gzip compress failed: $e'); + } + } + + // Send POST + final ioReq = await httpClient.postUrl(uri).timeout(const Duration(seconds: 5)); + + // Set headers + ioReq.headers.set(HttpHeaders.contentTypeHeader, 'application/json; charset=utf-8'); + if (compression == 'gzip') { + ioReq.headers.set(HttpHeaders.contentEncodingHeader, 'gzip'); + } + + // Write body and close + ioReq.add(body); + final ioResp = await ioReq.close().timeout(const Duration(seconds: 30)); + final respText = await ioResp.transform(utf8.decoder).join(); + if (ioResp.statusCode >= 200 && ioResp.statusCode < 300) { + logger.i('reportServer delivered to ${server.name} (${uri.toString()}), status=${ioResp.statusCode}'); + } else { + logger.w('reportServer delivery to ${server.name} failed, status=${ioResp.statusCode}, body=$respText'); + } + } catch (e, st) { + logger.e("reportServer error $requestUrl", error: e, stackTrace: st); + } + } +} diff --git a/lib/network/handle/http_proxy_handle.dart b/lib/network/handle/http_proxy_handle.dart index 0164916..a659f20 100644 --- a/lib/network/handle/http_proxy_handle.dart +++ b/lib/network/handle/http_proxy_handle.dart @@ -50,6 +50,9 @@ class HttpProxyChannelHandler extends ChannelHandler { void exceptionCaught(ChannelContext channelContext, Channel channel, error, {StackTrace? trace}) { super.exceptionCaught(channelContext, channel, error, trace: trace); ProxyHelper.exceptionHandler(channelContext, channel, listener, channelContext.currentRequest, error); + for (var interceptor in interceptors) { + interceptor.onError(channelContext.currentRequest, error, trace); + } } @override @@ -277,4 +280,12 @@ class HttpResponseProxyHandler extends ChannelHandler { void channelInactive(ChannelContext channelContext, Channel channel) { clientChannel.close(); } + + @override + void exceptionCaught(ChannelContext channelContext, Channel channel, error, {StackTrace? trace}) { + super.exceptionCaught(channelContext, channel, error, trace: trace); + for (var interceptor in interceptors) { + interceptor.onError(channelContext.currentRequest, error, trace); + } + } } diff --git a/lib/ui/desktop/request/list.dart b/lib/ui/desktop/request/list.dart index 888b6b6..f746b5e 100644 --- a/lib/ui/desktop/request/list.dart +++ b/lib/ui/desktop/request/list.dart @@ -35,6 +35,7 @@ import 'package:proxypin/utils/listenable_list.dart'; import '../../component/model/search_model.dart'; import 'domians.dart'; +import 'package:proxypin/ui/desktop/request/report_servers.dart'; /// @author wanghongen class DesktopRequestListWidget extends StatefulWidget { @@ -121,28 +122,28 @@ class DesktopRequestListState extends State with Autom itemBuilder: (BuildContext context) { return [ CustomPopupMenuItem( - height: 35, - onTap: () => searchKey.currentState?.searchDialog(), + height: 37, + onTap: () => searchKey.currentState?.searchDialog(), child: IconText( icon: const Icon(Icons.search, size: 17), text: localizations.search, textStyle: const TextStyle(fontSize: 13))), CustomPopupMenuItem( - height: 35, + height: 37, onTap: () => export('ProxyPin_${DateTime.now().dateFormat()}.har'), child: IconText( icon: const Icon(Icons.share, size: 16), text: localizations.viewExport, textStyle: const TextStyle(fontSize: 13))), CustomPopupMenuItem( - height: 35, + height: 37, onTap: () => repeatAllRequests(), child: IconText( icon: const Icon(Icons.repeat, size: 16), text: localizations.repeatAllRequests, textStyle: const TextStyle(fontSize: 13))), CustomPopupMenuItem( - height: 35, + height: 37, onTap: () { sortDesc = !sortDesc; requestSequenceKey.currentState?.sort(sortDesc); @@ -152,6 +153,15 @@ class DesktopRequestListState extends State with Autom icon: const Icon(Icons.sort, size: 16), text: sortDesc ? localizations.timeAsc : localizations.timeDesc, textStyle: const TextStyle(fontSize: 13))), + CustomPopupMenuItem( + height: 37, + onTap: () { + showReportServersDialog(context); + }, + child: IconText( + icon: Icon(Icons.cloud_upload_outlined, size: 16), + text: localizations.reportServers, + textStyle: TextStyle(fontSize: 13))), ]; }); } diff --git a/lib/ui/desktop/request/report_servers.dart b/lib/ui/desktop/request/report_servers.dart new file mode 100644 index 0000000..e84e5fc --- /dev/null +++ b/lib/ui/desktop/request/report_servers.dart @@ -0,0 +1,333 @@ +/* + * 上报服务器配置页面 + */ +import 'package:flutter/material.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/network/components/manager/report_server_manager.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +import '../../../l10n/app_localizations.dart'; + +// 以弹框的方式展示上报服务器管理 +Future showReportServersDialog(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: SizedBox( + width: 570, + height: 560, + child: const ReportServersPage(), + ), + ), + ); +} + +class ReportServersPage extends StatefulWidget { + const ReportServersPage({super.key}); + + @override + State createState() => _ReportServersPageState(); +} + +class _ReportServersPageState extends State { + List _servers = []; + bool _loading = true; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final manager = await ReportServerManager.instance; + final list = manager.servers; + setState(() { + _servers = List.of(list); + _loading = false; + }); + } + + InputBorder focusedBorder() { + return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); + } + + // 统一的新增/编辑弹窗 + Future _showServerDialog({ReportServer? initial}) async { + final nameCtrl = TextEditingController(text: initial?.name ?? ''); + final matchUrlCtrl = TextEditingController(text: initial?.matchUrl ?? ''); + final serverUrlCtrl = TextEditingController(text: initial?.serverUrl ?? ''); + String compression = initial?.compression ?? 'none'; + bool enabled = initial?.enabled ?? true; + + // 紧凑的 Outline 输入框装饰 + InputDecoration dec({String? hint}) => InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()); + + Widget labeled(String label, Widget field, {bool expanded = true}) => Row( + children: [ + SizedBox(width: 85, child: Text(label)), + const SizedBox(width: 12), + expanded ? Expanded(child: field) : field, + ], + ); + + final formKey = GlobalKey(); + + final result = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(initial == null ? localizations.addReportServer : localizations.editReportServer, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + content: Form( + key: formKey, + child: SizedBox( + width: 460, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + labeled( + '${localizations.name}: ', + TextField(controller: nameCtrl, decoration: dec(hint: localizations.pleaseEnter)), + ), + const SizedBox(height: 12), + labeled( + '${localizations.match} URL: ', + TextFormField( + controller: matchUrlCtrl, + keyboardType: TextInputType.url, + validator: (val) => val?.isNotEmpty == true ? null : "", + decoration: dec(hint: 'https://example.com/api/*')), + ), + const SizedBox(height: 12), + labeled( + '${localizations.serverUrl}: ', + TextFormField( + controller: serverUrlCtrl, + keyboardType: TextInputType.url, + validator: (val) => val?.isNotEmpty == true ? null : "", + decoration: dec(hint: 'http://example.com/report')), + ), + const SizedBox(height: 12), + labeled( + '${localizations.compression}: ', + expanded: false, + SizedBox( + width: 100, + child: DropdownButtonFormField( + initialValue: compression, + decoration: dec(), + isDense: true, + items: [ + DropdownMenuItem(value: 'none', child: Text(localizations.compressionNone)), + DropdownMenuItem(value: 'gzip', child: Text("GZIP")), + ], + onChanged: (v) => compression = v ?? 'none', + ), + )), + const SizedBox(height: 12), + labeled( + '${localizations.enable}: ', + Align( + alignment: Alignment.centerLeft, + child: SwitchWidget(value: enabled, scale: 0.83, onChanged: (v) => enabled = v), + ), + ), + ], + ), + ), + )), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, null), + child: Text(localizations.cancel), + ), + FilledButton( + onPressed: () { + if (!(formKey.currentState as FormState).validate()) { + FlutterToastr.show("${localizations.serverUrl} ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + return; + } + + final matchUrl = matchUrlCtrl.text.trim(); + var serverUrl = serverUrlCtrl.text.trim(); + // 修复此前的前缀判断逻辑:仅当不以 http/https 开头时补全 + if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) { + serverUrl = 'http://$serverUrl'; + } + + final server = ReportServer( + name: nameCtrl.text.trim(), + matchUrl: matchUrl, + serverUrl: serverUrl, + enabled: enabled, + compression: compression, + ); + Navigator.pop(ctx, server); + }, + child: Text(localizations.save), + ), + ], + ); + }, + ); + + return result; + } + + Future _addServerDialog() async { + final server = await _showServerDialog(); + if (server != null) { + final manager = await ReportServerManager.instance; + await manager.add(server); + await _load(); + } + } + + Future _editServerDialog(int index) async { + final initial = _servers[index]; + final server = await _showServerDialog(initial: initial); + if (server != null) { + final manager = await ReportServerManager.instance; + await manager.update(index, server); + setState(() => _servers[index] = server); + } + } + + Future _confirmDelete(int index) async { + showConfirmDialog(context, onConfirm: () async { + final manager = await ReportServerManager.instance; + await manager.removeAt(index); + + await _load(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(localizations.reportServers), + centerTitle: true, + actions: [ + TextButton.icon( + label: Text(localizations.newBuilt), + onPressed: _addServerDialog, + icon: const Icon(Icons.add), + ), + const SizedBox(width: 12), + IconButton( + tooltip: localizations.close, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.close, size: 22), + ), + const SizedBox(width: 6), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _servers.isEmpty + ? Center(child: Text(localizations.emptyData)) + : Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowHeight: 38, + dataRowMinHeight: 40, + dataRowMaxHeight: 48, + horizontalMargin: 12, + showBottomBorder: true, + dividerThickness: 0.26, + columnSpacing: 8, + columns: [ + DataColumn(label: Center(child: Text(localizations.name))), + DataColumn(label: Center(child: Text(localizations.enable))), + DataColumn(label: Center(child: Text('${localizations.match} URL'))), + DataColumn(label: Center(child: Text(localizations.serverUrl))), + DataColumn(label: Center(child: Text(localizations.action))), + ], + rows: [ + for (final entry in _servers.asMap().entries) + DataRow(cells: [ + DataCell( + SizedBox( + width: 65, + child: Text( + entry.value.name.isEmpty ? '-' : entry.value.name, + maxLines: 1, + overflow: TextOverflow.fade, + )), + onTap: () => _editServerDialog(entry.key)), + DataCell(Center( + child: SizedBox( + width: 45, + child: SwitchWidget( + value: entry.value.enabled, + scale: 0.73, + onChanged: (v) async { + final manager = await ReportServerManager.instance; + await manager.toggleEnabled(entry.key, v); + setState(() => _servers[entry.key] = entry.value.copyWith(enabled: v)); + }, + )))), + DataCell( + SizedBox( + width: 155, + child: Tooltip( + message: entry.value.matchUrl, + child: Text(entry.value.matchUrl, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ), + onTap: () => _editServerDialog(entry.key)), + DataCell( + SizedBox( + width: 155, + child: Tooltip( + message: entry.value.serverUrl, + child: Text(entry.value.serverUrl, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ), + onTap: () => _editServerDialog(entry.key)), + DataCell(Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: localizations.edit, + onPressed: () => _editServerDialog(entry.key), + icon: const Icon(Icons.edit_outlined, size: 18), + ), + IconButton( + tooltip: localizations.delete, + onPressed: () => _confirmDelete(entry.key), + icon: const Icon(Icons.delete_outline, size: 18), + ), + ], + ), + )), + ]) + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/desktop/setting/hosts.dart b/lib/ui/desktop/setting/hosts.dart index bc81b8a..a8f9bca 100644 --- a/lib/ui/desktop/setting/hosts.dart +++ b/lib/ui/desktop/setting/hosts.dart @@ -537,12 +537,13 @@ class _HostsEditDialogState extends State { controller: hostController, validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, decoration: const InputDecoration( + isDense: true, hintText: '*.example.com', hintStyle: TextStyle(color: Colors.grey), errorStyle: TextStyle(height: 0, fontSize: 0), border: OutlineInputBorder()))), ]), - const SizedBox(height: 10), + const SizedBox(height: 15), Row(children: [ SizedBox(width: 80, child: Text(localizations.toAddress)), Expanded( @@ -550,6 +551,7 @@ class _HostsEditDialogState extends State { controller: toAddressController, validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, decoration: const InputDecoration( + isDense: true, hintText: '202.108.22.5', errorStyle: TextStyle(height: 0, fontSize: 0), hintStyle: TextStyle(color: Colors.grey), diff --git a/lib/ui/desktop/setting/request_block.dart b/lib/ui/desktop/setting/request_block.dart index e69860c..77f3a8b 100644 --- a/lib/ui/desktop/setting/request_block.dart +++ b/lib/ui/desktop/setting/request_block.dart @@ -204,13 +204,17 @@ class RequestBlockAddDialog extends StatelessWidget { TextFormField( initialValue: item.url, decoration: const InputDecoration( - labelText: 'URL', hintText: 'https://example.com/*', border: OutlineInputBorder()), + isDense: true, + labelText: 'URL', + hintText: 'https://example.com/*', + border: OutlineInputBorder()), validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, onSaved: (val) => item.url = val!.trim()), const SizedBox(height: 20), DropdownButtonFormField( value: item.type, - decoration: InputDecoration(labelText: localizations.type, border: const OutlineInputBorder()), + decoration: InputDecoration( + isDense: true, labelText: localizations.type, border: const OutlineInputBorder()), items: BlockType.values .map((e) => DropdownMenuItem( value: e, child: Text(isCN ? e.label : e.name, style: const TextStyle(fontSize: 14)))) diff --git a/lib/ui/desktop/toolbar/toolbar.dart b/lib/ui/desktop/toolbar/toolbar.dart index 61de7b3..a1a5a8e 100644 --- a/lib/ui/desktop/toolbar/toolbar.dart +++ b/lib/ui/desktop/toolbar/toolbar.dart @@ -86,7 +86,7 @@ class _ToolbarState extends State { const Padding(padding: EdgeInsets.only(left: 18)), IconButton( tooltip: localizations.clear, - icon: const Icon(Icons.cleaning_services_outlined, size: 21), + icon: const Icon(Icons.delete_outline, size: 21), onPressed: () { widget.requestListStateKey.currentState?.clean(); }), @@ -97,7 +97,7 @@ class _ToolbarState extends State { const Padding(padding: EdgeInsets.only(left: 18)), IconButton( tooltip: localizations.mobileConnect, - icon: const Icon(Icons.phone_iphone, size: 21), + icon: const Icon(Icons.phone_iphone_outlined, size: 21), onPressed: () async { final ips = await localIps(readCache: false); phoneConnect(ips, widget.proxyServer.port);