Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia a05f9bd4ec
Deploy / deploy (push) Failing after 8s
feat(shop): 门店 logo 上传 + 侧边栏品牌替换
- shops 表新增 logo_url 字段,AutoMigrate 自动建列
- 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储
- UpdateInfo 支持传入 logo_url
- 侧边栏「岩美」替换为门店真实名称 + 自定义 logo
- 无 logo 时显示店名首字文字头像(深蓝底白字)
- 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮
- 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计
- 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增
- settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:03:43 +08:00

752 lines
27 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 '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/shop_provider.dart';
import '../../providers/update_provider.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;
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'),
if (info.releaseNotes.isNotEmpty) ...[
const SizedBox(height: 12),
Text(info.releaseNotes,
style: const TextStyle(
color: AppTheme.textSecondary, fontSize: 13)),
],
],
),
actions: [
ElevatedButton(
onPressed: () => launchUpdateUrl(info.downloadUrls),
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.settings, label: '系统设置', path: '/settings'),
];
@override
Widget build(BuildContext context) {
final user = ref.watch(authStateProvider).user;
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
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';
return SelectionArea(
child: Scaffold(
body: Column(
children: [
// Top Bar
Container(
height: 56,
color: AppTheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
icon: Icon(
_sidebarExpanded ? Icons.menu_open : Icons.menu,
color: Colors.white),
onPressed: () =>
setState(() => _sidebarExpanded = !_sidebarExpanded),
tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏',
),
const SizedBox(width: 4),
_ShopButton(user: user, version: appVersion),
const Spacer(),
if (user != null) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _showShopPanel(context, user, version: appVersion),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.business,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.shopNo,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
],
),
),
),
const SizedBox(width: 20),
const Icon(Icons.person_outline,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.username,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
const SizedBox(width: 8),
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 PopupMenuDivider(height: 1),
const PopupMenuItem<String>(
value: 'logout',
padding: EdgeInsets.zero,
child: _HoverMenuItem(
icon: Icons.logout_outlined,
label: '退出登录',
danger: true,
),
),
],
),
const SizedBox(width: 8),
],
],
),
),
// Main area
Expanded(
child: Row(
children: [
// Sidebar
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(),
),
),
],
),
),
// 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}'
'${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}',
style: const TextStyle(
color: Color(0xFF5D4037),
fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () => launchUpdateUrl(
updateInfo.downloadUrls),
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();
}),
// 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),
Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
],
),
),
Expanded(child: widget.child),
// Status bar
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),
],
),
);
},
),
],
),
),
],
),
),
],
),
),
);
}
}
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: 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;
final bool danger;
const _HoverMenuItem({
required this.icon,
required this.label,
this.danger = false,
});
@override
State<_HoverMenuItem> createState() => _HoverMenuItemState();
}
class _HoverMenuItemState extends State<_HoverMenuItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final color = widget.danger
? const Color(0xFFE53935)
: const Color(0xFF333333);
final hoverBg = widget.danger
? const Color(0xFFFFF0F0)
: const 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,
),
),
],
),
),
);
}
}