From 90f318e246e0ba1eb3d9fc049b1888ccbf00a96d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 17 Jun 2026 23:13:05 +0800 Subject: [PATCH] chore: release client-v1.0.57 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备/状态管理屏(查看本店在线设备/会话,管理员可强制下线)、登录携带设备信息、 会话心跳(/auth/ping)、被踢下线/会话失效提示、顶栏精简 + 用户名移至左侧栏底部。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG-client.md | 9 + client/lib/core/api/api_client.dart | 16 +- client/lib/core/auth/auth_state.dart | 19 ++ client/lib/core/router/app_router.dart | 5 + client/lib/models/session.dart | 83 ++++++ client/lib/providers/session_heartbeat.dart | 40 +++ client/lib/providers/session_provider.dart | 51 ++++ client/lib/repositories/auth_repository.dart | 6 + .../lib/repositories/session_repository.dart | 21 ++ client/lib/screens/auth/login_screen.dart | 11 + .../devices/device_management_screen.dart | 237 ++++++++++++++++++ client/lib/screens/shell/app_shell.dart | 81 +++--- 12 files changed, 547 insertions(+), 32 deletions(-) create mode 100644 client/lib/models/session.dart create mode 100644 client/lib/providers/session_heartbeat.dart create mode 100644 client/lib/providers/session_provider.dart create mode 100644 client/lib/repositories/session_repository.dart create mode 100644 client/lib/screens/devices/device_management_screen.dart diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 7a6fa30..d26cb85 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.57] - 2026-06-17 + +### 新功能 +- 新增「设备 / 状态管理」页面:可查看本店当前在线的设备与登录会话(用户、平台、登录时间、最近活跃、在线状态);管理员/超级管理员可一键将指定设备强制下线 + +### 改进 +- 顶栏精简,不再显示门店号;用户名移到左侧导航栏底部,点击可查看门店 / 账号 / 版本信息 +- 账号在其他设备登录或会话失效时,自动退出并给出明确提示 + ## [1.0.56] - 2026-06-17 ### 新功能 diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index a9b76f6..cc386fd 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -33,8 +33,11 @@ final apiClientProvider = Provider((ref) { onTokenRefreshed: (newToken) { ref.read(authStateProvider.notifier).updateAccessToken(newToken); }, - onAuthFailed: () { + onAuthFailed: (reason) { if (ref.read(authStateProvider).isLoggedIn) { + if (reason != null) { + ref.read(sessionEndedMessageProvider.notifier).state = reason; + } ref.read(authStateProvider.notifier).logout(); } }, @@ -54,7 +57,7 @@ class ApiClient { String? token, String? refreshToken, void Function(String newToken)? onTokenRefreshed, - void Function()? onAuthFailed, + void Function(String? reason)? onAuthFailed, void Function()? onConnectionError, }) { _dio = Dio(BaseOptions( @@ -110,7 +113,14 @@ class ApiClient { return handler.resolve(retryResp); } catch (refreshErr) { debugPrint('[ApiClient] refresh failed: $refreshErr'); - if (!_disposed) onAuthFailed?.call(); + // 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示 + String? reason; + if (refreshErr is DioException && + refreshErr.response?.data is Map && + refreshErr.response?.data['code'] == 'SESSION_REVOKED') { + reason = '您的账号已在其他设备登录,或登录已失效,请重新登录'; + } + if (!_disposed) onAuthFailed?.call(reason); } } else if (e.response?.statusCode == 401) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index 6170989..c0f5a4c 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -1,6 +1,8 @@ +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../config/app_config.dart'; const _kAccessToken = 'access_token'; const _kRefreshToken = 'refresh_token'; @@ -111,6 +113,20 @@ class AuthNotifier extends StateNotifier { Future logout() async { debugPrint('[Auth] logout() called! stack: ${StackTrace.current}'); + // 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出) + final token = state.user?.accessToken; + if (token != null && token.isNotEmpty) { + try { + await Dio(BaseOptions( + baseUrl: AppConfig.apiBaseUrl, + connectTimeout: const Duration(seconds: 4), + receiveTimeout: const Duration(seconds: 4), + headers: {'Authorization': 'Bearer $token'}, + )).post('/auth/logout'); + } catch (_) { + // ignore: 后端不可达或会话已失效都无所谓 + } + } final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kAccessToken); await prefs.remove(_kRefreshToken); @@ -127,6 +143,9 @@ final authStateProvider = StateNotifierProvider( (ref) => AuthNotifier(), ); +/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。 +final sessionEndedMessageProvider = StateProvider((ref) => null); + /// 当前登录用户是否为只读角色(role == 'readonly')。 /// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮, /// 后端亦有 middleware.ReadOnly() 兜底返回 403。 diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index a756de2..e4bf2a2 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ import '../../screens/public/public_product_screen.dart'; import '../../screens/public/public_shop_products_screen.dart'; import '../../screens/settings/settings_screen.dart'; import '../../screens/about/about_screen.dart'; +import '../../screens/devices/device_management_screen.dart'; import '../auth/auth_state.dart'; Page _noTransition(Widget child) => @@ -142,6 +143,10 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/settings', pageBuilder: (_, __) => _noTransition(const SettingsScreen())), + GoRoute( + path: '/devices', + pageBuilder: (_, __) => + _noTransition(const DeviceManagementScreen())), GoRoute( path: '/about', pageBuilder: (_, __) => _noTransition(const AboutScreen())), diff --git a/client/lib/models/session.dart b/client/lib/models/session.dart new file mode 100644 index 0000000..25ccde6 --- /dev/null +++ b/client/lib/models/session.dart @@ -0,0 +1,83 @@ +/// 在线会话/设备(对应后端 service.SessionView)。 +class DeviceSession { + final int id; + final int userId; + final String username; + final String realName; + final String platform; + final String platformClass; + final String deviceName; + final String ip; + final DateTime? createdAt; + final DateTime? lastSeenAt; + final bool online; + final bool isCurrent; + + const DeviceSession({ + required this.id, + required this.userId, + required this.username, + required this.realName, + required this.platform, + required this.platformClass, + required this.deviceName, + required this.ip, + required this.createdAt, + required this.lastSeenAt, + required this.online, + required this.isCurrent, + }); + + factory DeviceSession.fromJson(Map json) { + DateTime? parse(String? s) => + (s == null || s.isEmpty) ? null : DateTime.tryParse(s)?.toLocal(); + return DeviceSession( + id: (json['id'] as num).toInt(), + userId: (json['user_id'] as num?)?.toInt() ?? 0, + username: json['username'] as String? ?? '', + realName: json['real_name'] as String? ?? '', + platform: json['platform'] as String? ?? '', + platformClass: json['platform_class'] as String? ?? '', + deviceName: json['device_name'] as String? ?? '', + ip: json['ip'] as String? ?? '', + createdAt: parse(json['created_at'] as String?), + lastSeenAt: parse(json['last_seen_at'] as String?), + online: json['online'] as bool? ?? false, + isCurrent: json['is_current'] as bool? ?? false, + ); + } + + /// 平台中文显示名。 + String get platformLabel { + switch (platform) { + case 'windows': + return 'Windows'; + case 'macos': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'android': + return 'Android'; + case 'ios': + return 'iOS'; + case 'web': + return '网页版'; + default: + return platform.isEmpty ? '未知' : platform; + } + } + + /// 平台类中文显示名。 + String get platformClassLabel { + switch (platformClass) { + case 'desktop': + return '桌面端'; + case 'mobile': + return '移动端'; + case 'web': + return '网页端'; + default: + return platformClass; + } + } +} diff --git a/client/lib/providers/session_heartbeat.dart b/client/lib/providers/session_heartbeat.dart new file mode 100644 index 0000000..84a29d2 --- /dev/null +++ b/client/lib/providers/session_heartbeat.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; + +/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。 +/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401, +/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout, +/// 因此空闲用户也能在 ~30s 内感知到被下线。 +final sessionHeartbeatProvider = Provider((ref) { + final hb = SessionHeartbeat(ref); + ref.onDispose(hb.dispose); + hb.start(); + return hb; +}); + +class SessionHeartbeat { + final Ref _ref; + Timer? _timer; + + SessionHeartbeat(this._ref); + + void start() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) => _ping()); + } + + Future _ping() async { + if (!_ref.read(authStateProvider).isLoggedIn) return; + try { + await _ref.read(apiClientProvider).post('/auth/ping'); + } catch (e) { + // 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略 + debugPrint('[Heartbeat] ping failed: $e'); + } + } + + void dispose() => _timer?.cancel(); +} diff --git a/client/lib/providers/session_provider.dart b/client/lib/providers/session_provider.dart new file mode 100644 index 0000000..b46ed50 --- /dev/null +++ b/client/lib/providers/session_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../models/session.dart'; +import '../repositories/session_repository.dart'; + +final sessionRepositoryProvider = Provider((ref) { + return SessionRepository(ref.watch(apiClientProvider)); +}); + +final sessionListProvider = + AsyncNotifierProvider>( + SessionListNotifier.new, +); + +class SessionListNotifier extends AsyncNotifier> { + List _cache = []; + + @override + Future> build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + try { + final result = await ref.read(sessionRepositoryProvider).list(); + _cache = result; + return result; + } catch (_) { + if (_cache.isNotEmpty) return _cache; + rethrow; + } + } + + Future reload() async { + state = const AsyncValue.loading(); + try { + final result = await ref.read(sessionRepositoryProvider).list(); + _cache = result; + state = AsyncValue.data(result); + } catch (e, st) { + if (_cache.isNotEmpty) { + state = AsyncValue.data(_cache); + } else { + state = AsyncValue.error(e, st); + } + } + } + + Future forceLogout(int id) async { + await ref.read(sessionRepositoryProvider).forceLogout(id); + await reload(); + } +} diff --git a/client/lib/repositories/auth_repository.dart b/client/lib/repositories/auth_repository.dart index 732b6bf..38bfd57 100644 --- a/client/lib/repositories/auth_repository.dart +++ b/client/lib/repositories/auth_repository.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; import '../core/api/api_client.dart'; import '../core/auth/auth_state.dart'; +import '../core/device/device_id.dart'; typedef AuthLoginFn = Future Function({ required String shopCode, @@ -37,10 +38,15 @@ class AuthRepository { ); } try { + final deviceId = await DeviceId.get(); + final platform = DeviceId.platformName; final resp = await PublicApiClient.post('/auth/login', data: { 'shop_code': shopCode, 'username': username, 'password': password, + 'device_id': deviceId, + 'device_name': platform, + 'platform': platform, }); final data = resp.data['data'] as Map; diff --git a/client/lib/repositories/session_repository.dart b/client/lib/repositories/session_repository.dart new file mode 100644 index 0000000..7b38d09 --- /dev/null +++ b/client/lib/repositories/session_repository.dart @@ -0,0 +1,21 @@ +import '../core/api/api_client.dart'; +import '../models/session.dart'; + +class SessionRepository { + final ApiClient _client; + SessionRepository(this._client); + + /// GET /api/v1/sessions —— 本店在线会话(所有登录用户只读) + Future> list() async { + final resp = await _client.get('/sessions'); + final data = (resp.data['data'] as List?) ?? []; + return data + .map((e) => DeviceSession.fromJson(e as Map)) + .toList(); + } + + /// DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin) + Future forceLogout(int id) async { + await _client.delete('/sessions/$id'); + } +} diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index e87e5ba..281b52a 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -244,6 +244,17 @@ class _LoginScreenState extends ConsumerState { @override Widget build(BuildContext context) { + // 被踢下线 / 会话失效:登出后跳回登录页,弹一次提示并清空 + ref.listen(sessionEndedMessageProvider, (prev, next) { + if (next != null && next.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(next), backgroundColor: AppTheme.danger)); + ref.read(sessionEndedMessageProvider.notifier).state = null; + }); + } + }); return Scaffold( backgroundColor: AppTheme.primaryDark, body: Stack( diff --git a/client/lib/screens/devices/device_management_screen.dart b/client/lib/screens/devices/device_management_screen.dart new file mode 100644 index 0000000..7e83bc6 --- /dev/null +++ b/client/lib/screens/devices/device_management_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../core/auth/auth_state.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/session.dart'; +import '../../providers/session_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/mobile_list_card.dart'; + +/// 设备 / 状态管理:本店在线设备/会话列表。 +/// 所有登录用户只读;管理员/超级管理员可「强制下线」其他登录者。 +class DeviceManagementScreen extends ConsumerWidget { + const DeviceManagementScreen({super.key}); + + static final _fmt = DateFormat('MM-dd HH:mm'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncSessions = ref.watch(sessionListProvider); + final role = ref.watch(authStateProvider.select((s) => s.user?.role)); + final canKick = role == 'admin' || role == 'superadmin'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), + child: Row( + children: [ + const Text('设备 / 状态管理', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + Text( + canKick ? '管理员可强制下线其他登录设备' : '仅查看,无操作权限', + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary), + ), + const Spacer(), + IconButton( + tooltip: '刷新', + icon: const Icon(Icons.refresh, size: 20), + onPressed: () => + ref.read(sessionListProvider.notifier).reload(), + ), + ], + ), + ), + Expanded( + child: asyncSessions.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + Text('加载失败:$e', + style: + const TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(sessionListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (sessions) => + _buildTable(context, ref, sessions, canKick), + ), + ), + ], + ); + } + + Widget _buildTable(BuildContext context, WidgetRef ref, + List sessions, bool canKick) { + Widget onlineBadge(bool online) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, + size: 9, + color: online ? AppTheme.success : AppTheme.textSecondary), + const SizedBox(width: 4), + Text(online ? '在线' : '离线', + style: TextStyle( + fontSize: 13, + color: + online ? AppTheme.success : AppTheme.textSecondary)), + ], + ); + + String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t); + + Widget? kickButton(DeviceSession s, {bool dense = false}) { + if (!canKick) return null; + if (s.isCurrent) { + return const Text('当前设备', + style: TextStyle(fontSize: 12, color: AppTheme.textSecondary)); + } + return TextButton( + key: Key('btn_kick_${s.id}'), + onPressed: () => _confirmKick(context, ref, s), + child: Text('强制下线', + style: TextStyle( + fontSize: dense ? 13 : 12, color: AppTheme.danger)), + ); + } + + Widget sessionCard(DeviceSession s) { + final action = kickButton(s, dense: true); + return MobileListCard( + title: Text(s.username.isEmpty ? '用户#${s.userId}' : s.username), + subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'), + trailing: onlineBadge(s.online), + fields: [ + if (s.deviceName.isNotEmpty) MobileCardField('设备', s.deviceName), + if (s.ip.isNotEmpty) MobileCardField('IP', s.ip), + MobileCardField('登录时间', fmt(s.createdAt)), + MobileCardField('最近活跃', fmt(s.lastSeenAt)), + if (s.isCurrent) const MobileCardField('备注', '当前设备'), + ], + actions: action == null ? null : [action], + ); + } + + return DataTableCard( + mobileCards: sessions.map(sessionCard).toList(), + columns: const [ + DataColumn(label: Text('用户')), + DataColumn(label: Text('平台')), + DataColumn(label: Text('设备')), + DataColumn(label: Text('IP')), + DataColumn(label: Text('登录时间')), + DataColumn(label: Text('最近活跃')), + DataColumn(label: Text('状态')), + DataColumn(label: Text('操作')), + ], + rows: sessions.isEmpty + ? [ + const DataRow(cells: [ + DataCell(Text('暂无在线设备', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : sessions.map((s) { + return DataRow(cells: [ + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(s.username.isEmpty ? '用户#${s.userId}' : s.username, + style: + const TextStyle(fontWeight: FontWeight.w500)), + if (s.isCurrent) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.primary.withAlpha(26), + borderRadius: BorderRadius.circular(4), + ), + child: const Text('本机', + style: TextStyle( + fontSize: 11, color: AppTheme.primary)), + ), + ], + ], + )), + DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')), + DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)), + DataCell(Text(s.ip.isEmpty ? '-' : s.ip, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(fmt(s.createdAt))), + DataCell(Text(fmt(s.lastSeenAt))), + DataCell(onlineBadge(s.online)), + DataCell(kickButton(s) ?? const SizedBox()), + ]); + }).toList(), + ); + } + + Future _confirmKick( + BuildContext context, WidgetRef ref, DeviceSession s) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('强制下线'), + content: Text( + '确认将「${s.username.isEmpty ? '用户#${s.userId}' : s.username}」的' + '${s.platformLabel}设备下线?该设备约 30 秒内退出登录。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('强制下线'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await ref.read(sessionListProvider.notifier).forceLogout(s.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已下线'), backgroundColor: AppTheme.success)); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('操作失败:$e'), backgroundColor: AppTheme.danger)); + } + } + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 67ab9fc..6e124e4 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -11,6 +11,7 @@ import '../../core/config/app_config.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../providers/connectivity_provider.dart'; +import '../../providers/session_heartbeat.dart'; import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; import '../../core/update/app_updater.dart'; @@ -72,6 +73,7 @@ class _AppShellState extends ConsumerState { path: '/finance'), _NavItem(icon: Icons.people, label: '往来单位', path: '/partners'), _NavItem(icon: Icons.category, label: '基础数据', path: '/products'), + _NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'), _NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'), _NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'), ]; @@ -166,6 +168,8 @@ class _AppShellState extends ConsumerState { @override Widget build(BuildContext context) { final user = ref.watch(authStateProvider).user; + // 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线 + ref.watch(sessionHeartbeatProvider); final isOnline = ref.watch(connectivityProvider); final location = GoRouterState.of(context).matchedLocation; final isMobile = context.isMobile; @@ -237,35 +241,7 @@ class _AppShellState extends ConsumerState { ], const Spacer(), if (user != null) ...[ - // 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤 - if (!isMobile) ...[ - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => - _showShopPanel(context, user, version: appVersion), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.business, - color: Colors.white70, size: 14), - const SizedBox(width: 4), - Text(user.shopNo, - style: const TextStyle( - color: Colors.white70, fontSize: 13)), - ], - ), - ), - ), - const SizedBox(width: 20), - const Icon(Icons.person_outline, - color: Colors.white70, size: 14), - const SizedBox(width: 4), - Text(user.username, - style: const TextStyle( - color: Colors.white70, fontSize: 13)), - const SizedBox(width: 8), - ], + // 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。 PopupMenuButton( icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70), @@ -324,6 +300,53 @@ class _AppShellState extends ConsumerState { }).toList(), ), ), + // 当前登录账号(顶栏移下来的用户名):点击弹门店/账号/版本面板 + if (user != null) ...[ + const Divider(height: 1, color: Colors.white24), + SizedBox( + height: 48, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showShopPanel(context, user, + version: appVersion), + hoverColor: Colors.white.withAlpha(13), + splashColor: Colors.white.withAlpha(26), + child: Row( + children: [ + SizedBox( + width: _sidebarExpanded ? 16 : 3), + Expanded( + child: Row( + mainAxisAlignment: _sidebarExpanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + const Icon(Icons.person_outline, + color: Colors.white60, + size: 20), + if (_sidebarExpanded) ...[ + const SizedBox(width: 12), + Expanded( + child: Text( + user.username, + style: const TextStyle( + color: Colors.white70, + fontSize: 14), + overflow: + TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ], // 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口) const Divider(height: 1, color: Colors.white24), SizedBox(