Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia 393e227de5 feat: 商品详情页、XLS导入修复、分页选择器、导出功能
后端:
- 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%)
- products 表新增 public_id(UUID)、description 字段
- 新增商品详情接口、二维码接口、公开商品接口(无鉴权)
- 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls
- 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug)
- 所有导入接口返回 total/imported/skipped 统计
- config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖
- 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正

前端:
- 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制
- 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA
- 商品详情列表(批次追踪)商品名超链接跳转详情页
- 导航「商品管理」改名「商品详情」
- 所有列表表格新增每页条数选择(10/20/50/100)
- 表格列头内嵌筛选(FilterableColumnHeader)
- 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位)
- 网络恢复自动刷新 + 离线缓存展示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:29:51 +08:00

696 lines
25 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 '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/theme/app_theme.dart';
import '../../providers/connectivity_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;
showDialog(
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.track_changes, label: '商品详情', path: '/batches'),
_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 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'}) {
showDialog(
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 StatelessWidget {
final AuthUser? user;
final String version;
const _ShopButton({this.user, this.version = 'v1.0.0'});
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wine_bar, color: Colors.white, size: 22),
SizedBox(width: 8),
Text(
'酒库管理系统',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5),
),
],
),
),
);
}
}
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,
),
),
],
),
),
);
}
}