e850a1987c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
739 lines
25 KiB
Dart
739 lines
25 KiB
Dart
// 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<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, // 原型 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<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)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|