自定义线路与线路优化

This commit is contained in:
2026-06-04 17:52:16 +08:00
parent 33509de4e3
commit e6c60a9d6d
16 changed files with 1184 additions and 203 deletions
@@ -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 '未配置地址';