自定义线路与线路优化
This commit is contained in:
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -45,6 +46,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
_NetworkDiagnostics? _networkDiagnostics;
|
||||
_ServiceDiagnostics? _serviceDiagnostics;
|
||||
_CustomLineDiagnostics? _customDiagnostics;
|
||||
String? _selectedCustomNodeId;
|
||||
String? _networkError;
|
||||
String? _serviceError;
|
||||
String? _customError;
|
||||
@@ -52,6 +54,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_loadSelectedCustomNode());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
||||
}
|
||||
|
||||
@@ -122,7 +125,9 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
loading: _customLoading,
|
||||
error: _customError,
|
||||
diagnostics: _customDiagnostics,
|
||||
selectedNodeId: _selectedCustomNodeId,
|
||||
onRefresh: _runCustomLineTest,
|
||||
onSelectNode: _selectCustomNode,
|
||||
onOpenTelegram: () => _openExternalUrl(_telegramUri),
|
||||
),
|
||||
},
|
||||
@@ -173,6 +178,43 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -229,9 +271,11 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
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(() {
|
||||
@@ -240,6 +284,7 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
nodes: probes,
|
||||
recommended: recommended,
|
||||
);
|
||||
_selectedCustomNodeId = selectedNodeId;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _customError = e.toString());
|
||||
@@ -248,9 +293,29 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -277,7 +342,10 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||
|
||||
final nodesResponse = await http
|
||||
.get(
|
||||
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}),
|
||||
_ipNodeApiUri(
|
||||
'/api/v1/nodes',
|
||||
queryParameters: {'group': groupName, 'user_id': userId},
|
||||
),
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Client-UUID': clientId,
|
||||
@@ -834,7 +902,9 @@ 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({
|
||||
@@ -842,7 +912,9 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
required this.loading,
|
||||
required this.error,
|
||||
required this.diagnostics,
|
||||
required this.selectedNodeId,
|
||||
required this.onRefresh,
|
||||
required this.onSelectNode,
|
||||
required this.onOpenTelegram,
|
||||
});
|
||||
|
||||
@@ -868,11 +940,21 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
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),
|
||||
_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) ...[
|
||||
@@ -880,6 +962,24 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
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: '自定义节点',
|
||||
@@ -923,7 +1023,11 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
return [
|
||||
for (var i = 0; i < data.nodes.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 18),
|
||||
_CustomNodeTile(probe: data.nodes[i]),
|
||||
_CustomNodeTile(
|
||||
probe: data.nodes[i],
|
||||
selected: data.nodes[i].node.id == selectedNodeId,
|
||||
onSelect: () => onSelectNode(data.nodes[i]),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -931,48 +1035,106 @@ class _CustomLinePanel extends StatelessWidget {
|
||||
|
||||
class _CustomNodeTile extends StatelessWidget {
|
||||
final _CustomNodeProbe probe;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
const _CustomNodeTile({required this.probe});
|
||||
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);
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
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}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1125,6 +1287,13 @@ class _CustomLineDiagnostics {
|
||||
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 {
|
||||
@@ -1139,6 +1308,7 @@ class _CustomNode {
|
||||
final String province;
|
||||
final String carrier;
|
||||
final List<String> groups;
|
||||
final bool dedicated;
|
||||
final bool enabled;
|
||||
final String probeUrl;
|
||||
|
||||
@@ -1154,6 +1324,7 @@ class _CustomNode {
|
||||
required this.province,
|
||||
required this.carrier,
|
||||
required this.groups,
|
||||
required this.dedicated,
|
||||
required this.enabled,
|
||||
required this.probeUrl,
|
||||
});
|
||||
@@ -1171,6 +1342,7 @@ class _CustomNode {
|
||||
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() ?? '',
|
||||
);
|
||||
@@ -1189,6 +1361,20 @@ class _CustomNode {
|
||||
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 '未配置地址';
|
||||
|
||||
Reference in New Issue
Block a user