1350 lines
40 KiB
Dart
1350 lines
40 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
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';
|
||
|
||
enum _NetworkTestTab { network, service, custom }
|
||
|
||
class NetworkTestPage extends StatefulWidget {
|
||
const NetworkTestPage({super.key});
|
||
|
||
@override
|
||
State<NetworkTestPage> createState() => _NetworkTestPageState();
|
||
}
|
||
|
||
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() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('网络测试'),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: '说明',
|
||
icon: const Icon(Icons.info_outline),
|
||
onPressed: () => _openExternalUrl(_infoUri),
|
||
),
|
||
],
|
||
),
|
||
body: DesktopConstrained(
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: SegmentedButton<_NetworkTestTab>(
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: _NetworkTestTab.network,
|
||
icon: Icon(Icons.public),
|
||
label: Text('网络'),
|
||
),
|
||
ButtonSegment(
|
||
value: _NetworkTestTab.service,
|
||
icon: Icon(Icons.dns_outlined),
|
||
label: Text('服务'),
|
||
),
|
||
ButtonSegment(
|
||
value: _NetworkTestTab.custom,
|
||
icon: Icon(Icons.route_outlined),
|
||
label: Text('自定义线路'),
|
||
),
|
||
],
|
||
selected: {_tab},
|
||
onSelectionChanged: (selection) =>
|
||
_handleTabSelection(selection.first),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 180),
|
||
child: switch (_tab) {
|
||
_NetworkTestTab.network => _NetworkPanel(
|
||
key: const ValueKey('network'),
|
||
loading: _networkLoading,
|
||
error: _networkError,
|
||
diagnostics: _networkDiagnostics,
|
||
onRefresh: _runNetworkTest,
|
||
),
|
||
_NetworkTestTab.service => _ServicePanel(
|
||
key: const ValueKey('service'),
|
||
loading: _serviceLoading,
|
||
error: _serviceError,
|
||
diagnostics: _serviceDiagnostics,
|
||
onRefresh: _runServiceTest,
|
||
),
|
||
_NetworkTestTab.custom => _CustomLinePanel(
|
||
key: const ValueKey('custom'),
|
||
loading: _customLoading,
|
||
error: _customError,
|
||
diagnostics: _customDiagnostics,
|
||
onRefresh: _runCustomLineTest,
|
||
onOpenTelegram: () => _openExternalUrl(_telegramUri),
|
||
),
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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: Text(message),
|
||
actions: [
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||
child: const Text('好'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _openExternalUrl(Uri uri) async {
|
||
try {
|
||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
if (!opened) {
|
||
ToastHelper.failure('无法打开链接: $uri');
|
||
}
|
||
} catch (e) {
|
||
ToastHelper.failure('无法打开链接: $e');
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
setState(() {
|
||
_networkLoading = true;
|
||
_networkError = null;
|
||
});
|
||
|
||
try {
|
||
final ipFuture = _fetchIpInfo();
|
||
final panProbe = await _probeUri(_panUri);
|
||
final stability = await _testStability(_panUri);
|
||
final ipInfo = await ipFuture;
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_networkDiagnostics = _NetworkDiagnostics(
|
||
panReachable: panProbe.reachable,
|
||
latencyMs: panProbe.latencyMs,
|
||
ipInfo: ipInfo,
|
||
stable: stability.stable,
|
||
sampleCount: stability.sampleCount,
|
||
successCount: stability.successCount,
|
||
);
|
||
});
|
||
} catch (e) {
|
||
if (mounted) setState(() => _networkError = e.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _networkLoading = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _runServiceTest() async {
|
||
if (_serviceLoading) return;
|
||
|
||
setState(() {
|
||
_serviceLoading = true;
|
||
_serviceError = null;
|
||
});
|
||
|
||
try {
|
||
final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun');
|
||
|
||
_ServiceItem monitor(String label, int monitorId) {
|
||
return _monitorItem(
|
||
label: label,
|
||
monitorId: monitorId,
|
||
heartbeats: monitorHeartbeats,
|
||
);
|
||
}
|
||
|
||
final telegramBot = await _probeItem(
|
||
label: 'Telegram bot',
|
||
uri: Uri.parse('https://bot.mygongyun.com'),
|
||
healthyStatusCodes: const {200, 401},
|
||
);
|
||
|
||
final business = <_ServiceItem>[
|
||
monitor('官网', 3),
|
||
monitor('网盘', 2),
|
||
monitor('短链', 37),
|
||
monitor('SSO', 39),
|
||
telegramBot,
|
||
];
|
||
|
||
final dc = <_ServiceItem>[
|
||
monitor('DC3-CN-SZ', 13),
|
||
monitor('DC3-CN-MIAMI', 11),
|
||
monitor('DC3-CN-NY', 12),
|
||
monitor('DC3-CN-LV', 10),
|
||
];
|
||
|
||
final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)];
|
||
|
||
final storage = <_ServiceItem>[
|
||
monitor('DC-S-NA-1', 5),
|
||
monitor('DC-S-NA-2', 6),
|
||
monitor('DC-S-EU', 7),
|
||
monitor('DC-S-EP', 8),
|
||
];
|
||
|
||
final acceleration = <_ServiceItem>[
|
||
monitor('香港', 18),
|
||
monitor('香港', 19),
|
||
monitor('新加坡', 22),
|
||
monitor('洛杉矶', 24),
|
||
monitor('蒙特利尔', 21),
|
||
];
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_serviceDiagnostics = _ServiceDiagnostics(
|
||
fetchedAt: DateTime.now(),
|
||
business: business,
|
||
dc: dc,
|
||
backend: backend,
|
||
storage: storage,
|
||
acceleration: acceleration,
|
||
);
|
||
});
|
||
} catch (e) {
|
||
if (mounted) setState(() => _serviceError = e.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _serviceLoading = false);
|
||
}
|
||
}
|
||
|
||
Future<_IpInfo> _fetchIpInfo() async {
|
||
try {
|
||
final response = await http
|
||
.get(_primaryIpLookupUri)
|
||
.timeout(const Duration(seconds: 8));
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||
if (data['success'] != false) {
|
||
final connection = data['connection'];
|
||
final connectionMap = connection is Map<String, dynamic>
|
||
? connection
|
||
: <String, dynamic>{};
|
||
return _IpInfo(
|
||
ip: data['ip']?.toString() ?? '未知',
|
||
asn: connectionMap['asn']?.toString() ?? '未知',
|
||
operatorName: connectionMap['isp']?.toString().isNotEmpty == true
|
||
? connectionMap['isp'].toString()
|
||
: (connectionMap['org']?.toString() ?? '未知'),
|
||
);
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// Fall back to the next public endpoint.
|
||
}
|
||
|
||
try {
|
||
final response = await http
|
||
.get(_secondaryIpLookupUri)
|
||
.timeout(const Duration(seconds: 8));
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||
return _IpInfo(
|
||
ip: data['ip']?.toString() ?? '未知',
|
||
asn: data['asn']?.toString() ?? '未知',
|
||
operatorName: data['org']?.toString() ?? '未知',
|
||
);
|
||
}
|
||
} catch (_) {
|
||
// Handled by the default value below.
|
||
}
|
||
|
||
return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知');
|
||
}
|
||
|
||
_ServiceItem _monitorItem({
|
||
required String label,
|
||
required int monitorId,
|
||
required Map<int, _MonitorHeartbeat> heartbeats,
|
||
}) {
|
||
final heartbeat = heartbeats[monitorId];
|
||
return _ServiceItem(
|
||
label: label,
|
||
online: heartbeat?.online ?? false,
|
||
latencyMs: heartbeat?.pingMs,
|
||
);
|
||
}
|
||
|
||
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
|
||
String pageId,
|
||
) async {
|
||
try {
|
||
final response = await http
|
||
.get(
|
||
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
|
||
headers: _probeHeaders,
|
||
)
|
||
.timeout(const Duration(seconds: 10));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return const {};
|
||
}
|
||
|
||
final decoded = jsonDecode(response.body);
|
||
if (decoded is! Map<String, dynamic>) return const {};
|
||
final data = decoded['data'];
|
||
if (data is! Map<String, dynamic>) return const {};
|
||
final heartbeatList = data['heartbeatList'];
|
||
if (heartbeatList is! Map<String, dynamic>) return const {};
|
||
|
||
final result = <int, _MonitorHeartbeat>{};
|
||
for (final entry in heartbeatList.entries) {
|
||
final monitorId = int.tryParse(entry.key);
|
||
final records = entry.value;
|
||
if (monitorId == null || records is! List || records.isEmpty) continue;
|
||
final latest = records.last;
|
||
if (latest is! Map<String, dynamic>) continue;
|
||
final status = _asInt(latest['status']);
|
||
final ping = _asInt(latest['ping']);
|
||
result[monitorId] = _MonitorHeartbeat(
|
||
online: status == 1,
|
||
pingMs: ping,
|
||
);
|
||
}
|
||
|
||
return result;
|
||
} catch (_) {
|
||
return const {};
|
||
}
|
||
}
|
||
|
||
int? _asInt(Object? value) {
|
||
if (value is int) return value;
|
||
if (value is num) return value.round();
|
||
return int.tryParse(value?.toString() ?? '');
|
||
}
|
||
|
||
Future<_ServiceItem> _probeItem({
|
||
required String label,
|
||
required Uri uri,
|
||
Set<int> healthyStatusCodes = const {},
|
||
}) async {
|
||
final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes);
|
||
return _ServiceItem(
|
||
label: label,
|
||
online: probe.reachable,
|
||
latencyMs: probe.latencyMs,
|
||
);
|
||
}
|
||
|
||
Future<_ProbeResult> _probeUri(
|
||
Uri uri, {
|
||
Set<int> healthyStatusCodes = const {},
|
||
}) async {
|
||
final stopwatch = Stopwatch()..start();
|
||
try {
|
||
var response = await http
|
||
.head(uri, headers: _probeHeaders)
|
||
.timeout(const Duration(seconds: 8));
|
||
if (_shouldRetryProbeWithGet(response.statusCode)) {
|
||
response = await http
|
||
.get(uri, headers: _probeGetFallbackHeaders)
|
||
.timeout(const Duration(seconds: 8));
|
||
}
|
||
stopwatch.stop();
|
||
final isHealthy = _isHealthyStatus(
|
||
response.statusCode,
|
||
healthyStatusCodes,
|
||
);
|
||
return _ProbeResult(
|
||
reachable: isHealthy,
|
||
latencyMs: stopwatch.elapsedMilliseconds,
|
||
);
|
||
} catch (_) {
|
||
stopwatch.stop();
|
||
return const _ProbeResult(reachable: false);
|
||
}
|
||
}
|
||
|
||
bool _shouldRetryProbeWithGet(int statusCode) {
|
||
return statusCode == 403 || statusCode == 405 || statusCode == 501;
|
||
}
|
||
|
||
bool _isHealthyStatus(int statusCode, Set<int> healthyStatusCodes) {
|
||
if (healthyStatusCodes.isNotEmpty) {
|
||
return healthyStatusCodes.contains(statusCode);
|
||
}
|
||
return statusCode >= 200 && statusCode < 500;
|
||
}
|
||
|
||
Future<_StabilityResult> _testStability(Uri uri) async {
|
||
final latencies = <int>[];
|
||
var successCount = 0;
|
||
const sampleCount = 3;
|
||
|
||
for (var i = 0; i < sampleCount; i++) {
|
||
final probe = await _probeUri(uri);
|
||
if (probe.reachable) {
|
||
successCount += 1;
|
||
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
|
||
}
|
||
if (i < sampleCount - 1) {
|
||
await Future<void>.delayed(const Duration(milliseconds: 350));
|
||
}
|
||
}
|
||
|
||
final jitter = latencies.isEmpty
|
||
? 999999
|
||
: latencies.reduce(max) - latencies.reduce(min);
|
||
return _StabilityResult(
|
||
stable: successCount == sampleCount && jitter < 1500,
|
||
sampleCount: sampleCount,
|
||
successCount: successCount,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NetworkPanel extends StatelessWidget {
|
||
final bool loading;
|
||
final String? error;
|
||
final _NetworkDiagnostics? diagnostics;
|
||
final VoidCallback onRefresh;
|
||
|
||
const _NetworkPanel({
|
||
super.key,
|
||
required this.loading,
|
||
required this.error,
|
||
required this.diagnostics,
|
||
required this.onRefresh,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final data = diagnostics;
|
||
final errorColor = Theme.of(context).colorScheme.error;
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
||
children: [
|
||
_SectionCard(
|
||
title: '网络',
|
||
children: [
|
||
if (loading) const LinearProgressIndicator(),
|
||
if (error != null) _ErrorText(error!),
|
||
_InfoRow(
|
||
label: '公云存储网盘访问测试',
|
||
value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'),
|
||
valueColor: _statusColor(context, data?.panReachable),
|
||
),
|
||
_InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'),
|
||
_InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'),
|
||
_InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'),
|
||
_InfoRow(
|
||
label: '连接时延',
|
||
value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms',
|
||
),
|
||
_InfoRow(
|
||
label: '长期连接稳定性',
|
||
value: data == null
|
||
? '检测中'
|
||
: '${data.stable ? '稳定' : '不稳定'} '
|
||
'(${data.successCount}/${data.sampleCount})',
|
||
valueColor: _statusColor(context, data?.stable),
|
||
),
|
||
],
|
||
),
|
||
_SectionCard(
|
||
title: '说明',
|
||
children: const [
|
||
_DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'),
|
||
_DescriptionLine(
|
||
'当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。',
|
||
),
|
||
_DescriptionLine(
|
||
'由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象',
|
||
),
|
||
_DescriptionLine(
|
||
'如若出现网络连接问题,您可将本页面内容向公云存储团队报告,团队会根据相关诊断线索进行排查与优化。',
|
||
),
|
||
],
|
||
),
|
||
_SectionCard(
|
||
title: '警告',
|
||
titleColor: errorColor,
|
||
children: [
|
||
Text(
|
||
'本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!',
|
||
style: TextStyle(color: errorColor, fontWeight: FontWeight.w700),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
FilledButton.icon(
|
||
onPressed: loading ? null : onRefresh,
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('重新检测'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ServicePanel extends StatelessWidget {
|
||
final bool loading;
|
||
final String? error;
|
||
final _ServiceDiagnostics? diagnostics;
|
||
final VoidCallback onRefresh;
|
||
|
||
const _ServicePanel({
|
||
super.key,
|
||
required this.loading,
|
||
required this.error,
|
||
required this.diagnostics,
|
||
required this.onRefresh,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final data = diagnostics;
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
||
children: [
|
||
_SectionCard(
|
||
title: '业务状态',
|
||
children: [
|
||
if (loading) const LinearProgressIndicator(),
|
||
if (error != null) _ErrorText(error!),
|
||
..._itemsOrLoading(data?.business),
|
||
],
|
||
),
|
||
_SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)),
|
||
_SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)),
|
||
_SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)),
|
||
_SectionCard(
|
||
title: '加速线路',
|
||
children: _itemsOrLoading(data?.acceleration, showLatency: true),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FilledButton.icon(
|
||
onPressed: loading ? null : onRefresh,
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('重新检测'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
List<Widget> _itemsOrLoading(
|
||
List<_ServiceItem>? items, {
|
||
bool showLatency = false,
|
||
}) {
|
||
if (items == null) {
|
||
return const [_InfoRow(label: '状态', value: '检测中')];
|
||
}
|
||
return items
|
||
.map(
|
||
(item) => _InfoRow(
|
||
label: item.label,
|
||
value: showLatency
|
||
? item.latencyLabel
|
||
: (item.online ? '正常' : '离线'),
|
||
valueColor: item.online ? Colors.green : Colors.red,
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
}
|
||
|
||
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;
|
||
final Color? titleColor;
|
||
|
||
const _SectionCard({
|
||
required this.title,
|
||
required this.children,
|
||
this.titleColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
color: titleColor,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
...children,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoRow extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color? valueColor;
|
||
|
||
const _InfoRow({required this.label, required this.value, this.valueColor});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
|
||
const SizedBox(width: 16),
|
||
Flexible(
|
||
child: Text(
|
||
value,
|
||
textAlign: TextAlign.right,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: valueColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DescriptionLine extends StatelessWidget {
|
||
final String text;
|
||
|
||
const _DescriptionLine(this.text);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||
child: Text(text),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorText extends StatelessWidget {
|
||
final String error;
|
||
|
||
const _ErrorText(this.error);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'检测失败:$error',
|
||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Color? _statusColor(BuildContext context, bool? value) {
|
||
if (value == null) return null;
|
||
return value ? Colors.green : Theme.of(context).colorScheme.error;
|
||
}
|
||
|
||
class _NetworkDiagnostics {
|
||
final bool panReachable;
|
||
final int? latencyMs;
|
||
final _IpInfo ipInfo;
|
||
final bool stable;
|
||
final int sampleCount;
|
||
final int successCount;
|
||
|
||
const _NetworkDiagnostics({
|
||
required this.panReachable,
|
||
required this.latencyMs,
|
||
required this.ipInfo,
|
||
required this.stable,
|
||
required this.sampleCount,
|
||
required this.successCount,
|
||
});
|
||
}
|
||
|
||
class _IpInfo {
|
||
final String ip;
|
||
final String asn;
|
||
final String operatorName;
|
||
|
||
const _IpInfo({
|
||
required this.ip,
|
||
required this.asn,
|
||
required this.operatorName,
|
||
});
|
||
}
|
||
|
||
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;
|
||
final List<_ServiceItem> dc;
|
||
final List<_ServiceItem> backend;
|
||
final List<_ServiceItem> storage;
|
||
final List<_ServiceItem> acceleration;
|
||
|
||
const _ServiceDiagnostics({
|
||
required this.fetchedAt,
|
||
required this.business,
|
||
required this.dc,
|
||
required this.backend,
|
||
required this.storage,
|
||
required this.acceleration,
|
||
});
|
||
}
|
||
|
||
class _ServiceItem {
|
||
final String label;
|
||
final bool online;
|
||
final int? latencyMs;
|
||
|
||
const _ServiceItem({
|
||
required this.label,
|
||
required this.online,
|
||
this.latencyMs,
|
||
});
|
||
|
||
String get latencyLabel {
|
||
if (!online) return '离线';
|
||
if (latencyMs == null) return '正常 / -- ms';
|
||
return '正常 / $latencyMs ms';
|
||
}
|
||
}
|
||
|
||
class _MonitorHeartbeat {
|
||
final bool online;
|
||
final int? pingMs;
|
||
|
||
const _MonitorHeartbeat({required this.online, this.pingMs});
|
||
}
|
||
|
||
class _ProbeResult {
|
||
final bool reachable;
|
||
final int? latencyMs;
|
||
|
||
const _ProbeResult({required this.reachable, this.latencyMs});
|
||
}
|
||
|
||
class _StabilityResult {
|
||
final bool stable;
|
||
final int sampleCount;
|
||
final int successCount;
|
||
|
||
const _StabilityResult({
|
||
required this.stable,
|
||
required this.sampleCount,
|
||
required this.successCount,
|
||
});
|
||
}
|