90f318e246
Deploy Client / build-windows (push) Failing after 21s
Deploy Client / build-client-web (push) Successful in 42s
Deploy Client / build-macos (push) Successful in 2m8s
Deploy Client / build-android (push) Successful in 1m25s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Has been skipped
设备/状态管理屏(查看本店在线设备/会话,管理员可强制下线)、登录携带设备信息、 会话心跳(/auth/ping)、被踢下线/会话失效提示、顶栏精简 + 用户名移至左侧栏底部。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1036 lines
40 KiB
Dart
1036 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/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);
|
||
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),
|
||
SizedBox(
|
||
height: 48,
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: () => _showShopPanel(context, user,
|
||
version: appVersion),
|
||
hoverColor: Colors.white.withAlpha(13),
|
||
splashColor: Colors.white.withAlpha(26),
|
||
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 Divider(height: 1, color: Colors.white24),
|
||
SizedBox(
|
||
height: 48,
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: () {
|
||
ref
|
||
.read(authStateProvider.notifier)
|
||
.logout();
|
||
context.go('/login');
|
||
},
|
||
hoverColor: Colors.white.withAlpha(13),
|
||
splashColor: Colors.white.withAlpha(26),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(width: _sidebarExpanded ? 16 : 3),
|
||
Expanded(
|
||
child: Row(
|
||
mainAxisAlignment: _sidebarExpanded
|
||
? MainAxisAlignment.start
|
||
: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.logout,
|
||
color: Colors.white60, size: 20),
|
||
if (_sidebarExpanded) ...[
|
||
const SizedBox(width: 12),
|
||
const Text('退出登录',
|
||
style: TextStyle(
|
||
color: Colors.white70,
|
||
fontSize: 14)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLicenseBanner(LicenseInfo lic) {
|
||
final Color bg;
|
||
final String msg;
|
||
switch (lic.phase) {
|
||
case 'locked':
|
||
bg = AppTheme.danger;
|
||
msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码';
|
||
case 'readonly':
|
||
bg = const Color(0xFFB71C1C);
|
||
msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)';
|
||
default: // grace
|
||
bg = const Color(0xFFE65100);
|
||
msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费';
|
||
}
|
||
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'),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.white70),
|
||
child: const Text('去激活'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
||
final String title;
|
||
final String body;
|
||
final Color titleColor;
|
||
switch (lic.phase) {
|
||
case 'locked':
|
||
title = '授权已锁定';
|
||
body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。';
|
||
titleColor = AppTheme.danger;
|
||
case 'readonly':
|
||
title = '授权已过期 · 只读模式';
|
||
body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。';
|
||
titleColor = AppTheme.danger;
|
||
default: // grace
|
||
title = '授权即将到期';
|
||
body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。';
|
||
titleColor = Colors.orange[800]!;
|
||
}
|
||
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');
|
||
},
|
||
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)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|