Files
app/lib/presentation/pages/settings/network_test_page.dart
T

1536 lines
47 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/custom_line_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? _selectedCustomNodeId;
String? _networkError;
String? _serviceError;
String? _customError;
@override
void initState() {
super.initState();
unawaited(_loadSelectedCustomNode());
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,
selectedNodeId: _selectedCustomNodeId,
onRefresh: _runCustomLineTest,
onSelectNode: _selectCustomNode,
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();
}
}
Future<void> _loadSelectedCustomNode() async {
final selected = await CustomLineService.instance.getSelectedNode();
if (!mounted) return;
setState(() => _selectedCustomNodeId = selected?.id);
}
Future<void> _selectCustomNode(_CustomNodeProbe? probe) async {
if (probe == null) {
await CustomLineService.instance.clearSelectedNode();
if (mounted) setState(() => _selectedCustomNodeId = null);
ToastHelper.success('已关闭自定义线路节点');
return;
}
if (probe.successCount <= 0) {
ToastHelper.failure('该节点当前不可用,无法选择');
return;
}
if (!probe.node.canRewriteDownloadUrl) {
ToastHelper.failure('该节点未配置可用于下载加速的 IP 地址');
return;
}
await CustomLineService.instance.setSelectedNode(
probe.node.toSelectedNode(),
);
final endpoint = await CustomLineService.instance
.activateSelectedNodeForHost(probe.node.host);
if (endpoint == null) {
await CustomLineService.instance.clearSelectedNode();
ToastHelper.failure('节点启用失败,请稍后重试');
return;
}
if (mounted) setState(() => _selectedCustomNodeId = probe.node.id);
ToastHelper.success('已选择节点:${probe.node.displayName}');
}
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,
userId: auth.user!.id,
);
final probes = await Future.wait(nodes.map(_testCustomNode));
final recommended = _bestCustomNode(probes);
final selectedNodeId = await _resolveSelectedCustomNodeId(probes);
if (!mounted) return;
setState(() {
_customDiagnostics = _CustomLineDiagnostics(
groupName: groupName,
nodes: probes,
recommended: recommended,
);
_selectedCustomNodeId = selectedNodeId;
});
} catch (e) {
if (mounted) setState(() => _customError = e.toString());
} finally {
if (mounted) setState(() => _customLoading = false);
}
}
Future<String?> _resolveSelectedCustomNodeId(
List<_CustomNodeProbe> probes,
) async {
final selectable = probes
.where(
(probe) => probe.successCount > 0 && probe.node.canRewriteDownloadUrl,
)
.toList();
final current = await CustomLineService.instance.getSelectedNode();
if (current != null &&
selectable.any((probe) => probe.node.id == current.id)) {
return current.id;
}
await CustomLineService.instance.clearSelectedNode();
return null;
}
Future<List<_CustomNode>> _fetchCustomNodes({
required String clientId,
required String groupName,
required String userId,
}) 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, 'user_id': userId},
),
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 String? selectedNodeId;
final VoidCallback onRefresh;
final ValueChanged<_CustomNodeProbe?> onSelectNode;
final VoidCallback onOpenTelegram;
const _CustomLinePanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.selectedNodeId,
required this.onRefresh,
required this.onSelectNode,
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.latencyLabel),
_InfoRow(label: '丢包率', value: recommended.lossLabel),
_InfoRow(label: '样本', value: recommended.sampleLabel),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed:
recommended.successCount > 0 &&
recommended.node.canRewriteDownloadUrl
? () => onSelectNode(recommended)
: null,
icon: const Icon(Icons.check_circle_outline),
label: const Text('使用推荐节点'),
),
),
] else if (!loading && data != null) ...[
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
] else if (!loading && error == null) ...[
const _DescriptionLine('进入页面后将自动获取节点并进行推荐。'),
],
],
),
if (data != null && selectedNodeId != null)
_SectionCard(
title: '当前选择',
children: [
_InfoRow(
label: '节点',
value: data.selectedNodeName(selectedNodeId!) ?? '已选择节点',
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => onSelectNode(null),
icon: const Icon(Icons.close),
label: const Text('关闭自定义线路'),
),
),
],
),
_SectionCard(title: '可选节点', children: _availableNodeChildren(data)),
_SectionCard(
title: '自定义节点',
children: const [_DescriptionLine('预留功能,后续将支持手动填写和保存自定义节点。')],
),
_SectionCard(
title: '专线节点申请',
children: [
const _DescriptionLine(
'如需申请专线节点,可以联系公云存储官方邮箱 operate@gongyun.org 或 Telegramhttps://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],
selected: data.nodes[i].node.id == selectedNodeId,
onSelect: () => onSelectNode(data.nodes[i]),
),
],
];
}
}
class _CustomNodeTile extends StatelessWidget {
final _CustomNodeProbe probe;
final bool selected;
final VoidCallback onSelect;
const _CustomNodeTile({
required this.probe,
required this.selected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final available = probe.successCount > 0;
final statusColor = _statusColor(context, available);
final selectable = available && probe.node.canRewriteDownloadUrl;
return InkWell(
onTap: selectable ? onSelect : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: selected ? Colors.green : statusColor,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
probe.node.displayName,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w700),
),
if (probe.node.dedicated)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: const Color(0xFFE5B454),
),
),
child: const Text(
'专线',
style: TextStyle(
color: Color(0xFF8A5A00),
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 12),
Text(
available ? '可用' : '不可用',
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Text(
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
),
if (available && !probe.node.canRewriteDownloadUrl) ...[
const SizedBox(height: 4),
Text(
'未配置可用于下载加速的 IP',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
),
],
),
),
);
}
}
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,
});
String? selectedNodeName(String nodeId) {
for (final probe in nodes) {
if (probe.node.id == nodeId) return probe.node.displayName;
}
return null;
}
}
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 dedicated;
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.dedicated,
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']),
dedicated: json['dedicated'] == true,
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();
}
bool get canRewriteDownloadUrl =>
ip.trim().isNotEmpty && host.trim().isNotEmpty;
SelectedCustomLineNode toSelectedNode() {
return SelectedCustomLineNode(
id: id,
name: displayName,
ip: ip.trim(),
host: host.trim(),
protocol: protocol.trim(),
port: port,
);
}
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,
});
}