diff --git a/package.json b/package.json index 2b138b8..ce74c2c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@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", "axios": "^1.20.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95cdd5e..464b870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@base-ui/react': 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) + '@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': specifier: ^5.103.2 version: 5.103.2(react@19.2.8) @@ -316,6 +322,12 @@ packages: '@floating-ui/utils@0.2.12': 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': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} @@ -3473,6 +3485,10 @@ snapshots: '@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)': dependencies: hono: 4.13.8 diff --git a/src/app/(auth)/components/auth-frame.tsx b/src/app/(auth)/components/auth-frame.tsx new file mode 100644 index 0000000..d024e7c --- /dev/null +++ b/src/app/(auth)/components/auth-frame.tsx @@ -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; +}; + +export const AuthFrame = ({ + title, + description, + destination, + children, + ref +}: AuthFrameProps) => { + return ( +
+
+
+

+ {title} +

+

+ {description.map((phrase) => ( + + {phrase} + + ))} +

+
+ {destination} +
+ + + + {/* 主信息与左侧标题顶部对齐,次要入口与左侧去向底部对齐 */} +
{children}
+
+ ); +}; diff --git a/src/app/(auth)/components/auth-panel.tsx b/src/app/(auth)/components/auth-panel.tsx new file mode 100644 index 0000000..0bda0d5 --- /dev/null +++ b/src/app/(auth)/components/auth-panel.tsx @@ -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(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 ( + + ) : undefined + } + ref={frameRef} + title={content.title} + > + {mode === "login" ? ( + void switchMode("register")} + /> + ) : ( + { + setPrefillAccount(username); + void switchMode("login"); + }} + onSwitchToLogin={() => void switchMode("login")} + /> + )} + + ); +}; diff --git a/src/app/(auth)/components/mode-switch-link.tsx b/src/app/(auth)/components/mode-switch-link.tsx new file mode 100644 index 0000000..be87b1e --- /dev/null +++ b/src/app/(auth)/components/mode-switch-link.tsx @@ -0,0 +1,33 @@ +// 登录/注册互切的链接:普通点击在页内翻转切换,带修饰键时保留浏览器默认行为(如新标签打开) +"use client"; + +import Link from "next/link"; + +type ModeSwitchLinkProps = React.ComponentProps & { + onSwitch: () => void; +}; + +export const ModeSwitchLink = ({ + onClick, + onSwitch, + ...props +}: ModeSwitchLinkProps) => { + return ( + { + onClick?.(event); + + const isModified = + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + + if (event.defaultPrevented || isModified || event.button !== 0) { + return; + } + + event.preventDefault(); + onSwitch(); + }} + /> + ); +}; diff --git a/src/app/(auth)/components/password-input.tsx b/src/app/(auth)/components/password-input.tsx new file mode 100644 index 0000000..00dc97d --- /dev/null +++ b/src/app/(auth)/components/password-input.tsx @@ -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, "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) => { + setIsCapsLockOn(event.getModifierState("CapsLock")); + }; + + return ( + + { + setIsCapsLockOn(false); + onBlur?.(event); + }} + onKeyDown={(event) => { + syncCapsLock(event); + onKeyDown?.(event); + }} + onKeyUp={(event) => { + syncCapsLock(event); + onKeyUp?.(event); + }} + type={isVisible ? "text" : "password"} + {...props} + /> + + {isCapsLockOn && !isVisible && ( + + + 大写锁定 + + )} + setIsVisible((visible) => !visible)} + size="icon-xs" + > + {isVisible ? : } + + + + ); +}; diff --git a/src/app/(auth)/components/return-destination.tsx b/src/app/(auth)/components/return-destination.tsx new file mode 100644 index 0000000..c5f8862 --- /dev/null +++ b/src/app/(auth)/components/return-destination.tsx @@ -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 ( +
+
{label}
+ {destination ? ( +
+ + {destination.host} + + {destination.path && ( + + {destination.path} + + )} +
+ ) : ( +
+ + 未指定 + + + 请从需要登录的系统进入本页面 + +
+ )} +
+ ); +}; diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 7fb7d8f..cd97df5 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,11 +1,33 @@ +// 认证页外壳:贯穿页面的中轴与框体共同构成「中」字 +import { BrandMark } from "@/components/brand-mark"; + export default function AuthLayout({ children }: { children: React.ReactNode; }) { return ( -
- {children} +
+ {/* 中轴:桌面端压在框体之上充当左右分隔,移动端退到框体之后 */} + + +
+ +

+ 中心平台 +

+
+ +
+ {children} +
); } diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index e8717d4..f74955c 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -9,17 +9,12 @@ import { useState, useSyncExternalStore } from "react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { CircleCheckIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel -} from "@/components/ui/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { useHydrated } from "@/hooks/use-hydrated"; import { useLogin } from "@/hooks/use-login"; import { useSystemConfig } from "@/hooks/use-system-config"; import { useVerify } from "@/hooks/use-verify"; @@ -29,8 +24,10 @@ import { setAccessToken, subscribeToAccessToken } from "@/lib/access-token"; -import { message } from "@/lib/message"; 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) => { @@ -41,13 +38,26 @@ const redirectWithToken = (fromUrl: string, token: string) => { }; const getAccessTokenServerSnapshot = () => null; +// 与中轴上的通行动画时长一致 const REDIRECT_DELAY_MS = 1000; -export const LoginForm = () => { - // 获取查询参数 - const searchParams = useSearchParams(); - const [account, setAccount] = useState(""); +type LoginFormProps = { + fromUrl: string; + /** 注册成功后带入的账号,存在时直接聚焦密码 */ + initialAccount?: string; + onSwitchToRegister: () => void; +}; + +export const LoginForm = ({ + fromUrl, + initialAccount = "", + onSwitchToRegister +}: LoginFormProps) => { + const [account, setAccount] = useState(initialAccount); const [password, setPassword] = useState(""); + const passwordRef = useRef(null); + // 表单随首屏输出,水合完成前禁止提交,避免浏览器原生提交丢失回跳地址 + const isHydrated = useHydrated(); // 同步外部存储中的访问令牌 const storedToken = useSyncExternalStore( subscribeToAccessToken, @@ -57,13 +67,12 @@ export const LoginForm = () => { // 是否重定向 const hasRedirected = useRef(false); const redirectTimer = useRef(null); - const [isRedirecting, setIsRedirecting] = useState(false); + const [redirectTarget, setRedirectTarget] = useState(null); + const isRedirecting = redirectTarget !== null; // 登录函数与登录状态 const { mutate: login, isPending } = useLogin(); // 注册入口只在配置明确开放时出现 const systemConfig = useSystemConfig(); - // 提取回跳地址 - const fromUrl = searchParams.get("from_url") ?? ""; // 判断是否存在回跳地址 const hasFromUrl = fromUrl.trim().length > 0; // 验证查询参数中的回跳地址与存储的访问令牌 @@ -72,14 +81,14 @@ export const LoginForm = () => { const isCheckingSession = hasFromUrl && Boolean(storedToken) && verifyQuery.isPending && !isRedirecting; - /** 先展示提示,一秒后再跳转 */ + /** 先展示通行状态,一秒后再跳转 */ const scheduleRedirect = useCallback((targetUrl: string, token: string) => { if (hasRedirected.current || redirectTimer.current !== null) { return; } hasRedirected.current = true; - setIsRedirecting(true); + setRedirectTarget(targetUrl); redirectTimer.current = window.setTimeout(() => { redirectWithToken(targetUrl, token); }, REDIRECT_DELAY_MS); @@ -99,7 +108,6 @@ export const LoginForm = () => { return; } - message.success("已登录,正在跳转"); scheduleRedirect(verifyQuery.data.from_url, storedToken); }, [scheduleRedirect, storedToken, verifyQuery.data]); @@ -119,83 +127,113 @@ export const LoginForm = () => { return; } - message.success("登录成功"); scheduleRedirect(data.from_url, data.token); setAccessToken(data.token); + }, + onError: () => { + // 账号通常没错,选中密码方便直接重输 + window.requestAnimationFrame(() => passwordRef.current?.select()); } } ); }; - // TODO: 确定中心平台直接登录后的默认跳转位置 - const isSubmitDisabled = !hasFromUrl || isPending || isRedirecting; - // 如果正在检查会话状态,则显示加载指示器 if (isCheckingSession) { return ( -
- -

正在验证登录状态

-
+
+ +

正在确认登录状态

+

+ 这台设备上已有登录记录,确认有效后会直接返回。 +

+
); } - return ( -
-
-

登录

+ // 通行状态:data-handoff 驱动外壳中轴与去向的动画 + if (redirectTarget) { + return ( +
+ +

已登录

+

+ 正在返回{" "} + + {describeReturnUrl(redirectTarget)?.host} + +

+ ); + } -
- - - 用户名或邮箱 - setAccount(event.target.value)} - required - value={account} - /> - + // TODO: 确定中心平台直接登录后的默认跳转位置 + const isFormDisabled = !hasFromUrl || isPending; - - 密码 - setPassword(event.target.value)} - required - type="password" - value={password} - /> - + return ( + + + + 用户名或邮箱 + setAccount(event.target.value)} + required + value={account} + /> + - {!hasFromUrl && ( - - 缺少可信的回跳地址,暂时无法登录 - - )} + + 密码 + setPassword(event.target.value)} + ref={passwordRef} + required + value={password} + /> + + - - -
+ {systemConfig.data?.registration_enabled === true && ( - +

+ 还没有账号?{" "} + + 注册 + +

)} -
+ ); }; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 2e43d4a..008604b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,18 +1,21 @@ // 登录页面 -import { Suspense } from "react"; import type { Metadata } from "next"; -import { LoginForm } from "./login-form"; +import { AuthPanel } from "../components/auth-panel"; export const metadata: Metadata = { title: "登录" }; -const LoginPage = () => { - return ( - - - - ); +type LoginPageProps = { + searchParams: Promise>; +}; + +const LoginPage = async ({ searchParams }: LoginPageProps) => { + // 回跳地址在服务端读取,首屏即可展示去向和完整表单 + const { from_url: fromUrlParam } = await searchParams; + const fromUrl = typeof fromUrlParam === "string" ? fromUrlParam : ""; + + return ; }; export default LoginPage; diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 2275740..ae67b7d 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,18 +1,20 @@ // 注册页面 -import { Suspense } from "react"; import type { Metadata } from "next"; -import { RegisterForm } from "./register-form"; +import { AuthPanel } from "../components/auth-panel"; export const metadata: Metadata = { title: "注册" }; -const RegisterPage = () => { - return ( - - - - ); +type RegisterPageProps = { + searchParams: Promise>; +}; + +const RegisterPage = async ({ searchParams }: RegisterPageProps) => { + const { from_url: fromUrlParam } = await searchParams; + const fromUrl = typeof fromUrlParam === "string" ? fromUrlParam : ""; + + return ; }; export default RegisterPage; diff --git a/src/app/(auth)/register/register-form.tsx b/src/app/(auth)/register/register-form.tsx index 4f53f6c..0c9d42a 100644 --- a/src/app/(auth)/register/register-form.tsx +++ b/src/app/(auth)/register/register-form.tsx @@ -2,37 +2,103 @@ "use client"; import { SyntheticEvent, useState } from "react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; +import { CheckIcon, CircleAlertIcon, DotIcon, LockIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from "@/components/ui/empty"; import { Field, FieldDescription, + FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { useHydrated } from "@/hooks/use-hydrated"; import { useRegister } from "@/hooks/use-register"; import { useSystemConfig } from "@/hooks/use-system-config"; import { message } from "@/lib/message"; +import { cn } from "@/lib/utils"; 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 router = useRouter(); - const searchParams = useSearchParams(); - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); +const FIELD_ORDER: RegisterField[] = [ + "username", + "email", + "password", + "confirmPassword" +]; + +const FIELD_IDS: Record = { + 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({ + username: "", + email: "", + password: "", + confirmPassword: "" + }); + // 离开过的字段才实时显示错误,提交后全部显示 + const [touched, setTouched] = useState>>( + {} + ); + const [hasSubmitted, setHasSubmitted] = useState(false); // 注册开关,只认内部系统配置 const systemConfig = useSystemConfig(); const { mutate: registerUser, isPending } = useRegister(); - const fromUrl = searchParams.get("from_url") ?? ""; const loginHref = buildAuthPath("/login", fromUrl); 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) => + setValues((current) => ({ ...current, [field]: event.target.value })), + onBlur: () => setTouched((current) => ({ ...current, [field]: true })) + }); + + /** 校验全部字段后提交注册,不通过时聚焦第一个错误字段 */ const handleSubmit = (event: SyntheticEvent) => { event.preventDefault(); @@ -40,149 +106,210 @@ export const RegisterForm = () => { return; } - if (password !== confirmPassword) { - message.error("两次输入的密码不一致"); + setHasSubmitted(true); + const firstInvalid = FIELD_ORDER.find((field) => errors[field]); + + if (firstInvalid) { + document.getElementById(FIELD_IDS[firstInvalid])?.focus(); return; } registerUser( { - username: username.trim(), - email: email.trim(), - password + username: values.username.trim(), + email: values.email.trim(), + password: values.password }, { - onSuccess: () => { + onSuccess: (user) => { message.success("注册成功,请登录"); - router.push(loginHref); + onRegistered(user.username); } } ); }; if (systemConfig.isPending) { + // 离线时查询会暂停,提示原因而不是一直转圈 + const isPaused = systemConfig.fetchStatus === "paused"; + return ( -
- -
+
+ +

+ {isPaused ? "网络已断开,恢复连接后会自动继续" : "正在确认是否开放注册"} +

+
); } if (systemConfig.isError || !registrationEnabled) { return ( -
-
-

+ + + + {systemConfig.isError ? : } + + {systemConfig.isError ? "暂时无法注册" : "注册已关闭"} -

-

- {systemConfig.isError ? "无法确认注册是否开放" : "当前未开放注册"} -

-
-

- + + {systemConfig.isError + ? "无法确认注册是否开放,请稍后重试。" + : "当前不开放自助注册,如需账号请联系管理员。"} + + + + {systemConfig.isError && ( + + )} +

+ + + ); } + const passwordError = visibleError("password"); + return ( -
-
-

注册

-
- -
- - - 用户名 - setUsername(event.target.value)} - pattern="^[A-Za-z0-9][A-Za-z0-9_.-]*[A-Za-z0-9]$" - required - value={username} - /> - - 3 到 64 位,以字母或数字开头和结尾,可包含 _ . - - - - - - 邮箱 - setEmail(event.target.value)} - required - type="email" - value={email} - /> - - - - 密码 - setPassword(event.target.value)} - pattern="(?=.*[A-Za-z])(?=.*[0-9]).{8,}" - required - type="password" - value={password} - /> - 至少 8 位,且同时包含字母和数字 - - - - 确认密码 - setConfirmPassword(event.target.value)} - required - type="password" - value={confirmPassword} - /> - - - - -
- -

- 已有账号?{" "} - + + - 返回登录 - + 用户名 + + {visibleError("username") ? ( + + {values.username.trim() ? USERNAME_HINT : visibleError("username")} + + ) : ( + {USERNAME_HINT} + )} + + + + 邮箱 + + {visibleError("email")} + + + + 密码 + +

    + {PASSWORD_RULES.map((rule) => { + const isMet = rule.test(values.password); + + return ( +
  • + {isMet ? ( +
  • + ); + })} +
+ + + + 确认密码 + + {visibleError("confirmPassword")} + + + + + +

+ 已有账号?{" "} + + 登录 +

-
+ ); }; diff --git a/src/app/(auth)/register/register-rules.ts b/src/app/(auth)/register/register-rules.ts new file mode 100644 index 0000000..11e9fb1 --- /dev/null +++ b/src/app/(auth)/register/register-rules.ts @@ -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> = {}; + 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; +}; diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..97fba30 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,8 +8,11 @@ --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-sans); + --font-serif: var(--font-serif); --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-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -49,24 +52,35 @@ } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + /* 思源宋体负责标题,正文使用 Source Sans 3 与系统中文黑体 */ + --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-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.24 0.025 258); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --popover-foreground: oklch(0.24 0.025 258); + --primary: oklch(0.255 0.03 258); + --primary-foreground: oklch(0.985 0.003 155); + --secondary: oklch(0.945 0.006 155); + --secondary-foreground: oklch(0.255 0.03 258); + --muted: oklch(0.945 0.006 155); + --muted-foreground: oklch(0.515 0.016 252); + --accent: oklch(0.945 0.006 155); + --accent-foreground: oklch(0.255 0.03 258); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --success: oklch(0.53 0.095 164); + --success-foreground: oklch(0.985 0.003 155); + --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-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); @@ -84,24 +98,26 @@ } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); + --background: oklch(0.2 0.016 256); + --foreground: oklch(0.95 0.005 155); + --card: oklch(0.235 0.018 256); + --card-foreground: oklch(0.95 0.005 155); + --popover: oklch(0.235 0.018 256); + --popover-foreground: oklch(0.95 0.005 155); + --primary: oklch(0.93 0.008 155); + --primary-foreground: oklch(0.24 0.025 258); + --secondary: oklch(0.28 0.018 256); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); + --muted: oklch(0.28 0.018 256); + --muted-foreground: oklch(0.72 0.012 252); + --accent: oklch(0.28 0.018 256); --accent-foreground: oklch(0.985 0 0); --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%); --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-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); @@ -125,6 +141,34 @@ @apply bg-background text-foreground; } html { - @apply font-sans; + @apply font-sans antialiased; } -} \ No newline at end of file +} + +/* 进场:中轴自上而下落笔,框体以中轴为轴从侧面转正;通行:石绿沿中轴走完跳转等待 */ +@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); + } + } +} diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..eb56567 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7841fd6..df167c3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,10 @@ import "./globals.css"; +// 字体随包自托管,按 unicode-range 分片,只下载页面用到的字形 +import "@fontsource-variable/noto-serif-sc"; +import "@fontsource-variable/source-sans-3"; import { Toaster } from "@/components/ui/toast"; import QueryProvider from "@/providers/query-provider"; -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; export const metadata: Metadata = { title: { @@ -10,6 +13,10 @@ export const metadata: Metadata = { } }; +export const viewport: Viewport = { + themeColor: "#eef1ee" +}; + export default function RootLayout({ children }: { diff --git a/src/components/brand-mark.tsx b/src/components/brand-mark.tsx new file mode 100644 index 0000000..33046a6 --- /dev/null +++ b/src/components/brand-mark.tsx @@ -0,0 +1,22 @@ +// 中心平台标志:「中」字的框与贯穿其中的中轴 +import { cn } from "@/lib/utils"; + +export const BrandMark = ({ + className, + ...props +}: React.ComponentProps<"svg">) => { + return ( + + ); +}; diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..e11ce34 --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,103 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx new file mode 100644 index 0000000..08cd6aa --- /dev/null +++ b/src/components/ui/input-group.tsx @@ -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 ( +
[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) { + return ( +
{ + 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, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset" + }) { + return ( +