fix: clone source login experience
This commit is contained in:
1669
src/app/globals.css
1669
src/app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,28 @@ import type { FormEvent } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters';
|
||||
import { OasisCanvas } from '@/components/login/OasisCanvas';
|
||||
|
||||
type LoginStatus = 'idle' | 'loading' | 'success';
|
||||
|
||||
function Icon({ type }: { type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check' }) {
|
||||
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||
function Icon({
|
||||
type,
|
||||
className = 'h-4 w-4',
|
||||
}: {
|
||||
type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check';
|
||||
className?: string;
|
||||
}) {
|
||||
const common = {
|
||||
className,
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2.2,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
if (type === 'user') return <svg {...common}><path d="M20 21a8 8 0 0 0-16 0" /><circle cx="12" cy="7" r="4" /></svg>;
|
||||
if (type === 'lock') return <svg {...common}><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></svg>;
|
||||
if (type === 'eye') return <svg {...common}><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>;
|
||||
@@ -25,7 +42,6 @@ function LoginInner() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [status, setStatus] = useState<LoginStatus>('idle');
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
@@ -53,10 +69,8 @@ function LoginInner() {
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setHasError(false);
|
||||
setMessage('');
|
||||
if (!username.trim() || !password) {
|
||||
setHasError(true);
|
||||
setMessage('账号和密码都要填。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,88 +89,105 @@ function LoginInner() {
|
||||
} catch {
|
||||
setStatus('idle');
|
||||
setHasError(true);
|
||||
setMessage('账号或密码不对。');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-backdrop" />
|
||||
<section className="login-shell">
|
||||
<div className="login-showcase">
|
||||
<div className="login-brand-mark">AI Toy</div>
|
||||
<h1>AI Toy Patent</h1>
|
||||
<p>进入玩具 IP 生成工作台,管理上传、生成、图库和操作记录。</p>
|
||||
<div className="login-character-strip">
|
||||
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
||||
<OasisCanvas />
|
||||
<div className="login-oasis-shade" />
|
||||
<div className="login-source-overlay">
|
||||
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
|
||||
<div className="login-top-brand" aria-hidden="true">
|
||||
<span className="login-top-brand__logo login-top-brand__wordmark">AI Toy Patent</span>
|
||||
<span className="login-top-brand__system">玩具 IP 工作台</span>
|
||||
</div>
|
||||
<div className="login-source-character-strip" aria-hidden="true">
|
||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="login-panel" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<div className="login-panel__eyebrow">Private Workspace</div>
|
||||
<h2>登录工作台</h2>
|
||||
</div>
|
||||
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
||||
<Icon type="user" className="h-4 w-4 text-white/45" />
|
||||
<input
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
||||
value={username}
|
||||
disabled={disabled}
|
||||
autoComplete="username"
|
||||
aria-label="账号"
|
||||
onFocus={() => setActiveField('username')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setUsername(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="login-field">
|
||||
<span><Icon type="user" /></span>
|
||||
<input
|
||||
value={username}
|
||||
disabled={disabled}
|
||||
autoComplete="username"
|
||||
placeholder="账号"
|
||||
onFocus={() => setActiveField('username')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setUsername(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
||||
<Icon type="lock" className="h-4 w-4 text-white/45" />
|
||||
<input
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
aria-label="密码"
|
||||
onFocus={() => setActiveField('password')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setPassword(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="grid h-9 w-9 place-items-center rounded-[8px] text-white/55 transition hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/45 disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => setShowPassword(value => !value)}
|
||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<Icon type={showPassword ? 'eyeOff' : 'eye'} className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="login-field">
|
||||
<span><Icon type="lock" /></span>
|
||||
<input
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="密码"
|
||||
onFocus={() => setActiveField('password')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setPassword(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-icon-button"
|
||||
disabled={disabled}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => setShowPassword(value => !value)}
|
||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<Icon type={showPassword ? 'eyeOff' : 'eye'} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div className="login-options">
|
||||
<label>
|
||||
<input type="checkbox" checked={remember} disabled={disabled} onChange={event => setRemember(event.target.checked)} />
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
disabled={disabled}
|
||||
onChange={event => setRemember(event.target.checked)}
|
||||
/>
|
||||
<span>保持登录</span>
|
||||
</label>
|
||||
{status === 'success' ? <span className="login-success"><Icon type="check" /> 已通过</span> : null}
|
||||
</div>
|
||||
|
||||
{message ? <div className="login-message">{message}</div> : null}
|
||||
{status === 'success' ? (
|
||||
<div className="mt-3">
|
||||
<div className="inline-flex h-9 w-9 items-center justify-center rounded-[8px] border border-emerald-300/30 bg-emerald-400/10 text-emerald-100">
|
||||
<Icon type="check" className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button className="login-submit" type="submit" disabled={disabled}>
|
||||
<Icon type="arrow" />
|
||||
{status === 'loading' ? '验证中' : '进入'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<button
|
||||
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
aria-label={status === 'loading' ? '验证中' : '进入'}
|
||||
>
|
||||
<Icon type="arrow" className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/components/login/OasisCanvas.tsx
Normal file
67
src/components/login/OasisCanvas.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function OasisCanvas() {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dispatchNativeMouseEvent = (type: 'mousemove' | 'mouseleave', event?: PointerEvent) => {
|
||||
const frameWindow = frameRef.current?.contentWindow;
|
||||
if (!frameWindow) return false;
|
||||
|
||||
try {
|
||||
const nativeEvent = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
clientX: event?.clientX ?? 99999,
|
||||
clientY: event?.clientY ?? 99999,
|
||||
screenX: event?.screenX ?? 99999,
|
||||
screenY: event?.screenY ?? 99999,
|
||||
buttons: event?.buttons ?? 0,
|
||||
view: frameWindow,
|
||||
});
|
||||
frameWindow.dispatchEvent(nativeEvent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendPointer = (type: 'pointermove' | 'pointerleave', event?: PointerEvent) => {
|
||||
const frameWindow = frameRef.current?.contentWindow;
|
||||
if (!frameWindow) return;
|
||||
dispatchNativeMouseEvent(type === 'pointermove' ? 'mousemove' : 'mouseleave', event);
|
||||
frameWindow.postMessage(
|
||||
{
|
||||
type: `skg-oasis-${type}`,
|
||||
x: event?.clientX ?? 99999,
|
||||
y: event?.clientY ?? 99999,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => sendPointer('pointermove', event);
|
||||
const onPointerLeave = () => sendPointer('pointerleave');
|
||||
const listenerOptions = { capture: true, passive: true };
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove, listenerOptions);
|
||||
window.addEventListener('pointerleave', onPointerLeave, listenerOptions);
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', onPointerMove, listenerOptions);
|
||||
window.removeEventListener('pointerleave', onPointerLeave, listenerOptions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
allow="webgpu; fullscreen"
|
||||
aria-hidden="true"
|
||||
className="login-oasis-canvas"
|
||||
loading="eager"
|
||||
src="/oasis-source/index.html?v=login-pointer-0515"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user