Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia 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
chore: release client-v1.0.57
设备/状态管理屏(查看本店在线设备/会话,管理员可强制下线)、登录携带设备信息、
会话心跳(/auth/ping)、被踢下线/会话失效提示、顶栏精简 + 用户名移至左侧栏底部。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:13:05 +08:00

1036 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '../../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '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,
),
),
],
),
),
);
}
}