登录页面与组件样式调整

This commit is contained in:
2025-03-19 15:49:18 +08:00
parent eaae095d0e
commit 906693be10
28 changed files with 1405 additions and 206 deletions

52
src/actions/auth/login.ts Normal file
View File

@@ -0,0 +1,52 @@
'use server'
import {cookies} from 'next/headers'
import {ApiResponse, call} from '@/lib/api'
export interface LoginParams {
username: string;
password: string;
remember?: boolean;
}
type LoginResp = {
token: string;
expires: number;
}
export async function login(props: LoginParams): Promise<ApiResponse> {
try {
// 尝试登录
const result = await call<LoginResp>('/api/auth/login/sms', {
username: props.username,
password: props.password,
remember: props.remember ?? false,
})
if (!result.success) {
return result
}
const data = result.data
console.log('login', data)
// 计算过期时间
const current = Math.floor(Date.now() / 1000)
const future = data.expires - current
// 保存到 cookies
const cookieStore = await cookies()
cookieStore.set('auth_token', data.token, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: Math.max(future, 0),
})
return {
success: true,
data: undefined,
}
}
catch (e) {
throw new Error('请求登陆失败', {cause: e})
}
}

View File

@@ -0,0 +1,69 @@
'use server'
// 验证验证码函数
import {cookies} from 'next/headers'
import crypto from 'crypto'
import {ApiResponse, call} from '@/lib/api'
export interface VerifyParams {
phone: string;
captcha: string; // 添加验证码字段
}
export default async function verify(props: VerifyParams): Promise<ApiResponse> {
try {
// 人机验证
if (!props.captcha?.length) {
return {
success: false,
status: 400,
message: '请输入验证码',
}
}
const valid = await verifyCaptcha(props.captcha)
if (!valid) {
return {
success: false,
status: 400,
message: '验证码错误或已过期',
}
}
// 请求发送短信
return await call('/api/auth/verify/sms', {
phone: props.phone,
purpose: 0,
})
}
catch (error) {
throw new Error('验证码验证失败', {cause: error})
}
}
async function verifyCaptcha(userInput: string): Promise<boolean> {
const cookieStore = await cookies()
const hash = cookieStore.get('captcha_hash')?.value
const salt = cookieStore.get('captcha_salt')?.value
// 如果没有找到验证码cookie验证失败
if (!hash || !salt) {
return false
}
// 使用相同的方法哈希用户输入的验证码
const userInputHash = crypto
.createHmac('sha256', salt)
.update(userInput.toLowerCase())
.digest('hex')
// 比较哈希值
const isValid = hash === userInputHash
// 验证后删除验证码cookie防止重复使用
if (isValid) {
cookieStore.delete('captcha_hash')
cookieStore.delete('captcha_salt')
}
return isValid
}

View File

@@ -0,0 +1,92 @@
'use server'
import {createCanvas} from 'canvas'
import crypto from 'crypto'
import {cookies} from 'next/headers'
// 生成随机验证码
function generateCaptchaText(length: number = 4): string {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let result = ''
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)]
}
return result
}
// 哈希验证码文本并使用随机盐值
function hashCaptcha(text: string): { hash: string; salt: string } {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto
.createHmac('sha256', salt)
.update(text.toLowerCase())
.digest('hex')
return {hash, salt}
}
// 生成验证码图片
function generateCaptchaImage(text: string) {
const canvas = createCanvas(180, 50)
const ctx = canvas.getContext('2d')
// 设置背景色
ctx.fillStyle = '#f3f4f6'
ctx.fillRect(0, 0, 180, 50)
// 绘制干扰线
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`
ctx.beginPath()
ctx.moveTo(Math.random() * 180, Math.random() * 50)
ctx.lineTo(Math.random() * 180, Math.random() * 50)
ctx.stroke()
}
// 绘制文本
ctx.font = '28px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 随机文本颜色和位置
for (let i = 0; i < text.length; i++) {
ctx.fillStyle = `rgb(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100})`
ctx.fillText(
text[i],
(180 / text.length) * (i + 0.5), // 均匀分布
25 + Math.random() * 10 - 5, // 中间位置上下浮动
)
}
return canvas.toBuffer('image/png')
}
export async function GET(request: Request) {
const captchaText = generateCaptchaText()
// 生成验证码图像
const captchaImage = generateCaptchaImage(captchaText)
// 生成验证码哈希和盐值
const {hash, salt} = hashCaptcha(captchaText)
const store = await cookies()
const coo = store
.set('captcha_hash', hash, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60,
})
.set('captcha_salt', salt, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60,
})
return new Response(captchaImage, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
'Set-Cookie': `${coo}`,
},
})
}

View File

@@ -0,0 +1,87 @@
import {useCallback, useEffect, useState} from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button'
import {Input} from '@/components/ui/input'
export type CaptchaProps = {
showCaptcha: boolean
setShowCaptcha: (show: boolean) => void
handleSendCode: (captchaCode: string) => boolean | Promise<boolean>
}
export default function Captcha(props: CaptchaProps) {
const {showCaptcha, setShowCaptcha, handleSendCode} = props
const [captchaImage, setCaptchaImage] = useState('/captcha?t=' + Date.now())
const [captchaCode, setCaptchaCode] = useState('')
// 刷新图形验证码
const refreshCaptcha = useCallback(() => {
setCaptchaImage('/captcha?t=' + Date.now())
setCaptchaCode('')
}, [])
const handleVerifyCaptcha = useCallback(async () => {
let refresh = handleSendCode(captchaCode)
if (refresh instanceof Promise) {
refresh = await refresh
}
if (refresh) {
refreshCaptcha()
}
}, [captchaCode, handleSendCode, refreshCaptcha])
useEffect(() => {
if (showCaptcha) {
refreshCaptcha()
}
}, [showCaptcha, refreshCaptcha])
return (
<Dialog open={showCaptcha} onOpenChange={setShowCaptcha}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between items-center">
<img
src={captchaImage}
alt="验证码"
width={180}
height={50}
className="border cursor-pointer"
onClick={refreshCaptcha}
/>
<Button
variant="outline"
onClick={refreshCaptcha}
className="text-sm"
>
</Button>
</div>
<Input
placeholder="请输入图形验证码"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
className="w-full"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCaptcha(false)}
className="mr-2"
>
</Button>
<Button
onClick={() => handleVerifyCaptcha()}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,101 +1,301 @@
'use client'
import { ReactNode, useState } from 'react'
import {useState, useCallback, useRef} from 'react'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Checkbox} from '@/components/ui/checkbox'
import {merge} from '@/lib/utils'
import Image from 'next/image'
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import logo from '@/assets/logo.webp'
import {
Card,
CardHeader,
CardContent,
CardTitle,
} from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {zodResolver} from '@hookform/resolvers/zod'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import Captcha from './captcha'
import verify from '@/actions/auth/verify'
import {login} from '@/actions/auth/login'
import {useRouter} from 'next/navigation'
import {toast} from 'sonner'
import {ApiResponse} from '@/lib/api'
export type LoginPageProps = {}
export default function LoginPage(props: LoginPageProps) {
const [countdown, setCountdown] = useState(0);
// 定义表单验证模式
const formSchema = zod.object({
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
password: zod.string().min(1, '请输入验证码'),
remember: zod.boolean().default(false),
})
type FormValues = zod.infer<typeof formSchema>
const handleSendCode = () => {
// 这里实现发送验证码的逻辑
setCountdown(60);
const timer = setInterval(() => {
export default function LoginPage(props: LoginPageProps) {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [countdown, setCountdown] = useState(0)
const [showCaptcha, setShowCaptcha] = useState(false)
const timerRef = useRef<NodeJS.Timeout>(undefined)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
password: '',
remember: false,
},
})
// 获取表单值的快捷方式
const username = form.watch('username')
// 处理短信验证码发送前的验证
const checkUsername = useCallback(() => {
if (!username || username.length !== 11) {
form.setError('username', {
type: 'manual',
message: '请输入正确的手机号码',
})
return
}
// 显示图形验证码
setShowCaptcha(true)
}, [username, form])
// 验证图形验证码并发送短信验证码
const sendCode = useCallback(async (captchaCode: string) => {
if (!captchaCode) {
toast.error('请输入图形验证码')
return false
}
// 发送验证码
const resp = await verify({
phone: username,
captcha: captchaCode,
})
// 处理验证码发送结果
let waiting = 60
if (!resp.success) {
if (resp.status == 429) {
setShowCaptcha(false)
waiting = parseInt(resp.message)
console.log(resp.message)
toast.error('发送频率过快', {
description: '请稍后再试',
})
}
else {
toast.error(resp.message)
return true
}
}
else {
setShowCaptcha(false)
toast.success('验证码已发送', {
description: '请注意查收短信',
})
}
// 开始倒计时
setCountdown(waiting)
if (timerRef.current) {
clearInterval(timerRef.current)
}
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
clearInterval(timerRef.current)
return 0
}
return prev - 1;
});
}, 1000);
};
return prev - 1
})
}, 1000)
return false
}, [username])
const setWaiting = (resp: ApiResponse<undefined>) => {
}
// 处理表单提交
const onSubmit = async (values: FormValues) => {
try {
setSubmitting(true)
// 验证表单数据
if (values.username?.length !== 11) {
form.setError('username', {
type: 'manual',
message: '请输入有效的手机号码',
})
return
}
if (!values.password) {
form.setError('password', {
type: 'manual',
message: '请输入验证码',
})
return
}
// 调用登录函数
const result = await login({
username: values.username,
password: values.password, // 使用验证码作为密码
remember: values.remember,
})
if (result.success) {
// 登录成功
toast.success('登陆成功', {
description: '欢迎回来!',
})
// 跳转到首页或用户仪表板
router.push('/')
router.refresh() // 刷新页面状态
}
else {
// 登录失败
toast.error(result.message, {
description: '请检查您的手机号码和验证码',
})
}
}
catch (e) {
toast.error('服务器错误', {
description: '请稍后再试',
})
}
finally {
setSubmitting(false)
}
}
return (
<main className="h-screen w-screen lg:pr-80 bg-[url(/login/bg.webp)] bg-cover bg-left flex justify-center lg:justify-end items-center">
<main className={merge(
`relative`,
`h-screen w-screen xl:pr-64 bg-[url(/login/bg.webp)] bg-cover bg-left`,
`flex justify-center xl:justify-end items-center`,
)}>
<Image src={logo} alt={`logo`} height={64} className={`absolute top-8 left-8`}/>
{/* 登录表单 */}
<div className="w-96 mx-4 p-8 lg:p-12 bg-white rounded-lg flex items-center justify-center">
<div className="w-full space-y-8">
<div className="text-center">
<h2 className="text-2xl text-gray-900">
/
</h2>
</div>
<Card className="w-96 mx-4 shadow-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl">/</CardTitle>
</CardHeader>
<form className="mt-8 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
name="phone"
type="tel"
placeholder="请输入手机号码"
autoComplete="tel"
required
/>
<CardContent className={`px-8`}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
{...field}
type="tel"
placeholder="请输入手机号码"
autoComplete="tel-national"
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel></FormLabel>
<div className="flex space-x-4">
<FormControl>
<Input
{...field}
className="h-12"
placeholder="请输入验证码"
/>
</FormControl>
<Button
type="button"
variant="outline"
className="whitespace-nowrap h-12"
onClick={checkUsername}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
</div>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remember"
render={({field}) => (
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel></FormLabel>
</div>
</FormItem>
)}
/>
<div className="flex flex-col gap-3">
<Button
className="w-full h-12 text-lg"
type="submit"
variant="gradient"
disabled={submitting}
>
{submitting ? '登录中...' : '注册 / 登录'}
</Button>
<p className="text-xs text-center text-gray-500">
<a href="#" className="text-blue-600 hover:text-blue-500"></a><a href="#" className="text-blue-600 hover:text-blue-500"></a>
</p>
</div>
</form>
</Form>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="verificationCode"></Label>
<div className="flex space-x-2">
<Input
id="verificationCode"
name="verificationCode"
type="text"
placeholder="请输入验证码"
required
/>
<Button
type="button"
variant="outline"
className="whitespace-nowrap"
onClick={handleSendCode}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
</div>
</div>
</div>
{/* 图形验证码弹窗 */}
<Captcha
showCaptcha={showCaptcha}
setShowCaptcha={setShowCaptcha}
handleSendCode={sendCode}
/>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id="remember-me" name="remember-me" />
<label htmlFor="remember-me" className="text-sm text-gray-900">
</label>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Button type="submit" className="w-full">
/
</Button>
<p className="text-xs text-center text-gray-500">
<a href="#" className="text-blue-600 hover:text-blue-500"></a><a href="#" className="text-blue-600 hover:text-blue-500"></a>
</p>
</div>
</form>
</div>
</div>
</main>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { LinkItem, MenuItem } from './_server/navs'
import {LinkItem, MenuItem} from './_server/navs'
import SolutionMenu from './_client/solution'
import ProductMenu from './_client/product'
import HelpMenu from './_client/help'
@@ -39,9 +39,9 @@ export default function Header(props: HeaderProps) {
const [menu, setMenu] = useState(false)
const [page, setPage] = useState(0)
const pages = useMemo(() => [
<ProductMenu key={`product`} />,
<SolutionMenu key={`solution`} />,
<HelpMenu key={`help`} />,
<ProductMenu key={`product`}/>,
<SolutionMenu key={`solution`}/>,
<HelpMenu key={`help`}/>,
], [])
// ======================
@@ -53,7 +53,6 @@ export default function Header(props: HeaderProps) {
`fixed top-0 w-full z-10`,
].join(' ')}>
<div className={[
``,
`transition-[background, shadow] duration-200 ease-in-out`,
menu
? `bg-[#fffe] backdrop-blur-sm`
@@ -65,13 +64,13 @@ export default function Header(props: HeaderProps) {
<div className="flex justify-between gap-8">
{/* logo */}
<Link href="/public" className={`flex items-center`}>
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`} />
<Image src={logo} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full`}/>
</Link>
{/* 菜单 */}
<nav>
<ul className="h-full flex items-stretch max-lg:hidden">
<LinkItem text={`首页`} href={`/`} />
<LinkItem text={`首页`} href={`/`}/>
<MenuItem
text={`产品订购`}
active={menu && page === 0}
@@ -106,9 +105,9 @@ export default function Header(props: HeaderProps) {
}}
/>
<LinkItem
text={`企业服务`} href={`#`} />
text={`企业服务`} href={`#`}/>
<LinkItem
text={`推广返利`} href={`#`} />
text={`推广返利`} href={`#`}/>
</ul>
</nav>
</div>
@@ -156,4 +155,3 @@ export default function Header(props: HeaderProps) {

View File

@@ -4,10 +4,6 @@
@custom-variant dark (&:is(.dark *));
body {
color: hsl(0, 0%, 10%);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
@@ -124,3 +120,7 @@ body {
@apply bg-background text-foreground;
}
}
body {
color: hsl(0, 0%, 10%);
}

View File

@@ -2,6 +2,11 @@ import {ReactNode} from 'react'
import {Metadata} from 'next'
import './globals.css'
import localFont from 'next/font/local'
import {Toaster} from '@/components/ui/sonner'
const font = localFont({
src: './NotoSansSC-VariableFont_wght.ttf',
})
export const metadata: Metadata = {
title: 'Create Next App',
@@ -15,8 +20,9 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-Cn">
<body className={`bg-blue-50`}>
<body className={`${font.className} bg-blue-50`}>
{children}
<Toaster position={'top-center'}/>
</body>
</html>
)

12
src/app/test/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import {NextRequest, NextResponse} from 'next/server'
import {cookies} from 'next/headers'
export async function GET(req: NextRequest) {
const store = await cookies()
store.set('test','test')
return NextResponse.json(JSON.stringify({
'test': 'value',
}))
}

View File

@@ -1,64 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import {Slot} from '@radix-ui/react-slot'
import {merge} from '@/lib/utils'
import { cn } from "@/lib/utils"
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'default' | 'outline' | 'gradient'
}
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 " +
"whitespace-nowrap rounded-md text-sm transition-all " +
"disabled:pointer-events-none disabled:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 " +
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] " +
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
function Button(rawProps: ButtonProps) {
const {className, variant, ...props} = rawProps
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
<button
className={merge(
`transition-all duration-200 ease-in-out`,
`h-10 px-4 rounded-md cursor-pointer`,
'whitespace-nowrap',
'inline-flex items-center justify-center gap-2',
'disabled:pointer-events-none disabled:opacity-50 ',
'[&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 ',
'outline-none focus-visible:ring-4 ring-blue-200',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2',
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
}[variant ?? 'default'],
className,
)}
{...props}
/>
)
}
export { Button, buttonVariants }
export {Button}

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { merge } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={merge(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={merge(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={merge("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={merge(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={merge("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={merge("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
className={merge(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { merge } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={merge(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={merge(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={merge("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={merge(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={merge("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -13,7 +13,7 @@ import {
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
@@ -80,7 +80,7 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
className={merge("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
@@ -97,7 +97,7 @@ function FormLabel({
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
className={merge("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
@@ -129,7 +129,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
className={merge("text-muted-foreground text-sm", className)}
{...props}
/>
)
@@ -147,7 +147,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
className={merge("text-destructive text-sm", className)}
{...props}
>
{body}

View File

@@ -1,21 +1,27 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import {merge} from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({className, type, ...props}: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
className={merge(
`transition-[color,box-shadow] duration-200 ease-in-out`,
`h-10 min-w-0 w-full`,
' placeholder:text-muted-foreground',
'selection:bg-primary selection:text-primary-foreground',
'flex rounded-md border bg-transparent px-3 py-1 text-base shadow-xs',
'outline-none focus-visible:ring-4 ring-blue-200',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 dark:bg-input/30',
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none',
className,
)}
{...props}
/>
)
}
export { Input }
export {Input}

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Label({
className,
@@ -12,8 +12,8 @@ function Label({
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className={merge(
"flex items-center gap-2 leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function RadioGroup({
className,
@@ -13,7 +13,7 @@ function RadioGroup({
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
className={merge("grid gap-3", className)}
{...props}
/>
)
@@ -26,7 +26,7 @@ function RadioGroupItem({
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
className={merge(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { merge } from "@/lib/utils"
function Select({
...props
@@ -36,7 +36,7 @@ function SelectTrigger({
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
className={merge(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
@@ -60,7 +60,7 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
className={merge(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
@@ -71,7 +71,7 @@ function SelectContent({
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
className={merge(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
@@ -92,7 +92,7 @@ function SelectLabel({
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm", className)}
className={merge("px-2 py-1.5 text-sm", className)}
{...props}
/>
)
@@ -106,7 +106,7 @@ function SelectItem({
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
className={merge(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
@@ -129,7 +129,7 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
className={merge("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
@@ -142,7 +142,7 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
className={merge(
"flex cursor-default items-center justify-center py-1",
className
)}
@@ -160,7 +160,7 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
className={merge(
"flex cursor-default items-center justify-center py-1",
className
)}

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

135
src/lib/api.ts Normal file
View File

@@ -0,0 +1,135 @@
// API工具函数
// 定义后端服务URL和OAuth2配置
const API_BASE_URL = process.env.API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID
const CLIENT_SECRET = process.env.CLIENT_SECRET
// OAuth令牌缓存
interface TokenCache {
token: string
expires: number // 过期时间戳
}
let tokenCache: TokenCache | null = null
// 获取OAuth2访问令牌
export async function getAccessToken(forceRefresh = false): Promise<string> {
try {
// 检查缓存的令牌是否可用
if (!forceRefresh && tokenCache && tokenCache.expires > Date.now()) {
return tokenCache.token
}
const addr = `http://${API_BASE_URL}/api/auth/token`
const body = {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: 'client_credentials',
}
const response = await fetch(addr, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`OAuth token request failed: ${response.status} ${await response.text()}`)
}
const data = await response.json()
// 缓存令牌和过期时间
// 通常后端会返回expires_in秒为单位
tokenCache = {
token: data.access_token,
expires: Date.now() + data.expires_in * 1000,
}
return tokenCache.token
}
catch (error) {
console.error('Failed to get access token:', error)
throw new Error('认证服务暂时不可用')
}
}
// 通用的API调用函数
export async function call<R = undefined>(endpoint: string, data: unknown): Promise<ApiResponse<R>> {
try {
// 发送请求
let accessToken = getAccessToken()
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await accessToken}`,
},
body: JSON.stringify(data),
}
let response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
// 如果返回401未授权尝试刷新令牌并重试一次
if (response.status === 401) {
accessToken = getAccessToken(true) // 强制刷新令牌
// 使用新令牌重试请求
requestOptions.headers['Authorization'] = `Bearer ${await accessToken}`
response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
}
// 解析响应数据
const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('application/json') !== -1) {
const json = await response.json()
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || '请求失败',
}
}
return {
success: true,
data: json,
}
}
else if (type.indexOf('text/plain') !== -1) {
const text = await response.text()
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, text)
return {
success: false,
status: response.status,
message: text || '请求失败',
}
}
return {
success: true,
data: undefined as unknown as R, // 强转类型,考虑优化
}
}
else {
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
}
}
catch (e) {
console.error('API call failed:', e)
throw new Error('服务调用失败', {cause: e})
}
}
// 统一的API响应类型
export type ApiResponse<T = undefined> = {
success: false
status: number
message: string
} | {
success: true
data: T
}

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import {ClassNameValue, twMerge} from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
export function merge(...inputs: ClassNameValue[]) {
return twMerge(inputs)
}