多角色的配置适配

This commit is contained in:
2026-09-14 12:35:20 +08:00
parent 3547f80383
commit a1e01da241
11 changed files with 333 additions and 187 deletions

View File

@@ -24,4 +24,8 @@ class EnvConfig {
// ─── HTTP 接口 ─── // ─── HTTP 接口 ───
static int get httpPort => isProduction ? 8081 : 59003; static int get httpPort => isProduction ? 8081 : 59003;
static String get baseUrl => 'http://$serverHost:$httpPort'; static String get baseUrl => 'http://$serverHost:$httpPort';
// ─── MQTT(一方后端:任务消息 / 割草机实时状态,prod=1883 / test=59007)───
// 注:第三方无人机 OSD(droneOsd,WebSocket 8083)不纳入环境管理,保持硬编码
static int get mqttPort => isProduction ? 1883 : 59007;
} }

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../../../env/env_config.dart';
/// MQTT 传输协议类型 /// MQTT 传输协议类型
enum MqttProtocol { enum MqttProtocol {
/// 纯 TCP 连接(原生 MQTT) /// 纯 TCP 连接(原生 MQTT)
@@ -67,11 +69,11 @@ class MqttConfig extends Equatable {
); );
} }
/// 任务状态消息(TCP MQTT) /// 任务状态消息(TCP MQTT)——端口随环境切换(prod=1883 / test=59007)
factory MqttConfig.taskMessage() { factory MqttConfig.taskMessage() {
return const MqttConfig( return MqttConfig(
host: '1.95.137.212', host: EnvConfig.serverHost,
port: 1883, port: EnvConfig.mqttPort,
username: 'maibu', username: 'maibu',
password: 'jsmbzn520', password: 'jsmbzn520',
protocol: MqttProtocol.tcp, 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 /// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post
factory MqttConfig.mowerRealtime() { factory MqttConfig.mowerRealtime() {
return const MqttConfig( return MqttConfig(
host: '1.95.137.212', host: EnvConfig.serverHost,
port: 59007, port: EnvConfig.mqttPort,
username: 'maibu', username: 'maibu',
password: 'jsmbzn520', password: 'jsmbzn520',
protocol: MqttProtocol.tcp, protocol: MqttProtocol.tcp,

View File

@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
import '../../../../core/storage/user_storage.dart'; import '../../../../core/storage/user_storage.dart';
import '../../domain/repositories/path_repository.dart'; import '../../domain/repositories/path_repository.dart';
@@ -47,7 +48,7 @@ class PathRepositoryImpl implements PathRepository {
required String userId, required String userId,
required String jsonData, required String jsonData,
}) async { }) 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 headers = {'Content-Type': 'application/json'};
final body = jsonEncode({ final body = jsonEncode({
'workName': workName, 'workName': workName,
@@ -74,7 +75,7 @@ class PathRepositoryImpl implements PathRepository {
}) async { }) async {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final url = Uri.parse( 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()}); ).replace(queryParameters: {'userId': userId, '_t': timestamp.toString()});
try { try {
@@ -103,7 +104,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final url = final url =
Uri.parse( Uri.parse(
'http://1.95.137.212:59003/iot/workRecord/deleteByWorkName', '${HttpApiConsts.baseUrl}/iot/workRecord/deleteByWorkName',
).replace( ).replace(
queryParameters: {'workName': workName, '_t': timestamp.toString()}, queryParameters: {'workName': workName, '_t': timestamp.toString()},
); );
@@ -132,7 +133,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final url = final url =
Uri.parse( Uri.parse(
'http://1.95.137.212:59003/iot/workRecord/selectByWorkName', '${HttpApiConsts.baseUrl}/iot/workRecord/selectByWorkName',
).replace( ).replace(
queryParameters: {'workName': workName, '_t': timestamp.toString()}, queryParameters: {'workName': workName, '_t': timestamp.toString()},
); );
@@ -268,7 +269,7 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final url = final url =
Uri.parse( Uri.parse(
'http://1.95.137.212:59003/iot/workRecord/selectBySiteId', '${HttpApiConsts.baseUrl}/iot/workRecord/selectBySiteId',
).replace( ).replace(
queryParameters: { queryParameters: {
'siteId': siteId.toString(), 'siteId': siteId.toString(),
@@ -508,7 +509,7 @@ class PathRepositoryImpl implements PathRepository {
} }
/// 鍒涘缓璁惧<E79281>浠诲姟锛堥€氳繃鎺ュ彛鎵ц<E98EB5>浣滀笟锛? /// 鍒涘缓璁惧<E79281>浠诲姟锛堥€氳繃鎺ュ彛鎵ц<E98EB5>浣滀笟锛?
/// 鎺ュ彛鍦板潃: http://1.95.137.212:59003/iot/deviceTask/createDeviceTask /// 鎺ュ彛鍦板潃: ${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask
/// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5} /// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5}
/// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔<E68DA2>D /// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔<E68DA2>D
@override @override
@@ -531,7 +532,7 @@ class PathRepositoryImpl implements PathRepository {
try { try {
final response = await dio.post( final response = await dio.post(
'http://1.95.137.212:59003/iot/deviceTask/createDeviceTask', '${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask',
data: body, data: body,
); );

View File

@@ -102,6 +102,9 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
taskPool: taskList, taskPool: taskList,
currentTask: currentTask, currentTask: currentTask,
currentTaskId: currentTask?.id, currentTaskId: currentTask?.id,
// 🔥 活跃任务为空时必须显式清空,否则 copyWith 会保留旧任务导致卡片一直显示
clearCurrentTask: currentTask == null,
clearCurrentTaskId: currentTask == null,
activeTasks: activeTasks, activeTasks: activeTasks,
), ),
); );
@@ -398,7 +401,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
/// 清除当前任务 /// 清除当前任务
void clearCurrentTask() { void clearCurrentTask() {
emit(state.copyWith(currentTask: null, currentTaskId: null)); emit(state.copyWith(clearCurrentTask: true, clearCurrentTaskId: true));
_logger.logWithLevel('🧹 清除当前任务'); _logger.logWithLevel('🧹 清除当前任务');
} }

View File

@@ -32,7 +32,9 @@ class DeviceTaskState extends Equatable {
DeviceTaskState copyWith({ DeviceTaskState copyWith({
List<DeviceTaskEntity>? taskPool, List<DeviceTaskEntity>? taskPool,
DeviceTaskEntity? currentTask, DeviceTaskEntity? currentTask,
bool clearCurrentTask = false, // 🔥 显式清空 currentTask(copyWith 传 null 无法置空)
int? currentTaskId, int? currentTaskId,
bool clearCurrentTaskId = false, // 🔥 显式清空 currentTaskId
List<DeviceTaskEntity>? activeTasks, // 🔥 新增 List<DeviceTaskEntity>? activeTasks, // 🔥 新增
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
@@ -41,8 +43,9 @@ class DeviceTaskState extends Equatable {
}) { }) {
return DeviceTaskState( return DeviceTaskState(
taskPool: taskPool ?? this.taskPool, taskPool: taskPool ?? this.taskPool,
currentTask: currentTask ?? this.currentTask, currentTask: clearCurrentTask ? null : (currentTask ?? this.currentTask),
currentTaskId: currentTaskId ?? this.currentTaskId, currentTaskId:
clearCurrentTaskId ? null : (currentTaskId ?? this.currentTaskId),
activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增 activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, errorMessage: errorMessage,

View File

@@ -183,6 +183,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
LatLng _mapCenter = const LatLng(39.9042, 116.4074); LatLng _mapCenter = const LatLng(39.9042, 116.4074);
double _headingAngle = 0.0; // 当前机器航向角(单位:度) double _headingAngle = 0.0; // 当前机器航向角(单位:度)
// 🔥 地图 zoom 日志节流:上次已打印过的 zoom 值(变化 ≥ 0.1 才再打)
double? _lastLoggedZoom;
List typedPathList = List typedPathList =
<work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng) <work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng)
@@ -274,6 +277,34 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}); });
} }
} }
// 🔥 新增: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 刷新 // 🌐 场站高清图层:监听动态范围更新 + 触发一次 GetCapabilities 刷新
@@ -687,6 +718,47 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
} }
} }
/// 🔥 选中路线任务时,自动校验"上次执行的任务"是否仍然存在
/// 若任务已被取消/完成/不存在,则清除当前任务,避免面板显示过期任务卡片
Future<void> _validateCurrentTaskOnSelect() async {
try {
final targetDevice =
context.read<RemoteControlCubit>().state.targetDevice;
final deviceId = targetDevice?.deviceName;
if (deviceId == null || deviceId.isEmpty) return;
final taskCubit = sl<DeviceTaskCubit>();
// 优先取内存中的 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<LatLng> points) { LatLngBounds? calculateBounds(List<LatLng> points) {
if (points.isEmpty) return null; if (points.isEmpty) return null;
@@ -1833,6 +1905,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
_selectedPlot = plot; _selectedPlot = plot;
}); });
// 🔥 选中任务时自动校验上次的任务是否还存在:不存在则不展示(等同手动刷新)
_validateCurrentTaskOnSelect();
try { try {
_traceManager.reset(); _traceManager.reset();
@@ -2081,7 +2156,25 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
'🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}', '🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}',
); );
if (allPoints.isNotEmpty) { 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 { } else {
debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!'); debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!');
} }
@@ -3262,8 +3355,10 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
gctracePoint?.clear(); gctracePoint?.clear();
tracePoint = []; tracePoint = [];
gctracePoint = []; gctracePoint = [];
// 🔥 取消任务后消除规划路径显示,只保留原先的边界框(gcjOuterPoints)
gcjPathPoints = [];
}); });
debugPrint('[停止作业] 最终 setState 完成,轨迹已清空'); debugPrint('[停止作业] 最终 setState 完成,轨迹与路径已清空,保留边界框');
} }
debugPrint('══════════ [停止作业] 结束 ══════════'); debugPrint('══════════ [停止作业] 结束 ══════════');

View File

@@ -50,6 +50,40 @@ class TabConfig extends Equatable {
return enabled; return enabled;
} }
/// roleKey == 'personal'(个人角色):底部导航与「系统设置 → Tab 设置」强制只保留
/// 「设备(device) + 我的(me)」,且常显、开关锁定不可关闭;其他角色不受影响。
static const String personalRoleKey = 'personal';
static const Set<String> personalVisibleTabIds = {'device', 'me'};
/// 底部导航实际渲染的 Tab:
/// - personal:强制返回 设备 + 我的(忽略 isEnabled,恒为启用),按 order 排序
/// - 其他角色:返回用户启用的 Tab(enabledItems)
List<TabConfigItem> 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<TabConfigItem> 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<TabConfigItem>? items}) { TabConfig copyWith({List<TabConfigItem>? items}) {
return TabConfig(items: items ?? this.items); return TabConfig(items: items ?? this.items);
} }

View File

@@ -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/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/di/injection.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.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 { class MainWrapper extends StatefulWidget {
const MainWrapper({super.key}); const MainWrapper({super.key});
@@ -30,8 +31,6 @@ class MainWrapper extends StatefulWidget {
class _MainWrapperState extends State<MainWrapper> { class _MainWrapperState extends State<MainWrapper> {
int _currentIndex = 0; int _currentIndex = 0;
final PageController _pageController = PageController(); final PageController _pageController = PageController();
final List<GlobalKey> _tabKeys = [];
double? _circleLeft;
@override @override
void initState() { void initState() {
@@ -39,22 +38,6 @@ class _MainWrapperState extends State<MainWrapper> {
// 🔥 确保 FloatBarSettingService 已初始化 // 🔥 确保 FloatBarSettingService 已初始化
GetIt.I<FloatBarSettingService>(); GetIt.I<FloatBarSettingService>();
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 @override
@@ -68,52 +51,36 @@ class _MainWrapperState extends State<MainWrapper> {
_currentIndex = index; _currentIndex = index;
}); });
_pageController.jumpToPage(index); _pageController.jumpToPage(index);
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateCirclePosition();
});
} }
IconData _getIconData(String iconName) { /// Tab 图标:选中用 filled(实心),未选用 outlined(描边),对比更清晰
IconData _navIcon(String iconName, bool selected) {
switch (iconName) { switch (iconName) {
case 'home_rounded': case 'home_rounded':
return Icons.home_rounded; return selected ? Icons.home_rounded : Icons.home_outlined;
case 'grid_view_rounded': case 'grid_view_rounded':
return Icons.grid_view_rounded; return selected ? Icons.grid_view_rounded : Icons.grid_view_outlined;
case 'devices_rounded': case 'devices_rounded':
return Icons.devices_rounded; return selected ? Icons.devices_rounded : Icons.devices_outlined;
case 'auto_awesome_rounded': case 'auto_awesome_rounded':
return Icons.auto_awesome_rounded; return selected
? Icons.auto_awesome_rounded
: Icons.auto_awesome_outlined;
case 'warning_amber_rounded': case 'warning_amber_rounded':
return Icons.warning_amber_rounded; return selected
? Icons.warning_amber_rounded
: Icons.warning_amber_outlined;
case 'assignment_rounded': case 'assignment_rounded':
return Icons.assignment_rounded; return selected ? Icons.assignment_rounded : Icons.assignment_outlined;
case 'post_add_rounded': case 'post_add_rounded':
return Icons.post_add_rounded; return selected ? Icons.post_add_rounded : Icons.post_add_outlined;
case 'person_rounded': case 'person_rounded':
return Icons.person_rounded; return selected ? Icons.person_rounded : Icons.person_outlined;
default: 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) { Widget _buildCurrentPage(TabConfigItem tab) {
switch (tab.id) { switch (tab.id) {
case 'home_v2': case 'home_v2':
@@ -147,6 +114,8 @@ class _MainWrapperState extends State<MainWrapper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 🔥 个人角色(roleKey == 'personal')底部导航强制只显示「设备 + 我的」
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
return BlocBuilder<TabConfigCubit, TabConfigState>( return BlocBuilder<TabConfigCubit, TabConfigState>(
builder: (context, state) { builder: (context, state) {
if (state is! TabConfigLoaded) { if (state is! TabConfigLoaded) {
@@ -156,7 +125,7 @@ class _MainWrapperState extends State<MainWrapper> {
} }
final config = state.config; final config = state.config;
final enabledItems = config.enabledItems; final enabledItems = config.navItemsForRole(roleKey);
if (enabledItems.isEmpty) { if (enabledItems.isEmpty) {
return const Scaffold(body: Center(child: Text('请至少启用一个 Tab'))); return const Scaffold(body: Center(child: Text('请至少启用一个 Tab')));
@@ -166,19 +135,10 @@ class _MainWrapperState extends State<MainWrapper> {
_currentIndex = 0; _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( return Scaffold(
extendBody: false, extendBody: false,
// 底部导航预留区(SafeArea 那条)与页面同色,消除突兀的底色带
backgroundColor: context.appColors.pageBackground,
body: Stack( body: Stack(
children: [ children: [
PageView( PageView(
@@ -205,102 +165,138 @@ class _MainWrapperState extends State<MainWrapper> {
), ),
], ],
), ),
bottomNavigationBar: Container( bottomNavigationBar: SafeArea(
decoration: BoxDecoration( top: false,
color: context.appColors.cardBackground, child: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: SafeArea(
top: false,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return Container( final itemCount = enabledItems.length;
margin: const EdgeInsets.symmetric( // Tab 较少时收缩整体宽度并居中,Tab 多时铺满可用宽度
horizontal: 12, const double preferredTabWidth = 84;
vertical: 16, const double barPaddingH = 6;
), final double maxBarWidth = constraints.maxWidth;
padding: const EdgeInsets.symmetric(vertical: 2), final double barWidth =
decoration: BoxDecoration( itemCount * preferredTabWidth < maxBarWidth
color: context.appColors.cardBackground, ? itemCount * preferredTabWidth
borderRadius: BorderRadius.circular(25), : maxBarWidth;
border: Border.all( final double tabWidth =
color: context.appColors.divider, (barWidth - barPaddingH * 2) / itemCount;
width: 1, // 选中高亮:包裹「图标 + 文字」的紧凑胶囊 chip
), const double indicatorH = 46;
boxShadow: [ final double indicatorW =
BoxShadow( tabWidth - 8 < 54 ? tabWidth - 8 : 54.0;
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;
return Expanded( // 用 Align + heightFactor 只水平居中,高度贴合内容;
key: _tabKeys[index], // 切勿用 Center:它在底部栏有界高度约束下会撑满全屏,把 body 挤没
child: InkWell( return Align(
onTap: () => _onTabChanged(index), alignment: Alignment.center,
child: SizedBox( heightFactor: 1.0,
height: 56, child: Container(
child: Column( width: barWidth,
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.symmetric(
mainAxisAlignment: MainAxisAlignment.center, horizontal: barPaddingH,
children: [ vertical: 2,
Icon( ),
_getIconData(item.icon), decoration: BoxDecoration(
size: 24, color: context.appColors.cardBackground,
color: isSelected borderRadius: BorderRadius.circular(30),
? context.appColors.textPrimary border: Border.all(
: context.appColors.textTertiary, color: context.appColors.divider,
), width: 1,
const SizedBox(height: 4), ),
Text( boxShadow: [
item.name, BoxShadow(
style: TextStyle( color: context.appColors.cardShadow,
fontSize: 11, blurRadius: 12,
color: isSelected offset: const Offset(0, 4),
? context.appColors.textPrimary ),
: context.appColors.textTertiary, ],
fontWeight: isSelected ),
? FontWeight.w600 child: SizedBox(
: FontWeight.w400, 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(),
),
],
), ),
], ),
), ),
); );
}, },

View File

@@ -73,7 +73,8 @@ class _MyPageContent extends StatelessWidget {
final userName = user?.nickname ?? user?.username ?? '未知用户'; final userName = user?.nickname ?? user?.username ?? '未知用户';
final userRole = user?.email ?? user?.phone ?? '未设置'; final userRole = user?.email ?? user?.phone ?? '未设置';
return Column( return SingleChildScrollView(
child: Column(
children: [ children: [
// 用户信息卡片(延伸到状态栏) // 用户信息卡片(延伸到状态栏)
UserProfileCard( UserProfileCard(
@@ -124,11 +125,9 @@ class _MyPageContent extends StatelessWidget {
), ),
], ],
), ),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
// 末尾追加退出登录,占据“关于我们”原来的位置 // 末尾追加退出登录,占据“关于我们”原来的位置
itemCount: state.menuItems.length + 1, itemCount: state.menuItems.length + 1,
@@ -160,6 +159,7 @@ class _MyPageContent extends StatelessWidget {
), ),
), ),
], ],
),
); );
} }

View File

@@ -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/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.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/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/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_cubit.dart';
import 'package:maibu_satabot_v2/core/update/update_state.dart'; import 'package:maibu_satabot_v2/core/update/update_state.dart';
@@ -146,6 +148,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
/// Tab 设置 /// Tab 设置
Widget _buildTabSettingsSection() { Widget _buildTabSettingsSection() {
// 🔥 个人角色(roleKey == 'personal'):Tab 设置只列出「设备 + 我的」,且开关常开锁定
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
final isPersonal = roleKey == TabConfig.personalRoleKey;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -177,7 +182,7 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final tabs = state.config.items; final tabs = state.config.settingItemsForRole(roleKey);
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
@@ -199,10 +204,13 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
), ),
), ),
Switch( Switch(
value: tab.isEnabled, // 个人角色:设备/我的 强制常开且不可关闭
onChanged: (value) { value: isPersonal ? true : tab.isEnabled,
context.read<TabConfigCubit>().toggleTab(tab.id); onChanged: isPersonal
}, ? null
: (value) {
context.read<TabConfigCubit>().toggleTab(tab.id);
},
activeColor: const Color(0xFF165DFF), activeColor: const Color(0xFF165DFF),
), ),
], ],

View File

@@ -132,10 +132,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -944,18 +944,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -1493,10 +1493,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
time: time:
dependency: transitive dependency: transitive
description: description: