Files
jiu/client/lib/screens/auth/login_screen.dart
T

739 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.
// screens/auth/login_screen.dart — 登录页,照原型 login.html 1:1 重建。
// 结构:--page 底居中 .auth 两栏卡片(左 .brand 品牌渐变面板:logo+名+tag+三卖点;
// 右 .formh1/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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
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<String> _shopHistory = [];
List<String> _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<void> _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<void>(
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<void> _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<String> 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<void> _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<void> _openRenewSite() async {
await launchUrl(
Uri.parse(AppConfig.publicBaseUrl),
mode: LaunchMode.externalApplication,
);
}
Future<void> _copySupportEmail() async {
await Clipboard.setData(const ClipboardData(text: _supportEmail));
if (!mounted) return;
showDsToast(context, '已复制客服邮箱:$_supportEmail');
}
@override
Widget build(BuildContext context) {
// 已在登录页时会话再失效(例如停留在登录页期间后台请求被吊销):弹窗提示。
// 进入登录页之前就置入的既有值由 initState 的首帧读取处理。
ref.listen<String?>(sessionEndedMessageProvider, (prev, next) {
if (next != null && next.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showSessionEndedDialog(next);
});
}
});
return AuthPageScaffold(
maxWidth: 880,
narrowMaxWidth: 440,
formWidth: 420, // 原型 gridminmax(0,1fr) 420px
brand: _brand(context),
form: _form(context),
);
}
// ── 左栏 .brandlogo + 名 + 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),
]),
],
),
);
}
}
/// 原型 .linkprimary 色文字链接(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<String> items;
final ValueChanged<String> 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)),
],
),
),
),
);
}
}