From 9de3a6c743fefba7c22b7b05e4e5cf5d4978338f Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 08:10:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=99=BB=E5=BD=95/=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E9=A1=B5=E9=87=8D=E8=AE=BE=E8=AE=A1(=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E5=8D=A1=E7=89=87=20+=20=E6=9A=96=E5=85=89=E6=99=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_screen.dart 整体重排:暖色径向光晕背景 + 居中卡片;hero 用 clay 渐变 app-icon(带光晕)+ 品牌 + 标语;胶囊分段 tab(滑块)替下划线;输入框聚焦态 (accent 边 + ring 光环);主按钮微光;进入错落淡入动效。全部走 token。 - 注册第二步:已验证邮箱胶囊 + 「改邮箱」返回 + 设密码;验证码框无占位点。 - 逻辑零回归:登录/注册两段式、发码、邮箱预填、密码显隐、错误提示全保留。 - design/preview/auth_redesign.html:HTML 视觉原型(评审用,不进构建)。 flutter analyze 0 error;114 tests passed(auth 不在 golden 集,无 golden 变化)。 Co-Authored-By: Claude Opus 4.8 --- client/lib/widgets/auth_screen.dart | 339 ++++++++++++++++++++-------- design/preview/auth_redesign.html | 246 ++++++++++++++++++++ 2 files changed, 494 insertions(+), 91 deletions(-) create mode 100644 design/preview/auth_redesign.html diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index eb977ab..f22d061 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -1,4 +1,8 @@ -// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程) +// auth_screen.dart — 登录 / 注册页(居中卡片 + 暖光晕重设计) +// +// 视觉:暖色径向光晕背景 + 居中卡片;hero 用 clay 渐变 app-icon + 品牌;胶囊分段 +// tab;输入框聚焦态(accent 边 + 光环);主按钮微光;进入错落淡入。全部走 token。 +// 逻辑零回归:登录/注册两段式、发码、邮箱预填、密码显隐、错误提示均保留。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,6 +20,8 @@ const _kApiUrl = String.fromEnvironment( defaultValue: 'http://localhost:8080', ); +const _easeOut = Cubic(0.22, 1, 0.36, 1); + enum _AuthMode { login, register } class AuthScreen extends ConsumerStatefulWidget { @@ -27,7 +33,8 @@ class AuthScreen extends ConsumerStatefulWidget { ConsumerState createState() => _AuthScreenState(); } -class _AuthScreenState extends ConsumerState { +class _AuthScreenState extends ConsumerState + with SingleTickerProviderStateMixin { _AuthMode _mode = _AuthMode.login; int _step = 0; bool _sent = false; @@ -39,6 +46,15 @@ class _AuthScreenState extends ConsumerState { final _code = TextEditingController(); final _pw = TextEditingController(); + final _emailFocus = FocusNode(); + final _pwFocus = FocusNode(); + final _codeFocus = FocusNode(); + + late final AnimationController _intro = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 620), + ); + late final AuthApi _api = AuthApi(baseUrl: _kApiUrl); bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); @@ -46,12 +62,17 @@ class _AuthScreenState extends ConsumerState { @override void initState() { super.initState(); + // 聚焦态驱动输入框边框变化。 + for (final f in [_emailFocus, _pwFocus, _codeFocus]) { + f.addListener(() => setState(() {})); + } // 预填上次登录邮箱。 ref.read(tokenStoreProvider).loadLastEmail().then((email) { if (email != null && email.isNotEmpty && mounted) { setState(() => _email.text = email); } }); + _intro.forward(); } @override @@ -59,11 +80,15 @@ class _AuthScreenState extends ConsumerState { _email.dispose(); _code.dispose(); _pw.dispose(); + _emailFocus.dispose(); + _pwFocus.dispose(); + _codeFocus.dispose(); + _intro.dispose(); _api.dispose(); super.dispose(); } - // ── 认证操作 ────────────────────────────────────────────────── + // ── 认证操作(逻辑不变)────────────────────────────────────────── Future _sendCode() async { setState(() { _loading = true; _errorZh = null; }); @@ -106,101 +131,212 @@ class _AuthScreenState extends ConsumerState { } } + void _setMode(_AuthMode m) => setState(() { + _mode = m; + _step = 0; + _sent = false; + _errorZh = null; + }); + // ── UI ────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; + final dark = Theme.of(context).brightness == Brightness.dark; + final glow = c.accent.withValues(alpha: dark ? 0.16 : 0.13); + return Scaffold( backgroundColor: c.bg, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - children: [ - const SizedBox(height: 18), - Column(children: [ - const PangolinMark(size: 52), - const SizedBox(height: 12), - Text(t.brand, style: PangolinText.h1.copyWith(color: c.fg1)), - const SizedBox(height: 4), - Text(t.authTagline, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)), - ]), - const SizedBox(height: 28), - Row(children: [ - _tab(_AuthMode.login, t.tabLogin, c), - _tab(_AuthMode.register, t.tabRegister, c), - ]), - Divider(height: 1, color: c.border), - const SizedBox(height: 22), - if (_errorZh != null) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: c.danger.withAlpha(25), - borderRadius: BorderRadius.circular(PangolinRadius.md), - border: Border.all(color: c.danger.withAlpha(80)), - ), - child: Row(children: [ - Icon(PangolinIcons.x, size: 16, color: c.danger), - const SizedBox(width: 8), - Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))), - ]), - ), - const SizedBox(height: 14), - ], - Expanded(child: _mode == _AuthMode.login ? _login(c, t) : _register(c, t)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Text(t.tos, textAlign: TextAlign.center, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + body: Stack(children: [ + _atmosphere(glow), + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _reveal(0.0, 0.55, _hero(c, t)), + const SizedBox(height: 26), + _reveal(0.22, 1.0, _card(c, t)), + const SizedBox(height: 18), + _reveal(0.42, 1.0, Text(t.tos, + textAlign: TextAlign.center, + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400))), + ]), ), - ], + ), ), ), - ), + ]), ); } - Widget _tab(_AuthMode m, String label, PangolinScheme c) { - final on = _mode == m; - return Expanded( - child: GestureDetector( - onTap: () => setState(() { - _mode = m; - _step = 0; - _sent = false; - _errorZh = null; - }), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: on ? c.accent : Colors.transparent, width: 2))), - child: Center(child: Text(label, style: PangolinText.body.copyWith(color: on ? c.fg1 : c.fg3, fontWeight: on ? FontWeight.w700 : FontWeight.w500))), + // 暖色径向光晕(顶部 + 右下两团)。 + Widget _atmosphere(Color glow) => Positioned.fill( + child: IgnorePointer( + child: Stack(children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -1.15), + radius: 1.0, + colors: [glow, Colors.transparent], + stops: const [0, 0.72], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0.85, 1.05), + radius: 0.7, + colors: [glow, Colors.transparent], + stops: const [0, 0.72], + ), + ), + ), + ]), ), + ); + + // 进入错落淡入 + 上移。 + Widget _reveal(double start, double end, Widget child) { + final anim = CurvedAnimation(parent: _intro, curve: Interval(start, end, curve: _easeOut)); + return AnimatedBuilder( + animation: anim, + builder: (_, c) => Opacity( + opacity: anim.value.clamp(0.0, 1.0), + child: Transform.translate(offset: Offset(0, 14 * (1 - anim.value)), child: c), ), + child: child, ); } - Widget _field(PangolinScheme c, {required IconData icon, required String label, required Widget child}) { + Widget _hero(PangolinScheme c, AppText t) => Column(children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(PangolinRadius.xl), + boxShadow: [ + BoxShadow(color: c.accent.withValues(alpha: 0.30), blurRadius: 44, spreadRadius: 2), + ...PangolinShadow.lg, + ], + ), + child: const PangolinAppIcon(size: 64), + ), + const SizedBox(height: 16), + Text(t.brand, style: PangolinText.h1.copyWith(color: c.fg1)), + const SizedBox(height: 6), + Text(t.authTagline, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)), + ]); + + Widget _card(PangolinScheme c, AppText t) => Container( + padding: const EdgeInsets.fromLTRB(24, 26, 24, 24), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xxl), + border: Border.all(color: c.border), + boxShadow: [...PangolinShadow.md, ...PangolinShadow.lg], + ), + child: Column(children: [ + _segTabs(c, t), + const SizedBox(height: 22), + if (_errorZh != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.dangerSubtle, + borderRadius: BorderRadius.circular(PangolinRadius.md), + border: Border.all(color: c.danger), + ), + child: Row(children: [ + Icon(PangolinIcons.x, size: 16, color: c.danger), + const SizedBox(width: 8), + Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))), + ]), + ), + const SizedBox(height: 14), + ], + _mode == _AuthMode.login ? _login(c, t) : _register(c, t), + ]), + ); + + // 胶囊分段 tab(滑块滑动)。 + Widget _segTabs(PangolinScheme c, AppText t) { + final reg = _mode == _AuthMode.register; + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Stack(children: [ + Positioned.fill( + child: AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: _easeOut, + alignment: reg ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: DecoratedBox( + decoration: BoxDecoration( + color: c.accent, + borderRadius: BorderRadius.circular(PangolinRadius.full), + boxShadow: PangolinShadow.md, + ), + ), + ), + ), + ), + Row(children: [ + _seg(c, t.tabLogin, !reg, () => _setMode(_AuthMode.login)), + _seg(c, t.tabRegister, reg, () => _setMode(_AuthMode.register)), + ]), + ]), + ); + } + + Widget _seg(PangolinScheme c, String label, bool active, VoidCallback onTap) => Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 9), + alignment: Alignment.center, + child: Text(label, + style: PangolinText.body.copyWith( + color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w600, fontSize: 15)), + ), + ), + ); + + // 输入字段:标签 + 图标 + 聚焦态(accent 边 + 光环)。 + Widget _field(PangolinScheme c, {required IconData icon, required String label, required bool focused, required Widget child}) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), const SizedBox(height: 7), - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.md), + border: Border.all(color: focused ? c.accent : c.borderStrong, width: 1.5), + boxShadow: focused ? [BoxShadow(color: c.ring, spreadRadius: 3)] : null, + ), child: Row(children: [Icon(icon, size: 18, color: c.fg3), const SizedBox(width: 10), Expanded(child: child)]), ), ]); } InputDecoration get _bare => - const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 12)); + const InputDecoration(border: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.symmetric(vertical: 13)); // 密码输入 + 显示/隐藏切换(眼睛按钮)。 Widget _pwInput(PangolinScheme c, String hint) => Row(children: [ Expanded( child: TextField( controller: _pw, + focusNode: _pwFocus, obscureText: !_pwVisible, decoration: _bare.copyWith(hintText: hint), onChanged: (_) => setState(() {}), @@ -215,34 +351,44 @@ class _AuthScreenState extends ConsumerState { ), ]); + Widget _primaryBtn(PangolinScheme c, {required String label, required VoidCallback? onPressed}) { + final enabled = onPressed != null; + return Container( + decoration: enabled + ? BoxDecoration( + borderRadius: BorderRadius.circular(PangolinRadius.full), + boxShadow: [BoxShadow(color: c.accent.withValues(alpha: 0.30), blurRadius: 20, offset: const Offset(0, 6))], + ) + : null, + child: PangolinButton(label: label, expand: true, onPressed: onPressed), + ); + } + Widget _login(PangolinScheme c, AppText t) { - return Column(children: [ - _field(c, icon: PangolinIcons.mail, label: t.emailLabel, - child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))), + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _field(c, icon: PangolinIcons.mail, label: t.emailLabel, focused: _emailFocus.hasFocus, + child: TextField(controller: _email, focusNode: _emailFocus, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))), const SizedBox(height: 16), - _field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.pwPh)), + _field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.pwPh)), Align( alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: 10), child: Text(t.forgotPw, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)), ), ), const SizedBox(height: 18), - PangolinButton( - label: _loading ? '...' : t.doLogin, - expand: true, - onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null, - ), + _primaryBtn(c, label: _loading ? '...' : t.doLogin, + onPressed: (!_loading && _emailValid && _pw.text.isNotEmpty) ? _doLogin : null), ]); } Widget _register(PangolinScheme c, AppText t) { if (_step == 0) { - return Column(children: [ - _field(c, icon: PangolinIcons.mail, label: t.emailLabel, + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _field(c, icon: PangolinIcons.mail, label: t.emailLabel, focused: _emailFocus.hasFocus, child: Row(children: [ - Expanded(child: TextField(controller: _email, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))), + Expanded(child: TextField(controller: _email, focusNode: _emailFocus, decoration: _bare.copyWith(hintText: t.emailPh), onChanged: (_) => setState(() {}))), GestureDetector( onTap: (!_loading && _emailValid) ? _sendCode : null, child: Text(_sent ? t.resend : t.sendCode, @@ -259,25 +405,36 @@ class _AuthScreenState extends ConsumerState { ]), ), const SizedBox(height: 16), - _field(c, icon: PangolinIcons.shieldCheck, label: t.codeLabel, - child: TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6, + _field(c, icon: PangolinIcons.shieldCheck, label: t.codeLabel, focused: _codeFocus.hasFocus, + child: TextField(controller: _code, focusNode: _codeFocus, keyboardType: TextInputType.number, maxLength: 6, style: PangolinText.mono.copyWith(letterSpacing: 6, color: c.fg1), decoration: _bare.copyWith(counterText: '', hintText: ''), onChanged: (_) => setState(() {}))), - const SizedBox(height: 8), - PangolinButton(label: t.doNext, expand: true, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null), + const SizedBox(height: 16), + _primaryBtn(c, label: t.doNext, onPressed: (_sent && _code.text.length == 6) ? () => setState(() => _step = 1) : null), ]); } - // Step 1: set password - return Column(children: [ - Row(children: [Icon(PangolinIcons.checkCircle, size: 15, color: c.success), const SizedBox(width: 7), Text(_email.text, style: PangolinText.sm.copyWith(color: c.fg2))]), - const SizedBox(height: 16), - _field(c, icon: PangolinIcons.lock, label: t.pwLabel, child: _pwInput(c, t.setPwPh)), - const SizedBox(height: 18), - PangolinButton( - label: _loading ? '...' : t.doCreate, - expand: true, - onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null, + // Step 1: 已验证邮箱(可返回改邮箱)+ 设密码 + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), + child: Row(children: [ + Icon(PangolinIcons.shieldCheck, size: 16, color: c.success), + const SizedBox(width: 8), + Expanded(child: Text(_email.text, overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))), + GestureDetector( + onTap: () => setState(() => _step = 0), + child: Text(t.lang == AppLang.zh ? '改邮箱' : 'Change', + style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)), + ), + ]), ), + const SizedBox(height: 16), + _field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh)), + const SizedBox(height: 18), + _primaryBtn(c, label: _loading ? '...' : t.doCreate, + onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null), ]); } } diff --git a/design/preview/auth_redesign.html b/design/preview/auth_redesign.html new file mode 100644 index 0000000..1efe09e --- /dev/null +++ b/design/preview/auth_redesign.html @@ -0,0 +1,246 @@ + + + + + + + +穿山甲 · 登录/注册 重设计 + + + +
+
+ + +
+ +
+
+
+ + + + + + + + + +
+
穿山甲
+
极速畅连 · 安全稳定
+
+ +
+
+ + + +
+ +
邮箱或密码不正确
+ + +
+ +
+ + +
+ +
+ + + +
+ 忘记密码? + +
+ + +
+ +
+ + + +
+
验证码已发送
+ +
+ + +
+ +
+ + +
+
+ + you@mail.com + 改邮箱 +
+ +
+ + + +
+ +
+
+ +
继续即代表同意 服务条款 · 隐私政策
+
+ + + +