// screens/auth/login_screen.dart — 登录页,照原型 login.html 1:1 重建。 // 结构:--page 底居中 .auth 两栏卡片(左 .brand 品牌渐变面板:logo+名+tag+三卖点; // 右 .form:h1/lead + 门店编号/登录账号/密码 三字段 + 记住我行 + 主按钮 + foot) // + 右上角 A/B/C 主题切换器。窄屏折叠单列、隐藏卖点(原型 @media 同构)。 // 原型之外保留的功能位(known diffs 见 design/CONTRACT.md): // · 门店编号/账号历史下拉(后缀箭头)与密码可见切换(后缀眼睛) // · 离线提示条 / 服务器错误条 / 授权锁定引导卡(表单内、按钮上方) // · 会话失效弹窗(被顶下线提示) import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_constants.dart'; import '../../core/config/store_compliance.dart'; import '../../core/responsive/responsive.dart'; import '../../core/storage/login_history.dart'; import '../../core/theme/app_chrome.g.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../providers/connectivity_provider.dart'; import '../../repositories/auth_repository.dart'; import '../../widgets/brand_mark.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; import '../../widgets/network_retry_button.dart'; import 'auth_shared.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _shopCodeCtrl = TextEditingController(); final _usernameCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); final _shopFocus = FocusNode(); final _usernameFocus = FocusNode(); final _shopLayerLink = LayerLink(); final _usernameLayerLink = LayerLink(); bool _loading = false; bool _obscure = true; bool _remember = true; String? _errorMessage; // 客服邮箱(授权续费 / 锁定协助)。单一来源 store_compliance.supportEmail, // 与官网 web/_data/site.json 的 support.email 保持一致。 static const _supportEmail = supportEmail; /// 当前错误是否为「授权锁定」——后端返回固定文案 /// `license locked, please renew or contact support`(见 service.ErrLicenseLocked)。 bool get _isLicenseLocked { final m = _errorMessage?.toLowerCase() ?? ''; return m.contains('license') && (m.contains('lock') || m.contains('expire')); } List _shopHistory = []; List _usernameHistory = []; OverlayEntry? _shopEntry; OverlayEntry? _usernameEntry; Timer? _closeShopTimer; Timer? _closeUsernameTimer; bool get _shopShowing => _shopEntry != null; bool get _usernameShowing => _usernameEntry != null; @override void initState() { super.initState(); _restorePrefill(); _shopFocus.addListener(() { if (_shopFocus.hasFocus) { // 取消失焦延迟关闭:快速失焦→回焦时旧 Timer 会误关刚打开的下拉 _closeShopTimer?.cancel(); _closeShop(); _loadHistory().then((_) { if (!mounted || !_shopFocus.hasFocus) return; if (_shopHistory.isNotEmpty) { _openDropdown(_shopLayerLink, _shopHistory, _shopCodeCtrl, isShop: true); } }); } else { // 延迟关闭:让候选词的 onTap(pointer-up)先执行,再关闭下拉框 _closeShopTimer?.cancel(); _closeShopTimer = Timer(AppConstants.dropdownCloseDelay, _closeShop); } }); _usernameFocus.addListener(() { if (_usernameFocus.hasFocus) { _closeUsernameTimer?.cancel(); _closeUsername(); _loadHistory().then((_) { if (!mounted || !_usernameFocus.hasFocus) return; if (_usernameHistory.isNotEmpty) { _openDropdown(_usernameLayerLink, _usernameHistory, _usernameCtrl, isShop: false); } }); } else { _closeUsernameTimer?.cancel(); _closeUsernameTimer = Timer(AppConstants.dropdownCloseDelay, _closeUsername); } }); // 处理「进入登录页之前」就已置入的会话失效提示:登出在 login_screen 挂载前就 // 设好了 sessionEndedMessageProvider,而 build 里的 ref.listen 只捕获注册之后的 // 变化,会漏掉这个既有值 → 之前「被顶下线无提示」的根因。这里在首帧主动读一次。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final msg = ref.read(sessionEndedMessageProvider); if (msg != null && msg.isNotEmpty) _showSessionEndedDialog(msg); }); } /// 「记住我」开启时,用最近一次登录的门店编号/账号预填表单。 Future _restorePrefill() async { final remember = await LoginHistoryStorage.getRemember(); await _loadHistory(); if (!mounted) return; setState(() { _remember = remember; if (remember) { if (_shopCodeCtrl.text.isEmpty && _shopHistory.isNotEmpty) { _shopCodeCtrl.text = _shopHistory.first; } if (_usernameCtrl.text.isEmpty && _usernameHistory.isNotEmpty) { _usernameCtrl.text = _usernameHistory.first; } } }); } /// 弹出「登录已失效 / 被强制下线」提示弹窗,并清空 provider 避免重复弹。 void _showSessionEndedDialog(String msg) { if (!mounted) return; ref.read(sessionEndedMessageProvider.notifier).state = null; showDialog( context: context, builder: (ctx) => AlertDialog( icon: Icon(LucideIcons.lock, color: context.tokens.danger), title: const Text('登录已失效'), content: Text(msg), actions: [ DsButton('我知道了', variant: DsBtnVariant.primary, onPressed: () => Navigator.of(ctx).pop()), ], ), ); } Future _loadHistory() async { final shops = await LoginHistoryStorage.getHotelCodes(); final users = await LoginHistoryStorage.getUsernames(); if (!mounted) return; setState(() { _shopHistory = shops; _usernameHistory = users; }); } void _openDropdown( LayerLink link, List items, TextEditingController ctrl, { required bool isShop, }) { if (isShop) { if (_shopEntry != null) return; _closeUsername(); } else { if (_usernameEntry != null) return; _closeShop(); } final entry = OverlayEntry( builder: (_) => Stack( children: [ // Transparent backdrop — tap outside closes dropdown Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: isShop ? _closeShop : _closeUsername, ), ), // Dropdown positioned below the field(宽随字段:登录表单列宽 340) CompositedTransformFollower( link: link, targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, showWhenUnlinked: false, offset: const Offset(0, 4), child: _HistoryMenu( width: context.dialogWidth(340), items: items, onSelected: (v) => _selectHistoryItem(v, ctrl, isShop: isShop), ), ), ], ), ); if (isShop) { _shopEntry = entry; } else { _usernameEntry = entry; } Overlay.of(context).insert(entry); setState(() {}); // refresh arrow icon } void _selectHistoryItem( String value, TextEditingController ctrl, { required bool isShop, }) { ctrl.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); if (isShop) { _closeShop(); _shopFocus.unfocus(); } else { _closeUsername(); _usernameFocus.unfocus(); } setState(() {}); } void _closeShop() { _shopEntry?.remove(); _shopEntry = null; if (mounted) setState(() {}); } void _closeUsername() { _usernameEntry?.remove(); _usernameEntry = null; if (mounted) setState(() {}); } @override void dispose() { _closeShopTimer?.cancel(); _closeUsernameTimer?.cancel(); _shopEntry?.remove(); _usernameEntry?.remove(); _shopCodeCtrl.dispose(); _usernameCtrl.dispose(); _passwordCtrl.dispose(); _shopFocus.dispose(); _usernameFocus.dispose(); super.dispose(); } Future _login() async { // 原型式校验:逐项 toast 提示(无内联错误文案,保持 38 高盒式字段不抖动) if (_shopCodeCtrl.text.trim().isEmpty) { showDsToast(context, '请输入门店编号'); return; } if (_usernameCtrl.text.trim().isEmpty) { showDsToast(context, '请输入登录账号'); return; } if (_passwordCtrl.text.isEmpty) { showDsToast(context, '请输入密码'); return; } // 登录前先检查连通性 final isOnline = ref.read(connectivityProvider); if (!isOnline) { setState(() => _errorMessage = '服务器不可达,请检查网络连接后重试'); return; } setState(() { _loading = true; _errorMessage = null; }); try { final user = await AuthRepository.login( shopCode: _shopCodeCtrl.text.trim(), username: _usernameCtrl.text.trim(), password: _passwordCtrl.text, ); await LoginHistoryStorage.setRemember(_remember); if (_remember) { await LoginHistoryStorage.record( _shopCodeCtrl.text.trim(), _usernameCtrl.text.trim(), ); } await ref.read(authStateProvider.notifier).login(user); if (mounted) context.go('/stock-in'); } on AuthException catch (e) { if (mounted) setState(() => _errorMessage = e.message); } catch (e) { if (mounted) setState(() => _errorMessage = '登录失败:$e'); } finally { if (mounted) setState(() => _loading = false); } } Future _openRenewSite() async { await launchUrl( Uri.parse(AppConfig.publicBaseUrl), mode: LaunchMode.externalApplication, ); } Future _copySupportEmail() async { await Clipboard.setData(const ClipboardData(text: _supportEmail)); if (!mounted) return; showDsToast(context, '已复制客服邮箱:$_supportEmail'); } @override Widget build(BuildContext context) { // 已在登录页时会话再失效(例如停留在登录页期间后台请求被吊销):弹窗提示。 // 进入登录页之前就置入的既有值由 initState 的首帧读取处理。 ref.listen(sessionEndedMessageProvider, (prev, next) { if (next != null && next.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { _showSessionEndedDialog(next); }); } }); return AuthPageScaffold( maxWidth: 880, narrowMaxWidth: 440, formWidth: 420, // 原型 grid:minmax(0,1fr) 420px brand: _brand(context), form: _form(context), ); } // ── 左栏 .brand:logo + 名 + tag + 三卖点 ──────────────────────────────── Widget _brand(BuildContext context) { final t = context.tokens; final narrow = context.isMobile; Widget pt(IconData icon, String text) => Row(children: [ Container( width: 30, height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: AppChrome.heroGlass, borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Icon(icon, size: 16, color: t.sideActiveFg), ), const SizedBox(width: 11), Expanded( child: Text(text, style: TextStyle(fontSize: AppDims.fsSm, color: t.sideFg)), ), ]); return AuthBrandPanel( padding: narrow ? const EdgeInsets.symmetric(horizontal: 32, vertical: 36) : const EdgeInsets.symmetric(horizontal: 40, vertical: 48), minHeight: narrow ? null : 480, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const BrandMark(size: 64), const SizedBox(height: 24), Text('岩美酒库', style: TextStyle( fontSize: AppDims.fsDisplay, fontWeight: FontWeight.w800, letterSpacing: 1, color: t.sideActiveFg)), const SizedBox(height: 8), Text('酒水进销存 · 审核驱动业务流', style: TextStyle( fontSize: AppDims.fsBody, color: t.sideFg, height: 1.7)), if (!narrow) ...[ const Spacer(), pt(LucideIcons.box, '入库出库 · 审核后实时改库存'), const SizedBox(height: 14), pt(LucideIcons.creditCard, '往来对账 · 财务一目了然'), const SizedBox(height: 14), pt(LucideIcons.circleCheck, '多端协同 · 多租户安全隔离'), ], ], ), ); } // ── 右栏 .form ─────────────────────────────────────────────────────────── Widget _form(BuildContext context) { final t = context.tokens; final narrow = context.isMobile; return Padding( padding: narrow ? const EdgeInsets.symmetric(horizontal: 32, vertical: 34) : const EdgeInsets.symmetric(horizontal: 40, vertical: 48), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('登录', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(height: 6), Text('欢迎回来,请输入账号信息', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 26), DsField('门店编号', input: CompositedTransformTarget( link: _shopLayerLink, child: DsInput( controller: _shopCodeCtrl, focusNode: _shopFocus, hintText: '例如 DSJH-001', suffix: _shopHistory.isNotEmpty ? Icon( _shopShowing ? LucideIcons.chevronUp : LucideIcons.chevronDown, size: 16, color: t.muted) : null, ), )), const SizedBox(height: 16), DsField('登录账号', input: CompositedTransformTarget( link: _usernameLayerLink, child: DsInput( controller: _usernameCtrl, focusNode: _usernameFocus, hintText: '请输入登录账号', suffix: _usernameHistory.isNotEmpty ? Icon( _usernameShowing ? LucideIcons.chevronUp : LucideIcons.chevronDown, size: 16, color: t.muted) : null, ), )), const SizedBox(height: 16), DsField('密码', input: DsInput( controller: _passwordCtrl, hintText: '请输入密码', obscureText: _obscure, onSubmitted: (_) => _login(), suffix: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => setState(() => _obscure = !_obscure), child: Icon(_obscure ? LucideIcons.eyeOff : LucideIcons.eye, size: 16, color: t.muted), ), ), )), // .remember:记住我 + 忘记密码(margin 4 0 22) const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ DsCheck( value: _remember, onChanged: (v) { setState(() => _remember = v); LoginHistoryStorage.setRemember(v); }, label: Text('记住我', style: TextStyle(fontSize: AppDims.fsBody, color: t.text)), ), _Link('忘记密码?', onTap: () => showDsToast(context, '找回密码请联系门店管理员')), ], ), ), const SizedBox(height: 14), // 离线提示(connectivityProvider 在 _AppBootstrap 中已预热) if (!ref.watch(connectivityProvider)) ...[ _noticeBox( color: t.warn, bg: t.warnBg, child: Row(children: [ Icon(LucideIcons.wifiOff, color: t.warn, size: 16), const SizedBox(width: 8), Expanded( child: Text('服务器不可达,请检查网络连接', style: TextStyle(color: t.warn, fontSize: AppDims.fsBody)), ), NetworkRetryButton(foreground: t.warn), ]), ), const SizedBox(height: 12), ], // 服务器返回错误(授权锁定时换成引导卡) if (_errorMessage != null) ...[ if (_isLicenseLocked) _licenseLockedNotice(context) else _noticeBox( color: t.danger, bg: t.dangerBg, child: Row(children: [ Icon(LucideIcons.circleAlert, color: t.danger, size: 16), const SizedBox(width: 8), Expanded( child: Text(_errorMessage!, style: TextStyle( color: t.danger, fontSize: AppDims.fsBody)), ), ]), ), const SizedBox(height: 12), ], SizedBox( width: double.infinity, child: DsButton(_loading ? '登录中…' : '登录', variant: DsBtnVariant.primary, large: true, onPressed: _loading ? null : _login), ), const SizedBox(height: 24), Center( child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('还没有门店账号?', style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), _Link('注册新门店 →', bold: true, onTap: () => context.go('/register')), ], ), ), const SizedBox(height: 8), Center( child: Text('岩美酒库 © 2026 · 仅授权门店可登录', style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), ), ], ), ); } /// 提示条通用外框(离线 / 错误):淡底 + 同色系边 + r-md。 Widget _noticeBox( {required Color color, required Color bg, required Widget child}) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(AppDims.rMd), border: Border.all(color: color.withAlpha(80)), ), child: child, ); } /// 授权锁定时的可操作提示:告诉用户「去哪里解决」——前往官网续费 / 联系客服。 Widget _licenseLockedNotice(BuildContext context) { final t = context.tokens; return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: t.dangerBg, borderRadius: BorderRadius.circular(AppDims.rMd), border: Border.all(color: t.danger.withAlpha(80)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(LucideIcons.lock, color: t.danger, size: 18), const SizedBox(width: 8), Expanded( child: Text('授权已过期,账号已锁定', style: TextStyle( color: t.danger, fontSize: AppDims.fsBody, fontWeight: FontWeight.w600)), ), ]), const SizedBox(height: 6), Text('当前门店授权已到期,暂时无法登录。请续费授权后重试,或联系客服协助开通。', style: TextStyle( color: t.muted, fontSize: AppDims.fsBody, height: 1.4)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ DsButton('前往官网续费', icon: LucideIcons.externalLink, variant: DsBtnVariant.danger, small: true, onPressed: _openRenewSite), DsButton('复制客服邮箱', icon: LucideIcons.headset, small: true, onPressed: _copySupportEmail), ]), ], ), ); } } /// 原型 .link:primary 色文字链接(hover 下划线从简)。 class _Link extends StatelessWidget { final String text; final bool bold; final VoidCallback onTap; const _Link(this.text, {this.bold = false, required this.onTap}); @override Widget build(BuildContext context) { final t = context.tokens; return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, child: Text(text, style: TextStyle( fontSize: bold ? AppDims.fsXs : AppDims.fsSm, fontWeight: bold ? FontWeight.w600 : FontWeight.w400, color: t.primary)), ), ); } } /// 历史账号下拉(surface 浮层 + border + sh,条目 hover=rowHover)。 class _HistoryMenu extends StatelessWidget { final double width; final List items; final ValueChanged onSelected; const _HistoryMenu( {required this.width, required this.items, required this.onSelected}); @override Widget build(BuildContext context) { final t = context.tokens; return Material( color: Colors.transparent, child: Container( width: width, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), boxShadow: [ BoxShadow( color: t.shadow, offset: const Offset(0, 4), blurRadius: 14), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final v in items) _HistoryMenuItem(value: v, onSelected: () => onSelected(v)), ], ), ), ); } } class _HistoryMenuItem extends StatefulWidget { final String value; final VoidCallback onSelected; const _HistoryMenuItem({ required this.value, required this.onSelected, }); @override State<_HistoryMenuItem> createState() => _HistoryMenuItemState(); } class _HistoryMenuItemState extends State<_HistoryMenuItem> { bool _hovered = false; @override Widget build(BuildContext context) { final t = context.tokens; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), cursor: SystemMouseCursors.click, child: GestureDetector( onTap: widget.onSelected, child: Container( width: double.infinity, color: _hovered ? t.rowHover : Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), child: Row( children: [ Icon(LucideIcons.history, size: 14, color: t.muted), const SizedBox(width: 8), Text(widget.value, style: TextStyle(fontSize: AppDims.fsBody, color: t.text)), ], ), ), ), ); } }