소스 검색

feat: 更新lib,调试好异常状态处理,增加反馈弹窗

lilu 6 달 전
부모
커밋
a4134befad

BIN
android/app/libs/libxray.aar


+ 0 - 4
android/app/src/main/kotlin/app/xixi/nomo/CoreApiImpl.kt

@@ -17,7 +17,6 @@ import android.net.VpnService
 import android.telephony.TelephonyManager
 import android.util.Base64
 import android.util.Log
-import app.xixi.nomo.XRayApi.Companion.VPN_STATE_DISCONNECTING
 import app.xixi.nomo.XRayApi.Companion.VPN_STATE_PERMISSION_DENIED
 import app.xixi.nomo.XRayApi.OnVpnServiceEvent
 import com.google.gson.Gson
@@ -258,8 +257,6 @@ class CoreApiImpl(private val activity: Activity) : CoreApi {
     override fun disconnect(): Boolean? {
         VLog.i(TAG, "============ 开始停止 V2Ray 服务 ============")
         
-        // 通知 Flutter 开始断开连接
-        notifyVpnStatusChange(VPN_STATE_DISCONNECTING, "")
         // 停止V2Ray服务
         VLog.i(TAG, "调用 xrayApi.stopXray()")
         xrayApi?.stopXray()
@@ -379,7 +376,6 @@ class CoreApiImpl(private val activity: Activity) : CoreApi {
         detectOptions.statusDetectInterval = 60
         val detectUrls = ArrayList<String>()
         detectUrls.add("https://www.baidu.com")
-        detectUrls.add("https://www.google.com")
         detectOptions.statusDetectUrls = detectUrls
         val jsonArray = safeStringToJsonArray(configJson)
         val proxyNodes= ArrayList<ProxyNode>()

+ 14 - 4
android/app/src/main/kotlin/app/xixi/nomo/XRayApi.kt

@@ -35,6 +35,10 @@ class XRayApi {
                     val message = msg.data?.getString("message") ?: ""
                     VLog.i(TAG, "接收到VPN状态消息: status=$status, message=$message")
                     vpnServiceEvent?.onVpnStatusChange(status, message)
+                    if(status == VPN_STATE_ERROR) {
+                        stopXray()
+                        uninit()
+                    }
                 }
                 XRayService.XRAY_MSG_REPLY_TIMER_UPDATE -> {
                     val currentTime = msg.data?.getLong("currentTime") ?: 0L
@@ -44,6 +48,10 @@ class XRayApi {
                     VLog.i(TAG, "接收到计时更新消息: time=$currentTime, mode=$mode, running=$isRunning, paused=$isPaused")
                     vpnServiceEvent?.onTimerUpdate(currentTime, mode, isRunning, isPaused)
                 }
+                XRayService.XRAY_MSG_REPLY_STOP -> {
+                    VLog.i(TAG, "接收到停止消息")
+                    uninit()
+                }
                 else -> {
                     VLog.w(TAG, "未知的消息类型: ${msg.what}")
                 }
@@ -87,9 +95,10 @@ class XRayApi {
                 mService = null
                 vpnState = VPN_STATE_IDLE
                 xraySvrState = XRAY_SVR_IDLE
-                vpnServiceEvent?.onVpnStatusChange(VPN_STATE_ERROR, "xray service disconnected")
+                vpnServiceEvent?.onVpnStatusChange(VPN_STATE_SERVICE_DISCONNECTED, "xray service disconnected")
             } finally {
                 lock.unlock()
+                uninit()
             }
         }
     }
@@ -189,7 +198,6 @@ class XRayApi {
         } else {
             context.startService(intent)
         }
-//        context.startService(intent)
         VLog.i(TAG, "XRayService 启动命令已发送")
     }
 
@@ -299,8 +307,10 @@ class XRayApi {
         const val VPN_STATE_CONNECTING = 1L
         const val VPN_STATE_CONNECTED = 2L
         const val VPN_STATE_ERROR = 3L
-        const val VPN_STATE_DISCONNECTING = 4L
-        const val VPN_STATE_PERMISSION_DENIED = 403L
+        const val VPN_STATE_SERVICE_DISCONNECTED = 1000L
+        const val VPN_STATE_PERMISSION_DENIED = 1001L
+
+
 
         fun isServiceRunning(mContext: Context): Boolean {
             val activityManager = mContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager

+ 27 - 4
android/app/src/main/kotlin/app/xixi/nomo/XRayService.kt

@@ -56,7 +56,7 @@ class XRayService : VpnService() {
                 if (timerMode == 1 && newTime <= 0) {
                     VLog.i(TAG, "倒计时结束 - 关闭VPN")
                     dealStopMsg()
-                    stopSelf();
+                    notifyStop()
                     return
                 }
                 
@@ -118,6 +118,8 @@ class XRayService : VpnService() {
 
     override fun onDestroy() {
         VLog.i(TAG, "onDestroy")
+        // 停止计时
+        stopTimer()
         // 先停止前台服务
         try {
             stopForeground(STOP_FOREGROUND_REMOVE)
@@ -208,6 +210,8 @@ class XRayService : VpnService() {
             }
         } catch (e: Exception) {
             VLog.e(TAG, "启动 XRay 失败", e)
+            dealStopMsg()
+            stopSelf()
         }
     }
 
@@ -245,7 +249,25 @@ class XRayService : VpnService() {
             }
             tunFileDescriptor = null
         }
-        VLog.i(TAG, "xray stopped")
+        VLog.i(TAG, "xray stopped") }
+
+    private fun notifyStop() {
+        replyMessenger?.let { messenger ->
+            try {
+                val msg = Message.obtain().apply {
+                    what = XRAY_MSG_REPLY_STOP
+                }
+                messenger.send(msg)
+                VLog.i(TAG, "停止消息已通过Messenger发送")
+            } catch (e: DeadObjectException) {
+                VLog.w(TAG, "Messenger已失效,清理引用", e)
+                replyMessenger = null
+            } catch (e: Exception) {
+                VLog.e(TAG, "发送Messenger停止消息失败", e)
+            }
+        } ?: run {
+            VLog.w(TAG, "replyMessenger为空,无法发送停止消息")
+        }
     }
 
     private fun sendStatusMessage(status: Long, message: String) {
@@ -256,7 +278,7 @@ class XRayService : VpnService() {
 //                startTimer(1, 10000L)
             }
             else if(status == VPN_STATE_ERROR) {
-                dealStopMsg()
+                VLog.i(TAG, "VPN 连接失败,status:$status")
             }
             replyMessenger?.let { messenger ->
                 try {
@@ -274,7 +296,7 @@ class XRayService : VpnService() {
                     VLog.w(TAG, "Messenger已失效,清理引用", e)
                     replyMessenger = null
                 } catch (e: Exception) {
-                    VLog.e(TAG, "发送Messenger消息失败", e)
+                    VLog.e(TAG, "发送Messenger状态消息失败", e)
                 }
             } ?: run {
                 VLog.w(TAG, "replyMessenger为空,无法发送状态消息")
@@ -444,6 +466,7 @@ class XRayService : VpnService() {
         const val XRAY_MSG_REG_MESSENGER = 3
         const val XRAY_MSG_REPLY_STATUS = 101
         const val XRAY_MSG_REPLY_TIMER_UPDATE = 102
+        const val XRAY_MSG_REPLY_STOP = 103
         private const val TAG = "ixvpn"
 
         private const val CHANNEL_ID: String = "nomo_channel_id"

+ 22 - 2
lib/app/constants/enums.dart

@@ -59,10 +59,9 @@ enum FirebaseEvent {
 }
 
 enum ConnectionState {
-  disconnected, // 默认状态
+  disconnected, // 默认断开状态
   connecting, // 连接中状态
   connected, // 连接成功状态
-  disconnecting, // 断开连接中状态
   error, // 连接错误状态
 }
 
@@ -85,3 +84,24 @@ enum MemberLevel {
     );
   }
 }
+
+enum VpnError {
+  idle(0, '空闲状态'), // 空闲状态
+  connecting(1, '连接中状态'), // 连接中状态
+  connected(2, '连接成功状态'), // 连接成功状态
+  error(3, '连接错误状态'), // 连接错误状态
+  serviceDisconnected(1000, '服务异常断开连接'), // 服务异常断开连接
+  permissionDenied(1001, '权限拒绝'); // 权限拒绝
+
+  final int value;
+  final String label;
+
+  const VpnError(this.value, this.label);
+
+  static VpnError fromValue(int value) {
+    return VpnError.values.firstWhere(
+      (e) => e.value == value,
+      orElse: () => VpnError.idle,
+    );
+  }
+}

+ 7 - 3
lib/app/controllers/api_controller.dart

@@ -431,7 +431,11 @@ class ApiController extends GetxService {
     return false;
   }
 
-  Future<Launch> getDispatchInfo({CancelToken? cancelToken}) async {
+  Future<Launch> getDispatchInfo(
+    int locationId,
+    String locationCode, {
+    CancelToken? cancelToken,
+  }) async {
     while (true) {
       try {
         ApiRouter().setbaseUrl(ApiDomains.instance.getRouterUrl());
@@ -443,8 +447,8 @@ class ApiController extends GetxService {
               .toList();
           request['disconnectDomainList'] = disconnectDomainList;
         }
-        request['locationId'] = 187;
-        request['locationCode'] = 'auto_mm';
+        request['locationId'] = locationId;
+        request['locationCode'] = locationCode;
         final result = await ApiRouter().getDispatchInfo(
           request,
           cancelToken: cancelToken,

+ 28 - 25
lib/app/controllers/core_controller.dart

@@ -11,7 +11,7 @@ import '../../utils/log/logger.dart';
 import '../constants/enums.dart';
 import '../data/models/vpn_message.dart';
 import '../dialog/error_dialog.dart';
-import '../widgets/feedback_bottom_sheet.dart';
+import '../dialog/feedback_bottom_sheet.dart';
 import 'api_controller.dart';
 
 class CoreController extends GetxService {
@@ -25,7 +25,7 @@ class CoreController extends GetxService {
   String get timer => _timer.value;
   set timer(String value) => _timer.value = value;
 
-  // 事件流订阅
+  // VPN 事件流订阅
   StreamSubscription<String>? _eventSubscription;
 
   CancelToken? _cancelToken;
@@ -64,7 +64,6 @@ class CoreController extends GetxService {
       getDispatchInfo();
     } else {
       // 断开连接
-      state = ConnectionState.disconnecting;
       CoreApi().disconnect();
     }
   }
@@ -81,7 +80,11 @@ class CoreController extends GetxService {
     _cancelToken = currentToken;
 
     try {
+      final locationId = 187;
+      final locationCode = 'auto_mm';
       final launch = await _apiController.getDispatchInfo(
+        locationId,
+        locationCode,
         cancelToken: currentToken,
       );
 
@@ -158,36 +161,34 @@ class CoreController extends GetxService {
   }
 
   void _handleVpnStatus(VpnStatusMessage message) {
-    log(TAG, 'VPN状态变化: status=${message.status}, message=${message.message}');
-
+    final vpnError = VpnError.fromValue(message.status);
+    log(TAG, 'VPN状态变化: ${vpnError.label}, message=${message.message}');
     // 根据状态码处理不同的VPN状态
-    switch (message.status) {
-      case 0:
+    switch (vpnError) {
+      case VpnError.idle:
         // disconnected
         _onVpnDisconnected();
         break;
-      case 1:
+      case VpnError.connecting:
         // connecting
         _onVpnConnecting();
         break;
-      case 2:
+      case VpnError.connected:
         // connected
         _onVpnConnected();
         break;
-      case 3:
+      case VpnError.error:
         // error
-        _onVpnError(message.message);
+        _onVpnError();
         break;
-      case 4:
-        // disconnecting
-        _onVpnDisconnecting();
+      case VpnError.serviceDisconnected:
+        // service disconnected
+        _onVpnServiceDisconnected();
         break;
-      case 403:
+      case VpnError.permissionDenied:
         // permission denied
         _onVpnPermissionDenied();
         break;
-      default:
-        log(TAG, '未知VPN状态: ${message.status}');
     }
   }
 
@@ -234,20 +235,22 @@ class CoreController extends GetxService {
     HapticFeedbackManager.connectionSuccess();
   }
 
-  void _onVpnError(String errorMessage) {
-    log(TAG, 'VPN连接错误: $errorMessage');
+  void _onVpnError() {
+    log(TAG, 'VPN连接错误');
     // 显示错误信息
     state = ConnectionState.disconnected;
     timer = "00:00:00";
     HapticFeedbackManager.connectionDisconnected();
-    // 可以显示错误提示
-    ErrorDialog.show(message: errorMessage);
   }
 
-  void _onVpnDisconnecting() {
-    log(TAG, 'VPN正在断开连接');
-    // 显示断开连接状态
-    state = ConnectionState.disconnecting;
+  void _onVpnServiceDisconnected() {
+    log(TAG, 'VPN服务异常断开连接');
+    // 显示错误信息
+    state = ConnectionState.disconnected;
+    timer = "00:00:00";
+    HapticFeedbackManager.connectionDisconnected();
+    // 可以显示错误提示
+    ErrorDialog.show(message: 'VPN服务异常断开连接');
   }
 
   void _onVpnPermissionDenied() {

+ 3 - 24
lib/app/data/models/launch/groups.dart

@@ -5,31 +5,21 @@ part 'groups.g.dart';
 
 @freezed
 class Groups with _$Groups {
-  const factory Groups({
-    Normal? normal,
-  }) = _Groups;
+  const factory Groups({Normal? normal, Normal? streaming}) = _Groups;
 
   factory Groups.fromJson(Map<String, Object?> json) => _$GroupsFromJson(json);
 }
 
 @freezed
 class Normal with _$Normal {
-  const factory Normal({
-    List<Tags>? tags,
-    List<LocationList>? list,
-  }) = _Normal;
+  const factory Normal({List<Tags>? tags, List<LocationList>? list}) = _Normal;
 
   factory Normal.fromJson(Map<String, Object?> json) => _$NormalFromJson(json);
 }
 
 @freezed
 class Tags with _$Tags {
-  const factory Tags({
-    int? id,
-    String? name,
-    String? icon,
-    int? sort,
-  }) = _Tags;
+  const factory Tags({int? id, String? name, String? icon, int? sort}) = _Tags;
 
   factory Tags.fromJson(Map<String, Object?> json) => _$TagsFromJson(json);
 }
@@ -54,8 +44,6 @@ class Locations with _$Locations {
     int? id,
     String? name,
     String? code,
-    Param? param,
-    dynamic paramV2,
     String? icon,
     String? country,
     int? sort,
@@ -65,12 +53,3 @@ class Locations with _$Locations {
   factory Locations.fromJson(Map<String, Object?> json) =>
       _$LocationsFromJson(json);
 }
-
-@freezed
-class Param with _$Param {
-  const factory Param({
-    String? g,
-  }) = _Param;
-
-  factory Param.fromJson(Map<String, Object?> json) => _$ParamFromJson(json);
-}

+ 47 - 238
lib/app/data/models/launch/groups.freezed.dart

@@ -22,6 +22,7 @@ Groups _$GroupsFromJson(Map<String, dynamic> json) {
 /// @nodoc
 mixin _$Groups {
   Normal? get normal => throw _privateConstructorUsedError;
+  Normal? get streaming => throw _privateConstructorUsedError;
 
   /// Serializes this Groups to a JSON map.
   Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -37,9 +38,10 @@ abstract class $GroupsCopyWith<$Res> {
   factory $GroupsCopyWith(Groups value, $Res Function(Groups) then) =
       _$GroupsCopyWithImpl<$Res, Groups>;
   @useResult
-  $Res call({Normal? normal});
+  $Res call({Normal? normal, Normal? streaming});
 
   $NormalCopyWith<$Res>? get normal;
+  $NormalCopyWith<$Res>? get streaming;
 }
 
 /// @nodoc
@@ -56,13 +58,17 @@ class _$GroupsCopyWithImpl<$Res, $Val extends Groups>
   /// with the given fields replaced by the non-null parameter values.
   @pragma('vm:prefer-inline')
   @override
-  $Res call({Object? normal = freezed}) {
+  $Res call({Object? normal = freezed, Object? streaming = freezed}) {
     return _then(
       _value.copyWith(
             normal: freezed == normal
                 ? _value.normal
                 : normal // ignore: cast_nullable_to_non_nullable
                       as Normal?,
+            streaming: freezed == streaming
+                ? _value.streaming
+                : streaming // ignore: cast_nullable_to_non_nullable
+                      as Normal?,
           )
           as $Val,
     );
@@ -81,6 +87,20 @@ class _$GroupsCopyWithImpl<$Res, $Val extends Groups>
       return _then(_value.copyWith(normal: value) as $Val);
     });
   }
+
+  /// Create a copy of Groups
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @pragma('vm:prefer-inline')
+  $NormalCopyWith<$Res>? get streaming {
+    if (_value.streaming == null) {
+      return null;
+    }
+
+    return $NormalCopyWith<$Res>(_value.streaming!, (value) {
+      return _then(_value.copyWith(streaming: value) as $Val);
+    });
+  }
 }
 
 /// @nodoc
@@ -91,10 +111,12 @@ abstract class _$$GroupsImplCopyWith<$Res> implements $GroupsCopyWith<$Res> {
   ) = __$$GroupsImplCopyWithImpl<$Res>;
   @override
   @useResult
-  $Res call({Normal? normal});
+  $Res call({Normal? normal, Normal? streaming});
 
   @override
   $NormalCopyWith<$Res>? get normal;
+  @override
+  $NormalCopyWith<$Res>? get streaming;
 }
 
 /// @nodoc
@@ -110,13 +132,17 @@ class __$$GroupsImplCopyWithImpl<$Res>
   /// with the given fields replaced by the non-null parameter values.
   @pragma('vm:prefer-inline')
   @override
-  $Res call({Object? normal = freezed}) {
+  $Res call({Object? normal = freezed, Object? streaming = freezed}) {
     return _then(
       _$GroupsImpl(
         normal: freezed == normal
             ? _value.normal
             : normal // ignore: cast_nullable_to_non_nullable
                   as Normal?,
+        streaming: freezed == streaming
+            ? _value.streaming
+            : streaming // ignore: cast_nullable_to_non_nullable
+                  as Normal?,
       ),
     );
   }
@@ -125,17 +151,19 @@ class __$$GroupsImplCopyWithImpl<$Res>
 /// @nodoc
 @JsonSerializable()
 class _$GroupsImpl with DiagnosticableTreeMixin implements _Groups {
-  const _$GroupsImpl({this.normal});
+  const _$GroupsImpl({this.normal, this.streaming});
 
   factory _$GroupsImpl.fromJson(Map<String, dynamic> json) =>
       _$$GroupsImplFromJson(json);
 
   @override
   final Normal? normal;
+  @override
+  final Normal? streaming;
 
   @override
   String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
-    return 'Groups(normal: $normal)';
+    return 'Groups(normal: $normal, streaming: $streaming)';
   }
 
   @override
@@ -143,7 +171,8 @@ class _$GroupsImpl with DiagnosticableTreeMixin implements _Groups {
     super.debugFillProperties(properties);
     properties
       ..add(DiagnosticsProperty('type', 'Groups'))
-      ..add(DiagnosticsProperty('normal', normal));
+      ..add(DiagnosticsProperty('normal', normal))
+      ..add(DiagnosticsProperty('streaming', streaming));
   }
 
   @override
@@ -151,12 +180,14 @@ class _$GroupsImpl with DiagnosticableTreeMixin implements _Groups {
     return identical(this, other) ||
         (other.runtimeType == runtimeType &&
             other is _$GroupsImpl &&
-            (identical(other.normal, normal) || other.normal == normal));
+            (identical(other.normal, normal) || other.normal == normal) &&
+            (identical(other.streaming, streaming) ||
+                other.streaming == streaming));
   }
 
   @JsonKey(includeFromJson: false, includeToJson: false)
   @override
-  int get hashCode => Object.hash(runtimeType, normal);
+  int get hashCode => Object.hash(runtimeType, normal, streaming);
 
   /// Create a copy of Groups
   /// with the given fields replaced by the non-null parameter values.
@@ -173,12 +204,15 @@ class _$GroupsImpl with DiagnosticableTreeMixin implements _Groups {
 }
 
 abstract class _Groups implements Groups {
-  const factory _Groups({final Normal? normal}) = _$GroupsImpl;
+  const factory _Groups({final Normal? normal, final Normal? streaming}) =
+      _$GroupsImpl;
 
   factory _Groups.fromJson(Map<String, dynamic> json) = _$GroupsImpl.fromJson;
 
   @override
   Normal? get normal;
+  @override
+  Normal? get streaming;
 
   /// Create a copy of Groups
   /// with the given fields replaced by the non-null parameter values.
@@ -874,8 +908,6 @@ mixin _$Locations {
   int? get id => throw _privateConstructorUsedError;
   String? get name => throw _privateConstructorUsedError;
   String? get code => throw _privateConstructorUsedError;
-  Param? get param => throw _privateConstructorUsedError;
-  dynamic get paramV2 => throw _privateConstructorUsedError;
   String? get icon => throw _privateConstructorUsedError;
   String? get country => throw _privateConstructorUsedError;
   int? get sort => throw _privateConstructorUsedError;
@@ -900,15 +932,11 @@ abstract class $LocationsCopyWith<$Res> {
     int? id,
     String? name,
     String? code,
-    Param? param,
-    dynamic paramV2,
     String? icon,
     String? country,
     int? sort,
     int? latency,
   });
-
-  $ParamCopyWith<$Res>? get param;
 }
 
 /// @nodoc
@@ -929,8 +957,6 @@ class _$LocationsCopyWithImpl<$Res, $Val extends Locations>
     Object? id = freezed,
     Object? name = freezed,
     Object? code = freezed,
-    Object? param = freezed,
-    Object? paramV2 = freezed,
     Object? icon = freezed,
     Object? country = freezed,
     Object? sort = freezed,
@@ -950,14 +976,6 @@ class _$LocationsCopyWithImpl<$Res, $Val extends Locations>
                 ? _value.code
                 : code // ignore: cast_nullable_to_non_nullable
                       as String?,
-            param: freezed == param
-                ? _value.param
-                : param // ignore: cast_nullable_to_non_nullable
-                      as Param?,
-            paramV2: freezed == paramV2
-                ? _value.paramV2
-                : paramV2 // ignore: cast_nullable_to_non_nullable
-                      as dynamic,
             icon: freezed == icon
                 ? _value.icon
                 : icon // ignore: cast_nullable_to_non_nullable
@@ -978,20 +996,6 @@ class _$LocationsCopyWithImpl<$Res, $Val extends Locations>
           as $Val,
     );
   }
-
-  /// Create a copy of Locations
-  /// with the given fields replaced by the non-null parameter values.
-  @override
-  @pragma('vm:prefer-inline')
-  $ParamCopyWith<$Res>? get param {
-    if (_value.param == null) {
-      return null;
-    }
-
-    return $ParamCopyWith<$Res>(_value.param!, (value) {
-      return _then(_value.copyWith(param: value) as $Val);
-    });
-  }
 }
 
 /// @nodoc
@@ -1007,16 +1011,11 @@ abstract class _$$LocationsImplCopyWith<$Res>
     int? id,
     String? name,
     String? code,
-    Param? param,
-    dynamic paramV2,
     String? icon,
     String? country,
     int? sort,
     int? latency,
   });
-
-  @override
-  $ParamCopyWith<$Res>? get param;
 }
 
 /// @nodoc
@@ -1036,8 +1035,6 @@ class __$$LocationsImplCopyWithImpl<$Res>
     Object? id = freezed,
     Object? name = freezed,
     Object? code = freezed,
-    Object? param = freezed,
-    Object? paramV2 = freezed,
     Object? icon = freezed,
     Object? country = freezed,
     Object? sort = freezed,
@@ -1057,14 +1054,6 @@ class __$$LocationsImplCopyWithImpl<$Res>
             ? _value.code
             : code // ignore: cast_nullable_to_non_nullable
                   as String?,
-        param: freezed == param
-            ? _value.param
-            : param // ignore: cast_nullable_to_non_nullable
-                  as Param?,
-        paramV2: freezed == paramV2
-            ? _value.paramV2
-            : paramV2 // ignore: cast_nullable_to_non_nullable
-                  as dynamic,
         icon: freezed == icon
             ? _value.icon
             : icon // ignore: cast_nullable_to_non_nullable
@@ -1093,8 +1082,6 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
     this.id,
     this.name,
     this.code,
-    this.param,
-    this.paramV2,
     this.icon,
     this.country,
     this.sort,
@@ -1111,10 +1098,6 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
   @override
   final String? code;
   @override
-  final Param? param;
-  @override
-  final dynamic paramV2;
-  @override
   final String? icon;
   @override
   final String? country;
@@ -1125,7 +1108,7 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
 
   @override
   String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
-    return 'Locations(id: $id, name: $name, code: $code, param: $param, paramV2: $paramV2, icon: $icon, country: $country, sort: $sort, latency: $latency)';
+    return 'Locations(id: $id, name: $name, code: $code, icon: $icon, country: $country, sort: $sort, latency: $latency)';
   }
 
   @override
@@ -1136,8 +1119,6 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
       ..add(DiagnosticsProperty('id', id))
       ..add(DiagnosticsProperty('name', name))
       ..add(DiagnosticsProperty('code', code))
-      ..add(DiagnosticsProperty('param', param))
-      ..add(DiagnosticsProperty('paramV2', paramV2))
       ..add(DiagnosticsProperty('icon', icon))
       ..add(DiagnosticsProperty('country', country))
       ..add(DiagnosticsProperty('sort', sort))
@@ -1152,8 +1133,6 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
             (identical(other.id, id) || other.id == id) &&
             (identical(other.name, name) || other.name == name) &&
             (identical(other.code, code) || other.code == code) &&
-            (identical(other.param, param) || other.param == param) &&
-            const DeepCollectionEquality().equals(other.paramV2, paramV2) &&
             (identical(other.icon, icon) || other.icon == icon) &&
             (identical(other.country, country) || other.country == country) &&
             (identical(other.sort, sort) || other.sort == sort) &&
@@ -1162,18 +1141,8 @@ class _$LocationsImpl with DiagnosticableTreeMixin implements _Locations {
 
   @JsonKey(includeFromJson: false, includeToJson: false)
   @override
-  int get hashCode => Object.hash(
-    runtimeType,
-    id,
-    name,
-    code,
-    param,
-    const DeepCollectionEquality().hash(paramV2),
-    icon,
-    country,
-    sort,
-    latency,
-  );
+  int get hashCode =>
+      Object.hash(runtimeType, id, name, code, icon, country, sort, latency);
 
   /// Create a copy of Locations
   /// with the given fields replaced by the non-null parameter values.
@@ -1194,8 +1163,6 @@ abstract class _Locations implements Locations {
     final int? id,
     final String? name,
     final String? code,
-    final Param? param,
-    final dynamic paramV2,
     final String? icon,
     final String? country,
     final int? sort,
@@ -1212,10 +1179,6 @@ abstract class _Locations implements Locations {
   @override
   String? get code;
   @override
-  Param? get param;
-  @override
-  dynamic get paramV2;
-  @override
   String? get icon;
   @override
   String? get country;
@@ -1231,157 +1194,3 @@ abstract class _Locations implements Locations {
   _$$LocationsImplCopyWith<_$LocationsImpl> get copyWith =>
       throw _privateConstructorUsedError;
 }
-
-Param _$ParamFromJson(Map<String, dynamic> json) {
-  return _Param.fromJson(json);
-}
-
-/// @nodoc
-mixin _$Param {
-  String? get g => throw _privateConstructorUsedError;
-
-  /// Serializes this Param to a JSON map.
-  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
-
-  /// Create a copy of Param
-  /// with the given fields replaced by the non-null parameter values.
-  @JsonKey(includeFromJson: false, includeToJson: false)
-  $ParamCopyWith<Param> get copyWith => throw _privateConstructorUsedError;
-}
-
-/// @nodoc
-abstract class $ParamCopyWith<$Res> {
-  factory $ParamCopyWith(Param value, $Res Function(Param) then) =
-      _$ParamCopyWithImpl<$Res, Param>;
-  @useResult
-  $Res call({String? g});
-}
-
-/// @nodoc
-class _$ParamCopyWithImpl<$Res, $Val extends Param>
-    implements $ParamCopyWith<$Res> {
-  _$ParamCopyWithImpl(this._value, this._then);
-
-  // ignore: unused_field
-  final $Val _value;
-  // ignore: unused_field
-  final $Res Function($Val) _then;
-
-  /// Create a copy of Param
-  /// with the given fields replaced by the non-null parameter values.
-  @pragma('vm:prefer-inline')
-  @override
-  $Res call({Object? g = freezed}) {
-    return _then(
-      _value.copyWith(
-            g: freezed == g
-                ? _value.g
-                : g // ignore: cast_nullable_to_non_nullable
-                      as String?,
-          )
-          as $Val,
-    );
-  }
-}
-
-/// @nodoc
-abstract class _$$ParamImplCopyWith<$Res> implements $ParamCopyWith<$Res> {
-  factory _$$ParamImplCopyWith(
-    _$ParamImpl value,
-    $Res Function(_$ParamImpl) then,
-  ) = __$$ParamImplCopyWithImpl<$Res>;
-  @override
-  @useResult
-  $Res call({String? g});
-}
-
-/// @nodoc
-class __$$ParamImplCopyWithImpl<$Res>
-    extends _$ParamCopyWithImpl<$Res, _$ParamImpl>
-    implements _$$ParamImplCopyWith<$Res> {
-  __$$ParamImplCopyWithImpl(
-    _$ParamImpl _value,
-    $Res Function(_$ParamImpl) _then,
-  ) : super(_value, _then);
-
-  /// Create a copy of Param
-  /// with the given fields replaced by the non-null parameter values.
-  @pragma('vm:prefer-inline')
-  @override
-  $Res call({Object? g = freezed}) {
-    return _then(
-      _$ParamImpl(
-        g: freezed == g
-            ? _value.g
-            : g // ignore: cast_nullable_to_non_nullable
-                  as String?,
-      ),
-    );
-  }
-}
-
-/// @nodoc
-@JsonSerializable()
-class _$ParamImpl with DiagnosticableTreeMixin implements _Param {
-  const _$ParamImpl({this.g});
-
-  factory _$ParamImpl.fromJson(Map<String, dynamic> json) =>
-      _$$ParamImplFromJson(json);
-
-  @override
-  final String? g;
-
-  @override
-  String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
-    return 'Param(g: $g)';
-  }
-
-  @override
-  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
-    super.debugFillProperties(properties);
-    properties
-      ..add(DiagnosticsProperty('type', 'Param'))
-      ..add(DiagnosticsProperty('g', g));
-  }
-
-  @override
-  bool operator ==(Object other) {
-    return identical(this, other) ||
-        (other.runtimeType == runtimeType &&
-            other is _$ParamImpl &&
-            (identical(other.g, g) || other.g == g));
-  }
-
-  @JsonKey(includeFromJson: false, includeToJson: false)
-  @override
-  int get hashCode => Object.hash(runtimeType, g);
-
-  /// Create a copy of Param
-  /// with the given fields replaced by the non-null parameter values.
-  @JsonKey(includeFromJson: false, includeToJson: false)
-  @override
-  @pragma('vm:prefer-inline')
-  _$$ParamImplCopyWith<_$ParamImpl> get copyWith =>
-      __$$ParamImplCopyWithImpl<_$ParamImpl>(this, _$identity);
-
-  @override
-  Map<String, dynamic> toJson() {
-    return _$$ParamImplToJson(this);
-  }
-}
-
-abstract class _Param implements Param {
-  const factory _Param({final String? g}) = _$ParamImpl;
-
-  factory _Param.fromJson(Map<String, dynamic> json) = _$ParamImpl.fromJson;
-
-  @override
-  String? get g;
-
-  /// Create a copy of Param
-  /// with the given fields replaced by the non-null parameter values.
-  @override
-  @JsonKey(includeFromJson: false, includeToJson: false)
-  _$$ParamImplCopyWith<_$ParamImpl> get copyWith =>
-      throw _privateConstructorUsedError;
-}

+ 7 - 13
lib/app/data/models/launch/groups.g.dart

@@ -10,10 +10,16 @@ _$GroupsImpl _$$GroupsImplFromJson(Map<String, dynamic> json) => _$GroupsImpl(
   normal: json['normal'] == null
       ? null
       : Normal.fromJson(json['normal'] as Map<String, dynamic>),
+  streaming: json['streaming'] == null
+      ? null
+      : Normal.fromJson(json['streaming'] as Map<String, dynamic>),
 );
 
 Map<String, dynamic> _$$GroupsImplToJson(_$GroupsImpl instance) =>
-    <String, dynamic>{'normal': instance.normal};
+    <String, dynamic>{
+      'normal': instance.normal,
+      'streaming': instance.streaming,
+    };
 
 _$NormalImpl _$$NormalImplFromJson(Map<String, dynamic> json) => _$NormalImpl(
   tags: (json['tags'] as List<dynamic>?)
@@ -67,10 +73,6 @@ _$LocationsImpl _$$LocationsImplFromJson(Map<String, dynamic> json) =>
       id: (json['id'] as num?)?.toInt(),
       name: json['name'] as String?,
       code: json['code'] as String?,
-      param: json['param'] == null
-          ? null
-          : Param.fromJson(json['param'] as Map<String, dynamic>),
-      paramV2: json['paramV2'],
       icon: json['icon'] as String?,
       country: json['country'] as String?,
       sort: (json['sort'] as num?)?.toInt(),
@@ -82,16 +84,8 @@ Map<String, dynamic> _$$LocationsImplToJson(_$LocationsImpl instance) =>
       'id': instance.id,
       'name': instance.name,
       'code': instance.code,
-      'param': instance.param,
-      'paramV2': instance.paramV2,
       'icon': instance.icon,
       'country': instance.country,
       'sort': instance.sort,
       'latency': instance.latency,
     };
-
-_$ParamImpl _$$ParamImplFromJson(Map<String, dynamic> json) =>
-    _$ParamImpl(g: json['g'] as String?);
-
-Map<String, dynamic> _$$ParamImplToJson(_$ParamImpl instance) =>
-    <String, dynamic>{'g': instance.g};

+ 17 - 40
lib/app/dialog/all_dialog.dart

@@ -83,6 +83,23 @@ class AllDialog {
     );
   }
 
+  /// 显示反馈弹窗
+  static void showFeedback() {
+    CustomDialog.showInfo(
+      title: 'Thank you for your feedback!',
+      message:
+          'We’re sorry you’re not enjoying your experience. We’ll do our best to improve it soon.',
+      buttonText: 'Done',
+      icon: Icons.favorite_border,
+      iconColor: Get.theme.textTheme.bodyLarge!.color,
+      onPressed: () {
+        // 处理邮件发送成功后的逻辑
+        print('Feedback submitted successfully');
+        Navigator.of(Get.context!).pop();
+      },
+    );
+  }
+
   /// 显示自定义成功弹窗
   static void showCustomSuccess({
     required String title,
@@ -155,43 +172,3 @@ class AllDialog {
     );
   }
 }
-
-/// 在控制器中使用弹窗的示例
-class ExampleController extends GetxController {
-  /// 处理Premium激活
-  void handlePremiumActivation() {
-    // 模拟激活过程
-    Future.delayed(const Duration(seconds: 2), () {
-      AllDialog.showPremiumActivated();
-    });
-  }
-
-  /// 处理邮件发送
-  void handleEmailSending() {
-    // 模拟发送过程
-    Future.delayed(const Duration(seconds: 1), () {
-      AllDialog.showEmailSent();
-    });
-  }
-
-  /// 处理网络错误
-  void handleNetworkError() {
-    AllDialog.showNetworkError();
-  }
-
-  /// 处理退出登录
-  void handleLogout() {
-    AllDialog.showLogoutConfirm();
-  }
-
-  /// 处理自定义操作
-  void handleCustomAction() {
-    AllDialog.showCustomSuccess(
-      title: '操作成功',
-      message: '您的操作已成功完成。',
-      onPressed: () {
-        print('自定义操作完成');
-      },
-    );
-  }
-}

+ 5 - 16
lib/app/widgets/feedback_bottom_sheet.dart → lib/app/dialog/feedback_bottom_sheet.dart

@@ -3,7 +3,8 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
 import 'package:get/get.dart';
 import 'package:nomo/app/widgets/click_opacity.dart';
 
-import 'submit_btn.dart';
+import '../widgets/submit_btn.dart';
+import 'all_dialog.dart';
 
 /// 反馈弹窗底部弹出框
 class FeedbackBottomSheet extends StatefulWidget {
@@ -214,8 +215,7 @@ class _FeedbackBottomSheetState extends State<FeedbackBottomSheet>
           selectedIssues.clear();
         });
       },
-      child: AnimatedContainer(
-        duration: const Duration(milliseconds: 200),
+      child: Container(
         width: 56.w,
         height: 56.w,
         decoration: BoxDecoration(
@@ -304,19 +304,8 @@ class _FeedbackBottomSheetState extends State<FeedbackBottomSheet>
 
   /// 处理发送
   void _handleSend() {
-    // TODO: 这里可以添加发送反馈的逻辑
-    Get.back();
-
+    Navigator.of(Get.context!).pop();
     // 显示成功提示
-    Get.snackbar(
-      'Thank you!',
-      'Your feedback has been submitted.',
-      snackPosition: SnackPosition.bottom,
-      backgroundColor: Get.theme.primaryColor,
-      colorText: Colors.white,
-      margin: EdgeInsets.all(16.w),
-      borderRadius: 12.r,
-      duration: const Duration(seconds: 2),
-    );
+    AllDialog.showFeedback();
   }
 }

+ 0 - 10
lib/app/modules/home/widgets/connection_button.dart

@@ -147,16 +147,6 @@ class _ConnectionButtonState extends State<ConnectionButton>
           _isAtTop = true;
         }
         break;
-      case ConnectionState.disconnecting:
-        gradientColor = [
-          Get.reactiveTheme.highlightColor,
-          Get.reactiveTheme.hintColor,
-        ];
-        imgPath = Assets.switchStatusDisconnected;
-        statusImgPath = Assets.disconnected;
-        text = "Disconnecting";
-        textColor = Get.reactiveTheme.hintColor;
-        break;
       case ConnectionState.error:
         gradientColor = [
           Get.reactiveTheme.highlightColor,

+ 51 - 1
lib/app/modules/node/controllers/node_controller.dart

@@ -1,8 +1,15 @@
 import 'package:get/get.dart';
+import '../../../data/models/launch/groups.dart';
+import '../../../data/sp/ix_sp.dart';
 
 class NodeController extends GetxController {
+  // Groups 数据
+  final _groups = Rxn<Groups>();
+  Groups? get groups => _groups.value;
+  set groups(Groups? value) => _groups.value = value;
+
   // 游戏tab列表
-  final _tabTextList = <String>['All', 'Streaming'].obs;
+  final _tabTextList = <String>[].obs;
   List<String> get tabTextList => _tabTextList;
   set tabTextList(List<String> value) => _tabTextList.assignAll(value);
 
@@ -15,6 +22,49 @@ class NodeController extends GetxController {
   // key 格式: "tabIndex_countryCode"
   final Map<String, bool> expandedStates = {};
 
+  @override
+  void onInit() {
+    super.onInit();
+    _loadGroups();
+  }
+
+  /// 加载 Groups 数据
+  void _loadGroups() {
+    final launch = IXSP.getLaunch();
+    if (launch?.groups != null) {
+      groups = launch!.groups;
+      _updateTabList();
+    }
+  }
+
+  /// 更新 Tab 列表
+  void _updateTabList() {
+    final tabs = <String>[];
+
+    // 添加 Normal tab
+    if (groups?.normal != null && groups!.normal!.list?.isNotEmpty == true) {
+      tabs.add('All');
+    }
+
+    // 添加 Streaming tab
+    if (groups?.streaming != null &&
+        groups!.streaming!.list?.isNotEmpty == true) {
+      tabs.add('Streaming');
+    }
+
+    tabTextList = tabs;
+  }
+
+  /// 根据 tab 索引获取对应的数据
+  Normal? getDataByTabIndex(int tabIndex) {
+    if (tabIndex == 0) {
+      return groups?.normal;
+    } else if (tabIndex == 1) {
+      return groups?.streaming;
+    }
+    return null;
+  }
+
   /// 获取国家的展开状态
   bool getExpandedState(int tabIndex, String countryCode) {
     final key = '${tabIndex}_$countryCode';

+ 97 - 161
lib/app/modules/node/widgets/node_list.dart

@@ -7,6 +7,7 @@ import 'package:nomo/app/widgets/click_opacity.dart';
 import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
 
 import '../../../constants/assets.dart';
+import '../../../data/models/launch/groups.dart';
 import '../controllers/node_controller.dart';
 
 class NodeList extends StatefulWidget {
@@ -44,145 +45,95 @@ class _NodeListState extends State<NodeList>
   Widget build(BuildContext context) {
     super.build(context);
 
-    final data = {
-      'Europe': [
-        {
-          'title': 'United Kingdom',
-          'code': 'GB',
-          'cities': [
-            'London',
-            'Edinburgh',
-            'Cardiff',
-            'Liverpool',
-            'Manchester',
-          ],
-        },
-        {
-          'title': 'Italy',
-          'code': 'IT',
-          'cities': ['Rome', 'Milan', 'Naples', 'Turin', 'Genoa'],
-        },
-        {
-          'title': 'Germany',
-          'code': 'DE',
-          'cities': ['Berlin', 'Hamburg', 'Munich', 'Frankfurt', 'Cologne'],
-        },
-      ],
-      'Asia': [
-        {
-          'title': 'China',
-          'code': 'CN',
-          'cities': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu'],
-        },
-        {
-          'title': 'Japan',
-          'code': 'JP',
-          'cities': ['Tokyo', 'Osaka', 'Nagoya', 'Sapporo', 'Fukuoka'],
-        },
-        {
-          'title': 'Korea',
-          'code': 'KR',
-          'cities': ['Seoul', 'Busan', 'Incheon', 'Daegu', 'Gwangju'],
-        },
-        {
-          'title': 'India',
-          'code': 'IN',
-          'cities': ['Mumbai', 'Delhi', 'Bangalore', 'Chennai', 'Hyderabad'],
-        },
-      ],
-      'America': [
-        {
-          'title': 'United States',
-          'code': 'US',
-          'cities': ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Miami'],
-        },
-        {
-          'title': 'Canada',
-          'code': 'CA',
-          'cities': ['Toronto', 'Montreal', 'Vancouver', 'Calgary', 'Edmonton'],
-        },
-        {
-          'title': 'Mexico',
-          'code': 'MX',
-          'cities': [
-            'Mexico City',
-            'Guadalajara',
-            'Monterrey',
-            'Tijuana',
-            'Puebla',
-          ],
-        },
-        {
-          'title': 'Brazil',
-          'code': 'BR',
-          'cities': [
-            'Sao Paulo',
-            'Rio de Janeiro',
-            'Brasilia',
-            'Salvador',
-            'Fortaleza',
-          ],
-        },
-      ],
-    };
+    // 获取当前 tab 的数据
+    final data = controller.getDataByTabIndex(widget.tabIndex);
+
+    if (data == null || data.tags == null || data.list == null) {
+      return Center(
+        child: Text(
+          '暂无数据',
+          style: TextStyle(fontSize: 16.sp, color: Get.reactiveTheme.hintColor),
+        ),
+      );
+    }
+
+    // 按 tag 分组数据
+    final groupedData = <int, List<dynamic>>{};
+    for (var location in data.list!) {
+      if (location.tag != null) {
+        groupedData.putIfAbsent(location.tag!, () => []).add(location);
+      }
+    }
+
+    // 创建 tag 名称映射
+    final tagNameMap = <int, String>{};
+    for (var tag in data.tags!) {
+      if (tag.id != null && tag.name != null) {
+        tagNameMap[tag.id!] = tag.name!;
+      }
+    }
+
+    // 按 sort 排序 tags
+    final sortedTags = data.tags!.toList()
+      ..sort((a, b) => (a.sort ?? 0).compareTo(b.sort ?? 0));
+
     return CustomScrollView(
       slivers: [
-        for (var region in data.entries)
-          SliverStickyHeader(
-            header: Container(
-              color: Get.reactiveTheme.scaffoldBackgroundColor,
-              padding: const EdgeInsets.all(16),
-              child: Text(
-                region.key,
-                style: TextStyle(
-                  color: Get.reactiveTheme.hintColor,
-                  fontSize: 16,
-                  fontWeight: FontWeight.bold,
+        for (var tag in sortedTags)
+          if (groupedData.containsKey(tag.id) &&
+              groupedData[tag.id]!.isNotEmpty)
+            SliverStickyHeader(
+              header: Container(
+                color: Get.reactiveTheme.scaffoldBackgroundColor,
+                padding: const EdgeInsets.all(16),
+                child: Text(
+                  tag.name ?? '',
+                  style: TextStyle(
+                    color: Get.reactiveTheme.hintColor,
+                    fontSize: 16,
+                    fontWeight: FontWeight.bold,
+                  ),
                 ),
               ),
-            ),
-            sliver: SliverList(
-              delegate: SliverChildBuilderDelegate((context, i) {
-                final country = region.value[i];
-                final countryCode = country['code'] as String;
-                final cities = country['cities'] as List<dynamic>;
+              sliver: SliverList(
+                delegate: SliverChildBuilderDelegate((context, i) {
+                  final locationList = groupedData[tag.id]![i];
+                  final countryCode = locationList.icon ?? '';
 
-                return _CountrySection(
-                  title: country['title'] as String,
-                  code: countryCode,
-                  cities: cities,
-                  // 传递展开状态
-                  expanded: _getExpandedState(countryCode),
-                  // 展开状态变化回调
-                  onExpandedChanged: (expanded) {
-                    _setExpandedState(countryCode, expanded);
-                  },
-                );
-              }, childCount: region.value.length),
+                  return _CountrySection(
+                    locationList: locationList,
+                    // 传递展开状态
+                    expanded: _getExpandedState(countryCode),
+                    // 展开状态变化回调
+                    onExpandedChanged: (expanded) {
+                      _setExpandedState(countryCode, expanded);
+                    },
+                  );
+                }, childCount: groupedData[tag.id]!.length),
+              ),
             ),
-          ),
       ],
     );
   }
 }
 
 class _CountrySection extends StatelessWidget {
-  final String title;
-  final String code;
-  final List<dynamic> cities;
+  final LocationList locationList;
   final bool expanded;
   final ValueChanged<bool> onExpandedChanged;
 
   const _CountrySection({
-    required this.title,
-    required this.code,
-    required this.cities,
+    required this.locationList,
     required this.expanded,
     required this.onExpandedChanged,
   });
 
   @override
   Widget build(BuildContext context) {
+    final countryIcon = locationList.icon ?? '';
+    final countryName = locationList.name ?? '';
+    final locations = locationList.locations ?? [];
+
     return Container(
       margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
       decoration: BoxDecoration(
@@ -199,31 +150,17 @@ class _CountrySection extends StatelessWidget {
                 children: [
                   // 国旗图标
                   ClipRRect(
-                    borderRadius: BorderRadius.circular(4.r), // 设置圆角
+                    borderRadius: BorderRadius.circular(4.r),
                     child: SvgPicture.asset(
-                      Assets.getCountryFlagImage(code),
+                      Assets.getCountryFlagImage(countryIcon),
                       width: 32.w,
                       height: 24.w,
                       fit: BoxFit.cover,
-                      // placeholderBuilder: (context) => Container(
-                      //   width: 32.w,
-                      //   height: 24.w,
-                      //   decoration: BoxDecoration(
-                      //     borderRadius: BorderRadius.circular(4.r),
-                      //     color: Colors.grey[200],
-                      //   ),
-                      //   alignment: Alignment.center,
-                      //   child: Icon(
-                      //     Icons.flag,
-                      //     size: 16.w,
-                      //     color: Colors.grey[400],
-                      //   ),
-                      // ),
                     ),
                   ),
                   10.horizontalSpace,
                   Text(
-                    title,
+                    countryName,
                     style: TextStyle(
                       fontSize: 16.sp,
                       height: 1.5,
@@ -256,11 +193,15 @@ class _CountrySection extends StatelessWidget {
                 opacity: expanded ? 1.0 : 0.0,
                 duration: const Duration(milliseconds: 300),
                 child: Column(
-                  children: cities.map((city) {
-                    final cityName = city as String;
+                  children: locations.map((location) {
+                    final locationName = location.name ?? '';
+                    final locationIcon = location.icon ?? countryIcon;
+                    final latency = location.latency;
 
                     return ClickOpacity(
-                      onTap: () {},
+                      onTap: () {
+                        // TODO: 处理节点选择
+                      },
                       child: Column(
                         children: [
                           Divider(
@@ -278,39 +219,34 @@ class _CountrySection extends StatelessWidget {
                             child: Row(
                               children: [
                                 ClipRRect(
-                                  borderRadius: BorderRadius.circular(
-                                    4.r,
-                                  ), // 设置圆角
+                                  borderRadius: BorderRadius.circular(4.r),
                                   child: SvgPicture.asset(
-                                    Assets.getCountryFlagImage(code),
+                                    Assets.getCountryFlagImage(locationIcon),
                                     width: 32.w,
                                     height: 24.w,
                                     fit: BoxFit.cover,
-                                    // placeholderBuilder: (context) => Container(
-                                    //   width: 32.w,
-                                    //   height: 24.w,
-                                    //   decoration: BoxDecoration(
-                                    //     borderRadius: BorderRadius.circular(4.r),
-                                    //     color: Colors.grey[200],
-                                    //   ),
-                                    //   alignment: Alignment.center,
-                                    //   child: Icon(
-                                    //     Icons.flag,
-                                    //     size: 16.w,
-                                    //     color: Colors.grey[400],
-                                    //   ),
-                                    // ),
                                   ),
                                 ),
                                 10.horizontalSpace,
-                                Text(
-                                  cityName,
-                                  style: TextStyle(
-                                    fontSize: 14.sp,
-                                    fontWeight: FontWeight.w500,
-                                    color: Get.reactiveTheme.hintColor,
+                                Expanded(
+                                  child: Text(
+                                    locationName,
+                                    style: TextStyle(
+                                      fontSize: 14.sp,
+                                      fontWeight: FontWeight.w500,
+                                      color: Get.reactiveTheme.hintColor,
+                                    ),
                                   ),
                                 ),
+                                // 显示延迟
+                                if (latency != null && latency > 0)
+                                  Text(
+                                    '${latency}ms',
+                                    style: TextStyle(
+                                      fontSize: 12.sp,
+                                      color: Get.reactiveTheme.hintColor,
+                                    ),
+                                  ),
                               ],
                             ),
                           ),