升级依赖版本并修复构建问题
This commit is contained in:
@@ -81,7 +81,12 @@ export async function GET(request: Request) {
|
||||
maxAge: 60,
|
||||
})
|
||||
|
||||
return new Response(captchaImage, {
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(captchaImage)
|
||||
controller.close()
|
||||
},
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'no-store',
|
||||
|
||||
@@ -1,51 +1,98 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import Image from 'next/image'
|
||||
import {useFormContext} from 'react-hook-form'
|
||||
import {LoginSchema} from './login-card'
|
||||
import {toast} from 'sonner'
|
||||
import {sendSMS} from '@/actions/verify'
|
||||
|
||||
const seed = () => Date.now()
|
||||
|
||||
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 [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 刷新图形验证码
|
||||
const refreshCaptcha = useCallback(() => {
|
||||
setCaptchaImage('/captcha?t=' + Date.now())
|
||||
setCaptchaCode('')
|
||||
}, [])
|
||||
const [code, setCode] = useState('')
|
||||
const [url, setUrl] = useState('/captcha?t=' + seed())
|
||||
const [show, setShow] = useState(false)
|
||||
const form = useFormContext<LoginSchema>()
|
||||
const username = form.watch('username')
|
||||
|
||||
const handleVerifyCaptcha = useCallback(async () => {
|
||||
let refresh = handleSendCode(captchaCode)
|
||||
if (refresh instanceof Promise) {
|
||||
refresh = await refresh
|
||||
const setShowWithCheck = async (value: boolean) => {
|
||||
setShow(value && await form.trigger('username'))
|
||||
}
|
||||
|
||||
const refreshCaptcha = () => {
|
||||
setUrl('/captcha?t=' + seed())
|
||||
setCode('')
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
if (!code.length) {
|
||||
throw new Error('请输入图形验证码')
|
||||
}
|
||||
|
||||
const valid = await form.trigger('username')
|
||||
if (!valid) {
|
||||
throw new Error('请输入正确的手机号')
|
||||
}
|
||||
|
||||
const resp = await sendSMS({
|
||||
phone: username,
|
||||
captcha: code,
|
||||
})
|
||||
if (!resp.success) {
|
||||
throw new Error(`验证码发送失败: ${resp.message}`)
|
||||
}
|
||||
|
||||
setCountdown(60)
|
||||
setShow(false)
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
if (refresh) {
|
||||
catch (e) {
|
||||
refreshCaptcha()
|
||||
toast.error('验证码发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}, [captchaCode, handleSendCode, refreshCaptcha])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showCaptcha) {
|
||||
refreshCaptcha()
|
||||
}
|
||||
}, [showCaptcha, refreshCaptcha])
|
||||
const interval = setInterval(() => {
|
||||
if (countdown > 0) {
|
||||
setCountdown(countdown - 1)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
return (
|
||||
<Dialog open={showCaptcha} onOpenChange={setShowCaptcha}>
|
||||
<Dialog open={show} onOpenChange={setShowWithCheck}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="whitespace-nowrap h-10"
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>请完成图形验证</DialogTitle>
|
||||
<DialogTitle>人机验证</DialogTitle>
|
||||
<DialogDescription>请在下方输入验证码</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<img
|
||||
src={captchaImage}
|
||||
<Image
|
||||
unoptimized
|
||||
src={url}
|
||||
alt="验证码"
|
||||
width={180}
|
||||
height={50}
|
||||
@@ -62,22 +109,22 @@ export default function Captcha(props: CaptchaProps) {
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入图形验证码"
|
||||
value={captchaCode}
|
||||
onChange={e => setCaptchaCode(e.target.value)}
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShowCaptcha(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCaptcha}
|
||||
>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShow(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={sendCode}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
187
src/app/(auth)/login/login-card.tsx
Normal file
187
src/app/(auth)/login/login-card.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Checkbox} from '@/components/ui/checkbox'
|
||||
import {Card, CardHeader, CardContent, CardTitle} from '@/components/ui/card'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import {EyeClosedIcon, EyeIcon} from 'lucide-react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import zod from 'zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {toast} from 'sonner'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {login} from '@/actions/auth'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
import Captcha from './captcha'
|
||||
|
||||
const smsSchema = zod.object({
|
||||
username: zod.string().length(11, '请输入正确的手机号码'),
|
||||
password: zod.string().length(6, '请输入正确的验证码'),
|
||||
remember: zod.boolean(),
|
||||
})
|
||||
|
||||
const pwdSchema = zod.object({
|
||||
username: zod.string(),
|
||||
password: zod.string().min(6, '请输入至少6位密码'),
|
||||
remember: zod.boolean(),
|
||||
})
|
||||
|
||||
export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
|
||||
|
||||
export default function LoginCard(props: {
|
||||
defaultMode?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
const [mode, setMode] = useState(props.defaultMode || 'phone_code')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<LoginSchema>({
|
||||
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
},
|
||||
})
|
||||
const handler = form.handleSubmit(async (data) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await login({...data, mode})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
await refreshProfile()
|
||||
router.push(props.redirect || '/')
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('登录失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
|
||||
return (
|
||||
<Card className="w-96 mx-4 shadow-lg relative z-20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">登录/注册</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8">
|
||||
{/* 登录方式切换 */}
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(val) => {
|
||||
setMode(val as typeof mode)
|
||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
||||
}}
|
||||
className="mb-6">
|
||||
<TabsList className="w-full h-10 flex justify-center gap-2">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="phone_code" className="flex-1">
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
|
||||
<FormField name="username" label={mode === 'phone_code' ? '手机号' : '用户名'}>
|
||||
{({id, field}) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
autoComplete="tel-national"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="password" label={mode === 'phone_code' ? '验证码' : '密码'}>
|
||||
{({id, field}) =>
|
||||
mode === 'phone_code' ? (
|
||||
<div className="flex space-x-4">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
className="h-10"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Captcha/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
className="h-10 pr-10"
|
||||
placeholder="至少6位密码,需包含字母和数字"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPwd(v => !v)}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPwd ? (
|
||||
<EyeIcon size={20}/>
|
||||
) : (
|
||||
<EyeClosedIcon size={20}/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
<FormField name="remember">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<Label>保持登录</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
type="submit"
|
||||
theme="gradient"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : (mode === 'phone_code' ? '首次登录即注册' : '立即登录')}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +1,19 @@
|
||||
'use client'
|
||||
import {useState, useCallback, useRef, useEffect} 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 {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
} 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 {login} from '@/actions/auth'
|
||||
import {sendSMS} from '@/actions/verify'
|
||||
import {useRouter, useSearchParams} from 'next/navigation'
|
||||
import {toast} from 'sonner'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import bg from './_assets/bg.webp'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
import Link from 'next/link'
|
||||
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import {EyeClosedIcon, EyeIcon} from 'lucide-react'
|
||||
import LoginCard from './login-card'
|
||||
|
||||
export type LoginPageProps = {}
|
||||
export type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
type?: 'phone_code' | 'password'
|
||||
redirect?: string
|
||||
}>
|
||||
}
|
||||
|
||||
const smsSchema = zod.object({
|
||||
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
|
||||
password: zod.string().min(1, '请输入验证码'),
|
||||
remember: zod.boolean().default(false),
|
||||
})
|
||||
|
||||
const pwdSchema = zod.object({
|
||||
username: zod.string().min(11, '请输入正确的手机号码').max(11, '请输入正确的手机号码'),
|
||||
password: zod.string().min(6, '请输入至少6位密码'),
|
||||
remember: zod.boolean().default(false),
|
||||
})
|
||||
|
||||
type FormValues = zod.infer<typeof smsSchema>
|
||||
|
||||
export default function LoginPage(props: LoginPageProps) {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [showCaptcha, setShowCaptcha] = useState(false)
|
||||
const [loginMode, setLoginMode] = useState<'sms' | 'password'>('password')
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const type = params.get('type')
|
||||
if (type === 'sms') {
|
||||
setLoginMode('sms')
|
||||
}
|
||||
else {
|
||||
setLoginMode('password')
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(loginMode === 'sms' ? smsSchema : pwdSchema),
|
||||
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
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
let resp: ApiResponse
|
||||
try {
|
||||
resp = await sendSMS({
|
||||
phone: username,
|
||||
captcha: captchaCode,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('短信发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理验证码发送结果
|
||||
let waiting = 60
|
||||
if (!resp.success) {
|
||||
if (resp.status != 429) {
|
||||
toast.error(resp.message)
|
||||
return true
|
||||
}
|
||||
setShowCaptcha(false)
|
||||
waiting = parseInt(resp.message)
|
||||
console.log(resp.message)
|
||||
toast.error('发送频率过快', {
|
||||
description: '请稍后再试',
|
||||
})
|
||||
}
|
||||
else {
|
||||
setShowCaptcha(false)
|
||||
toast.success('验证码已发送', {
|
||||
description: '请注意查收短信',
|
||||
})
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
setCountdown(waiting)
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return false
|
||||
}, [username])
|
||||
|
||||
// 处理表单提交
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
// 密码登录时增加更严格的校验
|
||||
if (loginMode === 'password') {
|
||||
const pwd = values.password || ''
|
||||
// 至少6位,包含字母和数字
|
||||
if (pwd.length < 6) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: '密码长度至少6位',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!/[A-Za-z]/.test(pwd) || !/[0-9]/.test(pwd)) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: '密码需包含字母和数字',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
|
||||
// 验证表单数据
|
||||
if (values.username?.length !== 11) {
|
||||
form.setError('username', {
|
||||
type: 'manual',
|
||||
message: '请输入有效的手机号码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: loginMode === 'sms' ? '请输入验证码' : '请输入密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用登录函数
|
||||
const result = await login({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
remember: values.remember,
|
||||
mode: loginMode === 'sms' ? 'phone_code' : 'password', // 后端区分登录方式
|
||||
})
|
||||
|
||||
// 登录失败
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
await refreshProfile()
|
||||
router.push(redirect || '/')
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
toast.error('登录失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// 重定向
|
||||
// ======================
|
||||
|
||||
const redirect = params.get('redirect')
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
|
||||
// ======================
|
||||
// render
|
||||
// ======================
|
||||
export default async function LoginPage(props: LoginPageProps) {
|
||||
const searchParams = await props.searchParams
|
||||
|
||||
return (
|
||||
<main className={merge(
|
||||
@@ -248,129 +27,10 @@ export default function LoginPage(props: LoginPageProps) {
|
||||
</Link>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Card className="w-96 mx-4 shadow-lg relative z-20">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">登录/注册</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8">
|
||||
{/* 登录方式切换 */}
|
||||
<Tabs
|
||||
value={loginMode}
|
||||
onValueChange={(val) => {
|
||||
setLoginMode(val as 'sms' | 'password')
|
||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
||||
}}
|
||||
className="mb-6">
|
||||
<TabsList className="w-full h-10 flex justify-center gap-2">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sms" className="flex-1">
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Form<FormValues> className="space-y-6" onSubmit={onSubmit} form={form}>
|
||||
<FormField name="username" label={loginMode === 'sms' ? '手机号' : '用户名'}>
|
||||
{({id, field}) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
autoComplete="tel-national"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="password" label={loginMode === 'sms' ? '验证码' : '密码'}>
|
||||
{({id, field}) =>
|
||||
loginMode === 'sms' ? (
|
||||
<div className="flex space-x-4">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
className="h-10"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="whitespace-nowrap h-10"
|
||||
onClick={checkUsername}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
className="h-10 pr-10"
|
||||
placeholder="至少6位密码,需包含字母和数字"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPwd(v => !v)}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPwd ? (
|
||||
<EyeIcon size={20}/>
|
||||
) : (
|
||||
<EyeClosedIcon size={20}/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
<FormField name="remember">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<Label>保持登录</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
type="submit"
|
||||
theme="gradient"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : (loginMode === 'sms' ? '首次登录即注册' : '立即登录')}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 图形验证码弹窗 */}
|
||||
{loginMode === 'sms' && (
|
||||
<Captcha
|
||||
showCaptcha={showCaptcha}
|
||||
setShowCaptcha={setShowCaptcha}
|
||||
handleSendCode={sendCode}
|
||||
/>
|
||||
)}
|
||||
<LoginCard
|
||||
defaultMode={searchParams.type}
|
||||
redirect={searchParams.redirect}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Tab(props: {
|
||||
<button
|
||||
className={[
|
||||
`w-full p-4 lg:p-6 text-base lg:text-lg cursor-pointer border-b lg:border-b-0 lg:border-r flex justify-center`,
|
||||
props.selected ? `bg-gradient-to-b lg:bg-gradient-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
|
||||
props.selected ? `bg-linear-to-b lg:bg-linear-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
|
||||
].join(' ')}
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import {useCallback, useEffect, useMemo, useState, PointerEvent, ComponentProps} from 'react'
|
||||
import {useCallback, useEffect, useMemo, useState, PointerEvent, ComponentProps, useSyncExternalStore} from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import {HeaderContext} from './_components/header/common'
|
||||
@@ -15,7 +15,6 @@ import UserCenter from '@/components/composites/user-center'
|
||||
import {MenuIcon} from 'lucide-react'
|
||||
import down from '@/assets/header/down.svg'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {User} from '@/lib/models'
|
||||
|
||||
export type HeaderProps = {}
|
||||
|
||||
@@ -24,20 +23,12 @@ export default function Header(props: HeaderProps) {
|
||||
// 滚动条状态
|
||||
// ======================
|
||||
|
||||
const [scroll, setScroll] = useState(false) // Changed to false for client-side rendering
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
setScroll(window.scrollY > 48)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize scroll state on client
|
||||
setScroll(window.scrollY > 48)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
const scroll = useSyncExternalStore((callback) => {
|
||||
window.addEventListener('scroll', callback)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('scroll', callback)
|
||||
}
|
||||
}, [handleScroll])
|
||||
}, () => window.scrollY > 48, () => false)
|
||||
|
||||
// ======================
|
||||
// 菜单状态
|
||||
|
||||
@@ -3,19 +3,19 @@ import Wrap from '@/components/wrap'
|
||||
import Purchase, {TabType} from '@/components/composites/purchase'
|
||||
|
||||
export type ProductPageProps = {
|
||||
searchParams?: {
|
||||
searchParams?: Promise<{
|
||||
type?: TabType
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export default function ProductPage(props: ProductPageProps) {
|
||||
export default async function ProductPage(props: ProductPageProps) {
|
||||
return (
|
||||
<main className="mt-20">
|
||||
<Wrap className="flex flex-col py-8 gap-4">
|
||||
<BreadCrumb items={[
|
||||
{label: '产品中心', href: '/product'},
|
||||
]}/>
|
||||
<Purchase defaultType={props.searchParams?.type ?? 'short'}/>
|
||||
<Purchase defaultTab={(await props.searchParams)?.type ?? 'short'}/>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,70 +1,21 @@
|
||||
'use client'
|
||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
||||
import {useState, useEffect} from 'react'
|
||||
import {User} from '@/lib/models'
|
||||
|
||||
export function PasswordSetupWrapper({profile}: {profile: User}) {
|
||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
|
||||
const [showRealnameDialog, setShowRealnameDialog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 每次profile变化时都检查是否需要显示弹窗
|
||||
if (!profile.has_password) {
|
||||
setShowPasswordDialog(true)
|
||||
}
|
||||
else if (!profile.id_token) {
|
||||
setShowRealnameDialog(true)
|
||||
}
|
||||
}, [profile.has_password, profile.id_token])
|
||||
|
||||
const handleDismiss = (type: 'password' | 'realname') => {
|
||||
// 可选:使用sessionStorage只在当前会话期间记住关闭状态
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(`dismissed${type === 'password' ? 'PasswordSetup' : 'RealnameAuth'}`, 'true')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPasswordDialog && (
|
||||
<ChangePasswordDialog
|
||||
triggerClassName="hidden"
|
||||
open={showPasswordDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowPasswordDialog(open)
|
||||
if (!open) {
|
||||
handleDismiss('password')
|
||||
if (!profile.id_token) {
|
||||
setShowRealnameDialog(true)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowPasswordDialog(false)
|
||||
if (!profile.id_token) {
|
||||
setShowRealnameDialog(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<RealnameAuthDialog
|
||||
hasAuthenticated={!!profile.id_token}
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.id_token}
|
||||
/>
|
||||
|
||||
{showRealnameDialog && (
|
||||
<RealnameAuthDialog
|
||||
hasAuthenticated={!!profile.id_token}
|
||||
triggerClassName="hidden"
|
||||
open={showRealnameDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowRealnameDialog(open)
|
||||
if (!open) {
|
||||
handleDismiss('realname')
|
||||
}
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowRealnameDialog(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChangePasswordDialog
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={profile.has_password}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {PageRecord} from '@/lib/api'
|
||||
import {Bill} from '@/lib/models'
|
||||
import {useStatus} from '@/lib/states'
|
||||
@@ -38,7 +38,22 @@ export default function BillsPage(props: BillsPageProps) {
|
||||
list: [],
|
||||
})
|
||||
|
||||
const refresh = async (page: number, size: number) => {
|
||||
const form = useForm<FilterSchema>({
|
||||
resolver: zodResolver(filterSchema),
|
||||
defaultValues: {
|
||||
type: 'all',
|
||||
trade_id: '',
|
||||
create_after: undefined,
|
||||
create_before: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (value: FilterSchema) => {
|
||||
console.log(value)
|
||||
await refresh(1, data.size)
|
||||
}
|
||||
|
||||
const refresh = useCallback(async (page: number, size: number) => {
|
||||
setStatus('load')
|
||||
try {
|
||||
const typeValue = form.getValues('type')
|
||||
@@ -62,26 +77,11 @@ export default function BillsPage(props: BillsPageProps) {
|
||||
catch (e) {
|
||||
setStatus('fail')
|
||||
}
|
||||
}
|
||||
}, [form, setStatus])
|
||||
|
||||
useEffect(() => {
|
||||
refresh(1, 10).then()
|
||||
}, [])
|
||||
|
||||
const form = useForm<FilterSchema>({
|
||||
resolver: zodResolver(filterSchema),
|
||||
defaultValues: {
|
||||
type: 'all',
|
||||
trade_id: '',
|
||||
create_after: undefined,
|
||||
create_before: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (value: FilterSchema) => {
|
||||
console.log(value)
|
||||
await refresh(1, data.size)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -4,7 +4,8 @@ import Navbar from './_client/navbar'
|
||||
import Layout from './_client/layout'
|
||||
import {getProfile} from '@/actions/auth'
|
||||
import {redirect} from 'next/navigation'
|
||||
import {PasswordSetupWrapper} from './_client/passwordSetupWrapper'
|
||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
||||
|
||||
export type AdminLayoutProps = {
|
||||
children: ReactNode
|
||||
@@ -25,10 +26,15 @@ export default async function AdminLayout(props: AdminLayoutProps) {
|
||||
content={(
|
||||
<>
|
||||
{props.children}
|
||||
{/* 需要时显示密码设置和实名认证 */}
|
||||
{(!profile?.has_password && !profile?.id_token) && (
|
||||
<PasswordSetupWrapper profile={profile}/>
|
||||
)}
|
||||
<RealnameAuthDialog
|
||||
hasAuthenticated={!!profile.id_token}
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.id_token}
|
||||
/>
|
||||
<ChangePasswordDialog
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={profile.has_password}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2,15 +2,15 @@ import Purchase, {TabType} from '@/components/composites/purchase'
|
||||
import Page from '@/components/page'
|
||||
|
||||
export type PurchasePageProps = {
|
||||
searchParams?: {
|
||||
searchParams?: Promise<{
|
||||
type?: TabType
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function PurchasePage(props: PurchasePageProps) {
|
||||
return (
|
||||
<Page className="flex-col">
|
||||
<Purchase defaultType={props.searchParams?.type ?? 'short'}/>
|
||||
<Purchase defaultTab={(await props.searchParams)?.type ?? 'short'}/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
8
src/app/admin/test/page.tsx
Normal file
8
src/app/admin/test/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Test Page</h1>
|
||||
<p>This is a test page.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import localFont from 'next/font/local'
|
||||
import {Toaster} from '@/components/ui/sonner'
|
||||
import StoresProvider from '@/components/stores-provider'
|
||||
import Effects from '@/app/effects'
|
||||
import {getProfile} from '@/actions/auth'
|
||||
|
||||
const font = localFont({
|
||||
src: './NotoSansSC-VariableFont_wght.ttf',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {updatePassword} from '@/actions/user'
|
||||
interface ChangePasswordDialogProps {
|
||||
triggerClassName?: string
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
@@ -22,10 +23,11 @@ interface ChangePasswordDialogProps {
|
||||
export function ChangePasswordDialog({
|
||||
triggerClassName,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: ChangePasswordDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
|
||||
const router = useRouter()
|
||||
|
||||
const actualOpen = open !== undefined ? open : internalOpen
|
||||
|
||||
@@ -8,6 +8,7 @@ interface RealnameAuthDialogProps {
|
||||
hasAuthenticated: boolean
|
||||
triggerClassName?: string
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
@@ -16,10 +17,11 @@ export function RealnameAuthDialog({
|
||||
hasAuthenticated,
|
||||
triggerClassName,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: RealnameAuthDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
|
||||
const router = useRouter()
|
||||
|
||||
const actualOpen = open !== undefined ? open : internalOpen
|
||||
|
||||
@@ -22,6 +22,7 @@ import ExtractDocs from '@/components/docs/extract.mdx'
|
||||
import Markdown from '@/components/markdown'
|
||||
import Link from 'next/link'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
|
||||
const schema = z.object({
|
||||
resource: z.number({required_error: '请选择套餐'}),
|
||||
prov: z.string().optional(),
|
||||
@@ -517,8 +518,10 @@ function ApplyLink() {
|
||||
const form = useFormContext<Schema>()
|
||||
const values = form.watch()
|
||||
|
||||
const type = useRef<'copy' | 'open'>('open')
|
||||
// let type: 'open' | 'copy' = 'open'
|
||||
const type = useRef<'open' | 'copy'>('open')
|
||||
const handler = form.handleSubmit(
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
async (values: z.infer<typeof schema>) => {
|
||||
const params = link(values)
|
||||
|
||||
@@ -572,6 +575,11 @@ function ApplyLink() {
|
||||
},
|
||||
)
|
||||
|
||||
const submit = (t: 'open' | 'copy') => {
|
||||
type.current = t
|
||||
handler()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={merge(
|
||||
`flex flex-col gap-4`,
|
||||
@@ -586,23 +594,11 @@ function ApplyLink() {
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
type.current = 'copy'
|
||||
await handler()
|
||||
}}
|
||||
>
|
||||
<Button type="button" onClick={() => submit('copy')}>
|
||||
<CopyIcon/>
|
||||
<span>复制链接</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
type.current = 'open'
|
||||
await handler()
|
||||
}}
|
||||
>
|
||||
<Button type="button" onClick={() => submit('open')}>
|
||||
<ExternalLinkIcon/>
|
||||
<span>打开链接</span>
|
||||
</Button>
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
'use client'
|
||||
import {ReactNode, useEffect, useState} from 'react'
|
||||
import {ReactNode, useState} from 'react'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import LongForm from '@/components/composites/purchase/long/form'
|
||||
import ShortForm from '@/components/composites/purchase/short/form'
|
||||
import {useProfileStore} from '@/components/stores-provider'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {useSearchParams} from 'next/navigation'
|
||||
export type TabType = 'short' | 'long' | 'fixed' | 'custom'
|
||||
|
||||
type PurchaseProps = {
|
||||
defaultType: TabType
|
||||
defaultTab: TabType
|
||||
}
|
||||
|
||||
export default function Purchase(props: PurchaseProps) {
|
||||
const [currentTab, setCurrentTab] = useState<string>(props.defaultType)
|
||||
const profile = useProfileStore(store => store.profile)
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
setCurrentTab(props.defaultType)
|
||||
// if (!profile) {
|
||||
// router.push('/login?redirect=/admin/purchase') // 未登录用户重定向到登录页
|
||||
// }
|
||||
}, [props.defaultType, profile, router])
|
||||
const [tab, setTab] = useState(props.defaultTab)
|
||||
|
||||
const params = useSearchParams()
|
||||
const updateTab = async (tab: string) => {
|
||||
setTab(tab as TabType)
|
||||
const newParams = new URLSearchParams(params)
|
||||
newParams.set('type', tab)
|
||||
window.history.pushState({}, '', `?${newParams.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Tabs value={currentTab} onValueChange={setCurrentTab} className="gap-4">
|
||||
<Tabs value={tab} onValueChange={updateTab} className="gap-4">
|
||||
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
|
||||
<Tab value="short">短效动态</Tab>
|
||||
<Tab value="long">长效静态</Tab>
|
||||
|
||||
@@ -59,12 +59,12 @@ export default function DateRangePicker({
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
return date && isValid(date) ? format(date, dateFormat) : ''
|
||||
}
|
||||
|
||||
// 格式化显示的日期范围
|
||||
const displayValue = React.useMemo(() => {
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
return date && isValid(date) ? format(date, dateFormat) : ''
|
||||
}
|
||||
|
||||
if (!value?.from) return placeholder
|
||||
|
||||
if (!value.to) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import {createContext, ReactNode, useContext, useEffect, useRef} from 'react'
|
||||
import {createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {StoreApi} from 'zustand/vanilla'
|
||||
import {useStore} from 'zustand/react'
|
||||
import {createProfileStore, ProfileStore} from '@/lib/stores/profile'
|
||||
@@ -41,34 +41,22 @@ export type ProfileProviderProps = {
|
||||
}
|
||||
|
||||
export default function StoresProvider(props: ProfileProviderProps) {
|
||||
// 用户信息
|
||||
const profile = useRef<StoreApi<ProfileStore>>(null)
|
||||
if (!profile.current) {
|
||||
console.log('📦 create profile store')
|
||||
profile.current = createProfileStore()
|
||||
}
|
||||
const profileStore = useRef(useStore(profile.current))
|
||||
console.log('init stores')
|
||||
|
||||
const [profile] = useState(createProfileStore())
|
||||
const [layout] = useState(createLayoutStore())
|
||||
const [client] = useState(createClientStore())
|
||||
|
||||
const refreshProfile = useStore(profile, store => store.refreshProfile)
|
||||
useEffect(() => {
|
||||
profileStore.current.refreshProfile()
|
||||
}, [])
|
||||
|
||||
const layout = useRef<StoreApi<LayoutStore>>(null)
|
||||
if (!layout.current) {
|
||||
console.log('📦 create layout store')
|
||||
layout.current = createLayoutStore()
|
||||
}
|
||||
|
||||
const client = useRef<StoreApi<ClientStore>>(null)
|
||||
if (!client.current) {
|
||||
console.log('📦 create client store')
|
||||
client.current = createClientStore()
|
||||
}
|
||||
refreshProfile()
|
||||
}, [refreshProfile])
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={{
|
||||
profile: profile.current,
|
||||
layout: layout.current,
|
||||
client: client.current,
|
||||
profile,
|
||||
layout,
|
||||
client,
|
||||
}}>
|
||||
{props.children}
|
||||
</StoreContext.Provider>
|
||||
|
||||
@@ -173,7 +173,7 @@ function ChartTooltipContent({
|
||||
return (
|
||||
<div
|
||||
className={merge(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -202,14 +202,11 @@ function ChartTooltipContent({
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={merge(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
'shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)',
|
||||
indicator === 'dot' && 'h-2.5 w-2.5',
|
||||
indicator === 'line' && 'w-1',
|
||||
indicator === 'dashed' && 'w-0 border-[1.5px] border-dashed bg-transparent',
|
||||
indicator === 'dashed' && nestLabel && 'my-0.5',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
@@ -290,7 +287,7 @@ function ChartLegendContent({
|
||||
<itemConfig.icon/>
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
className="h-2 w-2 shrink-0 rounded-xs"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const config = {
|
||||
],
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
export async function proxy(request: NextRequest) {
|
||||
console.log('👀 middleware triggered', request.method, request.nextUrl.pathname)
|
||||
|
||||
// 记录请求页面
|
||||
Reference in New Issue
Block a user