Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia 0ebf26c282 fix(client): 账号菜单改 showMenu 等宽触发器 + 向上弹(修菜单溢出盖内容)
上一版用 PopupMenuButton width:218 + offset:-12 反而把菜单外扩、右缘越过侧栏
盖住内容区。改为 GestureDetector + showMenu,菜单宽度 = 用户区实测宽度(自动与
侧栏内容等宽、绝不溢出),底部空间不足 showMenu 自动向上弹、不遮触发器。
加 user_menu_width_test:点开后断言菜单宽≈194、右缘≤218(弹出态回归)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 18:40:07 +08:00

943 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '../../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/license_copy.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.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';
import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
import '../../widgets/theme_picker_pill.dart';
import '../../widgets/notification_bell.dart';
import '../../widgets/app_status_bar.dart';
/// 侧栏导航项(含其 StatefulShellRoute 分支序号 = router 中的 branch index)。
class _NavItem {
final IconData icon;
final String label;
final int index;
const _NavItem(this.icon, this.label, this.index);
}
/// 侧栏分组(原型 nav-label:单据 / 经营 / 系统)。
class _NavGroup {
final String label;
final List<_NavItem> items;
const _NavGroup(this.label, this.items);
}
// 分支序号必须与 router StatefulShellRoute 的 branch 顺序一致(勿改顺序)。
const _navGroups = <_NavGroup>[
_NavGroup('单据', [
_NavItem(Icons.file_download_outlined, '入库管理', 0),
_NavItem(Icons.file_upload_outlined, '出库管理', 1),
]),
_NavGroup('经营', [
_NavItem(Icons.inventory_2_outlined, '库存管理', 2),
_NavItem(Icons.receipt_long_outlined, '财务管理', 3),
_NavItem(Icons.people_alt_outlined, '往来单位', 4),
_NavItem(Icons.grid_view_outlined, '基础数据', 5),
]),
_NavGroup('系统', [
_NavItem(Icons.devices_outlined, '设备管理', 6),
_NavItem(Icons.settings_outlined, '系统设置', 7),
_NavItem(Icons.info_outline, '关于我们', 8),
]),
];
String _roleLabel(String role) {
switch (role) {
case 'superadmin':
return '超级管理员';
case 'admin':
return '管理员';
case 'readonly':
return '只读';
default:
return '操作员';
}
}
class AppShell extends ConsumerStatefulWidget {
/// StatefulShellRoute 注入的导航壳:各栏目分支 Navigator 的 IndexedStack
/// 跨栏目切换时各分支页面 State 常驻(保住半填表单 / 内部 tab / 滚动位置)。
final StatefulNavigationShell navigationShell;
const AppShell({super.key, required this.navigationShell});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
final String _loginTime =
DateFormat('HH:mm:ss').format(AppStatusBar.clock());
bool _forceDialogShown = false;
bool _licenseDialogShown = false;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
// 侧栏底部用户区,用于实测宽度让弹出菜单等宽 + 定位(向上弹,不溢出内容区)
final GlobalKey _sideUserKey = GlobalKey();
/// 打开账号菜单(个人设置 / 退出登录)。菜单宽度 = 用户区实测宽度(自动与侧栏
/// 内容等宽、绝不溢出到内容区);底部空间不足时 showMenu 自动向上弹。
void _openUserMenu(BuildContext context) {
final t = context.tokens;
final rb = _sideUserKey.currentContext?.findRenderObject() as RenderBox?;
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (rb == null || overlay == null) return;
final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay);
final rect = topLeft & rb.size;
showMenu<String>(
context: context,
position: RelativeRect.fromRect(rect, Offset.zero & overlay.size),
constraints: BoxConstraints.tightFor(width: rb.size.width),
color: t.menuUserBg,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
items: [
_userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t),
_userMenuItem('logout', Icons.logout, '退出登录', t),
],
).then((v) {
if (v == 'logout') _logout();
});
}
/// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。
void _goBranch(int index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
}
void _logout() {
ref.read(authStateProvider.notifier).logout();
context.go('/login');
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authStateProvider).user;
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
ref.watch(sessionHeartbeatProvider);
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
ref.listen<String?>(apiMessageProvider, (prev, next) {
if (next == null || next.isEmpty) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(next)));
ref.read(apiMessageProvider.notifier).state = null;
});
final isOnline = ref.watch(connectivityProvider);
final isMobile = context.isMobile;
final topInset = MediaQuery.of(context).padding.top;
final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull;
final licenseInfo = ref.watch(licenseProvider).valueOrNull;
return SelectionArea(
child: Scaffold(
key: _scaffoldKey,
drawer: isMobile ? _buildDrawer(context, user) : null,
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
body: Column(
children: [
_buildTopBar(context, user, isMobile, topInset),
Expanded(
child: Row(
children: [
if (!isMobile) _buildSidebar(context, user),
Expanded(
child: Column(
children: [
..._buildBanners(
context, isOnline, updateInfo, updateNotifier, licenseInfo),
Expanded(child: widget.navigationShell),
if (!isMobile) AppStatusBar(loginTime: _loginTime),
],
),
),
],
),
),
],
),
),
);
}
// ── 顶栏(原型 .top, 56h):左两级品牌;右 主题器 + 通知铃 ───────────────────
Widget _buildTopBar(
BuildContext context, AuthUser? user, bool isMobile, double topInset) {
final t = context.tokens;
return Container(
height: 56 + topInset,
decoration: BoxDecoration(
color: t.topBg,
border: Border(bottom: BorderSide(color: t.topBorder)),
),
padding: EdgeInsets.only(top: topInset, left: isMobile ? 6 : 18, right: 14),
child: Row(
children: [
// 左组(占满剩余空间,靠左):把右组(主题器+铃)挤到最右
Expanded(
child: Row(
children: [
if (isMobile) ...[
IconButton(
icon: Icon(Icons.menu, color: t.topFg),
tooltip: '菜单',
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
const SizedBox(width: 2),
],
// 软件品牌(岩美酒库)—— 桌面常显,窄屏让位给门店
if (!isMobile) ...[
const _SoftwareBrand(),
Container(
width: 1,
height: 20,
color: t.topBorder,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
],
// 门店品牌(御品轩名酒坊)——点开门店信息;过长省略
Flexible(child: _ShopBrand(user: user)),
if (WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 10),
_readonlyBadge(t.topControl),
],
],
),
),
// 右组:主题器 + 通知铃,紧贴右侧
const SizedBox(width: 12),
const ThemePickerPill(),
const SizedBox(width: 14),
const NotificationBell(),
],
),
);
}
Widget _readonlyBadge(Color bg) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.visibility_outlined, size: 13, color: Colors.white),
SizedBox(width: 4),
Text('只读',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
],
),
);
}
// ── 侧栏(原型 .side, 218w):分组导航 + 底部 side-user ─────────────────────
Widget _buildSidebar(BuildContext context, AuthUser? user) {
final t = context.tokens;
return Container(
width: 218,
decoration: BoxDecoration(
color: t.sideBg,
border: Border(right: BorderSide(color: t.sideBorder)),
),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (final g in _navGroups) ...[
_navLabel(t, g.label),
for (final item in g.items)
_SidebarNav(
item: item,
active: widget.navigationShell.currentIndex == item.index,
onTap: () => _goBranch(item.index),
),
],
],
),
),
if (user != null) _buildSideUser(context, user),
],
),
);
}
Widget _navLabel(dynamic t, String label) => Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 6),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: t.sideMuted,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
);
Widget _buildSideUser(BuildContext context, AuthUser user) {
final t = context.tokens;
final initial = (user.realName.isNotEmpty ? user.realName : user.username);
final pic = initial.isNotEmpty ? initial.characters.first : '';
final shopName = ref.watch(shopInfoProvider).valueOrNull?.name ?? user.shopNo;
return Column(
children: [
Container(
height: 1,
color: t.sideBorder,
margin: const EdgeInsets.fromLTRB(4, 8, 4, 8),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openUserMenu(context),
child: Container(
key: _sideUserKey,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: t.sideActiveBg,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(pic,
style: TextStyle(
color: t.sideActiveFg,
fontWeight: FontWeight.w700,
fontSize: 14)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
user.realName.isNotEmpty
? user.realName
: user.username,
style: TextStyle(
color: t.sideActiveFg,
fontSize: 13,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
'$shopName · ${_roleLabel(user.role)}',
style: TextStyle(color: t.sideMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted),
],
),
),
),
),
],
);
}
PopupMenuItem<String> _userMenuItem(
String value, IconData icon, String label, dynamic t) {
return PopupMenuItem<String>(
value: value,
height: 40,
child: Row(
children: [
Icon(icon, size: 16, color: t.menuUserFg),
const SizedBox(width: 9),
Text(label, style: TextStyle(fontSize: 13, color: t.menuUserFg)),
],
),
);
}
/// 窄屏(手机)侧滑抽屉导航:与侧栏同分组结构。
Widget _buildDrawer(BuildContext context, AuthUser? user) {
final t = context.tokens;
return Drawer(
child: Container(
color: t.sideBg,
child: SafeArea(
child: Column(
children: [
if (user != null)
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
color: t.topBg,
child: Row(
children: [
const _SoftwareBrandMark(size: 30),
const SizedBox(width: 8),
const Text('岩美酒库',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700)),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
for (final g in _navGroups) ...[
_navLabel(t, g.label),
for (final item in g.items)
_SidebarNav(
item: item,
active:
widget.navigationShell.currentIndex == item.index,
onTap: () {
Navigator.pop(context);
_goBranch(item.index);
},
),
],
],
),
),
if (user != null) ...[
Container(
height: 1,
color: t.sideBorder,
margin: const EdgeInsets.symmetric(horizontal: 16)),
ListTile(
leading: Icon(Icons.logout, color: t.sideFg),
title: Text('退出登录',
style: TextStyle(
color: t.sideActiveFg,
fontWeight: FontWeight.w500)),
onTap: () {
Navigator.pop(context);
_logout();
},
),
],
],
),
),
),
);
}
// ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ───────────────────
List<Widget> _buildBanners(
BuildContext context,
bool isOnline,
AppUpdateInfo? updateInfo,
UpdateNotifier updateNotifier,
LicenseInfo? licenseInfo,
) {
final t = context.tokens;
return [
if (updateInfo != null &&
updateInfo.hasUpdate &&
!updateInfo.forceUpdate &&
!updateNotifier.isDismissed)
Container(
width: double.infinity,
color: t.warnBg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Icon(Icons.system_update, size: 16, color: t.warn),
const SizedBox(width: 8),
Expanded(
child: Text(
'发现新版本 v${updateInfo.latestVersion},建议立即更新以获得最新功能与修复',
style: TextStyle(color: t.text, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () => startInAppUpdate(context, updateInfo),
style: TextButton.styleFrom(foregroundColor: t.warn),
child: const Text('立即更新'),
),
TextButton(
onPressed: updateNotifier.dismiss,
style: TextButton.styleFrom(foregroundColor: t.muted),
child: const Text('稍后再说'),
),
],
),
),
if (updateInfo != null && updateInfo.hasUpdate && updateInfo.forceUpdate)
Builder(builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showForceUpdateDialog(ctx, updateInfo);
});
return const SizedBox.shrink();
}),
if (licenseInfo != null && licenseInfo.needsAttention)
_buildLicenseBanner(licenseInfo),
if (licenseInfo != null &&
licenseInfo.needsAttention &&
!_licenseDialogShown)
Builder(builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_licenseDialogShown && mounted) {
_licenseDialogShown = true;
_showLicenseExpiryDialog(ctx, licenseInfo);
}
});
return const SizedBox.shrink();
}),
if (!isOnline)
Container(
width: double.infinity,
color: t.danger,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: const Row(
children: [
Icon(Icons.wifi_off, size: 16, color: Colors.white),
SizedBox(width: 8),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
NetworkRetryButton(),
],
),
),
];
}
void _showForceUpdateDialog(BuildContext context, AppUpdateInfo info) {
if (_forceDialogShown) return;
_forceDialogShown = true;
showAppDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: AlertDialog(
title: Row(
children: [
Icon(Icons.system_update, color: context.tokens.primary),
const SizedBox(width: 8),
const Text('发现新版本'),
],
),
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
'更新以获得最新功能与修复。'),
actions: [
ElevatedButton(
onPressed: () => startInAppUpdate(context, info),
child: const Text('立即更新'),
),
],
),
),
);
}
Widget _buildLicenseBanner(LicenseInfo lic) {
final bg = LicenseCopy.bannerColor(lic.phase);
final msg = LicenseCopy.banner(lic);
return Container(
width: double.infinity,
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: Text(msg,
style: const TextStyle(color: Colors.white, fontSize: 13),
overflow: TextOverflow.ellipsis),
),
TextButton(
onPressed: () => context.go('/settings?tab=license'),
style: TextButton.styleFrom(foregroundColor: Colors.white70),
child: const Text('去激活'),
),
],
),
);
}
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
final (title, body) = LicenseCopy.dialog(lic);
final Color titleColor =
lic.phase == 'grace' ? Colors.orange[800]! : ctx.tokens.danger;
showDialog(
context: ctx,
barrierDismissible: true,
builder: (_) => AlertDialog(
title: Row(children: [
Icon(Icons.warning_amber_rounded, color: titleColor, size: 20),
const SizedBox(width: 8),
Text(title, style: TextStyle(color: titleColor, fontSize: 16)),
]),
content: Text(body, style: const TextStyle(fontSize: 14)),
actions: [
TextButton(
onPressed: () => Navigator.pop(_),
child: const Text('稍后处理'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
onPressed: () {
Navigator.pop(_);
ctx.go('/settings?tab=license');
},
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
// ── 软件品牌(岩美酒库):线描山形 mark + 名 ──────────────────────────────────
class _SoftwareBrand extends StatelessWidget {
const _SoftwareBrand();
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const _SoftwareBrandMark(size: 30),
const SizedBox(width: 7),
Text('岩美酒库',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 17,
color: t.topFg,
letterSpacing: 0.3)),
],
);
}
}
/// 软件 logo 线描山形(原型 prod-mk SVG):白色描边山峰 + 横线 + 酒色圆点。
class _SoftwareBrandMark extends StatelessWidget {
final double size;
const _SoftwareBrandMark({required this.size});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _BrandMarkPainter(context.tokens.topFg),
);
}
}
class _BrandMarkPainter extends CustomPainter {
final Color stroke;
_BrandMarkPainter(this.stroke);
@override
void paint(Canvas canvas, Size size) {
final s = size.width / 64.0; // 原型 viewBox 64
canvas.scale(s);
final p = Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = 5
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final peak = Path()
..moveTo(14, 33)
..lineTo(23, 16)
..lineTo(32, 25)
..lineTo(41, 16)
..lineTo(50, 33);
canvas.drawPath(peak, p);
canvas.drawLine(const Offset(15, 41), const Offset(49, 41), p);
canvas.drawCircle(const Offset(32, 48), 3.4,
Paint()..color = const Color(0xFFC97B86));
}
@override
bool shouldRepaint(_BrandMarkPainter old) => old.stroke != stroke;
}
// ── 门店品牌(鼎晟酒行):字标 mark + 店名,点开门店信息 ───────────────────────
class _ShopBrand extends ConsumerWidget {
final AuthUser? user;
const _ShopBrand({this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final shopAsync = ref.watch(shopInfoProvider);
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
final version = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ShopMark(
logoUrl: logoUrl,
shopName: shopName,
bg: t.topMarkBg,
fg: t.topMarkFg),
const SizedBox(width: 9),
Flexible(
child: Text(
shopName.isEmpty ? '' : shopName,
style: TextStyle(
color: t.topFg, fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
/// 门店字标(原型 .logo .mk, 26×26 圆角):有 logo 显示图,否则店名首字。
class _ShopMark extends StatelessWidget {
final String logoUrl;
final String shopName;
final Color bg;
final Color fg;
const _ShopMark(
{required this.logoUrl,
required this.shopName,
required this.bg,
required this.fg});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(8);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http')
? logoUrl
: '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: 26,
height: 26,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: 26,
height: 26,
decoration: BoxDecoration(color: bg, borderRadius: radius),
alignment: Alignment.center,
child: Text(initial,
style: TextStyle(
color: fg, fontSize: 13, fontWeight: FontWeight.w800)),
);
}
}
// ── 侧栏导航项(原型 .nav, 40h)───────────────────────────────────────────────
class _SidebarNav extends StatefulWidget {
final _NavItem item;
final bool active;
final VoidCallback onTap;
const _SidebarNav(
{required this.item, required this.active, required this.onTap});
@override
State<_SidebarNav> createState() => _SidebarNavState();
}
class _SidebarNavState extends State<_SidebarNav> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final t = context.tokens;
final active = widget.active;
final Color bg = active
? t.sideActiveBg
: (_hover ? t.sideHover : Colors.transparent);
final Color fg = active || _hover ? t.sideActiveFg : t.sideFg;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
height: 40,
margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Icon(widget.item.icon, size: 19, color: fg),
const SizedBox(width: 11),
Text(
widget.item.label,
style: TextStyle(
color: fg,
fontSize: 13,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
);
}
}
// ── 门店信息弹窗(点门店品牌触发,保留原行为)─────────────────────────────────
void _showShopPanel(BuildContext context, AuthUser u,
{String version = 'v1.0.0'}) {
showAppDialog(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: SizedBox(
width: ctx.dialogWidth(360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: ctx.tokens.primary,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(10)),
),
child: Row(
children: [
const Icon(Icons.store, color: Colors.white, size: 20),
const SizedBox(width: 10),
const Text('门店信息',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close,
color: Colors.white70, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo),
const SizedBox(height: 14),
_InfoRow(
icon: Icons.person, label: '登录账号', value: u.username),
const SizedBox(height: 14),
_InfoRow(
icon: Icons.badge_outlined,
label: '姓名',
value: u.realName),
const SizedBox(height: 14),
_InfoRow(
icon: Icons.info_outline, label: '系统版本', value: version),
],
),
),
],
),
),
),
);
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoRow(
{required this.icon, required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: context.tokens.muted),
const SizedBox(width: 10),
SizedBox(
width: 72,
child: Text(label,
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
),
Expanded(
child: Text(value,
style: TextStyle(
fontSize: 13,
color: context.tokens.text,
fontWeight: FontWeight.w500)),
),
],
);
}
}