升级依赖版本并修复构建问题

This commit is contained in:
2025-11-20 12:10:16 +08:00
parent fa6a4e5121
commit c02ffc9983
26 changed files with 669 additions and 649 deletions

View File

@@ -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',

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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}
>

View File

@@ -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)
// ======================
// 菜单状态

View File

@@ -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>
)

View File

@@ -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}
/>
</>
)
}

View File

@@ -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>

View File

@@ -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}
/>
</>
)}
/>

View File

@@ -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>
)
}

View File

@@ -0,0 +1,8 @@
export default function TestPage() {
return (
<div>
<h1>Test Page</h1>
<p>This is a test page.</p>
</div>
)
}

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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,
}}

View File

@@ -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)
// 记录请求页面