Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia c1ed81dfab feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:05:41 +08:00

698 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 '../../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/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;
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 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,
),
),
],
),
),
);
}
}