From a1e01da241c7918bb864bcebd17b84c8af8d573a Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Mon, 14 Sep 2026 12:35:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E8=A7=92=E8=89=B2=E7=9A=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/env/env_config.dart | 4 + .../mqtt/domain/models/mqtt_config.dart | 18 +- .../generate_path_repository_Impl.dart | 15 +- .../presentation/bloc/device_task_cubit.dart | 5 +- .../presentation/bloc/device_task_state.dart | 7 +- .../widgets/map/testmap_pages.dart | 99 +++++- .../main_container/domain/tab_config.dart | 34 ++ .../presentation/main_wrapper.dart | 296 +++++++++--------- .../v2/my/presentation/pages/my_page.dart | 8 +- .../pages/system_settings_page.dart | 18 +- pubspec.lock | 16 +- 11 files changed, 333 insertions(+), 187 deletions(-) diff --git a/lib/core/env/env_config.dart b/lib/core/env/env_config.dart index c9bdfd1d..0d1204e8 100644 --- a/lib/core/env/env_config.dart +++ b/lib/core/env/env_config.dart @@ -24,4 +24,8 @@ class EnvConfig { // ─── HTTP 接口 ─── static int get httpPort => isProduction ? 8081 : 59003; static String get baseUrl => 'http://$serverHost:$httpPort'; + + // ─── MQTT(一方后端:任务消息 / 割草机实时状态,prod=1883 / test=59007)─── + // 注:第三方无人机 OSD(droneOsd,WebSocket 8083)不纳入环境管理,保持硬编码 + static int get mqttPort => isProduction ? 1883 : 59007; } diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart index 8447c12f..77cae25d 100644 --- a/lib/core/network/mqtt/domain/models/mqtt_config.dart +++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../../../env/env_config.dart'; + /// MQTT 传输协议类型 enum MqttProtocol { /// 纯 TCP 连接(原生 MQTT) @@ -67,11 +69,11 @@ class MqttConfig extends Equatable { ); } - /// 任务状态消息(TCP MQTT) + /// 任务状态消息(TCP MQTT)——端口随环境切换(prod=1883 / test=59007) factory MqttConfig.taskMessage() { - return const MqttConfig( - host: '1.95.137.212', - port: 1883, + return MqttConfig( + host: EnvConfig.serverHost, + port: EnvConfig.mqttPort, username: 'maibu', password: 'jsmbzn520', protocol: MqttProtocol.tcp, @@ -81,12 +83,12 @@ class MqttConfig extends Equatable { ); } - /// 🔥 割草机机器状态(TCP MQTT)——独立连接,地址 1.95.137.212:59007 + /// 🔥 割草机机器状态(TCP MQTT)——独立连接,端口随环境切换(prod=1883 / test=59007) /// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post factory MqttConfig.mowerRealtime() { - return const MqttConfig( - host: '1.95.137.212', - port: 59007, + return MqttConfig( + host: EnvConfig.serverHost, + port: EnvConfig.mqttPort, username: 'maibu', password: 'jsmbzn520', protocol: MqttProtocol.tcp, diff --git a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart index 68f1b83a..2880adbf 100644 --- a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart +++ b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:xml/xml.dart'; +import '../../../../core/consts/http_api_consts.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/storage/user_storage.dart'; import '../../domain/repositories/path_repository.dart'; @@ -47,7 +48,7 @@ class PathRepositoryImpl implements PathRepository { required String userId, required String jsonData, }) async { - final url = Uri.parse('http://1.95.137.212:59003/iot/workRecord/add'); + final url = Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add'); final headers = {'Content-Type': 'application/json'}; final body = jsonEncode({ 'workName': workName, @@ -74,7 +75,7 @@ class PathRepositoryImpl implements PathRepository { }) async { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'http://1.95.137.212:59003/iot/workRecord/selectByUserId', + '${HttpApiConsts.baseUrl}/iot/workRecord/selectByUserId', ).replace(queryParameters: {'userId': userId, '_t': timestamp.toString()}); try { @@ -103,7 +104,7 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'http://1.95.137.212:59003/iot/workRecord/deleteByWorkName', + '${HttpApiConsts.baseUrl}/iot/workRecord/deleteByWorkName', ).replace( queryParameters: {'workName': workName, '_t': timestamp.toString()}, ); @@ -132,7 +133,7 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'http://1.95.137.212:59003/iot/workRecord/selectByWorkName', + '${HttpApiConsts.baseUrl}/iot/workRecord/selectByWorkName', ).replace( queryParameters: {'workName': workName, '_t': timestamp.toString()}, ); @@ -268,7 +269,7 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'http://1.95.137.212:59003/iot/workRecord/selectBySiteId', + '${HttpApiConsts.baseUrl}/iot/workRecord/selectBySiteId', ).replace( queryParameters: { 'siteId': siteId.toString(), @@ -508,7 +509,7 @@ class PathRepositoryImpl implements PathRepository { } /// 鍒涘缓璁惧�浠诲姟锛堥€氳繃鎺ュ彛鎵ц�浣滀笟锛? - /// 鎺ュ彛鍦板潃: http://1.95.137.212:59003/iot/deviceTask/createDeviceTask + /// 鎺ュ彛鍦板潃: ${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask /// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5} /// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔�D @override @@ -531,7 +532,7 @@ class PathRepositoryImpl implements PathRepository { try { final response = await dio.post( - 'http://1.95.137.212:59003/iot/deviceTask/createDeviceTask', + '${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask', data: body, ); diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart index eb314c24..b2a0f0d7 100644 --- a/lib/features/devices/presentation/bloc/device_task_cubit.dart +++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart @@ -102,6 +102,9 @@ class DeviceTaskCubit extends Cubit { taskPool: taskList, currentTask: currentTask, currentTaskId: currentTask?.id, + // 🔥 活跃任务为空时必须显式清空,否则 copyWith 会保留旧任务导致卡片一直显示 + clearCurrentTask: currentTask == null, + clearCurrentTaskId: currentTask == null, activeTasks: activeTasks, ), ); @@ -398,7 +401,7 @@ class DeviceTaskCubit extends Cubit { /// 清除当前任务 void clearCurrentTask() { - emit(state.copyWith(currentTask: null, currentTaskId: null)); + emit(state.copyWith(clearCurrentTask: true, clearCurrentTaskId: true)); _logger.logWithLevel('🧹 清除当前任务'); } diff --git a/lib/features/devices/presentation/bloc/device_task_state.dart b/lib/features/devices/presentation/bloc/device_task_state.dart index 911239d6..3787e1ae 100644 --- a/lib/features/devices/presentation/bloc/device_task_state.dart +++ b/lib/features/devices/presentation/bloc/device_task_state.dart @@ -32,7 +32,9 @@ class DeviceTaskState extends Equatable { DeviceTaskState copyWith({ List? taskPool, DeviceTaskEntity? currentTask, + bool clearCurrentTask = false, // 🔥 显式清空 currentTask(copyWith 传 null 无法置空) int? currentTaskId, + bool clearCurrentTaskId = false, // 🔥 显式清空 currentTaskId List? activeTasks, // 🔥 新增 bool? isLoading, String? errorMessage, @@ -41,8 +43,9 @@ class DeviceTaskState extends Equatable { }) { return DeviceTaskState( taskPool: taskPool ?? this.taskPool, - currentTask: currentTask ?? this.currentTask, - currentTaskId: currentTaskId ?? this.currentTaskId, + currentTask: clearCurrentTask ? null : (currentTask ?? this.currentTask), + currentTaskId: + clearCurrentTaskId ? null : (currentTaskId ?? this.currentTaskId), activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增 isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index eb6e2568..e2875956 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -183,6 +183,9 @@ class _MapPageEnterpriseState extends State { LatLng _mapCenter = const LatLng(39.9042, 116.4074); double _headingAngle = 0.0; // 当前机器航向角(单位:度) + + // 🔥 地图 zoom 日志节流:上次已打印过的 zoom 值(变化 ≥ 0.1 才再打) + double? _lastLoggedZoom; List typedPathList = []; // 存储生成路径的坐标列表(已转换为LatLng) @@ -274,6 +277,34 @@ class _MapPageEnterpriseState extends State { }); } } + + // 🔥 新增:zoom 变化日志(节流) + // 用途:方便开发者手动 pinch/双击缩放到“理想级别”,从日志里读出 zoom 数值, + // 反向调整 fitBounds 的 maxZoom(或其他缩放相关参数)。 + // 节流策略:只有 zoom 变化 ≥ 0.1 才打一次,避免拖拽/缩放过程中刷屏。 + // 事件来源(event.source)可区分“用户手势”还是“代码调用”: + // - dragUpdate / pinchZoomUpdate / doubleTapZoomAnimation* → 用户交互 + // - custom → controller.move / fitBounds 等代码调用 + if (mounted && _mapController.camera != null) { + final camera = _mapController.camera!; + final currentZoom = camera.zoom; + final shouldLog = + _lastLoggedZoom == null || + (currentZoom - _lastLoggedZoom!).abs() >= 0.1; + if (shouldLog) { + final delta = + _lastLoggedZoom == null + ? 'init' + : '${currentZoom > _lastLoggedZoom! ? "↑放大" : "↓缩小"} ' + '${(currentZoom - _lastLoggedZoom!).toStringAsFixed(2)}'; + debugPrint( + '🔍 [地图缩放] zoom=${currentZoom.toStringAsFixed(3)} ($delta), ' + 'center=(${camera.center.latitude.toStringAsFixed(6)}, ${camera.center.longitude.toStringAsFixed(6)}), ' + 'source=${event.source}', + ); + _lastLoggedZoom = currentZoom; + } + } }); // 🌐 场站高清图层:监听动态范围更新 + 触发一次 GetCapabilities 刷新 @@ -687,6 +718,47 @@ class _MapPageEnterpriseState extends State { } } + /// 🔥 选中路线任务时,自动校验"上次执行的任务"是否仍然存在 + /// 若任务已被取消/完成/不存在,则清除当前任务,避免面板显示过期任务卡片 + Future _validateCurrentTaskOnSelect() async { + try { + final targetDevice = + context.read().state.targetDevice; + final deviceId = targetDevice?.deviceName; + if (deviceId == null || deviceId.isEmpty) return; + + final taskCubit = sl(); + + // 优先取内存中的 taskId,其次从本地恢复(仅用于存在性校验,不提前构造卡片,避免闪现假任务) + var previousTaskId = taskCubit.state.currentTaskId; + if (previousTaskId == null) { + previousTaskId = await _restoreTaskIdFromLocal(deviceId); + } + + // 没有历史任务,无需校验,也不展示卡片 + if (previousTaskId == null) return; + + debugPrint( + '🔄 [选中校验] 校验上次任务是否存在, deviceId=$deviceId, taskId=$previousTaskId', + ); + await taskCubit.fetchAndFilterTask(deviceId); + + // 🔥 查询后如果上次的任务已不在活跃任务中,清除当前任务,卡片不再展示 + final stillExists = taskCubit.state.activeTasks.any( + (t) => t.id == previousTaskId, + ); + if (!stillExists) { + debugPrint('🔄 [选中校验] 任务已不存在,清除当前任务与本地缓存'); + taskCubit.clearCurrentTask(); + await _clearTaskIdFromLocal(deviceId); + } else { + debugPrint('🔄 [选中校验] 任务仍存在,保留显示'); + } + } catch (e) { + debugPrint('🔄 [选中校验] 校验失败: $e'); + } + } + // ========== 新增:计算坐标列表的边界范围 ========== LatLngBounds? calculateBounds(List points) { if (points.isEmpty) return null; @@ -1833,6 +1905,9 @@ class _MapPageEnterpriseState extends State { _selectedPlot = plot; }); + // 🔥 选中任务时自动校验上次的任务是否还存在:不存在则不展示(等同手动刷新) + _validateCurrentTaskOnSelect(); + try { _traceManager.reset(); @@ -2081,7 +2156,25 @@ class _MapPageEnterpriseState extends State { '🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}', ); if (allPoints.isNotEmpty) { - moveMapToPointsCenter(allPoints); + // 🔥 选中路线时自动定位到路径区域: + // - center = bounds.center = 金黄线(gcjPathPoints)+ 灰色边框(gcjOuterPoints)所有点的 + // bounding box 几何中心,保证路径永远居中,不会因屏幕当前位置而偏移 + // - zoom = 21.0 固定值(开发者实测的理想级别):不管路径大小都缩到同一级别,视觉一致 + // - ⚠️ zoom=21 超过 ESRI 底图 19 级上限,底图可能白或靠场站高清叠加层显示(开发者已确认可接受) + // - 一次性操作,不锁交互:用户之后仍可自由 pinch/drag 缩放平移 + final bounds = calculateBounds(allPoints); + if (bounds != null) { + const double targetZoom = 21.0; + final zoomBefore = _mapController.camera?.zoom; + _mapController.move(bounds.center, targetZoom); + debugPrint( + '🗺️ [选中地块] move 完成: zoom=$zoomBefore → $targetZoom, ' + 'center=${bounds.center} (路径几何中心)', + ); + } else { + debugPrint('⚠️ [选中地块] bounds 计算失败,回退到仅居中(保持原缩放)'); + moveMapToPointsCenter(allPoints); + } } else { debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!'); } @@ -3262,8 +3355,10 @@ class _MapPageEnterpriseState extends State { gctracePoint?.clear(); tracePoint = []; gctracePoint = []; + // 🔥 取消任务后消除规划路径显示,只保留原先的边界框(gcjOuterPoints) + gcjPathPoints = []; }); - debugPrint('[停止作业] 最终 setState 完成,轨迹已清空'); + debugPrint('[停止作业] 最终 setState 完成,轨迹与路径已清空,保留边界框'); } debugPrint('══════════ [停止作业] 结束 ══════════'); diff --git a/lib/features/main_container/domain/tab_config.dart b/lib/features/main_container/domain/tab_config.dart index c002cda6..e644ebc1 100644 --- a/lib/features/main_container/domain/tab_config.dart +++ b/lib/features/main_container/domain/tab_config.dart @@ -50,6 +50,40 @@ class TabConfig extends Equatable { return enabled; } + /// roleKey == 'personal'(个人角色):底部导航与「系统设置 → Tab 设置」强制只保留 + /// 「设备(device) + 我的(me)」,且常显、开关锁定不可关闭;其他角色不受影响。 + static const String personalRoleKey = 'personal'; + static const Set personalVisibleTabIds = {'device', 'me'}; + + /// 底部导航实际渲染的 Tab: + /// - personal:强制返回 设备 + 我的(忽略 isEnabled,恒为启用),按 order 排序 + /// - 其他角色:返回用户启用的 Tab(enabledItems) + List navItemsForRole(String? roleKey) { + if (roleKey == personalRoleKey) { + final forced = items + .where((item) => personalVisibleTabIds.contains(item.id)) + .map((item) => item.copyWith(isEnabled: true)) + .toList(); + forced.sort((a, b) => a.order.compareTo(b.order)); + return forced; + } + return enabledItems; + } + + /// 「Tab 设置」列表应展示的 Tab: + /// - personal:只列出 设备 + 我的,按 order 排序 + /// - 其他角色:列出全部 + List settingItemsForRole(String? roleKey) { + if (roleKey == personalRoleKey) { + final visible = items + .where((item) => personalVisibleTabIds.contains(item.id)) + .toList(); + visible.sort((a, b) => a.order.compareTo(b.order)); + return visible; + } + return items; + } + TabConfig copyWith({List? items}) { return TabConfig(items: items ?? this.items); } diff --git a/lib/features/main_container/presentation/main_wrapper.dart b/lib/features/main_container/presentation/main_wrapper.dart index 9f21abfb..be8d7424 100644 --- a/lib/features/main_container/presentation/main_wrapper.dart +++ b/lib/features/main_container/presentation/main_wrapper.dart @@ -19,6 +19,7 @@ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; class MainWrapper extends StatefulWidget { const MainWrapper({super.key}); @@ -30,8 +31,6 @@ class MainWrapper extends StatefulWidget { class _MainWrapperState extends State { int _currentIndex = 0; final PageController _pageController = PageController(); - final List _tabKeys = []; - double? _circleLeft; @override void initState() { @@ -39,22 +38,6 @@ class _MainWrapperState extends State { // 🔥 确保 FloatBarSettingService 已初始化 GetIt.I(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); - } - - void _updateCirclePosition() { - if (_tabKeys.isEmpty || _currentIndex >= _tabKeys.length) return; - - final key = _tabKeys[_currentIndex]; - final renderBox = key.currentContext?.findRenderObject() as RenderBox?; - if (renderBox != null) { - setState(() { - _circleLeft = renderBox.localToGlobal(Offset.zero).dx; - }); - } } @override @@ -68,52 +51,36 @@ class _MainWrapperState extends State { _currentIndex = index; }); _pageController.jumpToPage(index); - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); } - IconData _getIconData(String iconName) { + /// Tab 图标:选中用 filled(实心),未选用 outlined(描边),对比更清晰 + IconData _navIcon(String iconName, bool selected) { switch (iconName) { case 'home_rounded': - return Icons.home_rounded; + return selected ? Icons.home_rounded : Icons.home_outlined; case 'grid_view_rounded': - return Icons.grid_view_rounded; + return selected ? Icons.grid_view_rounded : Icons.grid_view_outlined; case 'devices_rounded': - return Icons.devices_rounded; + return selected ? Icons.devices_rounded : Icons.devices_outlined; case 'auto_awesome_rounded': - return Icons.auto_awesome_rounded; + return selected + ? Icons.auto_awesome_rounded + : Icons.auto_awesome_outlined; case 'warning_amber_rounded': - return Icons.warning_amber_rounded; + return selected + ? Icons.warning_amber_rounded + : Icons.warning_amber_outlined; case 'assignment_rounded': - return Icons.assignment_rounded; + return selected ? Icons.assignment_rounded : Icons.assignment_outlined; case 'post_add_rounded': - return Icons.post_add_rounded; + return selected ? Icons.post_add_rounded : Icons.post_add_outlined; case 'person_rounded': - return Icons.person_rounded; + return selected ? Icons.person_rounded : Icons.person_outlined; default: - return Icons.home_rounded; + return selected ? Icons.home_rounded : Icons.home_outlined; } } - double _getIndicatorPosition(int itemCount, int currentIndex) { - final screenWidth = MediaQuery.of(context).size.width; - final itemWidth = screenWidth / itemCount; - return currentIndex * itemWidth; - } - - double _getCircularLeftPosition(int itemCount, int currentIndex) { - final screenWidth = MediaQuery.of(context).size.width; - final itemWidth = screenWidth / itemCount; - // 圆形应该在每个Tab项的中心 - final tabCenterX = 12 + (currentIndex * itemWidth) + (itemWidth / 2); - final circularLeft = tabCenterX - 22; // 22 = 44/2,让圆形中心对齐Tab中心 - - // 边界限制 - final maxLeft = screenWidth - 12 - 44; - return circularLeft.clamp(12.0, maxLeft); - } - Widget _buildCurrentPage(TabConfigItem tab) { switch (tab.id) { case 'home_v2': @@ -147,6 +114,8 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { + // 🔥 个人角色(roleKey == 'personal')底部导航强制只显示「设备 + 我的」 + final roleKey = context.watch().state.user?.roleKey; return BlocBuilder( builder: (context, state) { if (state is! TabConfigLoaded) { @@ -156,7 +125,7 @@ class _MainWrapperState extends State { } final config = state.config; - final enabledItems = config.enabledItems; + final enabledItems = config.navItemsForRole(roleKey); if (enabledItems.isEmpty) { return const Scaffold(body: Center(child: Text('请至少启用一个 Tab'))); @@ -166,19 +135,10 @@ class _MainWrapperState extends State { _currentIndex = 0; } - // 初始化 GlobalKey - if (_tabKeys.length != enabledItems.length) { - _tabKeys.clear(); - for (int i = 0; i < enabledItems.length; i++) { - _tabKeys.add(GlobalKey()); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateCirclePosition(); - }); - } - return Scaffold( extendBody: false, + // 底部导航预留区(SafeArea 那条)与页面同色,消除突兀的底色带 + backgroundColor: context.appColors.pageBackground, body: Stack( children: [ PageView( @@ -205,102 +165,138 @@ class _MainWrapperState extends State { ), ], ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: context.appColors.cardBackground, - ), - child: SafeArea( - top: false, + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: LayoutBuilder( builder: (context, constraints) { - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 16, - ), - padding: const EdgeInsets.symmetric(vertical: 2), - decoration: BoxDecoration( - color: context.appColors.cardBackground, - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: context.appColors.divider, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: context.appColors.cardShadow, - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Stack( - children: [ - // 滑动圆形背景 - if (_circleLeft != null) - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - left: _circleLeft! - 8, // 减去外层 margin + 向右偏移 - top: 6, - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: context.appColors.divider, - shape: BoxShape.circle, - border: Border.all( - color: context.appColors.divider, - width: 1, - ), - ), - ), - ), - // Tab 项 - Row( - children: enabledItems.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final isSelected = _currentIndex == index; + final itemCount = enabledItems.length; + // Tab 较少时收缩整体宽度并居中,Tab 多时铺满可用宽度 + const double preferredTabWidth = 84; + const double barPaddingH = 6; + final double maxBarWidth = constraints.maxWidth; + final double barWidth = + itemCount * preferredTabWidth < maxBarWidth + ? itemCount * preferredTabWidth + : maxBarWidth; + final double tabWidth = + (barWidth - barPaddingH * 2) / itemCount; + // 选中高亮:包裹「图标 + 文字」的紧凑胶囊 chip + const double indicatorH = 46; + final double indicatorW = + tabWidth - 8 < 54 ? tabWidth - 8 : 54.0; - return Expanded( - key: _tabKeys[index], - child: InkWell( - onTap: () => _onTabChanged(index), - child: SizedBox( - height: 56, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _getIconData(item.icon), - size: 24, - color: isSelected - ? context.appColors.textPrimary - : context.appColors.textTertiary, - ), - const SizedBox(height: 4), - Text( - item.name, - style: TextStyle( - fontSize: 11, - color: isSelected - ? context.appColors.textPrimary - : context.appColors.textTertiary, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ], + // 用 Align + heightFactor 只水平居中,高度贴合内容; + // 切勿用 Center:它在底部栏有界高度约束下会撑满全屏,把 body 挤没 + return Align( + alignment: Alignment.center, + heightFactor: 1.0, + child: Container( + width: barWidth, + padding: const EdgeInsets.symmetric( + horizontal: barPaddingH, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.appColors.cardBackground, + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: context.appColors.divider, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: context.appColors.cardShadow, + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: SizedBox( + height: 56, + child: Stack( + children: [ + // 蓝色胶囊 chip:纯数学定位,居中包裹图标 + 文字 + AnimatedPositioned( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + left: _currentIndex * tabWidth + + (tabWidth - indicatorW) / 2, + top: (56 - indicatorH) / 2, + width: indicatorW, + height: indicatorH, + child: Container( + decoration: BoxDecoration( + color: context.appColors.primary.withOpacity( + 0.12, + ), + borderRadius: BorderRadius.circular( + indicatorH / 2, ), ), ), - ); - }).toList(), + ), + // Tab 项 + Row( + children: + enabledItems.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = _currentIndex == index; + + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular( + indicatorH / 2, + ), + onTap: () => _onTabChanged(index), + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + AnimatedScale( + scale: isSelected ? 1.1 : 1.0, + duration: const Duration( + milliseconds: 200, + ), + curve: Curves.easeOutBack, + child: Icon( + _navIcon(item.icon, isSelected), + size: 22, + color: isSelected + ? context.appColors.primary + : context + .appColors.textTertiary, + ), + ), + const SizedBox(height: 2), + AnimatedDefaultTextStyle( + duration: const Duration( + milliseconds: 200, + ), + style: TextStyle( + fontSize: 11, + height: 1.0, + color: isSelected + ? context.appColors.primary + : context + .appColors.textTertiary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + child: Text(item.name), + ), + ], + ), + ), + ); + }).toList(), + ), + ], ), - ], + ), ), ); }, diff --git a/lib/features/v2/my/presentation/pages/my_page.dart b/lib/features/v2/my/presentation/pages/my_page.dart index b2f3e849..ffbcfd91 100644 --- a/lib/features/v2/my/presentation/pages/my_page.dart +++ b/lib/features/v2/my/presentation/pages/my_page.dart @@ -73,7 +73,8 @@ class _MyPageContent extends StatelessWidget { final userName = user?.nickname ?? user?.username ?? '未知用户'; final userRole = user?.email ?? user?.phone ?? '未设置'; - return Column( + return SingleChildScrollView( + child: Column( children: [ // 用户信息卡片(延伸到状态栏) UserProfileCard( @@ -124,11 +125,9 @@ class _MyPageContent extends StatelessWidget { ), ], ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.5, - ), child: ListView.builder( shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, // 末尾追加退出登录,占据“关于我们”原来的位置 itemCount: state.menuItems.length + 1, @@ -160,6 +159,7 @@ class _MyPageContent extends StatelessWidget { ), ), ], + ), ); } diff --git a/lib/features/v2/my/presentation/pages/system_settings_page.dart b/lib/features/v2/my/presentation/pages/system_settings_page.dart index 0ed8c71d..1ea7767a 100644 --- a/lib/features/v2/my/presentation/pages/system_settings_page.dart +++ b/lib/features/v2/my/presentation/pages/system_settings_page.dart @@ -10,6 +10,8 @@ import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; +import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; import 'package:maibu_satabot_v2/core/update/update_cubit.dart'; import 'package:maibu_satabot_v2/core/update/update_state.dart'; @@ -146,6 +148,9 @@ class _SystemSettingsPageState extends State { /// Tab 设置 Widget _buildTabSettingsSection() { + // 🔥 个人角色(roleKey == 'personal'):Tab 设置只列出「设备 + 我的」,且开关常开锁定 + final roleKey = context.watch().state.user?.roleKey; + final isPersonal = roleKey == TabConfig.personalRoleKey; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -177,7 +182,7 @@ class _SystemSettingsPageState extends State { return const Center(child: CircularProgressIndicator()); } - final tabs = state.config.items; + final tabs = state.config.settingItemsForRole(roleKey); return ListView.builder( shrinkWrap: true, @@ -199,10 +204,13 @@ class _SystemSettingsPageState extends State { ), ), Switch( - value: tab.isEnabled, - onChanged: (value) { - context.read().toggleTab(tab.id); - }, + // 个人角色:设备/我的 强制常开且不可关闭 + value: isPersonal ? true : tab.isEnabled, + onChanged: isPersonal + ? null + : (value) { + context.read().toggleTab(tab.id); + }, activeColor: const Color(0xFF165DFF), ), ], diff --git a/pubspec.lock b/pubspec.lock index 39be5755..251afdf1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -132,10 +132,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -944,18 +944,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.flutter-io.cn" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1493,10 +1493,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.7" time: dependency: transitive description: