11413df3db
- showMSheet 登记「打开中 sheet」关闭器栈(任何方式关闭都注销) - 壳层横滑手势先查栈:有 sheet 开着 → 从左往右滑关顶层 sheet, 任何方向都不切 tab / 不退二级屏;无 sheet 才走原 tab 切换/返回逻辑 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1164 lines
40 KiB
Dart
1164 lines
40 KiB
Dart
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
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 '../../core/theme/app_dims.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';
|
||
import '../../widgets/ds/ds_atoms.dart';
|
||
import '../../widgets/ds/ds_toast.dart';
|
||
import '../../widgets/ds/m_sheet.dart';
|
||
import '../../widgets/ds/m_tab_bar.dart';
|
||
import '../../widgets/theme_sheet.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(LucideIcons.download, '入库管理', 0),
|
||
_NavItem(LucideIcons.upload, '出库管理', 1),
|
||
]),
|
||
_NavGroup('经营', [
|
||
_NavItem(LucideIcons.box, '库存管理', 2),
|
||
_NavItem(LucideIcons.receiptText, '财务管理', 3),
|
||
_NavItem(LucideIcons.users, '往来单位', 4),
|
||
_NavItem(LucideIcons.layoutGrid, '基础数据', 5),
|
||
]),
|
||
_NavGroup('系统', [
|
||
_NavItem(LucideIcons.monitorSmartphone, '设备管理', 6),
|
||
_NavItem(LucideIcons.settings, '系统设置', 7),
|
||
_NavItem(LucideIcons.info, '关于我们', 8),
|
||
]),
|
||
];
|
||
|
||
// ── 窄屏底部 tab(对齐原型 mobile-shell.js TABS:库存/入库/出库/财务/我的)──
|
||
// (图标, 文案, branch index);branch index 对应 router StatefulShellRoute 分支序号。
|
||
const _mobileTabs = <(IconData, String, int)>[
|
||
(LucideIcons.box, '库存', 2),
|
||
(LucideIcons.download, '入库', 0),
|
||
(LucideIcons.upload, '出库', 1),
|
||
(LucideIcons.receiptText, '财务', 3),
|
||
(LucideIcons.user, '我的', 9),
|
||
];
|
||
|
||
/// 窄屏 tab 根路由白名单:命中显示底部 tabbar;未命中即二级屏(隐藏 tabbar + 返回箭头)。
|
||
const _tabRoots = {'/inventory', '/stock-in', '/stock-out', '/finance', '/me'};
|
||
|
||
/// branch index → 底部 tab 高亮位;4-9(往来/基础数据/设备/设置/关于/我的)归「我的域」。
|
||
int _tabForBranch(int branch) => switch (branch) {
|
||
2 => 0,
|
||
0 => 1,
|
||
1 => 2,
|
||
3 => 3,
|
||
_ => 4,
|
||
};
|
||
|
||
/// 窄屏顶栏标题(对齐原型 m-top data-title)。
|
||
String _mobileTitle(String loc) {
|
||
if (loc.startsWith('/stock-in')) return '入库管理';
|
||
if (loc.startsWith('/stock-out')) return '出库管理';
|
||
if (loc == '/inventory/check') return '库存盘点';
|
||
if (loc.startsWith('/inventory')) return '库存管理';
|
||
if (loc.startsWith('/finance')) return '财务管理';
|
||
if (loc.startsWith('/partners')) return '往来单位';
|
||
if (loc.startsWith('/products/')) return '商品详情';
|
||
if (loc.startsWith('/products')) return '基础数据';
|
||
if (loc.startsWith('/devices')) return '设备管理';
|
||
if (loc == '/settings/users') return '用户管理';
|
||
if (loc.startsWith('/settings')) return '系统设置';
|
||
if (loc.startsWith('/about')) return '关于我们';
|
||
if (loc == '/me/license') return '授权管理';
|
||
if (loc.startsWith('/me')) return '我的';
|
||
return '';
|
||
}
|
||
|
||
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;
|
||
|
||
/// 当前匹配路由(router 透传):窄屏据此判定 tab 根 / 二级屏。
|
||
final String location;
|
||
const AppShell(
|
||
{super.key, required this.navigationShell, required this.location});
|
||
|
||
@override
|
||
ConsumerState<AppShell> createState() => _AppShellState();
|
||
}
|
||
|
||
class _AppShellState extends ConsumerState<AppShell>
|
||
with SingleTickerProviderStateMixin {
|
||
final String _loginTime = DateFormat('HH:mm:ss').format(AppStatusBar.clock());
|
||
bool _forceDialogShown = false;
|
||
bool _licenseDialogShown = false;
|
||
|
||
// ── 窄屏切页滑动过渡:方向感知的一次性滑入(tab 按左右序、进二级从右、返回从左)──
|
||
// 注意:必须在 initState 创建(惰性 late 若到 dispose 才首次求值,会在已卸载
|
||
// 的树上找 TickerMode 而崩溃——桌面路径全程不触碰该控制器)。
|
||
late final AnimationController _slideCtrl;
|
||
double _slideFrom = 1; // 1=从右滑入,-1=从左滑入
|
||
String _prevLocation = '';
|
||
int _prevBranch = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_slideCtrl = AnimationController(
|
||
vsync: this, duration: const Duration(milliseconds: 240), value: 1);
|
||
_prevLocation = widget.location;
|
||
_prevBranch = widget.navigationShell.currentIndex;
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(AppShell old) {
|
||
super.didUpdateWidget(old);
|
||
if (widget.location == _prevLocation) return;
|
||
final oldRoot = _tabRoots.contains(_prevLocation);
|
||
final newRoot = _tabRoots.contains(widget.location);
|
||
if (oldRoot && newRoot) {
|
||
// tab 间切换:按 tab 左右次序定方向
|
||
final oldPos = _tabForBranch(_prevBranch);
|
||
final newPos = _tabForBranch(widget.navigationShell.currentIndex);
|
||
_slideFrom = newPos >= oldPos ? 1 : -1;
|
||
} else if (newRoot) {
|
||
_slideFrom = -1; // 退出二级屏 → 从左滑入
|
||
} else {
|
||
_slideFrom = 1; // 进入二级屏/更深页 → 从右滑入
|
||
}
|
||
_prevLocation = widget.location;
|
||
_prevBranch = widget.navigationShell.currentIndex;
|
||
if (MediaQuery.sizeOf(context).width < 600) {
|
||
_slideCtrl.forward(from: 0);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_slideCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 打开账号菜单(个人设置 / 退出登录)。[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', LucideIcons.userCog, '个人设置', t),
|
||
_userMenuItem('logout', LucideIcons.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');
|
||
}
|
||
|
||
/// 窄屏二级屏返回:分支内有栈先 pop;分支根按来源兜底(商品详情→列表,其余→我的)。
|
||
void _backFromSecondary() {
|
||
if (context.canPop()) {
|
||
context.pop();
|
||
return;
|
||
}
|
||
final loc = widget.location;
|
||
if (loc.startsWith('/products/')) {
|
||
context.go('/products');
|
||
} else if (loc.startsWith('/stock-in/')) {
|
||
context.go('/stock-in');
|
||
} else if (loc.startsWith('/stock-out/')) {
|
||
context.go('/stock-out');
|
||
} else {
|
||
context.go('/me');
|
||
}
|
||
}
|
||
|
||
@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;
|
||
showDsToast(context, 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;
|
||
|
||
// 窄屏导航形态(对齐原型移动壳):tab 根显示底部 tabbar,二级屏隐藏 tabbar
|
||
// 并在顶栏出返回箭头(_buildTopBar 内)。
|
||
final isTabRoot = _tabRoots.contains(widget.location);
|
||
|
||
// 窄屏横滑手势(2026-07-04 拍板):tab 根左右滑切换相邻 tab(不循环);
|
||
// 二级屏从左往右滑退出(iOS 返回习惯,回来源/我的),向左滑无响应。
|
||
// 内容区里的横向滚动(分段 tab / 表格横滚)在手势竞技场中优先,互不冲突。
|
||
// 切页时按方向播放一次性滑入过渡(_slideCtrl,didUpdateWidget 触发)。
|
||
Widget shellContent = widget.navigationShell;
|
||
if (isMobile) {
|
||
shellContent = GestureDetector(
|
||
behavior: HitTestBehavior.translucent,
|
||
onHorizontalDragEnd: (d) {
|
||
final v = d.primaryVelocity ?? 0;
|
||
if (v.abs() < 200) return; // 轻微拖动不触发
|
||
// 有底部 sheet(详情/筛选等「抽屉」)开着:从左往右滑关掉顶层
|
||
// sheet,任何方向都不切 tab / 不退屏。
|
||
if (hasOpenMSheet) {
|
||
if (v > 0) closeTopMSheet();
|
||
return;
|
||
}
|
||
if (isTabRoot) {
|
||
final pos = _tabForBranch(widget.navigationShell.currentIndex);
|
||
final next = v < 0 ? pos + 1 : pos - 1; // 向左滑→下一个 tab
|
||
if (next < 0 || next >= _mobileTabs.length) return;
|
||
_goBranch(_mobileTabs[next].$3);
|
||
} else if (v > 0) {
|
||
_backFromSecondary(); // 从左往右滑=退出;向左滑无响应
|
||
}
|
||
},
|
||
child: AnimatedBuilder(
|
||
animation: _slideCtrl,
|
||
builder: (ctx, child) {
|
||
final p =
|
||
Curves.easeOutCubic.transform(_slideCtrl.value); // 0→1
|
||
final w = MediaQuery.sizeOf(ctx).width;
|
||
return Opacity(
|
||
opacity: 0.4 + 0.6 * p,
|
||
child: Transform.translate(
|
||
offset: Offset(_slideFrom * 0.18 * w * (1 - p), 0),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: widget.navigationShell,
|
||
),
|
||
);
|
||
}
|
||
|
||
final Widget shell = SelectionArea(
|
||
child: Scaffold(
|
||
body: Column(
|
||
children: [
|
||
_buildTopBar(context, user, isMobile, isTabRoot, topInset),
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
if (!isMobile) _buildSidebar(context, user),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
..._buildBanners(context, isOnline, updateInfo,
|
||
updateNotifier, licenseInfo),
|
||
Expanded(child: shellContent),
|
||
if (!isMobile) AppStatusBar(loginTime: _loginTime),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
bottomNavigationBar: isMobile && isTabRoot
|
||
? MTabBar(
|
||
items: [for (final t in _mobileTabs) MTabItem(t.$1, t.$2)],
|
||
currentIndex:
|
||
_tabForBranch(widget.navigationShell.currentIndex),
|
||
onTap: (i) => _goBranch(_mobileTabs[i].$3),
|
||
)
|
||
: null,
|
||
),
|
||
);
|
||
|
||
// 窄屏二级屏拦截系统返回键:分支根直接返回会退出 app,改走 _backFromSecondary
|
||
// 回「我的」。Web 不拦截(浏览器历史由 go_router 管理)。
|
||
if (!kIsWeb && isMobile && !isTabRoot) {
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, _) {
|
||
if (!didPop) _backFromSecondary();
|
||
},
|
||
child: shell,
|
||
);
|
||
}
|
||
return shell;
|
||
}
|
||
|
||
// ── 顶栏 ─────────────────────────────────────────────────────────────────
|
||
// 桌面(原型 .top, 56h):左两级品牌;右 主题器 + 通知铃。
|
||
// 窄屏(原型 .m-top, 52h):左 返回箭头(二级屏)/门店品牌(tab 根) + 屏标题;
|
||
// 右 主题 + 通知铃 + 头像(→我的)。
|
||
Widget _buildTopBar(BuildContext context, AuthUser? user, bool isMobile,
|
||
bool isTabRoot, double topInset) {
|
||
final t = context.tokens;
|
||
if (isMobile) {
|
||
return Container(
|
||
height: 52 + topInset,
|
||
decoration: BoxDecoration(
|
||
color: t.topBg,
|
||
border: Border(bottom: BorderSide(color: t.topBorder)),
|
||
),
|
||
padding: EdgeInsets.only(
|
||
top: topInset, left: isTabRoot ? 14 : 6, right: 14),
|
||
child: Row(
|
||
children: [
|
||
if (!isTabRoot)
|
||
IconButton(
|
||
icon: Icon(LucideIcons.arrowLeft, color: t.topFg, size: 22),
|
||
tooltip: '返回',
|
||
onPressed: _backFromSecondary,
|
||
),
|
||
Expanded(
|
||
child: Row(children: [
|
||
Flexible(
|
||
child: Text(
|
||
_mobileTitle(widget.location),
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: t.topFg,
|
||
fontSize: AppDims.fsTitle,
|
||
fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
if (WriteGuard.isReadonly(ref)) ...[
|
||
const SizedBox(width: 10),
|
||
_readonlyBadge(t.topControl),
|
||
],
|
||
]),
|
||
),
|
||
const SizedBox(width: 10),
|
||
IconButton(
|
||
icon: Icon(LucideIcons.shirt, color: t.topFg, size: 19),
|
||
tooltip: '主题外观',
|
||
onPressed: () => showThemeSheet(context),
|
||
),
|
||
const NotificationBell(),
|
||
const SizedBox(width: 8),
|
||
// 头像(.m-av 28px 圆)→ 我的
|
||
InkWell(
|
||
onTap: () => _goBranch(9),
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration:
|
||
BoxDecoration(color: t.primary, shape: BoxShape.circle),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
_avatarInitial(user),
|
||
style: TextStyle(
|
||
color: t.onPrimary,
|
||
fontSize: AppDims.fsSm,
|
||
fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return Container(
|
||
height: 56 + topInset,
|
||
decoration: BoxDecoration(
|
||
color: t.topBg,
|
||
border: Border(bottom: BorderSide(color: t.topBorder)),
|
||
),
|
||
padding: EdgeInsets.only(top: topInset, left: 18, right: 14),
|
||
child: Row(
|
||
children: [
|
||
// 左组(占满剩余空间,靠左):把右组(主题器+铃)挤到最右
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
// 软件品牌(岩美酒库)
|
||
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(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _avatarInitial(AuthUser? user) {
|
||
final name = user == null
|
||
? ''
|
||
: (user.realName.isNotEmpty ? user.realName : user.username);
|
||
return name.isNotEmpty ? name.characters.first : '用';
|
||
}
|
||
|
||
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(LucideIcons.eye, 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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ───────────────────
|
||
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(LucideIcons.hardDriveDownload, 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(LucideIcons.wifiOff, 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(LucideIcons.hardDriveDownload,
|
||
color: context.tokens.primary),
|
||
const SizedBox(width: 8),
|
||
const Text('发现新版本'),
|
||
],
|
||
),
|
||
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
|
||
'更新以获得最新功能与修复。'),
|
||
actions: [
|
||
DsButton('立即更新',
|
||
variant: DsBtnVariant.primary,
|
||
onPressed: () => startInAppUpdate(context, info)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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(LucideIcons.triangleAlert,
|
||
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(
|
||
// 窄屏授权入口是独立屏(我的→授权管理),宽屏保持设置授权 tab
|
||
onPressed: () => context.go(
|
||
context.isMobile ? '/me/license' : '/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(LucideIcons.triangleAlert, 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: [
|
||
DsButton('稍后处理', onPressed: () => Navigator.pop(_)),
|
||
DsButton('立即前往',
|
||
variant: lic.phase == 'grace'
|
||
? DsBtnVariant.primary
|
||
: DsBtnVariant.danger, onPressed: () {
|
||
Navigator.pop(_);
|
||
ctx.go(ctx.isMobile ? '/me/license' : '/settings?tab=license');
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 软件品牌(岩美酒库):线描山形 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(LucideIcons.chevronDown, 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(LucideIcons.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(LucideIcons.x,
|
||
color: AppChrome.onAccentFaint, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
children: [
|
||
_InfoRow(
|
||
icon: LucideIcons.tag, label: '门店编号', value: u.shopNo),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(
|
||
icon: LucideIcons.user, label: '登录账号', value: u.username),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(
|
||
icon: LucideIcons.idCard, label: '姓名', value: u.realName),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(
|
||
icon: LucideIcons.info, 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)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|