fix: clone source login experience
This commit is contained in:
2565
public/oasis-source/index.html
Normal file
2565
public/oasis-source/index.html
Normal file
File diff suppressed because it is too large
Load Diff
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 { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters';
|
import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters';
|
||||||
|
import { OasisCanvas } from '@/components/login/OasisCanvas';
|
||||||
|
|
||||||
type LoginStatus = 'idle' | 'loading' | 'success';
|
type LoginStatus = 'idle' | 'loading' | 'success';
|
||||||
|
|
||||||
function Icon({ type }: { type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check' }) {
|
function Icon({
|
||||||
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 };
|
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 === '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 === '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>;
|
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 [showPassword, setShowPassword] = useState(false);
|
||||||
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
|
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [status, setStatus] = useState<LoginStatus>('idle');
|
const [status, setStatus] = useState<LoginStatus>('idle');
|
||||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
@@ -53,10 +69,8 @@ function LoginInner() {
|
|||||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
setMessage('');
|
|
||||||
if (!username.trim() || !password) {
|
if (!username.trim() || !password) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setMessage('账号和密码都要填。');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,88 +89,105 @@ function LoginInner() {
|
|||||||
} catch {
|
} catch {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setMessage('账号或密码不对。');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-page">
|
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
||||||
<div className="login-backdrop" />
|
<OasisCanvas />
|
||||||
<section className="login-shell">
|
<div className="login-oasis-shade" />
|
||||||
<div className="login-showcase">
|
<div className="login-source-overlay">
|
||||||
<div className="login-brand-mark">AI Toy</div>
|
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
|
||||||
<h1>AI Toy Patent</h1>
|
<div className="login-top-brand" aria-hidden="true">
|
||||||
<p>进入玩具 IP 生成工作台,管理上传、生成、图库和操作记录。</p>
|
<span className="login-top-brand__logo login-top-brand__wordmark">AI Toy Patent</span>
|
||||||
<div className="login-character-strip">
|
<span className="login-top-brand__system">玩具 IP 工作台</span>
|
||||||
|
</div>
|
||||||
|
<div className="login-source-character-strip" aria-hidden="true">
|
||||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="login-panel" onSubmit={onSubmit}>
|
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<div className="login-panel__eyebrow">Private Workspace</div>
|
<label className="block">
|
||||||
<h2>登录工作台</h2>
|
<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">
|
||||||
</div>
|
<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">
|
<label className="block">
|
||||||
<span><Icon type="user" /></span>
|
<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">
|
||||||
<input
|
<Icon type="lock" className="h-4 w-4 text-white/45" />
|
||||||
value={username}
|
<input
|
||||||
disabled={disabled}
|
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
||||||
autoComplete="username"
|
value={password}
|
||||||
placeholder="账号"
|
disabled={disabled}
|
||||||
onFocus={() => setActiveField('username')}
|
type={showPassword ? 'text' : 'password'}
|
||||||
onBlur={() => setActiveField(null)}
|
autoComplete="current-password"
|
||||||
onChange={event => {
|
aria-label="密码"
|
||||||
setUsername(event.target.value);
|
onFocus={() => setActiveField('password')}
|
||||||
if (hasError) setHasError(false);
|
onBlur={() => setActiveField(null)}
|
||||||
}}
|
onChange={event => {
|
||||||
/>
|
setPassword(event.target.value);
|
||||||
</label>
|
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">
|
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||||
<span><Icon type="lock" /></span>
|
<input
|
||||||
<input
|
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
||||||
value={password}
|
type="checkbox"
|
||||||
disabled={disabled}
|
checked={remember}
|
||||||
type={showPassword ? 'text' : 'password'}
|
disabled={disabled}
|
||||||
autoComplete="current-password"
|
onChange={event => setRemember(event.target.checked)}
|
||||||
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)} />
|
|
||||||
<span>保持登录</span>
|
<span>保持登录</span>
|
||||||
</label>
|
</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}>
|
<button
|
||||||
<Icon type="arrow" />
|
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"
|
||||||
{status === 'loading' ? '验证中' : '进入'}
|
type="submit"
|
||||||
</button>
|
disabled={disabled}
|
||||||
</form>
|
aria-label={status === 'loading' ? '验证中' : '进入'}
|
||||||
</section>
|
>
|
||||||
|
<Icon type="arrow" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</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