feat(client): 登录/注册页重设计(居中卡片 + 暖光晕)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 08:10:33 +08:00
parent dfc2df90c7
commit 9de3a6c743
2 changed files with 494 additions and 91 deletions
+248 -91
View File
@@ -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<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends ConsumerState<AuthScreen> {
class _AuthScreenState extends ConsumerState<AuthScreen>
with SingleTickerProviderStateMixin {
_AuthMode _mode = _AuthMode.login;
int _step = 0;
bool _sent = false;
@@ -39,6 +46,15 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
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<AuthScreen> {
@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<AuthScreen> {
_email.dispose();
_code.dispose();
_pw.dispose();
_emailFocus.dispose();
_pwFocus.dispose();
_codeFocus.dispose();
_intro.dispose();
_api.dispose();
super.dispose();
}
// ── 认证操作 ──────────────────────────────────────────────────
// ── 认证操作(逻辑不变)──────────────────────────────────────────
Future<void> _sendCode() async {
setState(() { _loading = true; _errorZh = null; });
@@ -106,101 +131,212 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
}
}
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<AuthScreen> {
),
]);
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<AuthScreen> {
]),
),
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),
]);
}
}
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<!-- 穿山甲 · 登录/注册页重设计原型(阶段 A 视觉评审用,非 app 构建产物) -->
<!-- 用真实 Pangolin token + Sora/Manrope + 真品牌 mark;右上角可切 明暗 / 中英 / 登录注册 -->
<html lang="zh" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>穿山甲 · 登录/注册 重设计</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root{
--clay-50:#FAF3ED;--clay-100:#F2E2D4;--clay-200:#E6C7AC;--clay-300:#D9A982;--clay-400:#CC8B5C;
--clay-500:#B96A3D;--clay-600:#9E5630;--clay-700:#7E4426;--clay-800:#5E331D;--clay-900:#3D2213;
--sand-50:#FAF8F4;--sand-100:#F2EEE7;--sand-200:#E6DFD3;--sand-300:#D2C8B8;--sand-400:#B0A491;
--sand-500:#8C8270;--sand-600:#6B6253;--sand-700:#4E4940;--sand-800:#2E2A24;--sand-900:#1F1C18;--sand-950:#14110E;
--green-500:#5B8C5A;--red-500:#C0533B;
--bg:var(--sand-50);--bg-subtle:var(--sand-100);--surface:#fff;
--fg1:var(--sand-900);--fg2:var(--sand-600);--fg3:var(--sand-500);--fg-on-accent:#fff;
--accent:var(--clay-500);--accent-hover:var(--clay-600);--accent-subtle:var(--clay-50);--accent-border:var(--clay-200);
--border:var(--sand-200);--border-strong:var(--sand-300);--ring:rgba(185,106,61,.35);
--danger:var(--red-500);--danger-subtle:#F6E1DA;
--shadow-md:0 4px 14px rgba(45,30,20,.08);--shadow-lg:0 12px 32px rgba(45,30,20,.12);
--glow:rgba(185,106,61,.14);--icon-glow:rgba(185,106,61,.30);
--font-display:'Sora','Noto Sans SC',system-ui,sans-serif;
--font-sans:'Manrope','Noto Sans SC',system-ui,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,monospace;
}
[data-theme="dark"]{
--bg:var(--sand-950);--bg-subtle:var(--sand-900);--surface:#221E19;
--fg1:#F4EFE8;--fg2:#B6AC9C;--fg3:#897F6F;--fg-on-accent:#1F1C18;
--accent:var(--clay-400);--accent-hover:var(--clay-300);--accent-subtle:rgba(204,139,92,.14);--accent-border:rgba(204,139,92,.30);
--border:rgba(242,238,231,.10);--border-strong:rgba(242,238,231,.18);--ring:rgba(204,139,92,.45);
--danger:#D4715A;--danger-subtle:rgba(212,113,90,.16);
--shadow-md:0 4px 14px rgba(0,0,0,.45);--shadow-lg:0 12px 32px rgba(0,0,0,.5);
--glow:rgba(204,139,92,.16);--icon-glow:rgba(204,139,92,.32);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:var(--font-sans);background:var(--bg);color:var(--fg1);
display:grid;place-items:center;padding:24px;position:relative;overflow:hidden;
transition:background-color .3s ease;
}
/* 层1:暖色径向光晕 */
.atmosphere{position:fixed;inset:0;pointer-events:none;
background:
radial-gradient(60% 46% at 50% -4%, var(--glow), transparent 70%),
radial-gradient(40% 30% at 84% 92%, var(--glow), transparent 70%);}
/* 右上角控件 */
.controls{position:fixed;top:18px;right:18px;display:flex;gap:8px;z-index:10}
.controls button{
font-family:var(--font-sans);font-size:13px;font-weight:600;color:var(--fg2);
background:var(--surface);border:1px solid var(--border);border-radius:999px;
padding:7px 12px;cursor:pointer;box-shadow:var(--shadow-md);transition:.2s}
.controls button:hover{color:var(--accent);border-color:var(--accent-border)}
/* 层2:居中内容 */
.auth{width:100%;max-width:420px;position:relative;z-index:1}
@keyframes fadeUp{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.hero,.card,.tos{animation:fadeUp .55s cubic-bezier(.22,1,.36,1) both}
.hero{animation-delay:.05s}.card{animation-delay:.18s}.tos{animation-delay:.32s}
/* Hero */
.hero{text-align:center;margin-bottom:26px}
.app-icon{width:64px;height:64px;border-radius:20px;margin:0 auto 16px;
display:grid;place-items:center;
background:linear-gradient(135deg,var(--clay-500),var(--clay-700));
box-shadow:0 0 44px 4px var(--icon-glow), var(--shadow-lg);}
.app-icon svg{width:42px;height:42px}
.brand{font-family:var(--font-display);font-size:30px;font-weight:700;letter-spacing:-.02em;line-height:1.15}
.tagline{font-size:14px;color:var(--accent);font-weight:600;margin-top:6px;letter-spacing:.01em}
/* 卡片 */
.card{background:var(--surface);border:1px solid var(--border);border-radius:28px;
padding:26px 24px 24px;box-shadow:var(--shadow-md),var(--shadow-lg);}
/* 胶囊分段 tab */
.tabs{position:relative;display:flex;background:var(--bg-subtle);border-radius:999px;padding:4px;margin-bottom:22px}
.tabs .pill{position:absolute;top:4px;left:4px;width:calc(50% - 4px);height:calc(100% - 8px);
background:var(--accent);border-radius:999px;box-shadow:var(--shadow-md);
transition:transform .26s cubic-bezier(.22,1,.36,1)}
.tabs.reg .pill{transform:translateX(100%)}
.seg{flex:1;position:relative;z-index:1;background:none;border:0;cursor:pointer;
font-family:var(--font-sans);font-size:15px;font-weight:600;color:var(--fg3);
padding:9px 0;border-radius:999px;transition:color .2s}
.seg.on{color:var(--fg-on-accent)}
/* 错误条(示意,默认隐藏) */
.err{display:none;align-items:center;gap:8px;background:var(--danger-subtle);
border:1px solid var(--danger);border-radius:10px;padding:10px 14px;margin-bottom:14px;
color:var(--danger);font-size:14px}
.err.show{display:flex}
/* 表单 */
.form{display:none;flex-direction:column;gap:14px}
.form.on{display:flex}
.flabel{font-size:12px;font-weight:600;color:var(--fg2);margin-bottom:-6px}
.field{display:flex;align-items:center;gap:10px;background:var(--surface);
border:1.5px solid var(--border-strong);border-radius:10px;padding:0 14px;transition:.2s}
.field:focus-within{border-color:var(--accent);box-shadow:0 0 0 4px var(--ring)}
.field svg{width:18px;height:18px;color:var(--fg3);flex:none}
.field input{flex:1;border:0;outline:0;background:none;color:var(--fg1);
font-family:var(--font-sans);font-size:15px;padding:13px 0}
.field input::placeholder{color:var(--fg3)}
.field .eye,.field .send{background:none;border:0;cursor:pointer;color:var(--fg3);display:grid;place-items:center}
.field .send{color:var(--accent);font-weight:700;font-size:13px;font-family:var(--font-sans)}
.field input.code{font-family:var(--font-mono);letter-spacing:6px}
.forgot{align-self:flex-end;color:var(--accent);font-size:14px;font-weight:600;text-decoration:none;margin-top:-4px}
.primary{margin-top:6px;background:var(--accent);color:var(--fg-on-accent);border:0;border-radius:999px;
font-family:var(--font-sans);font-size:16px;font-weight:700;padding:14px;cursor:pointer;
box-shadow:0 6px 20px var(--icon-glow);transition:.2s}
.primary:hover{background:var(--accent-hover);transform:translateY(-1px)}
.sent{display:flex;align-items:center;gap:6px;color:var(--green-500);font-size:12px;margin-top:-6px}
.verified{display:flex;align-items:center;gap:8px;background:var(--bg-subtle);border-radius:10px;padding:11px 14px}
.verified svg{width:16px;height:16px;color:var(--green-500);flex:none}
.verified span{flex:1;font-weight:600;color:var(--fg1);font-size:14px}
.changemail{color:var(--accent);font-size:13px;font-weight:600;cursor:pointer;text-decoration:none}
.tos{text-align:center;color:var(--fg3);font-size:12px;margin-top:18px;line-height:1.6}
</style>
</head>
<body>
<div class="atmosphere"></div>
<div class="controls">
<button id="lang">EN</button>
<button id="theme">☾ 深色</button>
</div>
<main class="auth">
<div class="hero">
<div class="app-icon">
<svg viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="穿山甲">
<g transform="translate(96,0) scale(-1,1)">
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#F4EFE8"/>
<g stroke="#9E5630" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7">
<path d="M26 50 Q34 43 42 50"/><path d="M36 48 Q44 41 52 48"/><path d="M46 48 Q54 41 62 49"/>
</g>
<circle cx="23" cy="55" r="2.2" fill="#9E5630"/>
</g>
</svg>
</div>
<div class="brand" data-zh="穿山甲" data-en="Pangolin">穿山甲</div>
<div class="tagline" data-zh="极速畅连 · 安全稳定" data-en="Fast · Secure · Reliable">极速畅连 · 安全稳定</div>
</div>
<div class="card">
<div class="tabs" id="tabs">
<span class="pill"></span>
<button class="seg on" data-zh="登录" data-en="Sign in" onclick="setMode('login')">登录</button>
<button class="seg" data-zh="注册" data-en="Sign up" onclick="setMode('reg')">注册</button>
</div>
<div class="err"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>邮箱或密码不正确</span></div>
<!-- 登录 -->
<form class="form on" id="login" onsubmit="return false">
<label class="flabel" data-zh="邮箱" data-en="Email">邮箱</label>
<div class="field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 7L2 7"/></svg>
<input type="email" placeholder="you@mail.com" value="me@pangolin.app">
</div>
<label class="flabel" data-zh="密码" data-en="Password">密码</label>
<div class="field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="password" placeholder="••••••••" value="password">
<button type="button" class="eye" onclick="toggleEye(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<a class="forgot" data-zh="忘记密码?" data-en="Forgot?">忘记密码?</a>
<button class="primary" data-zh="登 录" data-en="Sign in">登 录</button>
</form>
<!-- 注册(step 0:邮箱→发码→验证码→下一步) -->
<form class="form" id="reg" onsubmit="return false">
<label class="flabel" data-zh="邮箱" data-en="Email">邮箱</label>
<div class="field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 7L2 7"/></svg>
<input type="email" placeholder="you@mail.com">
<button type="button" class="send" data-zh="发送验证码" data-en="Send code">发送验证码</button>
</div>
<div class="sent"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-zh="验证码已发送" data-en="Code sent">验证码已发送</span></div>
<label class="flabel" data-zh="验证码" data-en="Code">验证码</label>
<div class="field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
<input class="code" inputmode="numeric" maxlength="6" placeholder="">
</div>
<button class="primary" data-zh="下一步" data-en="Continue" onclick="regNext()">下一步</button>
</form>
<!-- 注册 step 1:已验证邮箱 + 设密码 -->
<form class="form" id="reg2" onsubmit="return false">
<div class="verified">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>
<span>you@mail.com</span>
<a class="changemail" data-zh="改邮箱" data-en="Change" onclick="regBack()">改邮箱</a>
</div>
<label class="flabel" data-zh="设置密码" data-en="Set password">设置密码</label>
<div class="field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input type="password" placeholder="•••••• (≥6 位)">
<button type="button" class="eye" onclick="toggleEye(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<button class="primary" data-zh="创建账号" data-en="Create account">创建账号</button>
</form>
</div>
<div class="tos" data-zh="继续即代表同意 服务条款 · 隐私政策" data-en="By continuing you agree to the Terms · Privacy">继续即代表同意 服务条款 · 隐私政策</div>
</main>
<script>
function setMode(m){
const reg = m==='reg';
document.getElementById('tabs').classList.toggle('reg',reg);
document.querySelectorAll('.seg').forEach((b,i)=>b.classList.toggle('on', i===(reg?1:0)));
document.getElementById('login').classList.toggle('on',!reg);
document.getElementById('reg').classList.toggle('on',reg);
document.getElementById('reg2').classList.remove('on');
}
function regNext(){document.getElementById('reg').classList.remove('on');document.getElementById('reg2').classList.add('on');}
function regBack(){document.getElementById('reg2').classList.remove('on');document.getElementById('reg').classList.add('on');}
function toggleEye(btn){
const inp = btn.parentElement.querySelector('input');
inp.type = inp.type==='password' ? 'text' : 'password';
}
document.getElementById('theme').onclick=function(){
const dark = document.documentElement.getAttribute('data-theme')==='dark';
document.documentElement.setAttribute('data-theme', dark?'light':'dark');
this.textContent = dark ? '☾ 深色' : '☀ 浅色';
};
let en=false;
document.getElementById('lang').onclick=function(){
en=!en; this.textContent = en?'中':'EN';
document.querySelectorAll('[data-zh]').forEach(el=>{
el.textContent = en ? el.getAttribute('data-en') : el.getAttribute('data-zh');
});
document.documentElement.lang = en?'en':'zh';
};
</script>
</body>
</html>