feat: 前端主题优化 #1
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,33 @@
|
||||
// 认证页外壳:贯穿页面的中轴与框体共同构成「中」字
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
|
||||
export default function AuthLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
{children}
|
||||
<div className="group/auth relative isolate flex min-h-svh flex-col px-4 sm:px-6">
|
||||
{/* 中轴:桌面端压在框体之上充当左右分隔,移动端退到框体之后 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
// 表单随首屏输出,水合完成前禁止提交,避免浏览器原生提交丢失回跳地址
|
||||
const isHydrated = useHydrated();
|
||||
// 同步外部存储中的访问令牌
|
||||
const storedToken = useSyncExternalStore(
|
||||
subscribeToAccessToken,
|
||||
@@ -57,13 +67,12 @@ export const LoginForm = () => {
|
||||
// 是否重定向
|
||||
const hasRedirected = useRef(false);
|
||||
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 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 (
|
||||
<main className="flex w-full max-w-sm flex-col items-center gap-3 px-6">
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">正在验证登录状态</p>
|
||||
</main>
|
||||
<div
|
||||
className="flex animate-in flex-col gap-3 duration-300 fade-in"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="size-5 text-muted-foreground" />
|
||||
<p className="text-lg font-semibold">正在确认登录状态</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
这台设备上已有登录记录,确认有效后会直接返回。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="w-full max-w-sm px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">登录</h1>
|
||||
// 通行状态:data-handoff 驱动外壳中轴与去向的动画
|
||||
if (redirectTarget) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
<form className="mt-8" onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<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>
|
||||
// TODO: 确定中心平台直接登录后的默认跳转位置
|
||||
const isFormDisabled = !hasFromUrl || isPending;
|
||||
|
||||
<Field data-disabled={isPending || isRedirecting || undefined}>
|
||||
<FieldLabel htmlFor="password">密码</FieldLabel>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
disabled={isPending || isRedirecting}
|
||||
id="password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
return (
|
||||
<form
|
||||
className="flex flex-1 flex-col gap-6"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field data-disabled={isFormDisabled || undefined}>
|
||||
<FieldLabel htmlFor="account">用户名或邮箱</FieldLabel>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
autoFocus={hasFromUrl && !initialAccount}
|
||||
className="h-11 sm:h-10"
|
||||
disabled={isFormDisabled}
|
||||
id="account"
|
||||
onChange={(event) => setAccount(event.target.value)}
|
||||
required
|
||||
value={account}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!hasFromUrl && (
|
||||
<FieldDescription>
|
||||
缺少可信的回跳地址,暂时无法登录
|
||||
</FieldDescription>
|
||||
)}
|
||||
<Field data-disabled={isFormDisabled || undefined}>
|
||||
<FieldLabel htmlFor="password">密码</FieldLabel>
|
||||
<PasswordInput
|
||||
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">
|
||||
{(isPending || isRedirecting) && <Spinner data-icon="inline-start" />}
|
||||
登录
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<Button
|
||||
className="h-11 w-full sm:h-10"
|
||||
disabled={isFormDisabled || !isHydrated}
|
||||
type="submit"
|
||||
>
|
||||
{isPending && <Spinner data-icon="inline-start" />}
|
||||
{isPending ? "正在登录" : "登录"}
|
||||
</Button>
|
||||
|
||||
{systemConfig.data?.registration_enabled === true && (
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
nativeButton={false}
|
||||
render={<Link href={buildAuthPath("/register", fromUrl)} />}
|
||||
variant="outline"
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
<p className="animate-in text-sm text-muted-foreground duration-300 fade-in lg:mt-auto">
|
||||
还没有账号?{" "}
|
||||
<ModeSwitchLink
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
href={buildAuthPath("/register", fromUrl)}
|
||||
onSwitch={onSwitchToRegister}
|
||||
>
|
||||
注册
|
||||
</ModeSwitchLink>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={null}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
type RegisterPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<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 { 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<HTMLInputElement>) =>
|
||||
setValues((current) => ({ ...current, [field]: event.target.value })),
|
||||
onBlur: () => setTouched((current) => ({ ...current, [field]: true }))
|
||||
});
|
||||
|
||||
/** 校验全部字段后提交注册,不通过时聚焦第一个错误字段 */
|
||||
const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<main className="flex w-full max-w-sm flex-col items-center gap-3 px-6">
|
||||
<Spinner />
|
||||
</main>
|
||||
<div className="flex flex-col items-start gap-3" role="status">
|
||||
<Spinner className="size-5 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPaused ? "网络已断开,恢复连接后会自动继续" : "正在确认是否开放注册"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (systemConfig.isError || !registrationEnabled) {
|
||||
return (
|
||||
<main className="w-full max-w-sm px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
<Empty className="animate-in items-start justify-start p-0 text-left duration-300 fade-in">
|
||||
<EmptyHeader className="items-start">
|
||||
<EmptyMedia variant="icon">
|
||||
{systemConfig.isError ? <CircleAlertIcon /> : <LockIcon />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-lg">
|
||||
{systemConfig.isError ? "暂时无法注册" : "注册已关闭"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{systemConfig.isError ? "无法确认注册是否开放" : "当前未开放注册"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-8 text-sm">
|
||||
<Link
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
href={loginHref}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{systemConfig.isError
|
||||
? "无法确认注册是否开放,请稍后重试。"
|
||||
: "当前不开放自助注册,如需账号请联系管理员。"}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row">
|
||||
{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>
|
||||
</p>
|
||||
</main>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
const passwordError = visibleError("password");
|
||||
|
||||
return (
|
||||
<main className="w-full max-w-sm px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">注册</h1>
|
||||
</div>
|
||||
|
||||
<form className="mt-8" onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field data-disabled={isPending || undefined}>
|
||||
<FieldLabel htmlFor="username">用户名</FieldLabel>
|
||||
<Input
|
||||
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}
|
||||
<form
|
||||
className="flex flex-1 flex-col gap-6"
|
||||
method="post"
|
||||
noValidate
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field
|
||||
data-disabled={isPending || undefined}
|
||||
data-invalid={Boolean(visibleError("username")) || undefined}
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
<FieldLabel htmlFor="username">用户名</FieldLabel>
|
||||
<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>
|
||||
</main>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 进场:中轴自上而下落笔,框体以中轴为轴从侧面转正;通行:石绿沿中轴走完跳转等待 */
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,13 @@
|
||||
// 判断客户端是否已完成水合
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
|
||||
/** 服务端与水合阶段返回 false,水合完成后返回 true */
|
||||
export const useHydrated = () => {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
};
|
||||
@@ -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: ""
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user