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 createState() => _AppShellState(); } class _AppShellState extends ConsumerState { final String _loginTime = DateFormat('HH:mm:ss').format(AppStatusBar.clock()); bool _forceDialogShown = false; bool _licenseDialogShown = false; final GlobalKey _scaffoldKey = GlobalKey(); /// 切换到第 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(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: [ 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 Spacer(), 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: PopupMenuButton( tooltip: '账号菜单', position: PopupMenuPosition.over, offset: const Offset(0, -8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), color: t.menuUserBg, elevation: 8, onSelected: (v) { if (v == 'logout') _logout(); }, itemBuilder: (_) => [ _userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t), _userMenuItem('logout', Icons.logout, '退出登录', t), ], child: Container( 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 _userMenuItem( String value, IconData icon, String label, dynamic t) { return PopupMenuItem( 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 _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)), ), ], ); } }