feat(auth): redesign auth UI to match Dart original
- Full-bleed bg_login.png background outside 500px MobileShell - Facebook landing: logo + Facebook pill button + "Other Sign In Options" link - Replace centered Dialog with true bottom sheet (drag handle, top radius 28px) - Email panel: internal login/register toggle, no separate route - Pink gradient primary button (#f96ADE → #f657A0) - Pill input fields (46px, white, radius 24, pink 10% shadow) - Pink circular checkbox + bold Privacy/Terms rich text (visual only, non-blocking) - Floating 40x40 white back button (top:20/left:20) - Wire AuthPanelModeChanged dispatch for panel switching - New design tokens (--color-auth-*, --auth-*, --radius-bottom-sheet) - New generic BottomSheet component - Copy ic-logo-login.png (+@2x, +@3x) from Dart assets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 底部弹层样式
|
||||
*
|
||||
* 原始 Dart: showModalBottomSheet(透明背景,底部白卡 + 顶部圆角 radius28)
|
||||
*/
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: var(--color-auth-surface);
|
||||
color: var(--color-auth-text-primary);
|
||||
border-radius: var(--radius-bottom-sheet) var(--radius-bottom-sheet) 0 0;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.18);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
animation: slideUp 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用底部弹层(Bottom Sheet)
|
||||
*
|
||||
* 原始 Dart: 散落在各处的 `showModalBottomSheet`(auth_other_options_dialog 等)
|
||||
*
|
||||
* 设计目标:
|
||||
* - portal 渲染到 body
|
||||
* - 固定底部、最大宽度 500px、顶部圆角 `--radius-bottom-sheet`
|
||||
* - ESC 关闭 / 点击 scrim 关闭 / 打开时锁定 body 滚动
|
||||
* - 简单 slide-up 动画(与原 Dart 行为接近)
|
||||
* - 调用方只关心内容布局
|
||||
*/
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import styles from "./bottom-sheet.module.css";
|
||||
|
||||
export interface BottomSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** 蒙层透明度(0-1)。默认 0.45。 */
|
||||
scrimOpacity?: number;
|
||||
/** 禁用点击外部 / ESC 关闭。 */
|
||||
persistent?: boolean;
|
||||
/** ARIA 标签。 */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function BottomSheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
scrimOpacity = 0.45,
|
||||
persistent = false,
|
||||
ariaLabel,
|
||||
}: BottomSheetProps) {
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === "undefined") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !persistent) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open, onClose, persistent]);
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={styles.scrim}
|
||||
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
||||
onClick={(e) => {
|
||||
if (!persistent && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className={styles.panel}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,75 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_email_panel.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 顶部:login 模式有 logo + 24px spacer;register 模式两个 24px spacer
|
||||
* - 表单 + 12px gap + 切换链接 + 16px gap + 弹层链接
|
||||
* - Spacer + 法律
|
||||
* - 切换链接:14px 黑色下划线居中
|
||||
* - "Other Sign In Options" 链接:14px 黑色下划线居中
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
align-self: flex-start;
|
||||
.topSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.spacer24 {
|
||||
height: var(--spacing-xxl);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
height: var(--auth-logo-height);
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
min-height: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-md) 0 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-auth-text-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
.toggleButton:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--spacing-md) 0 var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-auth-text-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,117 @@
|
||||
"use client";
|
||||
/**
|
||||
* Email 面板
|
||||
* Email 面板(login/register 内部切换)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_email_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 内部 `authMode`(login/register)切换显示 Login / Register 表单
|
||||
* - 提交派发 `AuthEmailLoginSubmitted` 或 `AuthEmailRegisterSubmitted`
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - login 模式:顶部 24px spacer + logo(120h) + 24px gap
|
||||
* - register 模式:48px spacer(两个 24px,无 logo)
|
||||
* - 表单(EmailLoginForm / EmailRegisterForm)
|
||||
* - 12px gap + 切换链接("Don't have an account? Sign up" / "Already have an account? Log in")
|
||||
* - 16px gap + "Other Sign In Options" 链接(打开底部弹层)
|
||||
* - Spacer + AuthLegalText
|
||||
* - 顶部 "← Back" 按钮由父级 AuthPanel 渲染(悬浮 40×40 圆形)
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import { EmailLoginForm } from "./email-login-form";
|
||||
import { EmailRegisterForm } from "./email-register-form";
|
||||
import styles from "./auth-email-panel.module.css";
|
||||
|
||||
export interface AuthEmailPanelProps {
|
||||
onBack: () => void;
|
||||
/** "Other Sign-in Options" 弹层中 Facebook 按钮回调(派发回 facebook 模式) */
|
||||
onSwitchToFacebook: () => void;
|
||||
/** "Other Sign-in Options" 弹层中 Google 按钮回调(可空;空时不渲染 Google 行) */
|
||||
onGoogle?: () => void;
|
||||
/** 弹层 Google 自定义子节点 */
|
||||
googleSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthEmailPanel({ onBack }: AuthEmailPanelProps) {
|
||||
export function AuthEmailPanel({
|
||||
onSwitchToFacebook,
|
||||
onGoogle,
|
||||
googleSlot,
|
||||
}: AuthEmailPanelProps) {
|
||||
const state = useAuthState();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<button type="button" className={styles.backButton} onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
<div className={styles.topSection}>
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<div className={styles.spacer24} />
|
||||
<img
|
||||
src="/images/auth/ic-logo-login.png"
|
||||
alt="Cozsweet"
|
||||
className={styles.logo}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.spacer24} />
|
||||
<div className={styles.spacer24} />
|
||||
</>
|
||||
)}
|
||||
<div className={styles.spacer24} />
|
||||
</div>
|
||||
|
||||
{mode === "login" ? (
|
||||
<EmailLoginForm
|
||||
onSwitchToRegister={() => setMode("register")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmailRegisterForm
|
||||
onSwitchToLogin={() => setMode("login")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuthErrorMessage message={state.errorMessage} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setMode((m) => (m === "login" ? "register" : "login"))}
|
||||
>
|
||||
{mode === "login"
|
||||
? "Don't have an account? Sign up"
|
||||
: "Already have an account? Log in"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => setShowOptions(true)}
|
||||
>
|
||||
Other Sign In Options
|
||||
</button>
|
||||
|
||||
<div className={styles.spacer} />
|
||||
<AuthLegalText />
|
||||
|
||||
<AuthOtherOptionsDialog
|
||||
open={showOptions}
|
||||
onClose={() => setShowOptions(false)}
|
||||
mode="email"
|
||||
onFacebook={onSwitchToFacebook}
|
||||
onGoogle={() => {
|
||||
setShowOptions(false);
|
||||
onGoogle?.();
|
||||
}}
|
||||
onEmail={() => {
|
||||
// 在 email 模式下不再显示 Email 入口
|
||||
}}
|
||||
googleSlot={googleSlot}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_error_message.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 细小红字,无背景,无圆角,无边框
|
||||
* - 12px 居中
|
||||
*/
|
||||
.banner {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
border: var(--border-light) solid rgba(255, 77, 79, 0.3);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,111 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_facebook_panel.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - Spacer → logo(120h)→ Spacer → Facebook 主按钮 → 链接 → Spacer → 法律
|
||||
* - Facebook 主按钮:46px 白胶囊 + Facebook "f" 图标(#1877f2)
|
||||
* - 链接:14px 黑色下划线居中
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
min-height: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
height: var(--auth-logo-height);
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.facebookButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
height: var(--auth-field-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-xxxl);
|
||||
background: var(--color-auth-surface);
|
||||
color: var(--color-auth-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: var(--spacing-md);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
transition: filter 0.15s ease, transform 0.05s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.facebookButton:hover:not(:disabled) {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
.facebookButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.facebookButton:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.facebookIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.facebookLabel {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-facebook-blue) transparent transparent transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-accent);
|
||||
text-align: center;
|
||||
padding: var(--spacing-md) 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-auth-text-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
opacity: 0.85;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client";
|
||||
/**
|
||||
* Facebook 面板(默认面板)
|
||||
* Facebook 面板(默认登录面板)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_facebook_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
||||
* - 社交登录直接调 `nextauth.facebookLogin()` / `nextauth.googleLogin()`(NextAuth 流程)
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - Spacer → logo(120h)→ Spacer → Facebook 主按钮 → 链接 → Spacer → 法律
|
||||
* - Facebook 主按钮:46px 白胶囊 + Facebook "f" 图标(#1877f2)+ "Login with Facebook"
|
||||
* - 社交登录走 NextAuth:`facebookLogin()` / `googleLogin()`
|
||||
* - "Other Sign-in Options" 链接打开底部弹层
|
||||
* - 弹层 Email 按钮 → 触发 `onSwitchToEmail`(父级派发 `AuthPanelModeChanged`)
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { facebookLogin, googleLogin } from "@/lib/auth/nextauth";
|
||||
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import styles from "./auth-facebook-panel.module.css";
|
||||
|
||||
export interface AuthFacebookPanelProps {
|
||||
@@ -26,65 +25,76 @@ export interface AuthFacebookPanelProps {
|
||||
}
|
||||
|
||||
export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
|
||||
const state = useAuthState();
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [busy, setBusy] = useState<null | "facebook" | "google">(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFacebook = async () => {
|
||||
setBusy("facebook");
|
||||
setError(null);
|
||||
const r = await facebookLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||
if (!r.success) setError(r.error?.message ?? "Facebook login failed");
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy("google");
|
||||
setError(null);
|
||||
const r = await googleLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||
if (!r.success) setError(r.error?.message ?? "Google login failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<AuthErrorMessage message={state.errorMessage} />
|
||||
<AuthSocialButtons
|
||||
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||
onFacebook={handleFacebook}
|
||||
onGoogle={handleGoogle}
|
||||
<div className={styles.spacer} />
|
||||
<img
|
||||
src="/images/auth/ic-logo-login.png"
|
||||
alt="Cozsweet"
|
||||
className={styles.logo}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<AuthPrimaryButton onClick={onSwitchToEmail} variant="primary">
|
||||
Continue with Email
|
||||
</AuthPrimaryButton>
|
||||
<div className={styles.spacer} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.facebookButton}
|
||||
onClick={() => void handleFacebook()}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "facebook" ? (
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
) : (
|
||||
<span className={styles.facebookIcon} aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#1877f2">
|
||||
<path d="M13 22v-8h3l1-4h-4V7.5c0-1.2.4-2 2-2h2V2.2C16.6 2.1 15.5 2 14.3 2 11.5 2 9.5 3.7 9.5 7v3h-3v4h3v8h3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.facebookLabel}>
|
||||
{busy === "facebook" ? "Logging in..." : "Login with Facebook"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => setShowOptions(true)}
|
||||
>
|
||||
Other sign-in options
|
||||
Other Sign In Options
|
||||
</button>
|
||||
|
||||
<div className={styles.spacer} />
|
||||
|
||||
<AuthErrorMessage message={error} />
|
||||
<AuthLegalText />
|
||||
|
||||
<AuthOtherOptionsDialog
|
||||
open={showOptions}
|
||||
onClose={() => setShowOptions(false)}
|
||||
onFacebook={() => {
|
||||
setShowOptions(false);
|
||||
void handleFacebook();
|
||||
}}
|
||||
onGoogle={() => {
|
||||
setShowOptions(false);
|
||||
void handleGoogle();
|
||||
}}
|
||||
onApple={() => {
|
||||
setShowOptions(false);
|
||||
console.warn("Apple login not implemented");
|
||||
}}
|
||||
onEmail={() => {
|
||||
setShowOptions(false);
|
||||
onSwitchToEmail();
|
||||
}}
|
||||
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||
showApple={false}
|
||||
mode="facebook"
|
||||
onFacebook={() => void handleFacebook()}
|
||||
onGoogle={() => void handleGoogle()}
|
||||
onEmail={onSwitchToEmail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_legal_text.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 行:16px 白色圆形复选框 + 12px 富文本
|
||||
* - 复选框选中时 10px 粉色实心圆
|
||||
* - 链接加粗(Privacy Policy / Terms of Service)
|
||||
*/
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--auth-legal-checkbox-size);
|
||||
height: var(--auth-legal-checkbox-size);
|
||||
border-radius: 50%;
|
||||
background: var(--color-auth-surface);
|
||||
border: 1px solid var(--color-auth-surface);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.checkboxInner {
|
||||
display: block;
|
||||
width: var(--auth-legal-checkbox-inner-size);
|
||||
height: var(--auth-legal-checkbox-inner-size);
|
||||
border-radius: 50%;
|
||||
background: var(--color-auth-legal-check);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
color: var(--color-auth-text-primary);
|
||||
text-align: left;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-auth-text-primary);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
"use client";
|
||||
/**
|
||||
* 法律声明(Terms / Privacy)
|
||||
* 法律声明(Terms / Privacy + 复选框)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_legal_text.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 行:16px 白色圆形复选框 + 12px 富文本
|
||||
* - 复选框:白底 + 1px 白色边框;选中时 10px 粉色实心圆
|
||||
* - 默认选中(仅视觉,不阻塞提交)
|
||||
* - 链接加粗(Privacy Policy / Terms of Service)
|
||||
* - 文字深色
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { AppConstants } from "@/core/constants/app_constants";
|
||||
|
||||
import styles from "./auth-legal-text.module.css";
|
||||
|
||||
export function AuthLegalText() {
|
||||
const [checked, setChecked] = useState(true);
|
||||
|
||||
return (
|
||||
<p className={styles.text}>
|
||||
By continuing, you agree to our{" "}
|
||||
<a href="/terms" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className={styles.row}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.checkbox}
|
||||
onClick={() => setChecked((c) => !c)}
|
||||
aria-label="Agree to terms"
|
||||
aria-pressed={checked}
|
||||
>
|
||||
{checked ? <span className={styles.checkboxInner} /> : null}
|
||||
</button>
|
||||
<p className={styles.text}>
|
||||
By continuing, you agree to {AppConstants.appTitle}'s{" "}
|
||||
<a
|
||||
href={AppConstants.privacyPolicyUrl}
|
||||
className={styles.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href={AppConstants.termsOfServiceUrl}
|
||||
className={styles.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,53 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_other_options_dialog.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - drag handle 40×4 灰胶囊
|
||||
* - 标题 24px bold 深色居中
|
||||
* - 选项按钮 40px 白胶囊
|
||||
* - Cancel 14px 深色居中文本按钮
|
||||
*/
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
padding: var(--spacing-md) var(--spacing-xxl)
|
||||
calc(var(--spacing-xxl) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.handle {
|
||||
align-self: center;
|
||||
width: var(--auth-drag-handle-width);
|
||||
height: var(--auth-drag-handle-height);
|
||||
border-radius: 2px;
|
||||
background: var(--color-auth-drag-handle);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: var(--spacing-sm) 0;
|
||||
font-size: var(--font-size-bottom-sheet-title);
|
||||
font-weight: 700;
|
||||
color: var(--color-auth-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emailButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.googleSlot {
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.emailButton:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
.cancel {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--spacing-md) 0;
|
||||
color: var(--color-auth-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
"use client";
|
||||
/**
|
||||
* "其他登录方式" 弹窗
|
||||
* "其他登录方式" 底部弹层
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_other_options_dialog.dart
|
||||
* (showModalBottomSheet:白卡 + 顶部圆角 radius28 + drag handle)
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 固定底部、宽 100%(≤500px)、顶部圆角 28px
|
||||
* - drag handle 40×4 灰胶囊
|
||||
* - 标题 "Other Sign-in Options" 24px bold 深色
|
||||
* - 条件渲染 Email / Facebook 按钮(40px 白胶囊 + 边框 + 图标)
|
||||
* - 始终渲染 Google 按钮
|
||||
* - Cancel 文本按钮
|
||||
*/
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { BottomSheet } from "@/app/_components/core/bottom-sheet";
|
||||
|
||||
import { AuthSocialButton } from "./auth-social-button";
|
||||
import styles from "./auth-other-options-dialog.module.css";
|
||||
|
||||
export interface AuthOtherOptionsDialogProps {
|
||||
@@ -14,11 +25,11 @@ export interface AuthOtherOptionsDialogProps {
|
||||
onClose: () => void;
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
onEmail: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
googleSlot?: React.ReactNode;
|
||||
/** 当前面板模式:facebook 模式下显示 Email + Google;email 模式下显示 Facebook + Google。 */
|
||||
mode: "facebook" | "email";
|
||||
/** 自定义子节点(如嵌入 GIS 按钮) */
|
||||
googleSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthOtherOptionsDialog({
|
||||
@@ -26,29 +37,99 @@ export function AuthOtherOptionsDialog({
|
||||
onClose,
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
onEmail,
|
||||
loadingProvider,
|
||||
showApple,
|
||||
mode,
|
||||
googleSlot,
|
||||
}: AuthOtherOptionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} ariaLabel="Other sign-in options">
|
||||
<BottomSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
ariaLabel="Other sign-in options"
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.handle} aria-hidden="true" />
|
||||
<h2 className={styles.title}>Other Sign-in Options</h2>
|
||||
<AuthSocialButtons
|
||||
onFacebook={onFacebook}
|
||||
onGoogle={onGoogle}
|
||||
onApple={onApple}
|
||||
loadingProvider={loadingProvider}
|
||||
showApple={showApple}
|
||||
googleSlot={googleSlot}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<button type="button" className={styles.emailButton} onClick={onEmail}>
|
||||
Continue with Email
|
||||
|
||||
{mode === "facebook" ? (
|
||||
<AuthSocialButton
|
||||
icon={
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z" />
|
||||
</svg>
|
||||
}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onEmail();
|
||||
}}
|
||||
>
|
||||
Email Sign In / Register
|
||||
</AuthSocialButton>
|
||||
) : (
|
||||
<AuthSocialButton
|
||||
icon={
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="var(--color-facebook-blue)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 22v-8h3l1-4h-4V7.5c0-1.2.4-2 2-2h2V2.2C16.6 2.1 15.5 2 14.3 2 11.5 2 9.5 3.7 9.5 7v3h-3v4h3v8h3.5z" />
|
||||
</svg>
|
||||
}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onFacebook();
|
||||
}}
|
||||
>
|
||||
Facebook Sign In
|
||||
</AuthSocialButton>
|
||||
)}
|
||||
|
||||
{googleSlot ? (
|
||||
<div className={styles.googleSlot}>{googleSlot}</div>
|
||||
) : (
|
||||
<AuthSocialButton
|
||||
icon={
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M22 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.6c-.2 1.3-1 2.4-2 3.1v2.6h3.2c1.9-1.7 2.9-4.2 2.9-7.5z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 22c2.7 0 4.9-.9 6.5-2.4l-3.2-2.5c-.9.6-2 1-3.3 1-2.6 0-4.7-1.7-5.5-4.1H3.2v2.5C4.8 19.7 8.1 22 12 22z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 14a6 6 0 010-3.9V7.5H3.2a10 10 0 000 9l3.3-2.5z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 2.9 14.7 2 12 2 8.1 2 4.8 4.3 3.2 7.5L6.5 10c.8-2.4 2.9-4.1 5.5-4.1z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onGoogle();
|
||||
}}
|
||||
>
|
||||
Continue with Google
|
||||
</AuthSocialButton>
|
||||
)}
|
||||
|
||||
<button type="button" className={styles.cancel} onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,42 @@
|
||||
.panel {
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_panel.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - panelShell 包裹整个面板(position: relative)
|
||||
* - 悬浮返回按钮:40×40 白色圆形 + 淡黑阴影,固定 top/left 20px
|
||||
*/
|
||||
.panelShell {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
padding: var(--spacing-xl);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
position: absolute;
|
||||
top: var(--spacing-xl);
|
||||
left: var(--spacing-xl);
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--auth-back-button-size);
|
||||
height: var(--auth-back-button-size);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--color-auth-surface);
|
||||
color: var(--color-auth-text-primary);
|
||||
padding: 0 2px 0 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: background 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.backButton:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,74 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证面板:顶层 switch(Facebook / Email)
|
||||
* 认证面板:顶层 switch(Facebook / Email)+ 悬浮返回按钮
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_panel.dart
|
||||
*
|
||||
* 行为对齐:
|
||||
* - 状态机:`authPanelMode`("facebook" | "email")由 XState 管理
|
||||
* - 切换派发 `AuthPanelModeChanged`
|
||||
* - 悬浮返回按钮(40×40 白圆,top:20, left:20):
|
||||
* - facebook 模式:`router.back()`
|
||||
* - email 模式:派发 `AuthPanelModeChanged(facebook)`
|
||||
* - Email 面板的弹层 Facebook 按钮同样派发 `AuthPanelModeChanged(facebook)`
|
||||
*/
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import { AuthEmailPanel } from "./auth-email-panel";
|
||||
import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import styles from "./auth-panel.module.css";
|
||||
|
||||
export function AuthPanel() {
|
||||
const state = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
const switchToEmail = () =>
|
||||
dispatch({ type: "AuthPanelModeChanged", mode: "email" });
|
||||
const switchToFacebook = () =>
|
||||
dispatch({ type: "AuthPanelModeChanged", mode: "facebook" });
|
||||
|
||||
const handleBack = () => {
|
||||
if (state.authPanelMode === "email") {
|
||||
switchToFacebook();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelShell}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{state.authPanelMode === "facebook" ? (
|
||||
<AuthFacebookPanel
|
||||
onSwitchToEmail={() => {
|
||||
// 切换模式由外部 dispatch 触发;这里不直接处理
|
||||
// 因为 auth-panel 受 `authPanelMode` 控制,调用方需要 dispatch `AuthPanelModeChanged`
|
||||
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
||||
// 实际上本组件直接渲染对应 panel
|
||||
<AuthFacebookPanel onSwitchToEmail={switchToEmail} />
|
||||
) : (
|
||||
<AuthEmailPanel
|
||||
onSwitchToFacebook={() => {
|
||||
switchToFacebook();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AuthEmailPanel onBack={() => undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_primary_button.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 46px 高
|
||||
* - 粉渐变(#f96ADE → #f657A0,左→右)
|
||||
* - 半径 24
|
||||
* - 20% 红阴影(#d00c41 @ 20%)
|
||||
* - 文字 16px bold 白色
|
||||
*/
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
height: var(--auth-field-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-xxxl);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-auth-primary-gradient-start),
|
||||
var(--color-auth-primary-gradient-end)
|
||||
);
|
||||
box-shadow: 0 3px 5px var(--color-auth-primary-button-shadow);
|
||||
color: #ffffff;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.05s ease;
|
||||
transition: filter 0.15s ease, transform 0.05s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -24,25 +39,18 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-accent),
|
||||
var(--color-facebook-button-gradient-end)
|
||||
);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
border-color: #ffffff transparent transparent transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证主按钮(粉/蓝渐变)
|
||||
* 认证主按钮(粉渐变,46px)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_primary_button.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 46px 高
|
||||
* - 粉渐变(#f96ADE → #f657A0)
|
||||
* - 半径 24
|
||||
* - 20% 红阴影(#d00c41 @ 20%)
|
||||
* - 文字 16px bold 白色
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
@@ -12,28 +19,21 @@ export interface AuthPrimaryButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
isLoading?: boolean;
|
||||
children: ReactNode;
|
||||
variant?: "accent" | "primary";
|
||||
}
|
||||
|
||||
export function AuthPrimaryButton({
|
||||
isLoading,
|
||||
children,
|
||||
variant = "accent",
|
||||
disabled,
|
||||
className,
|
||||
type = "button",
|
||||
...rest
|
||||
}: AuthPrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
type={type}
|
||||
disabled={disabled || isLoading}
|
||||
className={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
className={[styles.button, className].filter(Boolean).join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* 原始 Dart: lib/ui/auth/auth_screen.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 全屏 `bg_login.png` 背景(cover)
|
||||
* - 500px 内容列由 MobileShell 约束
|
||||
* - 内容列居中
|
||||
*/
|
||||
.fullBleed {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
background: url("/images/auth/bg-login.png") center / cover no-repeat;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
* 原始 Dart: lib/ui/auth/auth_screen.dart
|
||||
*
|
||||
* 行为对齐:
|
||||
* - 500px 移动端宽度(MobileShell)
|
||||
* - 已登录用户:自动跳 /chat
|
||||
* - 未登录:渲染 AuthPanel(含 Facebook/Email 两种面板)
|
||||
* - 登录成功:通知 ChatBloc + UserBloc,然后 router.replace(/chat)
|
||||
* - 500px 移动端宽度(MobileShell)
|
||||
* - 全屏 `bg_login.png` 背景(cover 模式,扩展到 viewport)
|
||||
* - 已登录用户:自动跳 /chat
|
||||
* - 未登录:渲染 AuthPanel(含 Facebook/Email 两种面板)
|
||||
* - 登录成功:通知 ChatBloc + UserBloc,然后 router.replace(/chat)
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -20,14 +21,9 @@ import { ROUTES } from "@/router/routes";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
||||
|
||||
export interface AuthScreenProps {
|
||||
/** 自定义背景(默认紫粉渐变)。 */
|
||||
background?: string;
|
||||
}
|
||||
import styles from "./auth-screen.module.css";
|
||||
|
||||
export function AuthScreen({
|
||||
background = "var(--color-sidebar-background)",
|
||||
}: AuthScreenProps) {
|
||||
export function AuthScreen() {
|
||||
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
|
||||
// 本组件只负责:
|
||||
// 1. 渲染登录面板
|
||||
@@ -50,18 +46,12 @@ export function AuthScreen({
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
return (
|
||||
<MobileShell background={background}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-lg)",
|
||||
}}
|
||||
>
|
||||
<AuthPanel />
|
||||
</div>
|
||||
</MobileShell>
|
||||
<div className={styles.fullBleed}>
|
||||
<MobileShell>
|
||||
<div className={styles.content}>
|
||||
<AuthPanel />
|
||||
</div>
|
||||
</MobileShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_social_button.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 弹层按钮对齐):
|
||||
* - 40px 高
|
||||
* - 白底 + 1px 边框 `--color-auth-border` (#d5d7db)
|
||||
* - 半径 20
|
||||
* - 无阴影
|
||||
* - 图标 20×20 + 标签
|
||||
* - 14px 文字深色
|
||||
*/
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
height: var(--auth-social-button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-auth-border);
|
||||
border-radius: var(--radius-xxl);
|
||||
background: var(--color-auth-surface);
|
||||
color: var(--color-auth-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
@@ -31,22 +41,7 @@
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
background: var(--color-facebook-blue);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.google {
|
||||
background: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.apple {
|
||||
background: #000000;
|
||||
border-color: #ffffff;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮
|
||||
* 社交/选项登录按钮(用于底部弹层)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_social_button.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 弹层按钮对齐):
|
||||
* - 40px 高
|
||||
* - 白底 + 1px 边框 `--color-auth-border`
|
||||
* - 半径 20
|
||||
* - 无阴影
|
||||
* - 图标 20×20 + 标签
|
||||
* - 14px 文字深色
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import styles from "./auth-social-button.module.css";
|
||||
|
||||
@@ -12,24 +23,21 @@ export interface AuthSocialButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: "facebook" | "google" | "apple" | "default";
|
||||
}
|
||||
|
||||
export function AuthSocialButton({
|
||||
icon,
|
||||
children,
|
||||
variant = "default",
|
||||
disabled,
|
||||
className,
|
||||
type = "button",
|
||||
...rest
|
||||
}: AuthSocialButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={[styles.button, styles[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
className={[styles.button, className].filter(Boolean).join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{icon ? <span className={styles.icon}>{icon}</span> : null}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/* 原始 Dart: lib/ui/auth/widgets/auth_text_field.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 46px 高
|
||||
* - 白底胶囊(半径 24)
|
||||
* - 无边框
|
||||
* - 10% 粉色阴影(accent #f759a8 @ 10%)
|
||||
* - padding: 0 16 0 24
|
||||
* - 文字深色 #333,占位符 #999
|
||||
*/
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -12,32 +22,35 @@
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
height: var(--auth-field-height);
|
||||
padding: 0 var(--spacing-lg) 0 var(--spacing-xxl);
|
||||
border: none;
|
||||
border-radius: var(--radius-xxxl);
|
||||
background: var(--color-auth-surface);
|
||||
color: var(--color-auth-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
box-shadow: 0 3px 5px var(--color-auth-input-shadow);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-input-placeholder);
|
||||
color: var(--color-auth-input-placeholder);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(248, 77, 150, 0.18);
|
||||
box-shadow: 0 3px 5px var(--color-auth-input-shadow),
|
||||
0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 3px 5px rgba(255, 77, 79, 0.18),
|
||||
0 0 0 1px var(--color-error);
|
||||
}
|
||||
|
||||
.invalid:focus {
|
||||
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.18);
|
||||
box-shadow: 0 3px 5px rgba(255, 77, 79, 0.18),
|
||||
0 0 0 2px var(--color-error);
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
/* 邮箱登录/注册表单样式
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 字段间距 12px(Dart AppSpacing.md)
|
||||
* - 错误横幅与主按钮之间 12px
|
||||
*/
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* 邮箱登录表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 字段无 label(仅 placeholder)
|
||||
* - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误
|
||||
* - 不显示 "Forgot password?" 链接
|
||||
* - 切换链接由父级 AuthEmailPanel 渲染
|
||||
* - 提交派发 `AuthEmailLoginSubmitted`
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -18,33 +25,22 @@ import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailLoginFormProps {
|
||||
onSwitchToRegister: () => void;
|
||||
onForgotPassword?: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailLoginForm({
|
||||
onSwitchToRegister,
|
||||
onForgotPassword,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailLoginFormProps) {
|
||||
const dispatch = useAuthDispatch();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState<{ email?: string; password?: string }>(
|
||||
{},
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
if (emailErr || passwordErr) {
|
||||
setErrors({ email: emailErr ?? undefined, password: passwordErr ?? undefined });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
if (emailErr || passwordErr) return;
|
||||
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
||||
};
|
||||
|
||||
@@ -56,45 +52,25 @@ export function EmailLoginForm({
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
placeholder="Email"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
{onForgotPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
) : null}
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Log In
|
||||
Login
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToRegister}
|
||||
>
|
||||
Don't have an account? <span className={styles.link}>Sign up</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* 邮箱注册表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_register_form.dart
|
||||
*
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - 4 字段:username / email / password / confirmPassword(顺序与 Dart 一致)
|
||||
* - 字段无 label(仅 placeholder)
|
||||
* - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误
|
||||
* - 切换链接由父级 AuthEmailPanel 渲染
|
||||
* - 提交派发 `AuthEmailRegisterSubmitted`
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -20,13 +27,11 @@ import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailRegisterFormProps {
|
||||
onSwitchToLogin: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailRegisterForm({
|
||||
onSwitchToLogin,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailRegisterFormProps) {
|
||||
@@ -35,25 +40,13 @@ export function EmailRegisterForm({
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<"email" | "password" | "username" | "confirmPassword", string>>
|
||||
>({});
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
const usernameErr = validateUsername(username);
|
||||
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
||||
if (emailErr || passwordErr || usernameErr || confirmErr) {
|
||||
setErrors({
|
||||
email: emailErr ?? undefined,
|
||||
password: passwordErr ?? undefined,
|
||||
username: usernameErr ?? undefined,
|
||||
confirmPassword: confirmErr ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
if (emailErr || passwordErr || usernameErr || confirmErr) return;
|
||||
dispatch({
|
||||
type: "AuthEmailRegisterSubmitted",
|
||||
email,
|
||||
@@ -71,53 +64,38 @@ export function EmailRegisterForm({
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="cozsweet_user"
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
errorMessage={errors.username ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
placeholder="Email"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
placeholder="Password"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
placeholder="••••••••"
|
||||
placeholder="Confirm Password"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.confirmPassword ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Create Account
|
||||
Register
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToLogin}
|
||||
>
|
||||
Already have an account? <span className={styles.link}>Log in</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,4 +53,34 @@
|
||||
|
||||
/* 半透明 / 模糊 */
|
||||
--color-blur-background: rgba(13, 11, 20, 0.85);
|
||||
|
||||
/* ==================== Auth 页专用(与 Dart auth_screen.dart 对齐) ==================== */
|
||||
/* 浅色表面(Auth 页内容卡片 / 弹层背景) */
|
||||
--color-auth-surface: #ffffff;
|
||||
|
||||
/* 弹层内第三方登录按钮边框 */
|
||||
--color-auth-border: #d5d7db;
|
||||
|
||||
/* Auth 页输入框占位符 */
|
||||
--color-auth-input-placeholder: #999999;
|
||||
|
||||
/* Auth 页文字主色(深色,区别于全局白字) */
|
||||
--color-auth-text-primary: #333333;
|
||||
--color-auth-text-secondary: #9e9e9e;
|
||||
|
||||
/* Auth 输入框阴影(accent #f759a8 10% 透明) */
|
||||
--color-auth-input-shadow: rgba(247, 89, 168, 0.10);
|
||||
|
||||
/* Auth 主按钮阴影(#d00c41 20% 透明) */
|
||||
--color-auth-primary-button-shadow: rgba(208, 12, 65, 0.20);
|
||||
|
||||
/* Auth 主按钮渐变(与 Dart primaryGradient 对齐) */
|
||||
--color-auth-primary-gradient-start: #f96ade;
|
||||
--color-auth-primary-gradient-end: #f657a0;
|
||||
|
||||
/* Legal 复选框选中点 */
|
||||
--color-auth-legal-check: #f657a0;
|
||||
|
||||
/* 底部弹层 drag handle */
|
||||
--color-auth-drag-handle: #3a3a3a;
|
||||
}
|
||||
|
||||
@@ -32,4 +32,31 @@
|
||||
/* 聊天媒体 */
|
||||
--chat-media-max-width: 240px;
|
||||
--chat-media-max-height: 240px;
|
||||
|
||||
/* ==================== Auth 页尺寸(与 Dart AppDimensions / Spacing 对齐) ==================== */
|
||||
/* 输入框 / 主按钮高度(46px = Dart AppDimensions.height46) */
|
||||
--auth-field-height: 46px;
|
||||
|
||||
/* 弹层内第三方登录按钮高度(40px) */
|
||||
--auth-social-button-height: 40px;
|
||||
|
||||
/* 主按钮固定宽度(Dart AppDimensions.buttonWidth = 334) */
|
||||
--auth-primary-button-width: 334px;
|
||||
|
||||
/* Logo 高度(120px) */
|
||||
--auth-logo-height: 120px;
|
||||
|
||||
/* Legal 圆形复选框 */
|
||||
--auth-legal-checkbox-size: 16px;
|
||||
--auth-legal-checkbox-inner-size: 10px;
|
||||
|
||||
/* 悬浮返回按钮(40×40 圆形) */
|
||||
--auth-back-button-size: 40px;
|
||||
|
||||
/* 底部弹层圆角(28px = Dart AppRadius.radius28) */
|
||||
--auth-bottom-sheet-radius: 28px;
|
||||
|
||||
/* 弹层 drag handle(40×4 胶囊) */
|
||||
--auth-drag-handle-width: 40px;
|
||||
--auth-drag-handle-height: 4px;
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
--radius-xxl: 20px;
|
||||
--radius-xxxl: 24px;
|
||||
--radius-full: 999px;
|
||||
--radius-bottom-sheet: 28px;
|
||||
}
|
||||
|
||||
@@ -28,4 +28,7 @@
|
||||
/* 扩展字号(迁移自 Flutter widgets) */
|
||||
--font-size-22: 22px;
|
||||
--font-size-26: 26px;
|
||||
|
||||
/* Auth 底部弹层标题(24px bold) */
|
||||
--font-size-bottom-sheet-title: 24px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user