32bd64d676
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info - 账号被停用/删除(401 USER_DISABLED)即强制重新登录 - 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回 - 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1038 lines
39 KiB
Dart
1038 lines
39 KiB
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 'dart:async';
|
||
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/app_theme.dart';
|
||
import '../../providers/connectivity_provider.dart';
|
||
import '../../providers/session_heartbeat.dart';
|
||
import '../../providers/shop_provider.dart';
|
||
import '../../providers/update_provider.dart';
|
||
import '../../core/update/app_updater.dart';
|
||
import '../../providers/license_provider.dart';
|
||
import '../../widgets/network_retry_button.dart';
|
||
|
||
class AppShell extends ConsumerStatefulWidget {
|
||
final Widget child;
|
||
const AppShell({super.key, required this.child});
|
||
|
||
@override
|
||
ConsumerState<AppShell> createState() => _AppShellState();
|
||
}
|
||
|
||
class _AppShellState extends ConsumerState<AppShell> {
|
||
bool _sidebarExpanded = true;
|
||
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||
bool _forceDialogShown = false;
|
||
bool _licenseDialogShown = false;
|
||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||
|
||
void _showForceUpdateDialog(
|
||
BuildContext context, AppUpdateInfo info) {
|
||
if (_forceDialogShown) return;
|
||
_forceDialogShown = true;
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => PopScope(
|
||
canPop: false,
|
||
child: AlertDialog(
|
||
title: const Row(
|
||
children: [
|
||
Icon(Icons.system_update, color: AppTheme.primary),
|
||
SizedBox(width: 8),
|
||
Text('发现新版本'),
|
||
],
|
||
),
|
||
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
|
||
'更新以获得最新功能与修复。'),
|
||
actions: [
|
||
ElevatedButton(
|
||
onPressed: () => startInAppUpdate(context, info),
|
||
child: const Text('立即更新'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final List<_NavItem> _navItems = const [
|
||
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
||
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
||
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
||
_NavItem(
|
||
icon: Icons.account_balance_wallet,
|
||
label: '财务管理',
|
||
path: '/finance'),
|
||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||
_NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'),
|
||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
|
||
];
|
||
|
||
/// 窄屏(手机)侧滑抽屉导航,容纳全部菜单项。
|
||
Widget _buildDrawer(
|
||
BuildContext context, AuthUser? user, String location) {
|
||
final shopAsync = ref.watch(shopInfoProvider);
|
||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||
return Drawer(
|
||
child: Container(
|
||
color: AppTheme.primaryDark,
|
||
child: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// 抽屉头部:门店 Logo + 名称 + 编号
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
||
color: AppTheme.primary,
|
||
child: Row(
|
||
children: [
|
||
_ShopLogo(
|
||
logoUrl: logoUrl, shopName: shopName, size: 40),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
shopName.isEmpty ? '—' : shopName,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (user != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'${user.shopNo} · ${user.username}',
|
||
style: const TextStyle(
|
||
color: Colors.white70, fontSize: 12),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: _navItems.map((item) {
|
||
final isActive = location.startsWith(item.path);
|
||
return _SidebarItem(
|
||
item: item,
|
||
isActive: isActive,
|
||
expanded: true,
|
||
onTap: () {
|
||
Navigator.pop(context); // 关闭抽屉
|
||
context.go(item.path);
|
||
},
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
// 退出登录(抽屉底部常驻,窄屏顶栏菜单不便时也能退出)
|
||
const Divider(height: 1, color: Colors.white24),
|
||
ListTile(
|
||
leading: const Icon(Icons.logout, color: Colors.white70),
|
||
title: const Text('退出登录',
|
||
style: TextStyle(
|
||
color: Colors.white, fontWeight: FontWeight.w500)),
|
||
onTap: () {
|
||
Navigator.pop(context); // 关闭抽屉
|
||
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 location = GoRouterState.of(context).matchedLocation;
|
||
final isMobile = context.isMobile;
|
||
// iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡
|
||
final topInset = MediaQuery.of(context).padding.top;
|
||
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
|
||
final updateNotifier = ref.watch(updateProvider.notifier);
|
||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||
final appVersion =
|
||
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||
final licenseInfo = ref.watch(licenseProvider).valueOrNull;
|
||
|
||
return SelectionArea(
|
||
child: Scaffold(
|
||
key: _scaffoldKey,
|
||
drawer: isMobile ? _buildDrawer(context, user, location) : null,
|
||
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
|
||
body: Column(
|
||
children: [
|
||
// Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏)
|
||
Container(
|
||
height: 56 + topInset,
|
||
color: AppTheme.primary,
|
||
padding: EdgeInsets.only(top: topInset, left: 8, right: 8),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(
|
||
isMobile
|
||
? Icons.menu
|
||
: (_sidebarExpanded ? Icons.menu_open : Icons.menu),
|
||
color: Colors.white),
|
||
onPressed: () {
|
||
if (isMobile) {
|
||
_scaffoldKey.currentState?.openDrawer();
|
||
} else {
|
||
setState(() => _sidebarExpanded = !_sidebarExpanded);
|
||
}
|
||
},
|
||
tooltip: isMobile
|
||
? '菜单'
|
||
: (_sidebarExpanded ? '收起侧边栏' : '展开侧边栏'),
|
||
),
|
||
const SizedBox(width: 4),
|
||
_ShopButton(user: user, version: appVersion),
|
||
if (WriteGuard.isReadonly(ref)) ...[
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white24,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.visibility_outlined,
|
||
size: 13, color: Colors.white),
|
||
SizedBox(width: 4),
|
||
Text('只读',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
if (user != null) ...[
|
||
// 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。
|
||
PopupMenuButton<String>(
|
||
icon: const Icon(Icons.keyboard_arrow_down,
|
||
color: Colors.white70),
|
||
offset: const Offset(0, 36),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(6)),
|
||
color: Colors.white,
|
||
elevation: 8,
|
||
onSelected: (v) {
|
||
if (v == 'logout') {
|
||
ref.read(authStateProvider.notifier).logout();
|
||
context.go('/login');
|
||
}
|
||
},
|
||
itemBuilder: (context) => [
|
||
const PopupMenuItem<String>(
|
||
value: 'profile',
|
||
padding: EdgeInsets.zero,
|
||
child: _HoverMenuItem(
|
||
icon: Icons.manage_accounts_outlined,
|
||
label: '个人设置',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
// Main area
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
// Sidebar(仅宽屏;窄屏改用 Drawer)
|
||
if (!isMobile)
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeInOut,
|
||
width: sidebarWidth,
|
||
color: AppTheme.primaryDark,
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: _navItems.map((item) {
|
||
final isActive =
|
||
location.startsWith(item.path);
|
||
return _SidebarItem(
|
||
item: item,
|
||
isActive: isActive,
|
||
expanded: _sidebarExpanded,
|
||
onTap: () => context.go(item.path),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
// 当前登录账号:点击弹下拉菜单(退出登录)
|
||
if (user != null) ...[
|
||
const Divider(height: 1, color: Colors.white24),
|
||
PopupMenuButton<String>(
|
||
tooltip: '账号菜单',
|
||
position: PopupMenuPosition.over,
|
||
offset: const Offset(0, -8),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(6)),
|
||
color: Colors.white,
|
||
elevation: 8,
|
||
onSelected: (v) {
|
||
if (v == 'logout') {
|
||
ref
|
||
.read(authStateProvider.notifier)
|
||
.logout();
|
||
context.go('/login');
|
||
}
|
||
},
|
||
itemBuilder: (context) => const [
|
||
PopupMenuItem<String>(
|
||
value: 'logout',
|
||
padding: EdgeInsets.zero,
|
||
child: _HoverMenuItem(
|
||
icon: Icons.logout,
|
||
label: '退出登录',
|
||
),
|
||
),
|
||
],
|
||
child: SizedBox(
|
||
height: 48,
|
||
child: Row(
|
||
children: [
|
||
SizedBox(width: _sidebarExpanded ? 16 : 3),
|
||
Expanded(
|
||
child: Row(
|
||
mainAxisAlignment: _sidebarExpanded
|
||
? MainAxisAlignment.start
|
||
: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.person_outline,
|
||
color: Colors.white60, size: 20),
|
||
if (_sidebarExpanded) ...[
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
user.username,
|
||
style: const TextStyle(
|
||
color: Colors.white70,
|
||
fontSize: 14),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const Icon(
|
||
Icons.keyboard_arrow_up,
|
||
color: Colors.white38,
|
||
size: 18),
|
||
const SizedBox(width: 12),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
// Content area
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
// Update banner(非强制更新)
|
||
if (updateInfo != null &&
|
||
updateInfo.hasUpdate &&
|
||
!updateInfo.forceUpdate &&
|
||
!updateNotifier.isDismissed)
|
||
Container(
|
||
width: double.infinity,
|
||
color: const Color(0xFFFFF8E1),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16, vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.system_update,
|
||
size: 16, color: Color(0xFFF57F17)),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'发现新版本 v${updateInfo.latestVersion},'
|
||
'建议立即更新以获得最新功能与修复',
|
||
style: const TextStyle(
|
||
color: Color(0xFF5D4037),
|
||
fontSize: 13),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () =>
|
||
startInAppUpdate(context, updateInfo),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor:
|
||
const Color(0xFFF57F17)),
|
||
child: const Text('立即更新'),
|
||
),
|
||
TextButton(
|
||
onPressed: updateNotifier.dismiss,
|
||
style: TextButton.styleFrom(
|
||
foregroundColor:
|
||
const Color(0xFF9E9E9E)),
|
||
child: const Text('稍后再说'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog)
|
||
if (updateInfo != null &&
|
||
updateInfo.hasUpdate &&
|
||
updateInfo.forceUpdate)
|
||
Builder(builder: (ctx) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_showForceUpdateDialog(ctx, updateInfo);
|
||
});
|
||
return const SizedBox.shrink();
|
||
}),
|
||
// License expiry banner
|
||
if (licenseInfo != null &&
|
||
licenseInfo.needsAttention)
|
||
_buildLicenseBanner(licenseInfo),
|
||
// License expiry dialog (once per session)
|
||
if (licenseInfo != null &&
|
||
licenseInfo.needsAttention &&
|
||
!_licenseDialogShown)
|
||
Builder(builder: (ctx) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_licenseDialogShown && mounted) {
|
||
_licenseDialogShown = true;
|
||
_showLicenseExpiryDialog(ctx, licenseInfo);
|
||
}
|
||
});
|
||
return const SizedBox.shrink();
|
||
}),
|
||
// Offline banner
|
||
if (!isOnline)
|
||
Container(
|
||
width: double.infinity,
|
||
color: AppTheme.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(),
|
||
],
|
||
),
|
||
),
|
||
Expanded(child: widget.child),
|
||
// Status bar(仅宽屏;窄屏隐藏,离线已有顶部横幅提示)
|
||
if (!isMobile)
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final w = constraints.maxWidth;
|
||
// Three tiers: wide / medium / narrow
|
||
final wide = w >= 650;
|
||
final medium = w >= 190;
|
||
final iconOnly = !medium;
|
||
|
||
return Container(
|
||
height: 28,
|
||
color: isOnline
|
||
? const Color(0xFF37474F)
|
||
: AppTheme.danger,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Row(
|
||
children: [
|
||
if (!isOnline) ...[
|
||
const Icon(Icons.wifi_off,
|
||
size: 11, color: Colors.white70),
|
||
if (!iconOnly) ...[
|
||
const SizedBox(width: 4),
|
||
const Text('离线',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600)),
|
||
],
|
||
const _StatusDivider(),
|
||
],
|
||
if (isOnline && user != null) ...[
|
||
_StatusItem(
|
||
icon: Icons.store,
|
||
text: user.shopNo,
|
||
iconOnly: iconOnly),
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.person,
|
||
text: user.username,
|
||
iconOnly: iconOnly),
|
||
if (wide) ...[
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.login,
|
||
text: '登录时间:$_loginTime'),
|
||
const _StatusDivider(),
|
||
const _ClockWidget(),
|
||
] else
|
||
const _StatusDivider(),
|
||
],
|
||
const Spacer(),
|
||
_StatusItem(
|
||
icon: isOnline
|
||
? Icons.cloud_done_outlined
|
||
: Icons.cloud_off_outlined,
|
||
text: isOnline ? '已连接' : '连接已断开',
|
||
iconOnly: iconOnly,
|
||
),
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.info_outline,
|
||
text: ref
|
||
.watch(appVersionProvider)
|
||
.valueOrNull ??
|
||
'v1.0.0',
|
||
iconOnly: iconOnly),
|
||
_LicenseStatusItem(
|
||
lic: licenseInfo, iconOnly: iconOnly),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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]! : AppTheme.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)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavItem {
|
||
final IconData icon;
|
||
final String label;
|
||
final String path;
|
||
const _NavItem(
|
||
{required this.icon, required this.label, required this.path});
|
||
}
|
||
|
||
class _SidebarItem extends StatelessWidget {
|
||
final _NavItem item;
|
||
final bool isActive;
|
||
final bool expanded;
|
||
final VoidCallback onTap;
|
||
|
||
const _SidebarItem({
|
||
required this.item,
|
||
required this.isActive,
|
||
required this.expanded,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 48,
|
||
child: Material(
|
||
color: isActive ? AppTheme.primary.withAlpha(76) : Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
hoverColor: Colors.white.withAlpha(13),
|
||
splashColor: Colors.white.withAlpha(26),
|
||
highlightColor: Colors.white.withAlpha(13),
|
||
child: Row(
|
||
children: [
|
||
// Active indicator bar
|
||
Container(
|
||
width: 3,
|
||
height: isActive ? 28 : 0,
|
||
color: Colors.white,
|
||
margin: EdgeInsets.only(right: expanded ? 13 : 0),
|
||
),
|
||
if (!isActive) SizedBox(width: expanded ? 16 : 3),
|
||
Expanded(
|
||
child: Row(
|
||
mainAxisAlignment: expanded
|
||
? MainAxisAlignment.start
|
||
: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
item.icon,
|
||
color: isActive ? Colors.white : Colors.white60,
|
||
size: 20,
|
||
),
|
||
if (expanded) ...[
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Text(
|
||
item.label,
|
||
style: TextStyle(
|
||
color: isActive ? Colors.white : Colors.white70,
|
||
fontSize: 14,
|
||
fontWeight: isActive
|
||
? FontWeight.w500
|
||
: FontWeight.normal,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusItem extends StatelessWidget {
|
||
final IconData icon;
|
||
final String text;
|
||
final bool iconOnly;
|
||
const _StatusItem(
|
||
{required this.icon, required this.text, this.iconOnly = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 11, color: Colors.white54),
|
||
if (!iconOnly) ...[
|
||
const SizedBox(width: 4),
|
||
Text(text,
|
||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。
|
||
/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。
|
||
/// 对应后端 phase:normal / grace / readonly|locked。
|
||
class _LicenseStatusItem extends StatelessWidget {
|
||
final LicenseInfo? lic;
|
||
final bool iconOnly;
|
||
const _LicenseStatusItem({required this.lic, this.iconOnly = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final lic = this.lic;
|
||
if (lic == null) return const SizedBox.shrink();
|
||
|
||
final color = LicenseCopy.phaseColor(lic.phase);
|
||
final icon = LicenseCopy.phaseIcon(lic.phase);
|
||
|
||
final String date = lic.expiresAt == null
|
||
? ''
|
||
: DateFormat('yyyy-MM-dd').format(lic.expiresAt!);
|
||
final String text = LicenseCopy.statusBar(lic, date);
|
||
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const _StatusDivider(),
|
||
Icon(icon, size: 11, color: color),
|
||
if (!iconOnly) ...[
|
||
const SizedBox(width: 4),
|
||
Text(text,
|
||
style: TextStyle(
|
||
color: color, fontSize: 11, fontWeight: FontWeight.w600)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusDivider extends StatelessWidget {
|
||
const _StatusDivider();
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 1,
|
||
height: 12,
|
||
color: Colors.white24,
|
||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ClockWidget extends StatefulWidget {
|
||
const _ClockWidget();
|
||
@override
|
||
State<_ClockWidget> createState() => _ClockWidgetState();
|
||
}
|
||
|
||
class _ClockWidgetState extends State<_ClockWidget> {
|
||
late Timer _timer;
|
||
late String _time;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_time = DateFormat('HH:mm:ss').format(DateTime.now());
|
||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) setState(() => _time = DateFormat('HH:mm:ss').format(DateTime.now()));
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _StatusItem(icon: Icons.access_time, text: '当前时间:$_time');
|
||
}
|
||
}
|
||
|
||
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: [
|
||
// Header
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||
decoration: const BoxDecoration(
|
||
color: AppTheme.primary,
|
||
borderRadius: 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(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Info rows
|
||
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 _ShopButton extends ConsumerWidget {
|
||
final AuthUser? user;
|
||
final String version;
|
||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final shopAsync = ref.watch(shopInfoProvider);
|
||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (user != null) _showShopPanel(context, user!, version: version);
|
||
},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
shopName.isEmpty ? '—' : shopName,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.5),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
|
||
class _ShopLogo extends StatelessWidget {
|
||
final String logoUrl;
|
||
final String shopName;
|
||
final double size;
|
||
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final radius = BorderRadius.circular(size * 0.19);
|
||
if (logoUrl.isNotEmpty) {
|
||
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||
return ClipRRect(
|
||
borderRadius: radius,
|
||
child: Image.network(
|
||
fullUrl,
|
||
width: size,
|
||
height: size,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => _initial(radius),
|
||
),
|
||
);
|
||
}
|
||
return _initial(radius);
|
||
}
|
||
|
||
Widget _initial(BorderRadius radius) {
|
||
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||
return Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0F3057),
|
||
borderRadius: radius,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
initial,
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: size * 0.5,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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: AppTheme.textSecondary),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 72,
|
||
child: Text(label,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppTheme.textSecondary)),
|
||
),
|
||
Expanded(
|
||
child: Text(value,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppTheme.textPrimary,
|
||
fontWeight: FontWeight.w500)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HoverMenuItem extends StatefulWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
|
||
const _HoverMenuItem({
|
||
required this.icon,
|
||
required this.label,
|
||
});
|
||
|
||
@override
|
||
State<_HoverMenuItem> createState() => _HoverMenuItemState();
|
||
}
|
||
|
||
class _HoverMenuItemState extends State<_HoverMenuItem> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const color = Color(0xFF333333);
|
||
const hoverBg = Color(0xFFF0F4FF);
|
||
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
cursor: SystemMouseCursors.click,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 120),
|
||
width: 152,
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: _hovered ? hoverBg : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(widget.icon, size: 16, color: color),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
widget.label,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: color,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|