feat(platform): 开源发布 - 初始化 Open AI Canvas
This commit is contained in:
commit
aea35896eb
365 files changed
+66410
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ConfigProvider, Tabs } from "antd";
|
||||
import { ArrowLeft, Play } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
|
||||
const AUTH_VIDEO_URL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4";
|
||||
const AUTH_VIDEO_POSTER = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg";
|
||||
const AUTH_TABS = [
|
||||
{ key: "login", label: "登录" },
|
||||
{ key: "register", label: "注册" },
|
||||
];
|
||||
|
||||
const authCopy = {
|
||||
login: {
|
||||
eyebrow: "WELCOME BACK",
|
||||
title: "进入创作现场",
|
||||
description: "继续编辑你的画布、素材与生成任务。",
|
||||
},
|
||||
register: {
|
||||
eyebrow: "CREATE ACCOUNT",
|
||||
title: "建立你的创作空间",
|
||||
description: "一个账号管理画布、素材、技能和模型偏好。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function LinuxDOIcon() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, #1d1d1f 0 33.333%, #efefef 33.333% 66.666%, #feb005 66.666% 100%)",
|
||||
boxShadow: "0 0 0 1px rgba(255,255,255,.14)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthScene() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const activeTab = location.pathname === "/register" ? "register" : "login";
|
||||
const copy = activeTab === "register" ? authCopy.register : authCopy.login;
|
||||
|
||||
return (
|
||||
<main className="h-dvh min-h-0 overflow-y-auto bg-[#08090c] text-white lg:overflow-hidden">
|
||||
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
|
||||
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label="无限画布品牌影片">
|
||||
<video
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
src={AUTH_VIDEO_URL}
|
||||
poster={AUTH_VIDEO_POSTER}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
|
||||
<div aria-hidden className="absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] bg-[linear-gradient(90deg,transparent_0%,rgba(11,12,16,.68)_58%,#0b0c10_100%)] lg:block" />
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
|
||||
<Link to="/" className="inline-flex items-center gap-2.5 text-sm font-semibold text-white drop-shadow-sm transition-opacity hover:opacity-80">
|
||||
<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
无限画布
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[11px] text-white/76 backdrop-blur-xl">
|
||||
<Play className="size-3 fill-current" />
|
||||
创作正在发生
|
||||
</span>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="absolute inset-x-0 bottom-0 max-w-2xl p-5 sm:p-7 lg:p-10"
|
||||
>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">INFINITE CANVAS</p>
|
||||
<h1 className="mt-3 max-w-xl text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">灵感,自此拥有空间。</h1>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="relative flex min-h-[620px] items-start justify-center overflow-y-auto bg-[#0b0c10] px-4 pb-8 pt-20 sm:px-8 lg:min-h-0 lg:px-10 lg:pb-10 lg:pt-20">
|
||||
<Link to="/" className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回首页
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
layout={!reducedMotion}
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="my-auto w-full max-w-[460px]"
|
||||
>
|
||||
<ConfigProvider theme={getAntThemeConfig(true)}>
|
||||
<SpotlightSurface spotlightColor="rgba(103,232,249,.16)" spotlightRadius={300} className="flex h-auto rounded-[26px]">
|
||||
<CometCard
|
||||
rotateDepth={1.8}
|
||||
translateDepth={1.5}
|
||||
glare={!reducedMotion}
|
||||
containerClassName="h-auto"
|
||||
className="auth-card-dark h-auto overflow-hidden rounded-[26px] border border-white/12 bg-[#111216]/88 shadow-[0_40px_120px_rgba(0,0,0,.48)] backdrop-blur-2xl"
|
||||
>
|
||||
<section aria-label={copy.title} className="flex min-h-[620px] flex-col sm:min-h-[640px]">
|
||||
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-cyan-300/80">{copy.eyebrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
|
||||
</header>
|
||||
<div className="border-b border-white/[0.08] px-6 sm:px-8">
|
||||
<Tabs
|
||||
className="auth-card-tabs"
|
||||
activeKey={activeTab}
|
||||
items={AUTH_TABS}
|
||||
onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
|
||||
/>
|
||||
</div>
|
||||
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<Outlet />
|
||||
</div>
|
||||
</section>
|
||||
</CometCard>
|
||||
</SpotlightSurface>
|
||||
</ConfigProvider>
|
||||
</motion.div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Button, Divider, Input } from "antd";
|
||||
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
|
||||
import { LinuxDOIcon } from "./auth-scene";
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||
const next = safeNext(params.get("next"));
|
||||
|
||||
useEffect(() => {
|
||||
void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined);
|
||||
const oauthError = params.get("oauth_error");
|
||||
if (oauthError) message.error(oauthError);
|
||||
}, [message, params]);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login({ username, password });
|
||||
await applyUserSession(await getAuthSession());
|
||||
message.success("登录成功");
|
||||
navigate(next, { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "登录失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<AuthField label="用户名 / 邮箱"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required /></AuthField>
|
||||
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" autoComplete="current-password" required /></AuthField>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPosition="end">登录</Button>
|
||||
{linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 登录</Button></> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/canvas";
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Button, Divider, Input } from "antd";
|
||||
import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth";
|
||||
import { LinuxDOIcon } from "./auth-scene";
|
||||
|
||||
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [settings, setSettings] = useState<AuthSettings | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailCode, setEmailCode] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const next = safeNext(params.get("next"));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAuthSettings().then((value) => !cancelled && setSettings(value)).catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
|
||||
return () => { cancelled = true; };
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const sendCode = async () => {
|
||||
if (!email.trim()) {
|
||||
message.warning("请先输入邮箱");
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
await sendRegistrationEmailCode(email.trim());
|
||||
setCountdown(60);
|
||||
message.success("验证码已发送,请检查邮箱");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "发送验证码失败");
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
message.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register({ username, email, emailCode, displayName, password });
|
||||
await applyUserSession(await getAuthSession());
|
||||
if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1");
|
||||
message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功");
|
||||
navigate(next, { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "注册失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const registrationClosed = settings?.registrationEnabled === false;
|
||||
const mailUnavailable = Boolean(settings && !settings.firstUser && settings.emailCodeRequired && !settings.emailEnabled);
|
||||
const disabled = registrationClosed || mailUnavailable;
|
||||
const requireCode = Boolean(settings && !settings.firstUser && settings.emailCodeRequired);
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{settings?.firstUser ? <Notice icon={<Info className="size-3.5" />} tone="cyan">首个账号自动成为管理员,邮箱验证码暂不要求。</Notice> : null}
|
||||
{registrationClosed ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">当前已关闭普通注册,请联系管理员创建账号。</Notice> : null}
|
||||
{mailUnavailable ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">管理员尚未配置注册邮件,普通邮箱注册暂不可用。</Notice> : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AuthField label="用户名"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} /></AuthField>
|
||||
<AuthField label="显示名称"><Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} /></AuthField>
|
||||
</div>
|
||||
|
||||
<AuthField label="邮箱"><Input size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} onChange={(event) => setEmail(event.target.value)} placeholder="用于登录与安全验证" autoComplete="email" required={!settings?.firstUser} disabled={disabled} /></AuthField>
|
||||
|
||||
{requireCode ? (
|
||||
<AuthField label="邮箱验证码">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
|
||||
<Input size="large" prefix={<ShieldCheck className="size-4 text-white/35" />} value={emailCode} onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" required disabled={disabled} />
|
||||
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>{countdown > 0 ? `${countdown}s` : "获取验证码"}</Button>
|
||||
</div>
|
||||
</AuthField>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位" autoComplete="new-password" required disabled={disabled} /></AuthField>
|
||||
<AuthField label="确认密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} placeholder="再次输入密码" autoComplete="new-password" required disabled={disabled} /></AuthField>
|
||||
</div>
|
||||
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled} icon={<ArrowRight className="size-4" />} iconPosition="end">创建账号</Button>
|
||||
{settings?.linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 注册 / 登录</Button></> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function Notice({ icon, tone, children }: { icon: ReactNode; tone: "cyan" | "amber"; children: ReactNode }) {
|
||||
return <div className={`flex items-start gap-2 rounded-xl border px-3 py-2.5 text-xs leading-5 ${tone === "cyan" ? "border-cyan-300/15 bg-cyan-300/[0.06] text-cyan-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}><span className="mt-0.5 shrink-0">{icon}</span>{children}</div>;
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/canvas";
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user