feat: 前端主题优化 #1

Merged
jiawei merged 1 commits from feat/0.6.3 into main 2026-10-13 17:27:20 +08:00
22 changed files with 1287 additions and 252 deletions
+2
View File
@@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.8.0", "@base-ui/react": "^1.8.0",
"@fontsource-variable/noto-serif-sc": "^5.3.0",
"@fontsource-variable/source-sans-3": "^5.3.0",
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0", "axios": "^1.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+16
View File
@@ -11,6 +11,12 @@ importers:
'@base-ui/react': '@base-ui/react':
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fontsource-variable/noto-serif-sc':
specifier: ^5.3.0
version: 5.3.0
'@fontsource-variable/source-sans-3':
specifier: ^5.3.0
version: 5.3.0
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.103.2 specifier: ^5.103.2
version: 5.103.2(react@19.2.8) version: 5.103.2(react@19.2.8)
@@ -316,6 +322,12 @@ packages:
'@floating-ui/utils@0.2.12': '@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@fontsource-variable/noto-serif-sc@5.3.0':
resolution: {integrity: sha512-7LcN2NEf4HDjoSkGWc79WqDDyMK8JvSPdA/gsHjJpZ8l9A9mcK/GQTazp3klmkBwJooy0eqeNvlmaZFXPupHrA==}
'@fontsource-variable/source-sans-3@5.3.0':
resolution: {integrity: sha512-dpi0GZk7EQe2tYpg6Q0Fx0OmUgnuGu+0rHPgtXqtKhglYmJuP7jZ12Mu1uN+NfRaJRY1Emn8AQJEWzmoZ4wa9g==}
'@hono/node-server@2.1.1': '@hono/node-server@2.1.1':
resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3473,6 +3485,10 @@ snapshots:
'@floating-ui/utils@0.2.12': {} '@floating-ui/utils@0.2.12': {}
'@fontsource-variable/noto-serif-sc@5.3.0': {}
'@fontsource-variable/source-sans-3@5.3.0': {}
'@hono/node-server@2.1.1(hono@4.13.8)': '@hono/node-server@2.1.1(hono@4.13.8)':
dependencies: dependencies:
hono: 4.13.8 hono: 4.13.8
+47
View File
@@ -0,0 +1,47 @@
// 认证页框体:左侧说明去向,右侧填写身份,中间由外壳的中轴分隔
import { Separator } from "@/components/ui/separator";
type AuthFrameProps = {
title: string;
/** 按语义分好的短句,只在短句之间换行,避免中文在词中断开 */
description: string[];
destination?: React.ReactNode;
children: React.ReactNode;
ref?: React.Ref<HTMLElement>;
};
export const AuthFrame = ({
title,
description,
destination,
children,
ref
}: AuthFrameProps) => {
return (
<section
className="relative grid w-full max-w-[30rem] rounded-md border border-foreground/15 bg-card text-card-foreground motion-safe:animate-frame-turn-in lg:min-h-[32rem] lg:max-w-[56rem] lg:grid-cols-2"
ref={ref}
>
<div className="flex flex-col gap-10 p-7 sm:p-10 lg:justify-between lg:p-12">
<div className="flex flex-col gap-4">
<h1 className="font-serif text-5xl leading-none font-black tracking-[0.04em] sm:text-6xl lg:text-7xl">
{title}
</h1>
<p className="max-w-[20em] text-base leading-relaxed text-muted-foreground">
{description.map((phrase) => (
<span className="inline-block" key={phrase}>
{phrase}
</span>
))}
</p>
</div>
{destination}
</div>
<Separator className="lg:hidden" />
{/* 主信息与左侧标题顶部对齐,次要入口与左侧去向底部对齐 */}
<div className="flex flex-col p-7 sm:p-10 lg:p-12">{children}</div>
</section>
);
};
+187
View File
@@ -0,0 +1,187 @@
// 登录与注册共用的面板:在页内切换模式,框体以中轴为轴翻转,不经过路由跳转
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buildAuthPath } from "@/utils/auth-path";
import { LoginForm } from "../login/login-form";
import { RegisterForm } from "../register/register-form";
import { AuthFrame } from "./auth-frame";
import { ReturnDestination } from "./return-destination";
export type AuthMode = "login" | "register";
const MODE_CONTENT: Record<
AuthMode,
{ title: string; description: string[]; destinationLabel: string; path: string }
> = {
login: {
title: "登录",
description: ["使用中心平台账号登录"],
destinationLabel: "登录后返回",
path: "/login"
},
register: {
title: "注册",
description: ["创建中心平台账号"],
destinationLabel: "注册并登录后返回",
path: "/register"
}
};
// 翻转分两段:先转到侧面收进中轴,侧面朝外时换内容,再从另一侧转出
const TURN_OUT: KeyframeAnimationOptions = {
duration: 240,
easing: "cubic-bezier(0.55, 0, 1, 0.45)",
fill: "forwards"
};
const TURN_IN: KeyframeAnimationOptions = {
duration: 460,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards"
};
const turn = (
frame: HTMLElement,
from: number,
to: number,
options: KeyframeAnimationOptions
) => {
return frame.animate(
[
{ transform: `perspective(2000px) rotateY(${from}deg)` },
{ transform: `perspective(2000px) rotateY(${to}deg)` }
],
options
);
};
const getModeFromPath = (pathname: string): AuthMode =>
pathname.startsWith("/register") ? "register" : "login";
type AuthPanelProps = {
fromUrl: string;
initialMode: AuthMode;
};
export const AuthPanel = ({ fromUrl, initialMode }: AuthPanelProps) => {
const [mode, setMode] = useState(initialMode);
// 注册成功后带入登录表单的账号
const [prefillAccount, setPrefillAccount] = useState("");
const frameRef = useRef<HTMLElement>(null);
const modeRef = useRef(initialMode);
const isTurning = useRef(false);
/** 翻转框体切换模式;shouldPush 为 false 时来自浏览器前进后退 */
const switchMode = useCallback(
async (target: AuthMode, shouldPush = true) => {
const frame = frameRef.current;
if (!frame || isTurning.current || target === modeRef.current) {
return;
}
isTurning.current = true;
// 地址随点击立即更新,不等待动画
if (shouldPush) {
window.history.pushState(
null,
"",
buildAuthPath(MODE_CONTENT[target].path, fromUrl)
);
}
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let next: AuthMode | null = target;
// 翻转期间用户若点了前进后退,循环一次让内容追上地址
while (next) {
const current: AuthMode = next;
// 去注册向左翻,回登录向右翻,来回方向相反
const direction = current === "register" ? 1 : -1;
modeRef.current = current;
/** 侧面朝外时换内容,标签页标题同步更新 */
const swap = () => {
flushSync(() => setMode(current));
document.title = `${MODE_CONTENT[current].title} | 中心平台`;
};
try {
if (reduceMotion) {
swap();
} else {
const turnOut = turn(frame, 0, -90 * direction, TURN_OUT);
await turnOut.finished;
swap();
const turnIn = turn(frame, 90 * direction, 0, TURN_IN);
turnOut.cancel();
await turnIn.finished;
// 结束后移除变换,避免残留的合成层影响文字渲染
turnIn.cancel();
}
} catch {
// 动画被取消(如组件卸载)时,直接以目标模式为准
swap();
}
const urlMode = getModeFromPath(window.location.pathname);
next = urlMode === current ? null : urlMode;
}
isTurning.current = false;
},
[fromUrl]
);
useEffect(() => {
const handlePopState = () => {
void switchMode(getModeFromPath(window.location.pathname), false);
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [switchMode]);
const content = MODE_CONTENT[mode];
const showDestination = mode === "login" || fromUrl.length > 0;
return (
<AuthFrame
description={content.description}
destination={
showDestination ? (
<ReturnDestination fromUrl={fromUrl} label={content.destinationLabel} />
) : undefined
}
ref={frameRef}
title={content.title}
>
{mode === "login" ? (
<LoginForm
fromUrl={fromUrl}
initialAccount={prefillAccount}
onSwitchToRegister={() => void switchMode("register")}
/>
) : (
<RegisterForm
fromUrl={fromUrl}
onRegistered={(username) => {
setPrefillAccount(username);
void switchMode("login");
}}
onSwitchToLogin={() => void switchMode("login")}
/>
)}
</AuthFrame>
);
};
@@ -0,0 +1,33 @@
// 登录/注册互切的链接:普通点击在页内翻转切换,带修饰键时保留浏览器默认行为(如新标签打开)
"use client";
import Link from "next/link";
type ModeSwitchLinkProps = React.ComponentProps<typeof Link> & {
onSwitch: () => void;
};
export const ModeSwitchLink = ({
onClick,
onSwitch,
...props
}: ModeSwitchLinkProps) => {
return (
<Link
{...props}
onClick={(event) => {
onClick?.(event);
const isModified =
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (event.defaultPrevented || isModified || event.button !== 0) {
return;
}
event.preventDefault();
onSwitch();
}}
/>
);
};
@@ -0,0 +1,76 @@
// 密码输入框:可切换明文,并在大写锁定开启时就地提示
"use client";
import { KeyboardEvent, useState } from "react";
import { ArrowBigUpIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
type PasswordInputProps = Omit<React.ComponentProps<"input">, "type"> & {
groupClassName?: string;
};
export const PasswordInput = ({
disabled,
groupClassName,
onBlur,
onKeyDown,
onKeyUp,
...props
}: PasswordInputProps) => {
const [isVisible, setIsVisible] = useState(false);
const [isCapsLockOn, setIsCapsLockOn] = useState(false);
/** 按键时读取大写锁定状态 */
const syncCapsLock = (event: KeyboardEvent<HTMLInputElement>) => {
setIsCapsLockOn(event.getModifierState("CapsLock"));
};
return (
<InputGroup className={cn("h-11 sm:h-10", groupClassName)}>
<InputGroupInput
disabled={disabled}
onBlur={(event) => {
setIsCapsLockOn(false);
onBlur?.(event);
}}
onKeyDown={(event) => {
syncCapsLock(event);
onKeyDown?.(event);
}}
onKeyUp={(event) => {
syncCapsLock(event);
onKeyUp?.(event);
}}
type={isVisible ? "text" : "password"}
{...props}
/>
<InputGroupAddon align="inline-end">
{isCapsLockOn && !isVisible && (
<InputGroupText
className="animate-in text-xs duration-200 fade-in"
role="status"
>
<ArrowBigUpIcon />
大写锁定
</InputGroupText>
)}
<InputGroupButton
aria-label={isVisible ? "隐藏密码" : "显示密码"}
aria-pressed={isVisible}
disabled={disabled}
onClick={() => setIsVisible((visible) => !visible)}
size="icon-xs"
>
{isVisible ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
};
@@ -0,0 +1,38 @@
// 展示认证完成后要回到的系统,让用户在输入密码前确认去向
import { describeReturnUrl } from "@/utils/return-url";
type ReturnDestinationProps = {
fromUrl: string;
label: string;
};
export const ReturnDestination = ({ fromUrl, label }: ReturnDestinationProps) => {
const destination = describeReturnUrl(fromUrl);
return (
<dl className="flex min-w-0 flex-col gap-1.5">
<dt className="text-sm text-muted-foreground">{label}</dt>
{destination ? (
<dd className="flex min-w-0 flex-col gap-0.5">
<span className="text-2xl leading-tight font-semibold break-all transition-colors duration-500 group-has-data-handoff/auth:text-success">
{destination.host}
</span>
{destination.path && (
<span className="truncate text-sm text-muted-foreground">
{destination.path}
</span>
)}
</dd>
) : (
<dd className="flex flex-col gap-0.5">
<span className="text-2xl leading-tight font-semibold text-muted-foreground">
未指定
</span>
<span className="text-sm text-muted-foreground">
请从需要登录的系统进入本页面
</span>
</dd>
)}
</dl>
);
};
+24 -2
View File
@@ -1,11 +1,33 @@
// 认证页外壳:贯穿页面的中轴与框体共同构成「中」字
import { BrandMark } from "@/components/brand-mark";
export default function AuthLayout({ export default function AuthLayout({
children children
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="group/auth relative isolate flex min-h-svh flex-col px-4 sm:px-6">
{children} {/* 中轴:桌面端压在框体之上充当左右分隔,移动端退到框体之后 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 lg:z-10"
>
<span className="absolute inset-0 origin-top bg-foreground/15 motion-safe:animate-axis-draw" />
{/* 登录完成后由表单标记 data-handoff,石绿沿中轴走完跳转等待 */}
<span className="absolute inset-y-0 -inset-x-px origin-top scale-y-0 bg-success group-has-data-handoff/auth:motion-safe:animate-axis-pass group-has-data-handoff/auth:motion-reduce:scale-y-100" />
</div>
<header className="relative z-20 grid h-20 shrink-0 grid-cols-[1fr_auto_1fr] items-center sm:h-24">
<BrandMark className="col-start-2 size-7 bg-background text-foreground transition-colors duration-500 group-has-data-handoff/auth:text-success" />
<p className="col-start-3 pl-3 font-serif text-[0.9375rem] font-semibold tracking-[0.08em]">
中心平台
</p>
</header>
<main className="flex flex-1 items-center justify-center pb-20 sm:pb-24">
{children}
</main>
</div> </div>
); );
} }
+114 -76
View File
@@ -9,17 +9,12 @@ import {
useState, useState,
useSyncExternalStore useSyncExternalStore
} from "react"; } from "react";
import Link from "next/link"; import { CircleCheckIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
Field,
FieldDescription,
FieldGroup,
FieldLabel
} from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useHydrated } from "@/hooks/use-hydrated";
import { useLogin } from "@/hooks/use-login"; import { useLogin } from "@/hooks/use-login";
import { useSystemConfig } from "@/hooks/use-system-config"; import { useSystemConfig } from "@/hooks/use-system-config";
import { useVerify } from "@/hooks/use-verify"; import { useVerify } from "@/hooks/use-verify";
@@ -29,8 +24,10 @@ import {
setAccessToken, setAccessToken,
subscribeToAccessToken subscribeToAccessToken
} from "@/lib/access-token"; } from "@/lib/access-token";
import { message } from "@/lib/message";
import { buildAuthPath } from "@/utils/auth-path"; import { buildAuthPath } from "@/utils/auth-path";
import { describeReturnUrl } from "@/utils/return-url";
import { ModeSwitchLink } from "../components/mode-switch-link";
import { PasswordInput } from "../components/password-input";
// 根据回跳地址和访问令牌进行重定向 // 根据回跳地址和访问令牌进行重定向
const redirectWithToken = (fromUrl: string, token: string) => { const redirectWithToken = (fromUrl: string, token: string) => {
@@ -41,13 +38,26 @@ const redirectWithToken = (fromUrl: string, token: string) => {
}; };
const getAccessTokenServerSnapshot = () => null; const getAccessTokenServerSnapshot = () => null;
// 与中轴上的通行动画时长一致
const REDIRECT_DELAY_MS = 1000; const REDIRECT_DELAY_MS = 1000;
export const LoginForm = () => { type LoginFormProps = {
// 获取查询参数 fromUrl: string;
const searchParams = useSearchParams(); /** 注册成功后带入的账号,存在时直接聚焦密码 */
const [account, setAccount] = useState(""); initialAccount?: string;
onSwitchToRegister: () => void;
};
export const LoginForm = ({
fromUrl,
initialAccount = "",
onSwitchToRegister
}: LoginFormProps) => {
const [account, setAccount] = useState(initialAccount);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const passwordRef = useRef<HTMLInputElement>(null);
// 表单随首屏输出,水合完成前禁止提交,避免浏览器原生提交丢失回跳地址
const isHydrated = useHydrated();
// 同步外部存储中的访问令牌 // 同步外部存储中的访问令牌
const storedToken = useSyncExternalStore( const storedToken = useSyncExternalStore(
subscribeToAccessToken, subscribeToAccessToken,
@@ -57,13 +67,12 @@ export const LoginForm = () => {
// 是否重定向 // 是否重定向
const hasRedirected = useRef(false); const hasRedirected = useRef(false);
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false); const [redirectTarget, setRedirectTarget] = useState<string | null>(null);
const isRedirecting = redirectTarget !== null;
// 登录函数与登录状态 // 登录函数与登录状态
const { mutate: login, isPending } = useLogin(); const { mutate: login, isPending } = useLogin();
// 注册入口只在配置明确开放时出现 // 注册入口只在配置明确开放时出现
const systemConfig = useSystemConfig(); const systemConfig = useSystemConfig();
// 提取回跳地址
const fromUrl = searchParams.get("from_url") ?? "";
// 判断是否存在回跳地址 // 判断是否存在回跳地址
const hasFromUrl = fromUrl.trim().length > 0; const hasFromUrl = fromUrl.trim().length > 0;
// 验证查询参数中的回跳地址与存储的访问令牌 // 验证查询参数中的回跳地址与存储的访问令牌
@@ -72,14 +81,14 @@ export const LoginForm = () => {
const isCheckingSession = const isCheckingSession =
hasFromUrl && Boolean(storedToken) && verifyQuery.isPending && !isRedirecting; hasFromUrl && Boolean(storedToken) && verifyQuery.isPending && !isRedirecting;
/** 先展示提示,一秒后再跳转 */ /** 先展示通行状态,一秒后再跳转 */
const scheduleRedirect = useCallback((targetUrl: string, token: string) => { const scheduleRedirect = useCallback((targetUrl: string, token: string) => {
if (hasRedirected.current || redirectTimer.current !== null) { if (hasRedirected.current || redirectTimer.current !== null) {
return; return;
} }
hasRedirected.current = true; hasRedirected.current = true;
setIsRedirecting(true); setRedirectTarget(targetUrl);
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
redirectWithToken(targetUrl, token); redirectWithToken(targetUrl, token);
}, REDIRECT_DELAY_MS); }, REDIRECT_DELAY_MS);
@@ -99,7 +108,6 @@ export const LoginForm = () => {
return; return;
} }
message.success("已登录,正在跳转");
scheduleRedirect(verifyQuery.data.from_url, storedToken); scheduleRedirect(verifyQuery.data.from_url, storedToken);
}, [scheduleRedirect, storedToken, verifyQuery.data]); }, [scheduleRedirect, storedToken, verifyQuery.data]);
@@ -119,83 +127,113 @@ export const LoginForm = () => {
return; return;
} }
message.success("登录成功");
scheduleRedirect(data.from_url, data.token); scheduleRedirect(data.from_url, data.token);
setAccessToken(data.token); setAccessToken(data.token);
},
onError: () => {
// 账号通常没错,选中密码方便直接重输
window.requestAnimationFrame(() => passwordRef.current?.select());
} }
} }
); );
}; };
// TODO: 确定中心平台直接登录后的默认跳转位置
const isSubmitDisabled = !hasFromUrl || isPending || isRedirecting;
// 如果正在检查会话状态,则显示加载指示器 // 如果正在检查会话状态,则显示加载指示器
if (isCheckingSession) { if (isCheckingSession) {
return ( return (
<main className="flex w-full max-w-sm flex-col items-center gap-3 px-6"> <div
<Spinner /> className="flex animate-in flex-col gap-3 duration-300 fade-in"
<p className="text-sm text-muted-foreground">正在验证登录状态</p> role="status"
</main> >
<Spinner className="size-5 text-muted-foreground" />
<p className="text-lg font-semibold">正在确认登录状态</p>
<p className="text-sm text-muted-foreground">
这台设备上已有登录记录,确认有效后会直接返回。
</p>
</div>
); );
} }
return ( // 通行状态:data-handoff 驱动外壳中轴与去向的动画
<main className="w-full max-w-sm px-6"> if (redirectTarget) {
<div className="flex flex-col gap-2"> return (
<h1 className="text-2xl font-semibold">登录</h1> <div
className="flex animate-in flex-col gap-3 duration-500 fade-in"
data-handoff
role="status"
>
<CircleCheckIcon className="size-6 text-success" />
<p className="text-lg font-semibold">已登录</p>
<p className="text-sm break-all text-muted-foreground">
正在返回{" "}
<span className="font-medium text-foreground">
{describeReturnUrl(redirectTarget)?.host}
</span>
</p>
</div> </div>
);
}
<form className="mt-8" onSubmit={handleSubmit}> // TODO: 确定中心平台直接登录后的默认跳转位置
<FieldGroup> const isFormDisabled = !hasFromUrl || isPending;
<Field data-disabled={isPending || isRedirecting || undefined}>
<FieldLabel htmlFor="account">用户名或邮箱</FieldLabel>
<Input
autoComplete="username"
disabled={isPending || isRedirecting}
id="account"
onChange={(event) => setAccount(event.target.value)}
required
value={account}
/>
</Field>
<Field data-disabled={isPending || isRedirecting || undefined}> return (
<FieldLabel htmlFor="password">密码</FieldLabel> <form
<Input className="flex flex-1 flex-col gap-6"
autoComplete="current-password" method="post"
disabled={isPending || isRedirecting} onSubmit={handleSubmit}
id="password" >
onChange={(event) => setPassword(event.target.value)} <FieldGroup>
required <Field data-disabled={isFormDisabled || undefined}>
type="password" <FieldLabel htmlFor="account">用户名或邮箱</FieldLabel>
value={password} <Input
/> autoComplete="username"
</Field> autoFocus={hasFromUrl && !initialAccount}
className="h-11 sm:h-10"
disabled={isFormDisabled}
id="account"
onChange={(event) => setAccount(event.target.value)}
required
value={account}
/>
</Field>
{!hasFromUrl && ( <Field data-disabled={isFormDisabled || undefined}>
<FieldDescription> <FieldLabel htmlFor="password">密码</FieldLabel>
缺少可信的回跳地址,暂时无法登录 <PasswordInput
</FieldDescription> autoComplete="current-password"
)} autoFocus={hasFromUrl && Boolean(initialAccount)}
disabled={isFormDisabled}
id="password"
onChange={(event) => setPassword(event.target.value)}
ref={passwordRef}
required
value={password}
/>
</Field>
</FieldGroup>
<Button className="w-full" disabled={isSubmitDisabled} type="submit"> <Button
{(isPending || isRedirecting) && <Spinner data-icon="inline-start" />} className="h-11 w-full sm:h-10"
登录 disabled={isFormDisabled || !isHydrated}
</Button> type="submit"
</FieldGroup> >
</form> {isPending && <Spinner data-icon="inline-start" />}
{isPending ? "正在登录" : "登录"}
</Button>
{systemConfig.data?.registration_enabled === true && ( {systemConfig.data?.registration_enabled === true && (
<Button <p className="animate-in text-sm text-muted-foreground duration-300 fade-in lg:mt-auto">
className="mt-2 w-full" 还没有账号?{" "}
nativeButton={false} <ModeSwitchLink
render={<Link href={buildAuthPath("/register", fromUrl)} />} className="font-medium text-foreground underline-offset-4 hover:underline"
variant="outline" href={buildAuthPath("/register", fromUrl)}
> onSwitch={onSwitchToRegister}
注册 >
</Button> 注册
</ModeSwitchLink>
</p>
)} )}
</main> </form>
); );
}; };
+11 -8
View File
@@ -1,18 +1,21 @@
// 登录页面 // 登录页面
import { Suspense } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LoginForm } from "./login-form"; import { AuthPanel } from "../components/auth-panel";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "登录" title: "登录"
}; };
const LoginPage = () => { type LoginPageProps = {
return ( searchParams: Promise<Record<string, string | string[] | undefined>>;
<Suspense fallback={null}> };
<LoginForm />
</Suspense> const LoginPage = async ({ searchParams }: LoginPageProps) => {
); // 回跳地址在服务端读取,首屏即可展示去向和完整表单
const { from_url: fromUrlParam } = await searchParams;
const fromUrl = typeof fromUrlParam === "string" ? fromUrlParam : "";
return <AuthPanel fromUrl={fromUrl} initialMode="login" />;
}; };
export default LoginPage; export default LoginPage;
+10 -8
View File
@@ -1,18 +1,20 @@
// 注册页面 // 注册页面
import { Suspense } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { RegisterForm } from "./register-form"; import { AuthPanel } from "../components/auth-panel";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "注册" title: "注册"
}; };
const RegisterPage = () => { type RegisterPageProps = {
return ( searchParams: Promise<Record<string, string | string[] | undefined>>;
<Suspense fallback={null}> };
<RegisterForm />
</Suspense> const RegisterPage = async ({ searchParams }: RegisterPageProps) => {
); const { from_url: fromUrlParam } = await searchParams;
const fromUrl = typeof fromUrlParam === "string" ? fromUrlParam : "";
return <AuthPanel fromUrl={fromUrl} initialMode="register" />;
}; };
export default RegisterPage; export default RegisterPage;
+253 -126
View File
@@ -2,37 +2,103 @@
"use client"; "use client";
import { SyntheticEvent, useState } from "react"; import { SyntheticEvent, useState } from "react";
import Link from "next/link"; import { CheckIcon, CircleAlertIcon, DotIcon, LockIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from "@/components/ui/empty";
import { import {
Field, Field,
FieldDescription, FieldDescription,
FieldError,
FieldGroup, FieldGroup,
FieldLabel FieldLabel
} from "@/components/ui/field"; } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useHydrated } from "@/hooks/use-hydrated";
import { useRegister } from "@/hooks/use-register"; import { useRegister } from "@/hooks/use-register";
import { useSystemConfig } from "@/hooks/use-system-config"; import { useSystemConfig } from "@/hooks/use-system-config";
import { message } from "@/lib/message"; import { message } from "@/lib/message";
import { cn } from "@/lib/utils";
import { buildAuthPath } from "@/utils/auth-path"; import { buildAuthPath } from "@/utils/auth-path";
import { ModeSwitchLink } from "../components/mode-switch-link";
import { PasswordInput } from "../components/password-input";
import {
PASSWORD_RULES,
RegisterField,
RegisterValues,
validateRegister
} from "./register-rules";
export const RegisterForm = () => { const FIELD_ORDER: RegisterField[] = [
const router = useRouter(); "username",
const searchParams = useSearchParams(); "email",
const [username, setUsername] = useState(""); "password",
const [email, setEmail] = useState(""); "confirmPassword"
const [password, setPassword] = useState(""); ];
const [confirmPassword, setConfirmPassword] = useState("");
const FIELD_IDS: Record<RegisterField, string> = {
username: "username",
email: "email",
password: "password",
confirmPassword: "confirm-password"
};
const USERNAME_HINT = "3–64 位,以字母或数字开头和结尾,可包含 _ . -";
type RegisterFormProps = {
fromUrl: string;
/** 注册成功后切回登录,并带上新账号 */
onRegistered: (username: string) => void;
onSwitchToLogin: () => void;
};
export const RegisterForm = ({
fromUrl,
onRegistered,
onSwitchToLogin
}: RegisterFormProps) => {
// 水合完成前禁止提交,避免浏览器原生提交把密码带进地址栏
const isHydrated = useHydrated();
const [values, setValues] = useState<RegisterValues>({
username: "",
email: "",
password: "",
confirmPassword: ""
});
// 离开过的字段才实时显示错误,提交后全部显示
const [touched, setTouched] = useState<Partial<Record<RegisterField, boolean>>>(
{}
);
const [hasSubmitted, setHasSubmitted] = useState(false);
// 注册开关,只认内部系统配置 // 注册开关,只认内部系统配置
const systemConfig = useSystemConfig(); const systemConfig = useSystemConfig();
const { mutate: registerUser, isPending } = useRegister(); const { mutate: registerUser, isPending } = useRegister();
const fromUrl = searchParams.get("from_url") ?? "";
const loginHref = buildAuthPath("/login", fromUrl); const loginHref = buildAuthPath("/login", fromUrl);
const registrationEnabled = systemConfig.data?.registration_enabled === true; const registrationEnabled = systemConfig.data?.registration_enabled === true;
const errors = validateRegister(values);
/** 校验两次密码后提交注册 */ const visibleError = (field: RegisterField) =>
touched[field] || hasSubmitted ? errors[field] : undefined;
const bindField = (field: RegisterField) => ({
id: FIELD_IDS[field],
name: FIELD_IDS[field],
value: values[field],
disabled: isPending,
"aria-invalid": Boolean(visibleError(field)) || undefined,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
setValues((current) => ({ ...current, [field]: event.target.value })),
onBlur: () => setTouched((current) => ({ ...current, [field]: true }))
});
/** 校验全部字段后提交注册,不通过时聚焦第一个错误字段 */
const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -40,149 +106,210 @@ export const RegisterForm = () => {
return; return;
} }
if (password !== confirmPassword) { setHasSubmitted(true);
message.error("两次输入的密码不一致"); const firstInvalid = FIELD_ORDER.find((field) => errors[field]);
if (firstInvalid) {
document.getElementById(FIELD_IDS[firstInvalid])?.focus();
return; return;
} }
registerUser( registerUser(
{ {
username: username.trim(), username: values.username.trim(),
email: email.trim(), email: values.email.trim(),
password password: values.password
}, },
{ {
onSuccess: () => { onSuccess: (user) => {
message.success("注册成功,请登录"); message.success("注册成功,请登录");
router.push(loginHref); onRegistered(user.username);
} }
} }
); );
}; };
if (systemConfig.isPending) { if (systemConfig.isPending) {
// 离线时查询会暂停,提示原因而不是一直转圈
const isPaused = systemConfig.fetchStatus === "paused";
return ( return (
<main className="flex w-full max-w-sm flex-col items-center gap-3 px-6"> <div className="flex flex-col items-start gap-3" role="status">
<Spinner /> <Spinner className="size-5 text-muted-foreground" />
</main> <p className="text-sm text-muted-foreground">
{isPaused ? "网络已断开,恢复连接后会自动继续" : "正在确认是否开放注册"}
</p>
</div>
); );
} }
if (systemConfig.isError || !registrationEnabled) { if (systemConfig.isError || !registrationEnabled) {
return ( return (
<main className="w-full max-w-sm px-6"> <Empty className="animate-in items-start justify-start p-0 text-left duration-300 fade-in">
<div className="flex flex-col gap-2"> <EmptyHeader className="items-start">
<h1 className="text-2xl font-semibold"> <EmptyMedia variant="icon">
{systemConfig.isError ? <CircleAlertIcon /> : <LockIcon />}
</EmptyMedia>
<EmptyTitle className="text-lg">
{systemConfig.isError ? "暂时无法注册" : "注册已关闭"} {systemConfig.isError ? "暂时无法注册" : "注册已关闭"}
</h1> </EmptyTitle>
<p className="text-sm text-muted-foreground"> <EmptyDescription>
{systemConfig.isError ? "无法确认注册是否开放" : "当前未开放注册"} {systemConfig.isError
</p> ? "无法确认注册是否开放,请稍后重试。"
</div> : "当前不开放自助注册,如需账号请联系管理员。"}
<p className="mt-8 text-sm"> </EmptyDescription>
<Link </EmptyHeader>
className="text-primary underline-offset-4 hover:underline" <EmptyContent className="flex-row">
href={loginHref} {systemConfig.isError && (
<Button
disabled={systemConfig.isFetching}
onClick={() => systemConfig.refetch()}
variant="outline"
>
{systemConfig.isFetching && <Spinner data-icon="inline-start" />}
重试
</Button>
)}
<Button
nativeButton={false}
render={<ModeSwitchLink href={loginHref} onSwitch={onSwitchToLogin} />}
> >
返回登录 返回登录
</Link> </Button>
</p> </EmptyContent>
</main> </Empty>
); );
} }
const passwordError = visibleError("password");
return ( return (
<main className="w-full max-w-sm px-6"> <form
<div className="flex flex-col gap-2"> className="flex flex-1 flex-col gap-6"
<h1 className="text-2xl font-semibold">注册</h1> method="post"
</div> noValidate
onSubmit={handleSubmit}
<form className="mt-8" onSubmit={handleSubmit}> >
<FieldGroup> <FieldGroup>
<Field data-disabled={isPending || undefined}> <Field
<FieldLabel htmlFor="username">用户名</FieldLabel> data-disabled={isPending || undefined}
<Input data-invalid={Boolean(visibleError("username")) || undefined}
autoComplete="username"
disabled={isPending}
id="username"
maxLength={64}
minLength={3}
name="username"
onChange={(event) => setUsername(event.target.value)}
pattern="^[A-Za-z0-9][A-Za-z0-9_.-]*[A-Za-z0-9]$"
required
value={username}
/>
<FieldDescription>
3 到 64 位,以字母或数字开头和结尾,可包含 _ . -
</FieldDescription>
</Field>
<Field data-disabled={isPending || undefined}>
<FieldLabel htmlFor="email">邮箱</FieldLabel>
<Input
autoComplete="email"
disabled={isPending}
id="email"
maxLength={255}
name="email"
onChange={(event) => setEmail(event.target.value)}
required
type="email"
value={email}
/>
</Field>
<Field data-disabled={isPending || undefined}>
<FieldLabel htmlFor="password">密码</FieldLabel>
<Input
autoComplete="new-password"
disabled={isPending}
id="password"
maxLength={128}
minLength={8}
name="password"
onChange={(event) => setPassword(event.target.value)}
pattern="(?=.*[A-Za-z])(?=.*[0-9]).{8,}"
required
type="password"
value={password}
/>
<FieldDescription>至少 8 位,且同时包含字母和数字</FieldDescription>
</Field>
<Field data-disabled={isPending || undefined}>
<FieldLabel htmlFor="confirm-password">确认密码</FieldLabel>
<Input
autoComplete="new-password"
disabled={isPending}
id="confirm-password"
maxLength={128}
minLength={8}
name="confirm-password"
onChange={(event) => setConfirmPassword(event.target.value)}
required
type="password"
value={confirmPassword}
/>
</Field>
<Button className="w-full" disabled={isPending} type="submit">
{isPending && <Spinner data-icon="inline-start" />}
注册
</Button>
</FieldGroup>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
已有账号?{" "}
<Link
className="text-primary underline-offset-4 hover:underline"
href={loginHref}
> >
返回登录 <FieldLabel htmlFor="username">用户名</FieldLabel>
</Link> <Input
aria-describedby="username-hint"
autoComplete="username"
autoFocus
className="h-11 sm:h-10"
maxLength={64}
required
{...bindField("username")}
/>
{visibleError("username") ? (
<FieldError id="username-hint">
{values.username.trim() ? USERNAME_HINT : visibleError("username")}
</FieldError>
) : (
<FieldDescription id="username-hint">{USERNAME_HINT}</FieldDescription>
)}
</Field>
<Field
data-disabled={isPending || undefined}
data-invalid={Boolean(visibleError("email")) || undefined}
>
<FieldLabel htmlFor="email">邮箱</FieldLabel>
<Input
autoComplete="email"
className="h-11 sm:h-10"
inputMode="email"
maxLength={255}
required
type="email"
{...bindField("email")}
/>
<FieldError>{visibleError("email")}</FieldError>
</Field>
<Field
data-disabled={isPending || undefined}
data-invalid={Boolean(passwordError) || undefined}
>
<FieldLabel htmlFor="password">密码</FieldLabel>
<PasswordInput
aria-describedby="password-rules"
autoComplete="new-password"
maxLength={128}
required
{...bindField("password")}
/>
<ul
aria-label="密码要求"
className="flex flex-wrap gap-x-4 gap-y-1 text-sm"
id="password-rules"
>
{PASSWORD_RULES.map((rule) => {
const isMet = rule.test(values.password);
return (
<li
className={cn(
"flex items-center gap-1 text-muted-foreground transition-colors duration-200",
isMet && "text-success",
!isMet && passwordError && "text-destructive"
)}
key={rule.id}
>
{isMet ? (
<CheckIcon
aria-hidden="true"
className="size-4 animate-in duration-200 zoom-in-50"
/>
) : (
<DotIcon aria-hidden="true" className="size-4" />
)}
{rule.label}
<span className="sr-only">{isMet ? ",已满足" : ",未满足"}</span>
</li>
);
})}
</ul>
</Field>
<Field
data-disabled={isPending || undefined}
data-invalid={Boolean(visibleError("confirmPassword")) || undefined}
>
<FieldLabel htmlFor="confirm-password">确认密码</FieldLabel>
<PasswordInput
autoComplete="new-password"
maxLength={128}
required
{...bindField("confirmPassword")}
/>
<FieldError>{visibleError("confirmPassword")}</FieldError>
</Field>
</FieldGroup>
<Button
className="h-11 w-full sm:h-10"
disabled={isPending || !isHydrated}
type="submit"
>
{isPending && <Spinner data-icon="inline-start" />}
{isPending ? "正在注册" : "注册"}
</Button>
<p className="text-sm text-muted-foreground lg:mt-auto">
已有账号?{" "}
<ModeSwitchLink
className="font-medium text-foreground underline-offset-4 hover:underline"
href={loginHref}
onSwitch={onSwitchToLogin}
>
登录
</ModeSwitchLink>
</p> </p>
</main> </form>
); );
}; };
+50
View File
@@ -0,0 +1,50 @@
// 注册字段的校验规则,与后端约束保持一致
export type RegisterValues = {
username: string;
email: string;
password: string;
confirmPassword: string;
};
export type RegisterField = keyof RegisterValues;
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*[A-Za-z0-9]$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** 密码规则逐条展示,便于输入时实时勾选 */
export const PASSWORD_RULES = [
{ id: "length", label: "至少 8 位", test: (value: string) => value.length >= 8 },
{ id: "letter", label: "包含字母", test: (value: string) => /[A-Za-z]/.test(value) },
{ id: "digit", label: "包含数字", test: (value: string) => /[0-9]/.test(value) }
] as const;
/** 返回每个字段的错误信息,没有错误的字段不出现在结果中 */
export const validateRegister = (values: RegisterValues) => {
const errors: Partial<Record<RegisterField, string>> = {};
const username = values.username.trim();
const email = values.email.trim();
if (!username) {
errors.username = "请填写用户名";
} else if (username.length < 3 || !USERNAME_PATTERN.test(username)) {
errors.username = "用户名不符合要求";
}
if (!email) {
errors.email = "请填写邮箱";
} else if (!EMAIL_PATTERN.test(email)) {
errors.email = "邮箱格式不正确";
}
if (!PASSWORD_RULES.every((rule) => rule.test(values.password))) {
errors.password = "密码未满足全部要求";
}
if (!values.confirmPassword) {
errors.confirmPassword = "请再次输入密码";
} else if (values.confirmPassword !== values.password) {
errors.confirmPassword = "两次输入的密码不一致";
}
return errors;
};
+75 -31
View File
@@ -8,8 +8,11 @@
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-serif);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -49,24 +52,35 @@
} }
:root { :root {
--background: oklch(1 0 0); /* 思源宋体负责标题,正文使用 Source Sans 3 与系统中文黑体 */
--foreground: oklch(0.145 0 0); --font-sans:
"Source Sans 3 Variable", "PingFang SC", "HarmonyOS Sans SC",
"Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", system-ui, sans-serif;
--font-serif:
"Noto Serif SC Variable", "Source Han Serif SC", "Noto Serif CJK SC",
"Songti SC", "STSong", serif;
/* 纸色底、黛色墨、石绿作为通行状态色 */
--background: oklch(0.962 0.005 155);
--foreground: oklch(0.24 0.025 258);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.24 0.025 258);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.24 0.025 258);
--primary: oklch(0.205 0 0); --primary: oklch(0.255 0.03 258);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0.003 155);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.945 0.006 155);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.255 0.03 258);
--muted: oklch(0.97 0 0); --muted: oklch(0.945 0.006 155);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.515 0.016 252);
--accent: oklch(0.97 0 0); --accent: oklch(0.945 0.006 155);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.255 0.03 258);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --success: oklch(0.53 0.095 164);
--input: oklch(0.922 0 0); --success-foreground: oklch(0.985 0.003 155);
--ring: oklch(0.708 0 0); --border: oklch(0.885 0.007 160);
--input: oklch(0.855 0.009 160);
--ring: oklch(0.53 0.095 164);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
@@ -84,24 +98,26 @@
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.2 0.016 256);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.95 0.005 155);
--card: oklch(0.205 0 0); --card: oklch(0.235 0.018 256);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.95 0.005 155);
--popover: oklch(0.205 0 0); --popover: oklch(0.235 0.018 256);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.95 0.005 155);
--primary: oklch(0.922 0 0); --primary: oklch(0.93 0.008 155);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.24 0.025 258);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.28 0.018 256);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.28 0.018 256);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.72 0.012 252);
--accent: oklch(0.269 0 0); --accent: oklch(0.28 0.018 256);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.72 0.11 164);
--success-foreground: oklch(0.2 0.02 164);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.72 0.11 164);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
@@ -125,6 +141,34 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans antialiased;
} }
} }
/* 进场:中轴自上而下落笔,框体以中轴为轴从侧面转正;通行:石绿沿中轴走完跳转等待 */
@theme {
--animate-axis-draw: axis-grow 900ms cubic-bezier(0.65, 0, 0.35, 1) both;
--animate-frame-turn-in: frame-turn-in 900ms cubic-bezier(0.22, 1, 0.36, 1)
200ms backwards;
--animate-axis-pass: axis-grow 1000ms cubic-bezier(0.45, 0, 0.55, 1) forwards;
/* 使用独立的 scale 属性,与 Tailwind 的 scale-* 工具类作用于同一属性 */
@keyframes axis-grow {
from {
scale: 1 0;
}
to {
scale: 1 1;
}
}
/* 与登录/注册切换的翻转同一套透视参数,见 auth-panel.tsx */
@keyframes frame-turn-in {
from {
transform: perspective(2000px) rotateY(90deg);
}
to {
transform: perspective(2000px) rotateY(0deg);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#EEF1EE"/>
<g fill="none" stroke="#1B2331" stroke-width="2.5">
<rect x="6" y="10.5" width="20" height="11" rx="1"/>
<path d="M16 4v24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+8 -1
View File
@@ -1,7 +1,10 @@
import "./globals.css"; import "./globals.css";
// 字体随包自托管,按 unicode-range 分片,只下载页面用到的字形
import "@fontsource-variable/noto-serif-sc";
import "@fontsource-variable/source-sans-3";
import { Toaster } from "@/components/ui/toast"; import { Toaster } from "@/components/ui/toast";
import QueryProvider from "@/providers/query-provider"; import QueryProvider from "@/providers/query-provider";
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -10,6 +13,10 @@ export const metadata: Metadata = {
} }
}; };
export const viewport: Viewport = {
themeColor: "#eef1ee"
};
export default function RootLayout({ export default function RootLayout({
children children
}: { }: {
+22
View File
@@ -0,0 +1,22 @@
// 中心平台标志:「中」字的框与贯穿其中的中轴
import { cn } from "@/lib/utils";
export const BrandMark = ({
className,
...props
}: React.ComponentProps<"svg">) => {
return (
<svg
aria-hidden="true"
className={cn("size-6", className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}
>
<rect height="10" rx="1" width="18" x="3" y="6.5" />
<path d="M12 1v22" />
</svg>
);
};
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+13
View File
@@ -0,0 +1,13 @@
// 判断客户端是否已完成水合
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
/** 服务端与水合阶段返回 false,水合完成后返回 true */
export const useHydrated = () => {
return useSyncExternalStore(
subscribe,
() => true,
() => false
);
};
+23
View File
@@ -0,0 +1,23 @@
/** 把回跳地址拆成可读的主机名与路径,无法解析时原样返回 */
export const describeReturnUrl = (fromUrl: string) => {
const value = fromUrl.trim();
if (!value) {
return null;
}
try {
const url = new URL(value);
const path = `${url.pathname}${url.search}`;
return {
host: url.host,
path: path === "/" ? "" : path
};
} catch {
return {
host: value,
path: ""
};
}
};