mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-14 15:48:03 +08:00
Desktop support report server
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -25,4 +25,8 @@ abstract class Interceptor {
|
||||
Future<HttpResponse?> onResponse(HttpRequest request, HttpResponse response) async {
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<void> onError(HttpRequest? request, dynamic error, StackTrace? stackTrace) async {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
164
lib/network/components/manager/report_server_manager.dart
Normal file
164
lib/network/components/manager/report_server_manager.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../storage/path.dart';
|
||||
import '../../util/logger.dart';
|
||||
|
||||
class ReportServerManager {
|
||||
static ReportServerManager? _instance;
|
||||
|
||||
List<ReportServer> _list = [];
|
||||
|
||||
///单例
|
||||
static Future<ReportServerManager> get instance async {
|
||||
if (_instance == null) {
|
||||
_instance = ReportServerManager._internal();
|
||||
await _instance!.loadConfig();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
// Private constructor
|
||||
ReportServerManager._internal();
|
||||
|
||||
/// Get configured report servers
|
||||
List<ReportServer> get servers => _list;
|
||||
|
||||
Future<ReportServer?> matchServer(String url) async {
|
||||
final list = servers;
|
||||
for (var server in list) {
|
||||
if (server.match(url)) {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> add(ReportServer server) async {
|
||||
_list.add(server);
|
||||
await _flush();
|
||||
}
|
||||
|
||||
Future<void> removeAt(int index) async {
|
||||
final list = servers;
|
||||
list.removeAt(index);
|
||||
await _flush();
|
||||
}
|
||||
|
||||
Future<void> update(int index, ReportServer server) async {
|
||||
final list = servers;
|
||||
server.updateUrlReg();
|
||||
list[index] = server;
|
||||
await _flush();
|
||||
}
|
||||
|
||||
Future<void> toggleEnabled(int index, bool enabled) async {
|
||||
final list = servers;
|
||||
list[index] = list[index].copyWith(enabled: enabled);
|
||||
await _flush();
|
||||
}
|
||||
|
||||
Future<void> loadConfig() async {
|
||||
var list = <ReportServer>[];
|
||||
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<dynamic>;
|
||||
list = decoded.map((e) => ReportServer.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} catch (e, t) {
|
||||
logger.e('上报服务器配置解析失败', error: e, stackTrace: t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_list = list;
|
||||
}
|
||||
|
||||
Future<void> _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<String, String>? 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<String, String>? 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<String, dynamic> 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<String, String>.from(headers as Map),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'matchUrl': matchUrl,
|
||||
'serverUrl': serverUrl,
|
||||
'enabled': enabled,
|
||||
'compression': compression,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
lib/network/components/report_server_interceptor.dart
Normal file
113
lib/network/components/report_server_interceptor.dart
Normal file
@@ -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<ReportServerManager> get reportServerManager async => await ReportServerManager.instance;
|
||||
|
||||
static HttpClient httpClient = HttpClient();
|
||||
|
||||
@override
|
||||
int get priority => 1000;
|
||||
|
||||
@override
|
||||
Future<HttpResponse?> onResponse(HttpRequest request, HttpResponse response) async {
|
||||
// Fire-and-forget reporting; don't block the proxy pipeline
|
||||
unawaited(reportServer(request, response));
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(HttpRequest? request, error, StackTrace? stackTrace) async {
|
||||
if (request != null) {
|
||||
unawaited(reportServer(request, null, error: error, stackTrace: stackTrace));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> 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<int> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@ class HttpProxyChannelHandler extends ChannelHandler<HttpRequest> {
|
||||
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<HttpResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DesktopRequestListWidget> with Autom
|
||||
itemBuilder: (BuildContext context) {
|
||||
return <PopupMenuEntry>[
|
||||
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<DesktopRequestListWidget> 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))),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
333
lib/ui/desktop/request/report_servers.dart
Normal file
333
lib/ui/desktop/request/report_servers.dart
Normal file
@@ -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<void> 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<ReportServersPage> createState() => _ReportServersPageState();
|
||||
}
|
||||
|
||||
class _ReportServersPageState extends State<ReportServersPage> {
|
||||
List<ReportServer> _servers = [];
|
||||
bool _loading = true;
|
||||
|
||||
AppLocalizations get localizations => AppLocalizations.of(context)!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<ReportServer?> _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<FormState>();
|
||||
|
||||
final result = await showDialog<ReportServer>(
|
||||
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<String>(
|
||||
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<void> _addServerDialog() async {
|
||||
final server = await _showServerDialog();
|
||||
if (server != null) {
|
||||
final manager = await ReportServerManager.instance;
|
||||
await manager.add(server);
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
])
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -537,12 +537,13 @@ class _HostsEditDialogState extends State<HostsEditDialog> {
|
||||
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<HostsEditDialog> {
|
||||
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),
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -86,7 +86,7 @@ class _ToolbarState extends State<Toolbar> {
|
||||
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<Toolbar> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user