23a583918d
把原型 check-ds 的「单一真源」纪律延伸到 Flutter 代码: - client/tool/check_ds_code.mjs:扫 lib/(排除.g.dart) 硬编码 Color(0x..)/Colors.具名色, --strict/--changed 报错挡提交,合理特例须显式 // ds-ignore: 理由 - tokens.css(单源,client+原型同步) 加 chrome 前景色 --brand-dot/--on-accent/--on-accent-faint, codegen 进 AppChrome;外壳 14 处硬编码(白字/品牌点/grace橙)全部改走 token - 外壳+3 新 widget 现 0 违规;全量存量 172→158(其余屏后续逐步清) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
988 lines
33 KiB
Dart
988 lines
33 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 '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/config/license_copy.dart';
|
||
import '../../core/responsive/responsive.dart';
|
||
import '../../core/theme/context_tokens.dart';
|
||
import '../../core/theme/app_chrome.g.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';
|
||
import '../../widgets/theme_picker_pill.dart';
|
||
import '../../widgets/notification_bell.dart';
|
||
import '../../widgets/app_status_bar.dart';
|
||
|
||
/// 侧栏导航项(含其 StatefulShellRoute 分支序号 = router 中的 branch index)。
|
||
class _NavItem {
|
||
final IconData icon;
|
||
final String label;
|
||
final int index;
|
||
const _NavItem(this.icon, this.label, this.index);
|
||
}
|
||
|
||
/// 侧栏分组(原型 nav-label:单据 / 经营 / 系统)。
|
||
class _NavGroup {
|
||
final String label;
|
||
final List<_NavItem> items;
|
||
const _NavGroup(this.label, this.items);
|
||
}
|
||
|
||
// 分支序号必须与 router StatefulShellRoute 的 branch 顺序一致(勿改顺序)。
|
||
const _navGroups = <_NavGroup>[
|
||
_NavGroup('单据', [
|
||
_NavItem(Icons.file_download_outlined, '入库管理', 0),
|
||
_NavItem(Icons.file_upload_outlined, '出库管理', 1),
|
||
]),
|
||
_NavGroup('经营', [
|
||
_NavItem(Icons.inventory_2_outlined, '库存管理', 2),
|
||
_NavItem(Icons.receipt_long_outlined, '财务管理', 3),
|
||
_NavItem(Icons.people_alt_outlined, '往来单位', 4),
|
||
_NavItem(Icons.grid_view_outlined, '基础数据', 5),
|
||
]),
|
||
_NavGroup('系统', [
|
||
_NavItem(Icons.devices_outlined, '设备管理', 6),
|
||
_NavItem(Icons.settings_outlined, '系统设置', 7),
|
||
_NavItem(Icons.info_outline, '关于我们', 8),
|
||
]),
|
||
];
|
||
|
||
String _roleLabel(String role) {
|
||
switch (role) {
|
||
case 'superadmin':
|
||
return '超级管理员';
|
||
case 'admin':
|
||
return '管理员';
|
||
case 'readonly':
|
||
return '只读';
|
||
default:
|
||
return '操作员';
|
||
}
|
||
}
|
||
|
||
class AppShell extends ConsumerStatefulWidget {
|
||
/// StatefulShellRoute 注入的导航壳:各栏目分支 Navigator 的 IndexedStack,
|
||
/// 跨栏目切换时各分支页面 State 常驻(保住半填表单 / 内部 tab / 滚动位置)。
|
||
final StatefulNavigationShell navigationShell;
|
||
const AppShell({super.key, required this.navigationShell});
|
||
|
||
@override
|
||
ConsumerState<AppShell> createState() => _AppShellState();
|
||
}
|
||
|
||
class _AppShellState extends ConsumerState<AppShell> {
|
||
final String _loginTime =
|
||
DateFormat('HH:mm:ss').format(AppStatusBar.clock());
|
||
bool _forceDialogShown = false;
|
||
bool _licenseDialogShown = false;
|
||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||
|
||
/// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context,
|
||
/// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方**
|
||
/// (留 8px 间隙,不遮挡触发器,对齐原型)。
|
||
void _openUserMenu(BuildContext anchorContext) {
|
||
final t = anchorContext.tokens;
|
||
final rb = anchorContext.findRenderObject() as RenderBox?;
|
||
final overlay =
|
||
Overlay.of(anchorContext).context.findRenderObject() as RenderBox?;
|
||
if (rb == null || overlay == null) return;
|
||
final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay);
|
||
final items = <PopupMenuEntry<String>>[
|
||
_userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t),
|
||
_userMenuItem('logout', Icons.logout, '退出登录', t),
|
||
];
|
||
// 菜单弹到按钮上方:top = 按钮顶 − 菜单估高 − 间隙(菜单项 40 + 上下 padding 8*2)
|
||
const itemH = 40.0;
|
||
final menuH = items.length * itemH + 16;
|
||
final top = topLeft.dy - menuH - 8;
|
||
showMenu<String>(
|
||
context: anchorContext,
|
||
position: RelativeRect.fromLTRB(
|
||
topLeft.dx,
|
||
top,
|
||
overlay.size.width - topLeft.dx - rb.size.width,
|
||
overlay.size.height - top,
|
||
),
|
||
constraints: BoxConstraints.tightFor(width: rb.size.width),
|
||
color: t.menuUserBg,
|
||
elevation: 8,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||
items: items,
|
||
).then((v) {
|
||
if (v == 'logout') _logout();
|
||
});
|
||
}
|
||
|
||
/// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。
|
||
void _goBranch(int index) {
|
||
widget.navigationShell.goBranch(
|
||
index,
|
||
initialLocation: index == widget.navigationShell.currentIndex,
|
||
);
|
||
}
|
||
|
||
void _logout() {
|
||
ref.read(authStateProvider.notifier).logout();
|
||
context.go('/login');
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final user = ref.watch(authStateProvider).user;
|
||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||
ref.watch(sessionHeartbeatProvider);
|
||
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
|
||
ref.listen<String?>(apiMessageProvider, (prev, next) {
|
||
if (next == null || next.isEmpty) return;
|
||
ScaffoldMessenger.of(context)
|
||
..clearSnackBars()
|
||
..showSnackBar(SnackBar(content: Text(next)));
|
||
ref.read(apiMessageProvider.notifier).state = null;
|
||
});
|
||
final isOnline = ref.watch(connectivityProvider);
|
||
final isMobile = context.isMobile;
|
||
final topInset = MediaQuery.of(context).padding.top;
|
||
final updateNotifier = ref.watch(updateProvider.notifier);
|
||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||
final licenseInfo = ref.watch(licenseProvider).valueOrNull;
|
||
|
||
return SelectionArea(
|
||
child: Scaffold(
|
||
key: _scaffoldKey,
|
||
drawer: isMobile ? _buildDrawer(context, user) : null,
|
||
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
|
||
body: Column(
|
||
children: [
|
||
_buildTopBar(context, user, isMobile, topInset),
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
if (!isMobile) _buildSidebar(context, user),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
..._buildBanners(
|
||
context, isOnline, updateInfo, updateNotifier, licenseInfo),
|
||
Expanded(child: widget.navigationShell),
|
||
if (!isMobile) AppStatusBar(loginTime: _loginTime),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 顶栏(原型 .top, 56h):左两级品牌;右 主题器 + 通知铃 ───────────────────
|
||
Widget _buildTopBar(
|
||
BuildContext context, AuthUser? user, bool isMobile, double topInset) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
height: 56 + topInset,
|
||
decoration: BoxDecoration(
|
||
color: t.topBg,
|
||
border: Border(bottom: BorderSide(color: t.topBorder)),
|
||
),
|
||
padding: EdgeInsets.only(top: topInset, left: isMobile ? 6 : 18, right: 14),
|
||
child: Row(
|
||
children: [
|
||
// 左组(占满剩余空间,靠左):把右组(主题器+铃)挤到最右
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
if (isMobile) ...[
|
||
IconButton(
|
||
icon: Icon(Icons.menu, color: t.topFg),
|
||
tooltip: '菜单',
|
||
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
|
||
),
|
||
const SizedBox(width: 2),
|
||
],
|
||
// 软件品牌(岩美酒库)—— 桌面常显,窄屏让位给门店
|
||
if (!isMobile) ...[
|
||
const _SoftwareBrand(),
|
||
Container(
|
||
width: 1,
|
||
height: 20,
|
||
color: t.topBorder,
|
||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
],
|
||
// 门店品牌(御品轩名酒坊)——点开门店信息;过长省略
|
||
Flexible(child: _ShopBrand(user: user)),
|
||
if (WriteGuard.isReadonly(ref)) ...[
|
||
const SizedBox(width: 10),
|
||
_readonlyBadge(t.topControl),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
// 右组:主题器 + 通知铃,紧贴右侧
|
||
const SizedBox(width: 12),
|
||
const ThemePickerPill(),
|
||
const SizedBox(width: 14),
|
||
const NotificationBell(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _readonlyBadge(Color bg) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: const [
|
||
Icon(Icons.visibility_outlined, size: 13, color: AppChrome.onAccent),
|
||
SizedBox(width: 4),
|
||
Text('只读',
|
||
style: TextStyle(
|
||
color: AppChrome.onAccent,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 侧栏(原型 .side, 218w):分组导航 + 底部 side-user ─────────────────────
|
||
Widget _buildSidebar(BuildContext context, AuthUser? user) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
width: 218,
|
||
decoration: BoxDecoration(
|
||
color: t.sideBg,
|
||
border: Border(right: BorderSide(color: t.sideBorder)),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: ListView(
|
||
padding: EdgeInsets.zero,
|
||
children: [
|
||
for (final g in _navGroups) ...[
|
||
_navLabel(t, g.label),
|
||
for (final item in g.items)
|
||
_SidebarNav(
|
||
item: item,
|
||
active: widget.navigationShell.currentIndex == item.index,
|
||
onTap: () => _goBranch(item.index),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
if (user != null) _buildSideUser(context, user),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _navLabel(dynamic t, String label) => Padding(
|
||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 6),
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: t.sideMuted,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
);
|
||
|
||
Widget _buildSideUser(BuildContext context, AuthUser user) {
|
||
final t = context.tokens;
|
||
final shopName = ref.watch(shopInfoProvider).valueOrNull?.name ?? user.shopNo;
|
||
return Column(
|
||
children: [
|
||
Container(
|
||
height: 1,
|
||
color: t.sideBorder,
|
||
margin: const EdgeInsets.fromLTRB(4, 8, 4, 8),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: _SideUserTile(
|
||
user: user,
|
||
shopName: shopName,
|
||
roleLabel: _roleLabel(user.role),
|
||
onTap: _openUserMenu,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
PopupMenuItem<String> _userMenuItem(
|
||
String value, IconData icon, String label, dynamic t) {
|
||
return PopupMenuItem<String>(
|
||
value: value,
|
||
height: 40,
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 16, color: t.menuUserFg),
|
||
const SizedBox(width: 9),
|
||
Text(label, style: TextStyle(fontSize: 13, color: t.menuUserFg)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 窄屏(手机)侧滑抽屉导航:与侧栏同分组结构。
|
||
Widget _buildDrawer(BuildContext context, AuthUser? user) {
|
||
final t = context.tokens;
|
||
return Drawer(
|
||
child: Container(
|
||
color: t.sideBg,
|
||
child: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
if (user != null)
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||
color: t.topBg,
|
||
child: Row(
|
||
children: [
|
||
const _SoftwareBrandMark(size: 30),
|
||
const SizedBox(width: 8),
|
||
const Text('岩美酒库',
|
||
style: TextStyle(
|
||
color: AppChrome.onAccent,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700)),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
children: [
|
||
for (final g in _navGroups) ...[
|
||
_navLabel(t, g.label),
|
||
for (final item in g.items)
|
||
_SidebarNav(
|
||
item: item,
|
||
active:
|
||
widget.navigationShell.currentIndex == item.index,
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
_goBranch(item.index);
|
||
},
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
if (user != null) ...[
|
||
Container(
|
||
height: 1,
|
||
color: t.sideBorder,
|
||
margin: const EdgeInsets.symmetric(horizontal: 16)),
|
||
ListTile(
|
||
leading: Icon(Icons.logout, color: t.sideFg),
|
||
title: Text('退出登录',
|
||
style: TextStyle(
|
||
color: t.sideActiveFg,
|
||
fontWeight: FontWeight.w500)),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
_logout();
|
||
},
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ───────────────────
|
||
List<Widget> _buildBanners(
|
||
BuildContext context,
|
||
bool isOnline,
|
||
AppUpdateInfo? updateInfo,
|
||
UpdateNotifier updateNotifier,
|
||
LicenseInfo? licenseInfo,
|
||
) {
|
||
final t = context.tokens;
|
||
return [
|
||
if (updateInfo != null &&
|
||
updateInfo.hasUpdate &&
|
||
!updateInfo.forceUpdate &&
|
||
!updateNotifier.isDismissed)
|
||
Container(
|
||
width: double.infinity,
|
||
color: t.warnBg,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.system_update, size: 16, color: t.warn),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'发现新版本 v${updateInfo.latestVersion},建议立即更新以获得最新功能与修复',
|
||
style: TextStyle(color: t.text, fontSize: 13),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => startInAppUpdate(context, updateInfo),
|
||
style: TextButton.styleFrom(foregroundColor: t.warn),
|
||
child: const Text('立即更新'),
|
||
),
|
||
TextButton(
|
||
onPressed: updateNotifier.dismiss,
|
||
style: TextButton.styleFrom(foregroundColor: t.muted),
|
||
child: const Text('稍后再说'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (updateInfo != null && updateInfo.hasUpdate && updateInfo.forceUpdate)
|
||
Builder(builder: (ctx) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_showForceUpdateDialog(ctx, updateInfo);
|
||
});
|
||
return const SizedBox.shrink();
|
||
}),
|
||
if (licenseInfo != null && licenseInfo.needsAttention)
|
||
_buildLicenseBanner(licenseInfo),
|
||
if (licenseInfo != null &&
|
||
licenseInfo.needsAttention &&
|
||
!_licenseDialogShown)
|
||
Builder(builder: (ctx) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_licenseDialogShown && mounted) {
|
||
_licenseDialogShown = true;
|
||
_showLicenseExpiryDialog(ctx, licenseInfo);
|
||
}
|
||
});
|
||
return const SizedBox.shrink();
|
||
}),
|
||
if (!isOnline)
|
||
Container(
|
||
width: double.infinity,
|
||
color: t.danger,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.wifi_off, size: 16, color: AppChrome.onAccent),
|
||
SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||
style: TextStyle(
|
||
color: AppChrome.onAccent,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
NetworkRetryButton(),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
void _showForceUpdateDialog(BuildContext context, AppUpdateInfo info) {
|
||
if (_forceDialogShown) return;
|
||
_forceDialogShown = true;
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => PopScope(
|
||
canPop: false,
|
||
child: AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
Icon(Icons.system_update, color: context.tokens.primary),
|
||
const SizedBox(width: 8),
|
||
const Text('发现新版本'),
|
||
],
|
||
),
|
||
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
|
||
'更新以获得最新功能与修复。'),
|
||
actions: [
|
||
ElevatedButton(
|
||
onPressed: () => startInAppUpdate(context, info),
|
||
child: const Text('立即更新'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLicenseBanner(LicenseInfo lic) {
|
||
final bg = LicenseCopy.bannerColor(lic.phase);
|
||
final msg = LicenseCopy.banner(lic);
|
||
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: AppChrome.onAccent),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(msg,
|
||
style: const TextStyle(color: AppChrome.onAccent, fontSize: 13),
|
||
overflow: TextOverflow.ellipsis),
|
||
),
|
||
TextButton(
|
||
onPressed: () => context.go('/settings?tab=license'),
|
||
style: TextButton.styleFrom(foregroundColor: AppChrome.onAccentFaint),
|
||
child: const Text('去激活'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
||
final (title, body) = LicenseCopy.dialog(lic);
|
||
final Color titleColor =
|
||
lic.phase == 'grace' ? ctx.tokens.warn : ctx.tokens.danger;
|
||
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?tab=license');
|
||
},
|
||
child: const Text('立即前往', style: TextStyle(color: AppChrome.onAccent)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 软件品牌(岩美酒库):线描山形 mark + 名 ──────────────────────────────────
|
||
class _SoftwareBrand extends StatelessWidget {
|
||
const _SoftwareBrand();
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const _SoftwareBrandMark(size: 30),
|
||
const SizedBox(width: 7),
|
||
Text('岩美酒库',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 17,
|
||
color: t.topFg,
|
||
letterSpacing: 0.3)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 软件 logo 线描山形(原型 prod-mk SVG):白色描边山峰 + 横线 + 酒色圆点。
|
||
class _SoftwareBrandMark extends StatelessWidget {
|
||
final double size;
|
||
const _SoftwareBrandMark({required this.size});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CustomPaint(
|
||
size: Size(size, size),
|
||
painter: _BrandMarkPainter(context.tokens.topFg),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BrandMarkPainter extends CustomPainter {
|
||
final Color stroke;
|
||
_BrandMarkPainter(this.stroke);
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final s = size.width / 64.0; // 原型 viewBox 64
|
||
canvas.scale(s);
|
||
final p = Paint()
|
||
..color = stroke
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 5
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round;
|
||
final peak = Path()
|
||
..moveTo(14, 33)
|
||
..lineTo(23, 16)
|
||
..lineTo(32, 25)
|
||
..lineTo(41, 16)
|
||
..lineTo(50, 33);
|
||
canvas.drawPath(peak, p);
|
||
canvas.drawLine(const Offset(15, 41), const Offset(49, 41), p);
|
||
canvas.drawCircle(const Offset(32, 48), 3.4,
|
||
Paint()..color = AppChrome.brandDot);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_BrandMarkPainter old) => old.stroke != stroke;
|
||
}
|
||
|
||
// ── 门店品牌(鼎晟酒行):字标 mark + 店名,点开门店信息 ───────────────────────
|
||
class _ShopBrand extends ConsumerWidget {
|
||
final AuthUser? user;
|
||
const _ShopBrand({this.user});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final t = context.tokens;
|
||
final shopAsync = ref.watch(shopInfoProvider);
|
||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||
final version = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (user != null) _showShopPanel(context, user!, version: version);
|
||
},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ShopMark(
|
||
logoUrl: logoUrl,
|
||
shopName: shopName,
|
||
bg: t.topMarkBg,
|
||
fg: t.topMarkFg),
|
||
const SizedBox(width: 9),
|
||
Flexible(
|
||
child: Text(
|
||
shopName.isEmpty ? '—' : shopName,
|
||
style: TextStyle(
|
||
color: t.topFg, fontSize: 13, fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 门店字标(原型 .logo .mk, 26×26 圆角):有 logo 显示图,否则店名首字。
|
||
class _ShopMark extends StatelessWidget {
|
||
final String logoUrl;
|
||
final String shopName;
|
||
final Color bg;
|
||
final Color fg;
|
||
const _ShopMark(
|
||
{required this.logoUrl,
|
||
required this.shopName,
|
||
required this.bg,
|
||
required this.fg});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final radius = BorderRadius.circular(8);
|
||
if (logoUrl.isNotEmpty) {
|
||
final fullUrl = logoUrl.startsWith('http')
|
||
? logoUrl
|
||
: '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||
return ClipRRect(
|
||
borderRadius: radius,
|
||
child: Image.network(
|
||
fullUrl,
|
||
width: 26,
|
||
height: 26,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => _initial(radius),
|
||
),
|
||
);
|
||
}
|
||
return _initial(radius);
|
||
}
|
||
|
||
Widget _initial(BorderRadius radius) {
|
||
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||
return Container(
|
||
width: 26,
|
||
height: 26,
|
||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
||
alignment: Alignment.center,
|
||
child: Text(initial,
|
||
style: TextStyle(
|
||
color: fg, fontSize: 13, fontWeight: FontWeight.w800)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 侧栏底部用户区(原型 .side-user):hover 变色(同 nav 的 side-hover),点开账号菜单 ──
|
||
class _SideUserTile extends StatefulWidget {
|
||
final AuthUser user;
|
||
final String shopName;
|
||
final String roleLabel;
|
||
final void Function(BuildContext anchorContext) onTap;
|
||
const _SideUserTile({
|
||
required this.user,
|
||
required this.shopName,
|
||
required this.roleLabel,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
State<_SideUserTile> createState() => _SideUserTileState();
|
||
}
|
||
|
||
class _SideUserTileState extends State<_SideUserTile> {
|
||
bool _hover = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final u = widget.user;
|
||
final initial = u.realName.isNotEmpty ? u.realName : u.username;
|
||
final pic = initial.isNotEmpty ? initial.characters.first : '用';
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
onEnter: (_) => setState(() => _hover = true),
|
||
onExit: (_) => setState(() => _hover = false),
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => widget.onTap(context),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: _hover ? t.sideHover : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: t.sideActiveBg,
|
||
shape: BoxShape.circle,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(pic,
|
||
style: TextStyle(
|
||
color: t.sideActiveFg,
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 14)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
u.realName.isNotEmpty ? u.realName : u.username,
|
||
style: TextStyle(
|
||
color: t.sideActiveFg,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
'${widget.shopName} · ${widget.roleLabel}',
|
||
style: TextStyle(color: t.sideMuted, fontSize: 11),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 侧栏导航项(原型 .nav, 40h)───────────────────────────────────────────────
|
||
class _SidebarNav extends StatefulWidget {
|
||
final _NavItem item;
|
||
final bool active;
|
||
final VoidCallback onTap;
|
||
const _SidebarNav(
|
||
{required this.item, required this.active, required this.onTap});
|
||
|
||
@override
|
||
State<_SidebarNav> createState() => _SidebarNavState();
|
||
}
|
||
|
||
class _SidebarNavState extends State<_SidebarNav> {
|
||
bool _hover = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final active = widget.active;
|
||
final Color bg = active
|
||
? t.sideActiveBg
|
||
: (_hover ? t.sideHover : Colors.transparent);
|
||
final Color fg = active || _hover ? t.sideActiveFg : t.sideFg;
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
onEnter: (_) => setState(() => _hover = true),
|
||
onExit: (_) => setState(() => _hover = false),
|
||
child: GestureDetector(
|
||
onTap: widget.onTap,
|
||
child: Container(
|
||
height: 40,
|
||
margin: const EdgeInsets.only(bottom: 2),
|
||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(widget.item.icon, size: 19, color: fg),
|
||
const SizedBox(width: 11),
|
||
Text(
|
||
widget.item.label,
|
||
style: TextStyle(
|
||
color: fg,
|
||
fontSize: 13,
|
||
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 门店信息弹窗(点门店品牌触发,保留原行为)─────────────────────────────────
|
||
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: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||
decoration: BoxDecoration(
|
||
color: ctx.tokens.primary,
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(10)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.store, color: AppChrome.onAccent, size: 20),
|
||
const SizedBox(width: 10),
|
||
const Text('门店信息',
|
||
style: TextStyle(
|
||
color: AppChrome.onAccent,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600)),
|
||
const Spacer(),
|
||
IconButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
icon: const Icon(Icons.close,
|
||
color: AppChrome.onAccentFaint, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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 _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: context.tokens.muted),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 72,
|
||
child: Text(label,
|
||
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
|
||
),
|
||
Expanded(
|
||
child: Text(value,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: context.tokens.text,
|
||
fontWeight: FontWeight.w500)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|