正式上线"自定义线路"功能(仅限付费用户组)
This commit is contained in:
@@ -140,6 +140,8 @@ jobs:
|
||||
|
||||
- name: Build Android release APK
|
||||
shell: bash
|
||||
env:
|
||||
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -151,8 +153,13 @@ jobs:
|
||||
rm -rf "$pkg_dir"
|
||||
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..."
|
||||
flutter build apk -v --release
|
||||
flutter build apk "${build_args[@]}"
|
||||
|
||||
mkdir -p "$pkg_dir"
|
||||
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:http/http.dart' as http;
|
||||
import 'package:provider/provider.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/toast_helper.dart';
|
||||
|
||||
@@ -21,21 +25,29 @@ class NetworkTestPage extends StatefulWidget {
|
||||
class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
static final _panUri = Uri.parse('https://pan.gongyun.org');
|
||||
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 _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 _probeGetFallbackHeaders = {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Range': 'bytes=0-0',
|
||||
};
|
||||
static const _customProbeSampleCount = 3;
|
||||
|
||||
_NetworkTestTab _tab = _NetworkTestTab.network;
|
||||
bool _networkLoading = false;
|
||||
bool _serviceLoading = false;
|
||||
bool _customLoading = false;
|
||||
_NetworkDiagnostics? _networkDiagnostics;
|
||||
_ServiceDiagnostics? _serviceDiagnostics;
|
||||
_CustomLineDiagnostics? _customDiagnostics;
|
||||
String? _networkError;
|
||||
String? _serviceError;
|
||||
String? _customError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -82,24 +94,8 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
),
|
||||
],
|
||||
selected: {_tab},
|
||||
onSelectionChanged: (selection) {
|
||||
final selected = 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();
|
||||
}
|
||||
},
|
||||
onSelectionChanged: (selection) =>
|
||||
_handleTabSelection(selection.first),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -121,7 +117,14 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
diagnostics: _serviceDiagnostics,
|
||||
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>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('自定义线路'),
|
||||
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
FilledButton(
|
||||
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 {
|
||||
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 {
|
||||
final String title;
|
||||
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 {
|
||||
final DateTime fetchedAt;
|
||||
final List<_ServiceItem> business;
|
||||
|
||||
Reference in New Issue
Block a user