正式上线"自定义线路"功能(仅限付费用户组)
This commit is contained in:
@@ -140,6 +140,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Android release APK
|
- name: Build Android release APK
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -151,8 +153,13 @@ jobs:
|
|||||||
rm -rf "$pkg_dir"
|
rm -rf "$pkg_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build_args=(--release)
|
||||||
|
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
|
||||||
|
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Building Flutter Android release APK..."
|
echo "Building Flutter Android release APK..."
|
||||||
flutter build apk -v --release
|
flutter build apk "${build_args[@]}"
|
||||||
|
|
||||||
mkdir -p "$pkg_dir"
|
mkdir -p "$pkg_dir"
|
||||||
apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)"
|
apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)"
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import 'dart:math';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../services/api_service.dart';
|
||||||
|
import '../../../services/storage_service.dart';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/desktop_constrained.dart';
|
import '../../widgets/desktop_constrained.dart';
|
||||||
import '../../widgets/toast_helper.dart';
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
@@ -21,21 +25,29 @@ class NetworkTestPage extends StatefulWidget {
|
|||||||
class _NetworkTestPageState extends State<NetworkTestPage> {
|
class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||||
static final _panUri = Uri.parse('https://pan.gongyun.org');
|
static final _panUri = Uri.parse('https://pan.gongyun.org');
|
||||||
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
|
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
|
||||||
|
static final _telegramUri = Uri.parse('https://t.me/GYNetwork');
|
||||||
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
|
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
|
||||||
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
|
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
|
||||||
|
static const _ipNodeApiBaseUrlOverride = String.fromEnvironment(
|
||||||
|
'IP_NODE_API_BASE_URL',
|
||||||
|
);
|
||||||
static const _probeHeaders = {'Cache-Control': 'no-cache'};
|
static const _probeHeaders = {'Cache-Control': 'no-cache'};
|
||||||
static const _probeGetFallbackHeaders = {
|
static const _probeGetFallbackHeaders = {
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'Range': 'bytes=0-0',
|
'Range': 'bytes=0-0',
|
||||||
};
|
};
|
||||||
|
static const _customProbeSampleCount = 3;
|
||||||
|
|
||||||
_NetworkTestTab _tab = _NetworkTestTab.network;
|
_NetworkTestTab _tab = _NetworkTestTab.network;
|
||||||
bool _networkLoading = false;
|
bool _networkLoading = false;
|
||||||
bool _serviceLoading = false;
|
bool _serviceLoading = false;
|
||||||
|
bool _customLoading = false;
|
||||||
_NetworkDiagnostics? _networkDiagnostics;
|
_NetworkDiagnostics? _networkDiagnostics;
|
||||||
_ServiceDiagnostics? _serviceDiagnostics;
|
_ServiceDiagnostics? _serviceDiagnostics;
|
||||||
|
_CustomLineDiagnostics? _customDiagnostics;
|
||||||
String? _networkError;
|
String? _networkError;
|
||||||
String? _serviceError;
|
String? _serviceError;
|
||||||
|
String? _customError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -82,24 +94,8 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_tab},
|
selected: {_tab},
|
||||||
onSelectionChanged: (selection) {
|
onSelectionChanged: (selection) =>
|
||||||
final selected = selection.first;
|
_handleTabSelection(selection.first),
|
||||||
if (selected == _NetworkTestTab.custom) {
|
|
||||||
_showCustomUnavailableDialog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _tab = selected);
|
|
||||||
if (selected == _NetworkTestTab.network &&
|
|
||||||
_networkDiagnostics == null &&
|
|
||||||
!_networkLoading) {
|
|
||||||
_runNetworkTest();
|
|
||||||
}
|
|
||||||
if (selected == _NetworkTestTab.service &&
|
|
||||||
_serviceDiagnostics == null &&
|
|
||||||
!_serviceLoading) {
|
|
||||||
_runServiceTest();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -121,7 +117,14 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
diagnostics: _serviceDiagnostics,
|
diagnostics: _serviceDiagnostics,
|
||||||
onRefresh: _runServiceTest,
|
onRefresh: _runServiceTest,
|
||||||
),
|
),
|
||||||
_NetworkTestTab.custom => const SizedBox.shrink(),
|
_NetworkTestTab.custom => _CustomLinePanel(
|
||||||
|
key: const ValueKey('custom'),
|
||||||
|
loading: _customLoading,
|
||||||
|
error: _customError,
|
||||||
|
diagnostics: _customDiagnostics,
|
||||||
|
onRefresh: _runCustomLineTest,
|
||||||
|
onOpenTelegram: () => _openExternalUrl(_telegramUri),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -131,12 +134,54 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showCustomUnavailableDialog() async {
|
void _handleTabSelection(_NetworkTestTab selected) {
|
||||||
|
if (selected == _NetworkTestTab.custom) {
|
||||||
|
unawaited(_openCustomLineTab());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _tab = selected);
|
||||||
|
if (selected == _NetworkTestTab.network &&
|
||||||
|
_networkDiagnostics == null &&
|
||||||
|
!_networkLoading) {
|
||||||
|
_runNetworkTest();
|
||||||
|
}
|
||||||
|
if (selected == _NetworkTestTab.service &&
|
||||||
|
_serviceDiagnostics == null &&
|
||||||
|
!_serviceLoading) {
|
||||||
|
_runServiceTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCustomLineTab() async {
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final groupName = auth.user?.group?.name.trim();
|
||||||
|
if (!auth.isAuthenticated || auth.user == null) {
|
||||||
|
await _showCustomBlockedDialog('自定义线路需要登录后使用,请先登录后再试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (groupName == null || groupName.isEmpty || _isUserGroup(groupName)) {
|
||||||
|
await _showCustomBlockedDialog(
|
||||||
|
'自定义线路为会员功能,您所在用户组为普通用户组请成为非普通用户方可使用"自定义线路"功能。',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _tab = _NetworkTestTab.custom);
|
||||||
|
if (_customDiagnostics == null && !_customLoading) {
|
||||||
|
await _runCustomLineTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isUserGroup(String groupName) =>
|
||||||
|
groupName.trim().toLowerCase() == 'user';
|
||||||
|
|
||||||
|
Future<void> _showCustomBlockedDialog(String message) async {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('自定义线路'),
|
title: const Text('自定义线路'),
|
||||||
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
|
content: Text(message),
|
||||||
actions: [
|
actions: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
@@ -158,6 +203,191 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _runCustomLineTest() async {
|
||||||
|
if (_customLoading) return;
|
||||||
|
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final groupName = auth.user?.group?.name.trim();
|
||||||
|
if (!auth.isAuthenticated ||
|
||||||
|
auth.user == null ||
|
||||||
|
groupName == null ||
|
||||||
|
groupName.isEmpty ||
|
||||||
|
_isUserGroup(groupName)) {
|
||||||
|
setState(() {
|
||||||
|
_customError = '当前用户组无法使用自定义线路功能';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_customLoading = true;
|
||||||
|
_customError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||||
|
final nodes = await _fetchCustomNodes(
|
||||||
|
clientId: clientId,
|
||||||
|
groupName: groupName,
|
||||||
|
);
|
||||||
|
final probes = await Future.wait(nodes.map(_testCustomNode));
|
||||||
|
final recommended = _bestCustomNode(probes);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_customDiagnostics = _CustomLineDiagnostics(
|
||||||
|
groupName: groupName,
|
||||||
|
nodes: probes,
|
||||||
|
recommended: recommended,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _customError = e.toString());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _customLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_CustomNode>> _fetchCustomNodes({
|
||||||
|
required String clientId,
|
||||||
|
required String groupName,
|
||||||
|
}) async {
|
||||||
|
final handshakeResponse = await http
|
||||||
|
.post(
|
||||||
|
_ipNodeApiUri('/api/v1/handshake'),
|
||||||
|
headers: const {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'client_uuid': clientId}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (handshakeResponse.statusCode < 200 ||
|
||||||
|
handshakeResponse.statusCode >= 300) {
|
||||||
|
throw Exception('IP节点服务握手失败: HTTP ${handshakeResponse.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final handshakePayload = _payloadObject(
|
||||||
|
_decodeObject(handshakeResponse.body),
|
||||||
|
);
|
||||||
|
final sessionToken = handshakePayload['session_token']?.toString();
|
||||||
|
if (sessionToken == null || sessionToken.isEmpty) {
|
||||||
|
throw Exception('IP节点服务未返回有效会话密钥');
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodesResponse = await http
|
||||||
|
.get(
|
||||||
|
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Client-UUID': clientId,
|
||||||
|
'X-Session-Token': sessionToken,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
if (nodesResponse.statusCode < 200 || nodesResponse.statusCode >= 300) {
|
||||||
|
throw Exception('IP节点池获取失败: HTTP ${nodesResponse.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodesPayload = _payloadObject(_decodeObject(nodesResponse.body));
|
||||||
|
final rawNodes = nodesPayload['nodes'];
|
||||||
|
if (rawNodes is! List) return const [];
|
||||||
|
|
||||||
|
return rawNodes
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((node) => _CustomNode.fromJson(Map<String, dynamic>.from(node)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeObject(String body) {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) return decoded;
|
||||||
|
if (decoded is Map) return Map<String, dynamic>.from(decoded);
|
||||||
|
throw Exception('IP节点服务返回格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _payloadObject(Map<String, dynamic> response) {
|
||||||
|
final data = response['data'];
|
||||||
|
if (data is Map<String, dynamic>) return data;
|
||||||
|
if (data is Map) return Map<String, dynamic>.from(data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _ipNodeApiUri(String path, {Map<String, String>? queryParameters}) {
|
||||||
|
final base = _ipNodeApiBaseUri;
|
||||||
|
return Uri(
|
||||||
|
scheme: base.scheme,
|
||||||
|
userInfo: base.userInfo,
|
||||||
|
host: base.host,
|
||||||
|
port: base.hasPort ? base.port : null,
|
||||||
|
path: _joinUriPath(base.path, path),
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri get _ipNodeApiBaseUri {
|
||||||
|
final override = _ipNodeApiBaseUrlOverride.trim();
|
||||||
|
if (override.isNotEmpty) return Uri.parse(override);
|
||||||
|
|
||||||
|
final appApiBase = Uri.tryParse(ApiService.instance.dio.options.baseUrl);
|
||||||
|
if (appApiBase != null &&
|
||||||
|
appApiBase.hasScheme &&
|
||||||
|
appApiBase.host.isNotEmpty) {
|
||||||
|
return Uri(scheme: appApiBase.scheme, host: appApiBase.host, port: 7998);
|
||||||
|
}
|
||||||
|
return Uri.parse('http://127.0.0.1:7998');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _joinUriPath(String basePath, String path) {
|
||||||
|
final child = path.startsWith('/') ? path.substring(1) : path;
|
||||||
|
if (basePath.isEmpty || basePath == '/') return '/$child';
|
||||||
|
final normalizedBase = basePath.endsWith('/')
|
||||||
|
? basePath.substring(0, basePath.length - 1)
|
||||||
|
: basePath;
|
||||||
|
return '$normalizedBase/$child';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_CustomNodeProbe> _testCustomNode(_CustomNode node) async {
|
||||||
|
final probeUri = node.probeUri ?? node.endpointUri;
|
||||||
|
if (probeUri == null) {
|
||||||
|
return _CustomNodeProbe(
|
||||||
|
node: node,
|
||||||
|
sampleCount: _customProbeSampleCount,
|
||||||
|
successCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final latencies = <int>[];
|
||||||
|
var successCount = 0;
|
||||||
|
for (var i = 0; i < _customProbeSampleCount; i++) {
|
||||||
|
final probe = await _probeUri(probeUri);
|
||||||
|
if (probe.reachable) {
|
||||||
|
successCount += 1;
|
||||||
|
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
|
||||||
|
}
|
||||||
|
if (i < _customProbeSampleCount - 1) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final averageLatencyMs = latencies.isEmpty
|
||||||
|
? null
|
||||||
|
: (latencies.reduce((a, b) => a + b) / latencies.length).round();
|
||||||
|
return _CustomNodeProbe(
|
||||||
|
node: node,
|
||||||
|
sampleCount: _customProbeSampleCount,
|
||||||
|
successCount: successCount,
|
||||||
|
averageLatencyMs: averageLatencyMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_CustomNodeProbe? _bestCustomNode(List<_CustomNodeProbe> probes) {
|
||||||
|
final reachable = probes.where((probe) => probe.successCount > 0).toList();
|
||||||
|
if (reachable.isEmpty) return null;
|
||||||
|
reachable.sort((a, b) => a.score.compareTo(b.score));
|
||||||
|
return reachable.first;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runNetworkTest() async {
|
Future<void> _runNetworkTest() async {
|
||||||
if (_networkLoading) return;
|
if (_networkLoading) return;
|
||||||
|
|
||||||
@@ -600,6 +830,154 @@ class _ServicePanel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CustomLinePanel extends StatelessWidget {
|
||||||
|
final bool loading;
|
||||||
|
final String? error;
|
||||||
|
final _CustomLineDiagnostics? diagnostics;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
final VoidCallback onOpenTelegram;
|
||||||
|
|
||||||
|
const _CustomLinePanel({
|
||||||
|
super.key,
|
||||||
|
required this.loading,
|
||||||
|
required this.error,
|
||||||
|
required this.diagnostics,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.onOpenTelegram,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = diagnostics;
|
||||||
|
final recommended = data?.recommended;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
||||||
|
children: [
|
||||||
|
_SectionCard(
|
||||||
|
title: '推荐节点',
|
||||||
|
children: [
|
||||||
|
if (loading) const LinearProgressIndicator(),
|
||||||
|
if (error != null) _ErrorText(error!),
|
||||||
|
if (data != null) ...[
|
||||||
|
_InfoRow(label: '当前用户组', value: data.groupName),
|
||||||
|
_InfoRow(label: '可用节点数量', value: '${data.nodes.length}'),
|
||||||
|
],
|
||||||
|
if (recommended != null) ...[
|
||||||
|
_InfoRow(
|
||||||
|
label: '推荐节点',
|
||||||
|
value: recommended.node.displayName,
|
||||||
|
valueColor: Colors.green,
|
||||||
|
),
|
||||||
|
_InfoRow(label: '节点地址', value: recommended.node.endpointLabel),
|
||||||
|
_InfoRow(label: '地区', value: recommended.node.locationLabel),
|
||||||
|
_InfoRow(label: '运营商', value: recommended.node.carrierLabel),
|
||||||
|
_InfoRow(label: '平均延迟', value: recommended.latencyLabel),
|
||||||
|
_InfoRow(label: '丢包率', value: recommended.lossLabel),
|
||||||
|
] else if (!loading && data != null) ...[
|
||||||
|
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
|
||||||
|
] else if (!loading && error == null) ...[
|
||||||
|
const _DescriptionLine('进入页面后将自动获取节点并进行推荐。'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_SectionCard(title: '可选节点', children: _availableNodeChildren(data)),
|
||||||
|
_SectionCard(
|
||||||
|
title: '自定义节点',
|
||||||
|
children: const [_DescriptionLine('预留功能,后续将支持手动填写和保存自定义节点。')],
|
||||||
|
),
|
||||||
|
_SectionCard(
|
||||||
|
title: '专线节点申请',
|
||||||
|
children: [
|
||||||
|
const _DescriptionLine(
|
||||||
|
'如需申请专线节点,可以联系公云存储官方邮箱 operate@gongyun.org 或 Telegram:https://t.me/GYNetwork。',
|
||||||
|
),
|
||||||
|
const _DescriptionLine('申请时请提供所在国家/地区、对应省份和运营商,便于官方评估线路可用性。'),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: onOpenTelegram,
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
label: const Text('打开 Telegram'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: loading ? null : onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('重新检测'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _availableNodeChildren(_CustomLineDiagnostics? data) {
|
||||||
|
if (data == null) {
|
||||||
|
return [_InfoRow(label: '节点', value: loading ? '加载中' : '等待获取')];
|
||||||
|
}
|
||||||
|
if (data.nodes.isEmpty) {
|
||||||
|
return const [_DescriptionLine('当前用户组暂无可选节点。')];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < data.nodes.length; i++) ...[
|
||||||
|
if (i > 0) const Divider(height: 18),
|
||||||
|
_CustomNodeTile(probe: data.nodes[i]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomNodeTile extends StatelessWidget {
|
||||||
|
final _CustomNodeProbe probe;
|
||||||
|
|
||||||
|
const _CustomNodeTile({required this.probe});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final available = probe.successCount > 0;
|
||||||
|
final statusColor = _statusColor(context, available);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
probe.node.displayName,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
available ? '可用' : '不可用',
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(probe.node.endpointLabel),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${probe.node.locationLabel} / ${probe.node.carrierLabel}'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionCard extends StatelessWidget {
|
class _SectionCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
@@ -737,6 +1115,177 @@ class _IpInfo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CustomLineDiagnostics {
|
||||||
|
final String groupName;
|
||||||
|
final List<_CustomNodeProbe> nodes;
|
||||||
|
final _CustomNodeProbe? recommended;
|
||||||
|
|
||||||
|
const _CustomLineDiagnostics({
|
||||||
|
required this.groupName,
|
||||||
|
required this.nodes,
|
||||||
|
required this.recommended,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomNode {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String host;
|
||||||
|
final String ip;
|
||||||
|
final int? port;
|
||||||
|
final String protocol;
|
||||||
|
final String region;
|
||||||
|
final String country;
|
||||||
|
final String province;
|
||||||
|
final String carrier;
|
||||||
|
final List<String> groups;
|
||||||
|
final bool enabled;
|
||||||
|
final String probeUrl;
|
||||||
|
|
||||||
|
const _CustomNode({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.host,
|
||||||
|
required this.ip,
|
||||||
|
required this.port,
|
||||||
|
required this.protocol,
|
||||||
|
required this.region,
|
||||||
|
required this.country,
|
||||||
|
required this.province,
|
||||||
|
required this.carrier,
|
||||||
|
required this.groups,
|
||||||
|
required this.enabled,
|
||||||
|
required this.probeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _CustomNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
return _CustomNode(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '',
|
||||||
|
host: json['host']?.toString() ?? '',
|
||||||
|
ip: json['ip']?.toString() ?? '',
|
||||||
|
port: _asNullableInt(json['port']),
|
||||||
|
protocol: json['protocol']?.toString() ?? 'https',
|
||||||
|
region: json['region']?.toString() ?? '',
|
||||||
|
country: json['country']?.toString() ?? '',
|
||||||
|
province: json['province']?.toString() ?? '',
|
||||||
|
carrier: json['carrier']?.toString() ?? '',
|
||||||
|
groups: _asStringList(json['groups']),
|
||||||
|
enabled: json['enabled'] != false,
|
||||||
|
probeUrl: json['probe_url']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final trimmedName = name.trim();
|
||||||
|
if (trimmedName.isNotEmpty) return trimmedName;
|
||||||
|
final endpoint = endpointHost;
|
||||||
|
return endpoint.isEmpty ? '未命名节点' : endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get endpointHost {
|
||||||
|
final trimmedHost = host.trim();
|
||||||
|
if (trimmedHost.isNotEmpty) return trimmedHost;
|
||||||
|
return ip.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get endpointLabel {
|
||||||
|
final endpoint = endpointHost;
|
||||||
|
if (endpoint.isEmpty) return '未配置地址';
|
||||||
|
final scheme = protocol.trim().isEmpty ? 'https' : protocol.trim();
|
||||||
|
final portSuffix = port != null && port! > 0 ? ':$port' : '';
|
||||||
|
return '$scheme://$endpoint$portSuffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get locationLabel {
|
||||||
|
final parts = <String>[];
|
||||||
|
for (final value in [country, province, region]) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isNotEmpty && !parts.contains(trimmed)) {
|
||||||
|
parts.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.isEmpty ? '未配置地区' : parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String get carrierLabel {
|
||||||
|
final trimmedCarrier = carrier.trim();
|
||||||
|
return trimmedCarrier.isEmpty ? '未配置运营商' : trimmedCarrier;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? get probeUri {
|
||||||
|
final value = probeUrl.trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null;
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? get endpointUri {
|
||||||
|
final endpoint = endpointHost;
|
||||||
|
final scheme = protocol.trim().toLowerCase();
|
||||||
|
if (endpoint.isEmpty || (scheme != 'http' && scheme != 'https')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uri(scheme: scheme, host: endpoint, port: port);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _asNullableInt(Object? value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.round();
|
||||||
|
return int.tryParse(value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _asStringList(Object? value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.map((item) => item.toString().trim())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomNodeProbe {
|
||||||
|
final _CustomNode node;
|
||||||
|
final int sampleCount;
|
||||||
|
final int successCount;
|
||||||
|
final int? averageLatencyMs;
|
||||||
|
|
||||||
|
const _CustomNodeProbe({
|
||||||
|
required this.node,
|
||||||
|
required this.sampleCount,
|
||||||
|
required this.successCount,
|
||||||
|
this.averageLatencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get packetLossPercent {
|
||||||
|
if (sampleCount <= 0) return 100;
|
||||||
|
return ((sampleCount - successCount) / sampleCount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get score {
|
||||||
|
return (averageLatencyMs ?? 999999).toDouble() + packetLossPercent * 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get latencyLabel {
|
||||||
|
if (averageLatencyMs == null) return '-- ms';
|
||||||
|
return '$averageLatencyMs ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get lossLabel => '${packetLossPercent.toStringAsFixed(0)}%';
|
||||||
|
|
||||||
|
String get sampleLabel => '$successCount/$sampleCount';
|
||||||
|
}
|
||||||
|
|
||||||
class _ServiceDiagnostics {
|
class _ServiceDiagnostics {
|
||||||
final DateTime fetchedAt;
|
final DateTime fetchedAt;
|
||||||
final List<_ServiceItem> business;
|
final List<_ServiceItem> business;
|
||||||
|
|||||||
Reference in New Issue
Block a user