Files
jiu/client/lib/screens/shell/app_shell.dart
wangjia 23a583918d feat(tools): 代码端真相源闸 check_ds_code + 外壳硬编码颜色清零
把原型 check-ds 的「单一真源」纪律延伸到 Flutter 代码:
- client/tool/check_ds_code.mjs:扫 lib/(排除.g.dart) 硬编码 Color(0x..)/Colors.具名色,
  --strict/--changed 报错挡提交,合理特例须显式 // ds-ignore: 理由
- tokens.css(单源,client+原型同步) 加 chrome 前景色 --brand-dot/--on-accent/--on-accent-faint,
  codegen 进 AppChrome;外壳 14 处硬编码(白字/品牌点/grace橙)全部改走 token
- 外壳+3 新 widget 现 0 违规;全量存量 172→158(其余屏后续逐步清)

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

988 lines
33 KiB
Dart
Raw Permalink 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 '../../core/theme/app_chrome.g.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>();
/// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context
/// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方**
/// (留 8px 间隙,不遮挡触发器,对齐原型)。
void _openUserMenu(BuildContext anchorContext) {
final t = anchorContext.tokens;
final rb = anchorContext.findRenderObject() as RenderBox?;
final overlay =
Overlay.of(anchorContext).context.findRenderObject() as RenderBox?;
if (rb == null || overlay == null) return;
final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay);
final items = <PopupMenuEntry<String>>[
_userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t),
_userMenuItem('logout', Icons.logout, '退出登录', t),
];
// 菜单弹到按钮上方:top = 按钮顶 − 菜单估高 − 间隙(菜单项 40 + 上下 padding 8*2
const itemH = 40.0;
final menuH = items.length * itemH + 16;
final top = topLeft.dy - menuH - 8;
showMenu<String>(
context: anchorContext,
position: RelativeRect.fromLTRB(
topLeft.dx,
top,
overlay.size.width - topLeft.dx - rb.size.width,
overlay.size.height - top,
),
constraints: BoxConstraints.tightFor(width: rb.size.width),
color: t.menuUserBg,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
items: items,
).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: AppChrome.onAccent),
SizedBox(width: 4),
Text('只读',
style: TextStyle(
color: AppChrome.onAccent,
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 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: _SideUserTile(
user: user,
shopName: shopName,
roleLabel: _roleLabel(user.role),
onTap: _openUserMenu,
),
),
],
);
}
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: AppChrome.onAccent,
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: AppChrome.onAccent),
SizedBox(width: 8),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: AppChrome.onAccent,
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: AppChrome.onAccent),
const SizedBox(width: 8),
Expanded(
child: Text(msg,
style: const TextStyle(color: AppChrome.onAccent, fontSize: 13),
overflow: TextOverflow.ellipsis),
),
TextButton(
onPressed: () => context.go('/settings?tab=license'),
style: TextButton.styleFrom(foregroundColor: AppChrome.onAccentFaint),
child: const Text('去激活'),
),
],
),
);
}
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
final (title, body) = LicenseCopy.dialog(lic);
final Color titleColor =
lic.phase == 'grace' ? ctx.tokens.warn : 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: AppChrome.onAccent)),
),
],
),
);
}
}
// ── 软件品牌(岩美酒库):线描山形 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 = AppChrome.brandDot);
}
@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)),
);
}
}
// ── 侧栏底部用户区(原型 .side-user):hover 变色(同 nav 的 side-hover),点开账号菜单 ──
class _SideUserTile extends StatefulWidget {
final AuthUser user;
final String shopName;
final String roleLabel;
final void Function(BuildContext anchorContext) onTap;
const _SideUserTile({
required this.user,
required this.shopName,
required this.roleLabel,
required this.onTap,
});
@override
State<_SideUserTile> createState() => _SideUserTileState();
}
class _SideUserTileState extends State<_SideUserTile> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final t = context.tokens;
final u = widget.user;
final initial = u.realName.isNotEmpty ? u.realName : u.username;
final pic = initial.isNotEmpty ? initial.characters.first : '';
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onTap(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: _hover ? t.sideHover : Colors.transparent,
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(
u.realName.isNotEmpty ? u.realName : u.username,
style: TextStyle(
color: t.sideActiveFg,
fontSize: 13,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
'${widget.shopName} · ${widget.roleLabel}',
style: TextStyle(color: t.sideMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted),
],
),
),
),
);
}
}
// ── 侧栏导航项(原型 .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: AppChrome.onAccent, size: 20),
const SizedBox(width: 10),
const Text('门店信息',
style: TextStyle(
color: AppChrome.onAccent,
fontSize: 16,
fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close,
color: AppChrome.onAccentFaint, 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)),
),
],
);
}
}