c18c37944a
入库选填区/退单提示条 F0F6FF → infoSoft;表格列头 F0F4FF → thBg; 登录历史项/外壳用户菜单 hover → rowHover、菜单文字 → text。 深色主题 B / 暖色主题 C 下这些 chrome 现可正确随主题换色。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
1048 lines
40 KiB
Dart
1048 lines
40 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/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';
|
||
|
||
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> {
|
||
bool _sidebarExpanded = true;
|
||
|
||
/// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。
|
||
void _goBranch(int index) {
|
||
widget.navigationShell.goBranch(
|
||
index,
|
||
initialLocation: index == widget.navigationShell.currentIndex,
|
||
);
|
||
}
|
||
|
||
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: 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('立即更新'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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) {
|
||
final shopAsync = ref.watch(shopInfoProvider);
|
||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||
return Drawer(
|
||
child: Container(
|
||
color: context.tokens.primaryDark,
|
||
child: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// 抽屉头部:门店 Logo + 名称 + 编号
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
||
color: context.tokens.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.asMap().entries.map((e) {
|
||
final isActive =
|
||
widget.navigationShell.currentIndex == e.key;
|
||
return _SidebarItem(
|
||
item: e.value,
|
||
isActive: isActive,
|
||
expanded: true,
|
||
onTap: () {
|
||
Navigator.pop(context); // 关闭抽屉
|
||
_goBranch(e.key);
|
||
},
|
||
);
|
||
}).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 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) : null,
|
||
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
|
||
body: Column(
|
||
children: [
|
||
// Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏)
|
||
Container(
|
||
height: 56 + topInset,
|
||
color: context.tokens.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: context.tokens.primaryDark,
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: _navItems.asMap().entries.map((e) {
|
||
final isActive =
|
||
widget.navigationShell.currentIndex ==
|
||
e.key;
|
||
return _SidebarItem(
|
||
item: e.value,
|
||
isActive: isActive,
|
||
expanded: _sidebarExpanded,
|
||
onTap: () => _goBranch(e.key),
|
||
);
|
||
}).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: context.tokens.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.navigationShell),
|
||
// 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)
|
||
: context.tokens.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]! : 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)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 ? context.tokens.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: BoxDecoration(
|
||
color: context.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(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 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: 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)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
final color = context.tokens.text;
|
||
final hoverBg = context.tokens.rowHover;
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|