登录页面与组件样式调整
This commit is contained in:
52
src/actions/auth/login.ts
Normal file
52
src/actions/auth/login.ts
Normal 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})
|
||||
}
|
||||
}
|
||||
69
src/actions/auth/verify.ts
Normal file
69
src/actions/auth/verify.ts
Normal 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
|
||||
}
|
||||
92
src/app/(auth)/captcha/route.ts
Normal file
92
src/app/(auth)/captcha/route.ts
Normal 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}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
87
src/app/(auth)/login/captcha.tsx
Normal file
87
src/app/(auth)/login/captcha.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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
12
src/app/test/route.ts
Normal 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',
|
||||
}))
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
135
src/components/ui/dialog.tsx
Normal file
135
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal 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
135
src/lib/api.ts
Normal 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user