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 createState() => _NetworkTestPageState(); } class _NetworkTestPageState extends State { 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 _openCustomLineTab() async { final auth = context.read(); 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 _showCustomBlockedDialog(String message) async { await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('自定义线路'), content: Text(message), actions: [ FilledButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('好'), ), ], ), ); } Future _openExternalUrl(Uri uri) async { try { final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!opened) { ToastHelper.failure('无法打开链接: $uri'); } } catch (e) { ToastHelper.failure('无法打开链接: $e'); } } Future _runCustomLineTest() async { if (_customLoading) return; final auth = context.read(); 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> _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((node) => _CustomNode.fromJson(Map.from(node))) .toList(); } Map _decodeObject(String body) { final decoded = jsonDecode(body); if (decoded is Map) return decoded; if (decoded is Map) return Map.from(decoded); throw Exception('IP节点服务返回格式错误'); } Map _payloadObject(Map response) { final data = response['data']; if (data is Map) return data; if (data is Map) return Map.from(data); return response; } Uri _ipNodeApiUri(String path, {Map? 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 = []; 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.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 _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 _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; if (data['success'] != false) { final connection = data['connection']; final connectionMap = connection is Map ? connection : {}; 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; 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 heartbeats, }) { final heartbeat = heartbeats[monitorId]; return _ServiceItem( label: label, online: heartbeat?.online ?? false, latencyMs: heartbeat?.pingMs, ); } Future> _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) return const {}; final data = decoded['data']; if (data is! Map) return const {}; final heartbeatList = data['heartbeatList']; if (heartbeatList is! Map) return const {}; final result = {}; 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) 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 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 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 healthyStatusCodes) { if (healthyStatusCodes.isNotEmpty) { return healthyStatusCodes.contains(statusCode); } return statusCode >= 200 && statusCode < 500; } Future<_StabilityResult> _testStability(Uri uri) async { final latencies = []; 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.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 _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 _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 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 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 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 = []; 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 _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, }); }