开启 ppr 优化渲染性能

This commit is contained in:
2025-12-11 14:10:52 +08:00
parent 8fb6ba2f22
commit 5db63273bc
50 changed files with 2635 additions and 10426 deletions

12
.vscode/settings.json vendored
View File

@@ -1,12 +1,10 @@
{ {
"[typescript]": { "git.rebaseWhenSync": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.defaultFormatter": "dbaeumer.vscode-eslint",
}, "[jsonc]": {
"[typescriptreact]": { "editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.defaultFormatter": "vscode.json-language-features"
}, },
"git.rebaseWhenSync": true
} }

10830
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,13 @@ export default createMDX({
], ],
}, },
})({ })({
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
output: 'standalone', output: 'standalone',
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
cacheComponents: true,
reactCompiler: true,
experimental: {
turbopackFileSystemCacheForDev: true,
},
allowedDevOrigins: [ allowedDevOrigins: [
'192.168.3.42', '192.168.3.42',
'192.168.3.14', '192.168.3.14',

View File

@@ -7,9 +7,12 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint --fix", "lint": "eslint --fix",
"check": "tsc --noEmit",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@cap.js/server": "^4.0.4",
"@cap.js/widget": "^0.1.32",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
@@ -31,13 +34,12 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"canvas": "^3.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dev": "^0.1.3", "dev": "^0.1.3",
"lucide-react": "^0.479.0", "lucide-react": "^0.479.0",
"next": "^16.0.8", "next": "^16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.1", "react": "^19.2.1",
@@ -69,7 +71,8 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"babel-plugin-react-compiler": "^1.0.0"
}, },
"packageManager": "bun@1.2.19" "packageManager": "bun@1.2.19"
} }

View File

@@ -12,11 +12,13 @@ type TokenResp = {
scope?: string scope?: string
} }
export type LoginMode = 'phone_code' | 'password'
export async function login(props: { export async function login(props: {
username: string username: string
password: string password: string
remember: boolean remember: boolean
mode: 'phone_code' | 'password' mode: LoginMode
}): Promise<ApiResponse> { }): Promise<ApiResponse> {
// 尝试登录 // 尝试登录
const result = await callByDevice<TokenResp>('/api/auth/token', { const result = await callByDevice<TokenResp>('/api/auth/token', {

View File

@@ -1,8 +1,7 @@
'use server' 'use server'
import {ApiResponse} from '@/lib/api' import {ApiResponse} from '@/lib/api'
import {callByDevice} from '@/actions/base' import {callByDevice} from '@/actions/base'
import {cookies} from 'next/headers' import {getCap} from '@/lib/cap'
import crypto from 'crypto'
export async function sendSMS(props: { export async function sendSMS(props: {
phone: string phone: string
@@ -17,7 +16,9 @@ export async function sendSMS(props: {
message: '请输入验证码', message: '请输入验证码',
} }
} }
const valid = await checkCaptcha(props.captcha)
const cap = await getCap()
const valid = await cap.validateToken(props.captcha)
if (!valid) { if (!valid) {
return { return {
success: false, success: false,
@@ -37,32 +38,3 @@ export async function sendSMS(props: {
throw new Error('验证码验证失败', {cause: error}) throw new Error('验证码验证失败', {cause: error})
} }
} }
export async function checkCaptcha(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) {
console.log('验证码cookie不存在')
return false
}
// 使用相同的方法哈希用户输入的验证码
const userInputHash = crypto
.createHmac('sha256', salt)
.update(userInput.toLowerCase())
.digest('hex')
// 比较哈希值
const isValid = hash === userInputHash
// 验证后删除验证码cookie防止重复使用
if (isValid) {
cookieStore.delete('captcha_hash')
cookieStore.delete('captcha_salt')
}
return isValid
}

View File

@@ -1,96 +0,0 @@
'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 sans-serif'
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()
console.log('生成验证码:', captchaText)
// 生成验证码图像
const captchaImage = generateCaptchaImage(captchaText)
// 生成验证码哈希和盐值
const {hash, salt} = hashCaptcha(captchaText)
const store = await cookies()
const coo = store
.set('captcha_hash', hash, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60,
})
.set('captcha_salt', salt, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60,
})
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(captchaImage)
controller.close()
},
}), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
'Set-Cookie': `${coo}`,
},
})
}

View File

@@ -0,0 +1,8 @@
import {getCap} from '@/lib/cap'
import {NextResponse} from 'next/server'
export async function POST() {
const cap = await getCap()
const challenge = await cap.createChallenge()
return NextResponse.json(challenge)
}

View File

@@ -1,135 +0,0 @@
'use client'
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 = {
}
export default function Captcha(props: CaptchaProps) {
const [countdown, setCountdown] = useState(0)
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 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('验证码已发送')
}
catch (e) {
refreshCaptcha()
toast.error('验证码发送失败', {
description: (e as Error).message,
})
}
}
useEffect(() => {
const interval = setInterval(() => {
if (countdown > 0) {
setCountdown(countdown - 1)
}
}, 1000)
return () => clearInterval(interval)
}, [countdown])
return (
<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>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between items-center">
<Image
unoptimized
src={url}
alt="验证码"
width={180}
height={50}
className="border cursor-pointer"
onClick={refreshCaptcha}
/>
<Button
theme="outline"
onClick={refreshCaptcha}
className="text-sm"
>
</Button>
</div>
<Input
placeholder="请输入图形验证码"
value={code}
onChange={e => setCode(e.target.value)}
className="w-full"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
theme="outline"
onClick={() => setShow(false)}
className="mr-2"
>
</Button>
</DialogClose>
<Button onClick={sendCode}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -7,15 +7,16 @@ import {Form, FormField} from '@/components/ui/form'
import {Label} from '@/components/ui/label' import {Label} from '@/components/ui/label'
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs' import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {EyeClosedIcon, EyeIcon} from 'lucide-react' import {EyeClosedIcon, EyeIcon} from 'lucide-react'
import {useState, ReactNode} from 'react' import {useState, ReactNode, useEffect, Suspense} from 'react'
import zod from 'zod' import zod from 'zod'
import {useForm} from 'react-hook-form' import {useForm, useFormContext, useWatch} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {login} from '@/actions/auth' import {login, LoginMode} from '@/actions/auth'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import Captcha from './captcha' import SendMsg from '@/components/send-msg'
import '@cap.js/widget'
const smsSchema = zod.object({ const smsSchema = zod.object({
username: zod.string().length(11, '请输入正确的手机号码'), username: zod.string().length(11, '请输入正确的手机号码'),
@@ -31,15 +32,23 @@ const pwdSchema = zod.object({
export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema> export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard(props: { export default function LoginCard() {
defaultMode?: 'phone_code' | 'password'
redirect?: string
}) {
const router = useRouter() const router = useRouter()
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const [mode, setMode] = useState(props.defaultMode || 'phone_code') const [mode, setMode] = useState<LoginMode>('phone_code')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const updateLoginMode = (mode: LoginMode) => {
sessionStorage.setItem('login_mode', mode)
}
useEffect(() => {
const mode = sessionStorage.getItem('login_mode')
if (mode) {
setMode(mode as LoginMode)
}
}, [])
const form = useForm<LoginSchema>({ const form = useForm<LoginSchema>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema), resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: { defaultValues: {
@@ -56,9 +65,12 @@ export default function LoginCard(props: {
throw new Error(result.message || '请检查账号和密码/验证码是否正确') throw new Error(result.message || '请检查账号和密码/验证码是否正确')
} }
const params = new URLSearchParams(window.location.search)
// 登录成功 // 登录成功
await refreshProfile() await refreshProfile()
router.push(props.redirect || '/') updateLoginMode(mode)
router.push(params.get('redirect') || '/')
toast.success('登录成功', { toast.success('登录成功', {
description: '欢迎回来!', description: '欢迎回来!',
}) })
@@ -114,7 +126,7 @@ export default function LoginCard(props: {
placeholder="请输入验证码" placeholder="请输入验证码"
autoComplete="one-time-code" autoComplete="one-time-code"
/> />
<Captcha/> <SendMsgByUsername/>
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative">
@@ -193,3 +205,9 @@ function Tab(props: {
</TabsTrigger> </TabsTrigger>
) )
} }
function SendMsgByUsername() {
const form = useFormContext<LoginSchema>()
const phone = form.watch('username')
return <SendMsg phone={phone}/>
}

View File

@@ -5,16 +5,9 @@ import bg from './_assets/bg.webp'
import Link from 'next/link' import Link from 'next/link'
import LoginCard from './login-card' import LoginCard from './login-card'
export type LoginPageProps = { export type LoginPageProps = {}
searchParams: Promise<{
type?: 'phone_code' | 'password'
redirect?: string
}>
}
export default async function LoginPage(props: LoginPageProps) { export default async function LoginPage(props: LoginPageProps) {
const searchParams = await props.searchParams
return ( return (
<main className={merge( <main className={merge(
`relative`, `relative`,
@@ -27,10 +20,7 @@ export default async function LoginPage(props: LoginPageProps) {
</Link> </Link>
{/* 登录表单 */} {/* 登录表单 */}
<LoginCard <LoginCard/>
defaultMode={searchParams.type}
redirect={searchParams.redirect}
/>
</main> </main>
) )
} }

View File

@@ -0,0 +1,9 @@
import {NextRequest, NextResponse} from 'next/server'
import {getCap} from '@/lib/cap'
export async function POST(req: NextRequest) {
const body = await req.json()
const cap = await getCap()
const result = await cap.redeemChallenge(body)
return NextResponse.json(result)
}

View File

@@ -13,8 +13,7 @@ import check from '@/assets/check-accent.svg'
import banner from './_assets/Mask-group.webp' import banner from './_assets/Mask-group.webp'
import group from './_assets/Group.webp' import group from './_assets/Group.webp'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {useRouter} from 'next/navigation' import FreeTrial from '@/components/free-trial'
import {useProfileStore} from '@/components/stores-provider'
const formSchema = z.object({ const formSchema = z.object({
companyName: z.string().min(2, '企业名称至少2个字符'), companyName: z.string().min(2, '企业名称至少2个字符'),
@@ -27,7 +26,6 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema> type FormValues = z.infer<typeof formSchema>
export default function CollectPage() { export default function CollectPage() {
const router = useRouter()
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -38,8 +36,6 @@ export default function CollectPage() {
purpose: '', purpose: '',
}, },
}) })
// 从store中获取用户信息
const profile = useProfileStore(store => store.profile)
return ( return (
<main className="mt-20 flex flex-col gap-4"> <main className="mt-20 flex flex-col gap-4">
@@ -246,20 +242,7 @@ export default function CollectPage() {
<div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left"> <div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left">
5000IP 5000IP
</div> </div>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}/>
<Button
className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}
onClick={() => {
if (profile) {
router.push('/admin/purchase')
}
else {
router.push('/login?redirect=/admin/purchase')
}
}}
>
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {useCallback, useEffect, useMemo, useState, PointerEvent, ComponentProps, useSyncExternalStore} from 'react' import {useMemo, useState, PointerEvent, ComponentProps, useSyncExternalStore, use, Suspense} from 'react'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import {HeaderContext} from './_components/header/common' import {HeaderContext} from './_components/header/common'
@@ -10,7 +10,7 @@ import MobileMenu from './_components/header/menu-mobile'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp' import logo from '@/assets/logo.webp'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import UserCenter from '@/components/composites/user-center' import UserCenter from '@/components/composites/user-center'
import {MenuIcon} from 'lucide-react' import {MenuIcon} from 'lucide-react'
import down from '@/assets/header/down.svg' import down from '@/assets/header/down.svg'
@@ -151,33 +151,9 @@ export default function Header(props: HeaderProps) {
</nav> </nav>
{/* 登录 */} {/* 登录 */}
<div className="flex items-center"> <Suspense>
{profile == null <ProfileOrLogin/>
? ( </Suspense>
<>
<Link
href="/login"
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</Link>
<Link
href="/login"
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</>
)
: (
<UserCenter profile={profile}/>
)
}
</div>
</Wrap> </Wrap>
</div> </div>
@@ -271,3 +247,36 @@ function MenuItem(props: {
</li> </li>
) )
} }
function ProfileOrLogin() {
const profile = use(useProfileStore(store => store.profile))
return (
<div className="flex items-center">
{profile == null
? (
<>
<Link
href="/login"
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</Link>
<Link
href="/login"
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</>
)
: (
<UserCenter profile={profile}/>
)
}
</div>
)
}

View File

@@ -1,10 +1,6 @@
'use client'
import {ReactNode} from 'react' import {ReactNode} from 'react'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Image, {StaticImageData} from 'next/image' import Image, {StaticImageData} from 'next/image'
import React from 'react'
import {useRouter} from 'next/navigation'
import {useProfileStore} from '@/components/stores-provider'
import check_main from '@/assets/check-main.svg' import check_main from '@/assets/check-main.svg'
import banner from './_assets/banner.webp' import banner from './_assets/banner.webp'
import map from './_assets/map.webp' import map from './_assets/map.webp'
@@ -23,12 +19,9 @@ import s4_1_3 from './_assets/s4-1-3.webp'
import s4_2_1 from './_assets/s4-2-1.webp' import s4_2_1 from './_assets/s4-2-1.webp'
import s4_2_2 from './_assets/s4-2-2.webp' import s4_2_2 from './_assets/s4-2-2.webp'
import s4_2_3 from './_assets/s4-2-3.webp' import s4_2_3 from './_assets/s4-2-3.webp'
import FreeTrial from '@/components/free-trial'
export default function Home() { export default function Home() {
const router = useRouter()
// 从store中获取用户信息
const profile = useProfileStore(store => store.profile)
return ( return (
<main className="flex flex-col gap-16 lg:gap-32 pb-16 lg:pb-32 bg-white"> <main className="flex flex-col gap-16 lg:gap-32 pb-16 lg:pb-32 bg-white">
@@ -54,22 +47,10 @@ export default function Home() {
</p> </p>
</div> </div>
<button <FreeTrial className={[
className={[
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`, `mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`,
`bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl`, `bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl`,
].join(' ')} ].join(' ')}/>
onClick={() => {
if (profile) {
router.push('/admin/purchase') // 已登录用户跳转购买页
}
else {
router.push('/login?redirect=/admin/purchase') // 未登录跳转登录页并携带重定向路径
}
}}
>
</button>
</Wrap> </Wrap>
</section> </section>

View File

@@ -1,6 +1,7 @@
import BreadCrumb from '@/components/bread-crumb' import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Purchase, {TabType} from '@/components/composites/purchase' import Purchase, {TabType} from '@/components/composites/purchase'
import {Suspense} from 'react'
export type ProductPageProps = { export type ProductPageProps = {
searchParams?: Promise<{ searchParams?: Promise<{
@@ -8,14 +9,16 @@ export type ProductPageProps = {
}> }>
} }
export default async function ProductPage(props: ProductPageProps) { export default function ProductPage(props: ProductPageProps) {
return ( return (
<main className="mt-20"> <main className="mt-20">
<Wrap className="flex flex-col py-8 gap-4"> <Wrap className="flex flex-col py-8 gap-4">
<BreadCrumb items={[ <BreadCrumb items={[
{label: '产品中心', href: '/product'}, {label: '产品中心', href: '/product'},
]}/> ]}/>
<Suspense>
<Purchase/> <Purchase/>
</Suspense>
</Wrap> </Wrap>
</main> </main>
) )

View File

@@ -1,44 +0,0 @@
'use client'
import {PanelLeftCloseIcon, PanelLeftOpenIcon} from 'lucide-react'
import {Button} from '@/components/ui/button'
import {useLayoutStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
import UserCenter from '@/components/composites/user-center'
import {User} from '@/lib/models'
export type HeaderProps = {
profile: User
}
export default function Header(props: HeaderProps) {
const navbar = useLayoutStore(store => store.navbar)
const toggleNavbar = useLayoutStore(store => store.toggleNavbar)
return (
<header className={merge(
`flex-none h-16 overflow-hidden`,
`flex items-stretch`,
)}>
{/* left */}
<div className="flex-auto flex items-center gap-2">
<Button
theme="ghost"
className="w-9 h-9 ml-4 md:ml-0"
onClick={toggleNavbar}>
{navbar
? <PanelLeftCloseIcon/>
: <PanelLeftOpenIcon/>
}
</Button>
<span className="max-md:hidden">
</span>
</div>
{/* right */}
<div className="flex-none flex items-center justify-end pr-4">
<UserCenter profile={props.profile}/>
</div>
</header>
)
}

View File

@@ -1,81 +0,0 @@
'use client'
import {ReactNode} from 'react'
import {useLayoutStore} from '@/components/stores-provider'
import {merge} from '@/lib/utils'
type AdminLayoutProps = {
navbar: ReactNode
header: ReactNode
children: ReactNode
}
export default function Layout(props: AdminLayoutProps) {
const navbar = useLayoutStore(store => store.navbar)
const setNevBar = useLayoutStore(store => store.setNavbar)
return (
<div className="relative h-dvh overflow-hidden">
{/* 结构 */}
<div
data-expand={navbar}
className={merge(
`transition-[grid-template-columns] duration-300 ease-in-out`,
`w-full h-full grid`,
`grid-rows-[64px_1fr]`,
`data-[expand=true]:grid-cols-[200px_1fr]`,
`data-[expand=false]:grid-cols-[0px_1fr]`,
`md:data-[expand=false]:grid-cols-[64px_1fr]`,
)}
>
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
{props.navbar}
</div>
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
{props.header}
</div>
<svg className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none" preserveAspectRatio="none">
<defs>
<mask id="top-left-rounded-mask">
<rect width="100%" height="100%" fill="white"/>
<circle cx="16" cy="16" r="16" fill="black"/>
<rect x="16" y="0" width="100%" height="32" fill="black"/>
<rect x="0" y="16" width="32" height="100%" fill="black"/>
<rect x="16" y="16" width="100%" height="100%" fill="black"/>
</mask>
</defs>
<rect width="100%" height="100%" className="fill-card" mask="url(#top-left-rounded-mask)"/>
</svg>
{/* 遮罩层 */}
<div
data-expand={navbar}
className={merge(
`lg:hidden`,
`transition-opacity duration-300 ease-in-out`,
`col-start-1 row-start-1 col-span-2 row-span-2 bg-black/50 z-10`,
`data-[expand=true]:opacity-100 data-[expand=false]:opacity-0`,
`data-[expand=true]:pointer-events-auto data-[expand=false]:pointer-events-none`,
)}
onClick={() => setNevBar(false)}
>
</div>
</div>
{/* 内容 */}
<div
data-expand={navbar}
className={merge(
`transition-[margin] duration-300 ease-in-out`,
`absolute inset-0 overflow-hidden`,
`mt-16`,
`md:ml-16`,
`lg:data-[expand=true]:ml-[200px]`,
)}>
{props.children}
</div>
</div>
)
}

View File

@@ -1,27 +1,138 @@
'use client' 'use client'
import {ComponentProps, ReactNode, useState} from 'react' import {ReactNode, Suspense, use, useState} from 'react'
import {merge} from '@/lib/utils'
import {useLayoutStore} from '@/components/stores-provider'
import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import logoAvatar from '../_assets/logo-avatar.svg' import Link from 'next/link'
import logoText from '../_assets/logo-text.svg' import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import UserCenter from '@/components/composites/user-center'
import {Button} from '@/components/ui/button'
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip' import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'
import {UserRound} from 'lucide-react' import {Archive, ArchiveRestore, Eye, HardDriveUpload, IdCard, LockKeyhole, Package, PanelLeftCloseIcon, PanelLeftOpenIcon, ShoppingCart, UserRound, UserRoundPen, Wallet} from 'lucide-react'
import {UserRoundPen} from 'lucide-react' import {merge} from '@/lib/utils'
import {IdCard} from 'lucide-react' import logoAvatar from '@/assets/logo-avatar.svg'
import {LockKeyhole} from 'lucide-react' import logoText from '@/assets/logo-text.svg'
import {Wallet} from 'lucide-react' import {useLayoutStore} from '@/components/stores/layout'
import {ShoppingCart} from 'lucide-react' import {useProfileStore} from '@/components/stores/profile'
import {Package} from 'lucide-react' import {User} from '@/lib/models'
import {HardDriveUpload} from 'lucide-react'
import {Eye} from 'lucide-react'
import {Archive} from 'lucide-react'
import {ArchiveRestore} from 'lucide-react'
export type NavbarProps = {} & ComponentProps<'nav'> export function Shell(props: {
children: ReactNode
}) {
const navbar = useLayoutStore(store => store.navbar)
return (
<div
data-expand={navbar}
className={merge(
`transition-[grid-template-columns] duration-300 ease-in-out`,
`w-full h-full grid`,
`grid-rows-[64px_1fr]`,
`data-[expand=true]:grid-cols-[200px_1fr]`,
`data-[expand=false]:grid-cols-[0px_1fr]`,
`md:data-[expand=false]:grid-cols-[64px_1fr]`,
)}
>
{props.children}
</div>
)
}
export default function Navbar(props: NavbarProps) { export function Mask() {
const navbar = useLayoutStore(store => store.navbar)
const setNevBar = useLayoutStore(store => store.setNavbar)
return (
<div
data-expand={navbar}
className={merge(
`lg:hidden`,
`transition-opacity duration-300 ease-in-out`,
`col-start-1 row-start-1 col-span-2 row-span-2 bg-black/50 z-10`,
`data-[expand=true]:opacity-100 data-[expand=false]:opacity-0`,
`data-[expand=true]:pointer-events-auto data-[expand=false]:pointer-events-none`,
)}
onClick={() => setNevBar(false)}
>
</div>
)
}
export function Content(props: {children: ReactNode}) {
const navbar = useLayoutStore(store => store.navbar)
return (
<div
data-expand={navbar}
className={merge(
`transition-[margin] duration-300 ease-in-out`,
`absolute inset-0 overflow-hidden`,
`mt-16`,
`md:ml-16`,
`lg:data-[expand=true]:ml-[200px]`,
)}>
{props.children}
<Suspense>
<ContentResolved/>
</Suspense>
</div>
)
}
function ContentResolved() {
const profile = use(useProfileStore(store => store.profile))
if (!profile) throw new Error('登录状态异常')
return (
<>
<RealnameAuthDialog
triggerClassName="hidden"
defaultOpen={!profile.id_token}
/>
<ChangePasswordDialog
triggerClassName="hidden"
defaultOpen={!profile.has_password}
/>
</>
)
}
export function Header() {
const navbar = useLayoutStore(store => store.navbar)
const toggleNavbar = useLayoutStore(store => store.toggleNavbar)
return (
<header className={merge(
`flex-none h-16 overflow-hidden`,
`flex items-stretch`,
)}>
{/* left */}
<div className="flex-auto flex items-center gap-2">
<Button
theme="ghost"
className="w-9 h-9 ml-4 md:ml-0"
onClick={toggleNavbar}>
{navbar
? <PanelLeftCloseIcon/>
: <PanelLeftOpenIcon/>
}
</Button>
<span className="max-md:hidden">
</span>
</div>
{/* right */}
<div className="flex-none flex items-center justify-end pr-4">
<Suspense>
<HeaderUserCenter/>
</Suspense>
</div>
</header>
)
}
function HeaderUserCenter() {
const profile = use(useProfileStore(store => store.profile))
if (!profile) throw new Error('登录状态异常')
return <UserCenter profile={profile}/>
}
export function Navbar() {
const navbar = useLayoutStore(store => store.navbar) const navbar = useLayoutStore(store => store.navbar)
return ( return (

View File

@@ -9,9 +9,9 @@ import zod from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {Identify} from '@/actions/user' import {Identify} from '@/actions/user'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useEffect, useRef, useState} from 'react' import {ReactNode, Suspense, use, useEffect, useRef, useState} from 'react'
import * as qrcode from 'qrcode' import * as qrcode from 'qrcode'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import banner from './_assets/banner.webp' import banner from './_assets/banner.webp'
import personal from './_assets/personal.webp' import personal from './_assets/personal.webp'
@@ -123,12 +123,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</p> </p>
</div> </div>
{profile?.id_token ? ( <Suspense>
<p className="flex gap-2 items-center"> <IfNotIdentofy>
<CheckCircleIcon className="text-done"/>
<span></span>
</p>
) : (
<Dialog open={openDialog} onOpenChange={setOpenDialog}> <Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-full"></Button> <Button className="w-full"></Button>
@@ -179,7 +175,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} </IfNotIdentofy>
</Suspense>
</section> </section>
</div> </div>
</div> </div>
@@ -235,3 +232,15 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</Page> </Page>
) )
} }
function IfNotIdentofy(props: {children: ReactNode}) {
const profile = use(useProfileStore(store => store.profile))
return !profile?.id_token
? props.children
: (
<p className="flex gap-2 items-center">
<CheckCircleIcon className="text-done"/>
<span></span>
</p>
)
}

View File

@@ -1,39 +1,42 @@
import {ReactNode} from 'react' import {ReactNode} from 'react'
import Header from './_client/header' import {Shell, Content, Header, Navbar, Mask} from './clients'
import Navbar from './_client/navbar'
import Layout from './_client/layout'
import {getProfile} from '@/actions/auth'
import {redirect} from 'next/navigation'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
export type AdminLayoutProps = { export default function Template(props: {
children: ReactNode children: ReactNode
} }) {
export default async function AdminLayout(props: AdminLayoutProps) {
const resp = await getProfile()
const profile = resp.success ? resp.data : null
if (!profile) {
redirect('/login')
}
return ( return (
<Layout <div className="relative h-dvh overflow-hidden">
navbar={<Navbar/>} {/* 外壳 */}
header={<Header profile={profile}/>} <Shell>
> <div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
<Navbar/>
</div>
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
<Header/>
</div>
<svg className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none" preserveAspectRatio="none">
<defs>
<mask id="top-left-rounded-mask">
<rect width="100%" height="100%" fill="white"/>
<circle cx="16" cy="16" r="16" fill="black"/>
<rect x="16" y="0" width="100%" height="32" fill="black"/>
<rect x="0" y="16" width="32" height="100%" fill="black"/>
<rect x="16" y="16" width="100%" height="100%" fill="black"/>
</mask>
</defs>
<rect width="100%" height="100%" className="fill-card" mask="url(#top-left-rounded-mask)"/>
</svg>
{/* 遮罩层 */}
<Mask/>
</Shell>
{/* 内容 */}
<Content>
{props.children} {props.children}
<RealnameAuthDialog </Content>
hasAuthenticated={!!profile.id_token} </div>
triggerClassName="hidden"
defaultOpen={!profile.id_token}
/>
<ChangePasswordDialog
triggerClassName="hidden"
defaultOpen={!profile.has_password}
/>
</Layout>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import {update} from '@/actions/user' import {update} from '@/actions/user'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'

View File

@@ -57,7 +57,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
<> <>
<p className="text-sm">使</p> <p className="text-sm">使</p>
<RealnameAuthDialog <RealnameAuthDialog
hasAuthenticated={!!user.id_token} defaultOpen={!user.id_token}
triggerClassName="w-24" triggerClassName="w-24"
/> />
</> </>

View File

@@ -1,5 +1,6 @@
import Purchase, {TabType} from '@/components/composites/purchase' import Purchase, {TabType} from '@/components/composites/purchase'
import Page from '@/components/page' import Page from '@/components/page'
import {Suspense} from 'react'
export type PurchasePageProps = { export type PurchasePageProps = {
searchParams?: Promise<{ searchParams?: Promise<{
@@ -10,7 +11,9 @@ export type PurchasePageProps = {
export default async function PurchasePage(props: PurchasePageProps) { export default async function PurchasePage(props: PurchasePageProps) {
return ( return (
<Page className="flex-col"> <Page className="flex-col">
<Suspense>
<Purchase/> <Purchase/>
</Suspense>
</Page> </Page>
) )
} }

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {ReactNode, useEffect, useState} from 'react' import {useCallback, useEffect, useState} from 'react'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select' import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
@@ -8,24 +8,27 @@ import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Box, Eraser, Search, Timer} from 'lucide-react' import {Box, Eraser, Search, Timer} from 'lucide-react'
import DataTable from '@/components/data-table' import DataTable from '@/components/data-table'
import {format, intlFormatDistance, isAfter} from 'date-fns' import {format, isAfter, isSameDay} from 'date-fns'
import {useStatus} from '@/lib/states' import {useStatus} from '@/lib/states'
import {ExtraResp, PageRecord} from '@/lib/api' import {ExtraResp} from '@/lib/api'
import {Resource} from '@/lib/models'
import {listResourceShort} from '@/actions/resource' import {listResourceShort} from '@/actions/resource'
import zod from 'zod' import zod from 'zod'
import {useSearchParams} from 'next/navigation' import {useSearchParams} from 'next/navigation'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
export type ShortResourceProps = { const filterSchema = zod.object({
} resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
export default function ShortResource(props: ShortResourceProps) { type FilterSchema = zod.infer<typeof filterSchema>
// ======================
// 查询
// ======================
export default function ShortResource() {
const [status, setStatus] = useStatus() const [status, setStatus] = useStatus()
const [data, setData] = useState<ExtraResp<typeof listResourceShort>>({ const [data, setData] = useState<ExtraResp<typeof listResourceShort>>({
page: 1, page: 1,
@@ -34,7 +37,30 @@ export default function ShortResource(props: ShortResourceProps) {
list: [], list: [],
}) })
const refresh = async (page: number, size: number) => { const params = useSearchParams()
let paramType = params.get('type')
if (paramType !== 'all' && paramType !== 'expire' && paramType !== 'quota') {
paramType = 'all'
}
// 筛选表单
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: paramType as 'expire' | 'quota' | 'all',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined,
},
})
const handler = form.handleSubmit(async (value: FilterSchema) => {
await refresh(1, data.size)
})
// 查询
const refresh = useCallback(async (page: number, size: number) => {
setStatus('load') setStatus('load')
try { try {
const type = { const type = {
@@ -63,54 +89,19 @@ export default function ShortResource(props: ShortResourceProps) {
setStatus('done') setStatus('done')
} }
else { else {
throw new Error('Failed to load short resource') throw new Error(`Failed to load short resource`)
} }
} }
catch (e) { catch (e) {
setStatus('fail') setStatus('fail')
} }
} }, [form, setStatus])
useEffect(() => { useEffect(() => {
refresh(1, 10).then() refresh(1, 10).then()
}, []) }, [refresh])
// ======================
// 筛选 // 筛选
// ======================
const filterSchema = zod.object({
resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
const params = useSearchParams()
let paramType = params.get('type')
if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') {
paramType = 'all'
}
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: paramType as 'expire' | 'quota' | 'all',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined,
},
})
const handler = form.handleSubmit(async (value: FilterSchema) => {
await refresh(1, data.size)
})
return ( return (
<> <>
@@ -166,7 +157,7 @@ export default function ShortResource(props: ShortResourceProps) {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-sm">使</Label> <Label className="text-sm"></Label>
<div className="flex items-center"> <div className="flex items-center">
<FormField name="expire_after"> <FormField name="expire_after">
{({field}) => ( {({field}) => (
@@ -253,11 +244,7 @@ export default function ShortResource(props: ShortResourceProps) {
}, },
{ {
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span> <span>{row.original.short.live / 60}</span>
{row.original.short.live / 60}
{' '}
</span>
), ),
}, },
{ {
@@ -270,15 +257,12 @@ export default function ShortResource(props: ShortResourceProps) {
: <span className="text-red-500"></span>} : <span className="text-red-500"></span>}
<span>|</span> <span>|</span>
<span> <span>
{row.original.short.last_at {row.original.short.last_at && isSameDay(row.original.short.expire_at, new Date())
&& new Date(row.original.short.last_at).toDateString() === new Date().toDateString()
? row.original.short.daily ? row.original.short.daily
: 0}/{row.original.short.quota} : 0
}/{row.original.short.quota}
</span> </span>
<span>|</span> <span>|</span>
<span>
{intlFormatDistance(row.original.short.expire_at, new Date())}
</span>
</div> </div>
) : row.original.short.type === 2 ? ( ) : row.original.short.type === 2 ? (
<div className="flex gap-1"> <div className="flex gap-1">
@@ -301,28 +285,12 @@ export default function ShortResource(props: ShortResourceProps) {
), ),
}, },
{ {
accessorKey: 'last_at', header: '最近使用时间', cell: ({row}) => row.original.short.last_at
header: '最近使用时间', ? format(row.original.short.last_at, 'yyyy-MM-dd HH:mm')
cell: ({row}) => { : '-',
const lastAt = row.original.short.last_at
if (!lastAt) {
return '暂未使用'
}
return format(lastAt, 'yyyy-MM-dd HH:mm')
},
},
{
accessorKey: 'created_at', header: '开通时间', cell: ({row}) => (
format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: item => (
<div className="flex gap-2">
-
</div>
),
}, },
{header: '开通时间', cell: ({row}) => format(row.original.created_at, 'yyyy-MM-dd HH:mm')},
{header: '到期时间', cell: ({row}) => format(row.original.short.expire_at, 'yyyy-MM-dd HH:mm')},
]} ]}
/> />
</> </>

View File

@@ -2,6 +2,7 @@ import Page from '@/components/page'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import ShortResource from '@/app/admin/resources/_client/short' import ShortResource from '@/app/admin/resources/_client/short'
import LongResource from '@/app/admin/resources/_client/long' import LongResource from '@/app/admin/resources/_client/long'
import {Suspense} from 'react'
export default async function ResourcesPage() { export default async function ResourcesPage() {
// ====================== // ======================
@@ -16,10 +17,14 @@ export default async function ResourcesPage() {
<TabsTrigger value="long" className="w-30 h-9 data-[state=active]:bg-primary-muted text-base rounded-md"></TabsTrigger> <TabsTrigger value="long" className="w-30 h-9 data-[state=active]:bg-primary-muted text-base rounded-md"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="short" className="flex flex-col gap-4"> <TabsContent value="short" className="flex flex-col gap-4">
<Suspense>
<ShortResource/> <ShortResource/>
</Suspense>
</TabsContent> </TabsContent>
<TabsContent value="long" className="flex flex-col gap-4"> <TabsContent value="long" className="flex flex-col gap-4">
<Suspense>
<LongResource/> <LongResource/>
</Suspense>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</Page> </Page>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import {useClientStore} from '@/components/stores/client'
import {useLayoutStore} from '@/components/stores/layout'
import {ReactNode, useEffect} from 'react' import {ReactNode, useEffect} from 'react'
import {useClientStore, useLayoutStore} from '@/components/stores-provider'
export type EffectProviderProps = { export type EffectProviderProps = {
children?: ReactNode children?: ReactNode

View File

@@ -1,10 +1,12 @@
'use server' import './globals.css'
import {ReactNode} from 'react' import {ReactNode} from 'react'
import {Metadata} from 'next' import {Metadata} from 'next'
import './globals.css'
import {Toaster} from '@/components/ui/sonner' import {Toaster} from '@/components/ui/sonner'
import StoresProvider from '@/components/stores-provider'
import Effects from '@/app/effects' import Effects from '@/app/effects'
import {ProfileStoreProvider} from '@/components/stores/profile'
import {LayoutStoreProvider} from '@/components/stores/layout'
import {ClientStoreProvider} from '@/components/stores/client'
import {getProfile} from '@/actions/auth'
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -12,19 +14,29 @@ export async function generateMetadata(): Promise<Metadata> {
} }
} }
export default async function RootLayout({ export default async function RootLayout(props: Readonly<{
children,
}: Readonly<{
children: ReactNode children: ReactNode
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body> <body>
<StoresProvider> <StoreProviders>
<Effects>{children}</Effects> <Effects>{props.children}</Effects>
</StoresProvider> </StoreProviders>
<Toaster position="top-center" richColors expand/> <Toaster position="top-center" richColors expand/>
</body> </body>
</html> </html>
) )
} }
function StoreProviders(props: {children: ReactNode}) {
return (
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
<LayoutStoreProvider>
<ClientStoreProvider>
{props.children}
</ClientStoreProvider>
</LayoutStoreProvider>
</ProfileStoreProvider>
)
}

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,16 +1,30 @@
'use client' 'use client'
import {useEffect, useRef, useState} from 'react' import {useState} from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog' import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
import {useForm} from 'react-hook-form' import {useForm, useFormContext} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod' import * as z from 'zod'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {sendSMS} from '@/actions/verify'
import {updatePassword} from '@/actions/user' import {updatePassword} from '@/actions/user'
import SendMsg from '@/components/send-msg'
// 表单验证规则
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
captcha: z.string().nonempty('请输入验证码'),
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
password: z.string().min(6, `密码至少6位`),
confirm_password: z.string(),
}).refine(data => data.password === data.confirm_password, {
message: '两次输入的密码不一致',
path: ['confirm_password'],
})
type Schema = z.infer<typeof schema>
interface ChangePasswordDialogProps { interface ChangePasswordDialogProps {
triggerClassName?: string triggerClassName?: string
@@ -33,20 +47,6 @@ export function ChangePasswordDialog({
const actualOpen = open !== undefined ? open : internalOpen const actualOpen = open !== undefined ? open : internalOpen
const actualOnOpenChange = onOpenChange || setInternalOpen const actualOnOpenChange = onOpenChange || setInternalOpen
// 表单验证规则
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
captcha: z.string().nonempty('请输入验证码'),
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
password: z.string().min(6, `密码至少6位`),
confirm_password: z.string(),
}).refine(data => data.password === data.confirm_password, {
message: '两次输入的密码不一致',
path: ['confirm_password'],
})
type Schema = z.infer<typeof schema>
// 表单初始化 // 表单初始化
const form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver( resolver: zodResolver(
@@ -92,52 +92,6 @@ export function ChangePasswordDialog({
} }
}) })
// 验证码相关状态
const [captchaUrl, setCaptchaUrl] = useState(`/captcha?t=${new Date().getTime()}`)
const [captchaWait, setCaptchaWait] = useState(0)
const interval = useRef<NodeJS.Timeout>(null)
// 刷新验证码
const refreshCaptcha = () => {
setCaptchaUrl(`/captcha?t=${new Date().getTime()}`)
}
// 发送短信验证码
const sendVerifier = async () => {
const result = await form.trigger(['phone', 'captcha'])
if (!result) return
const {phone, captcha} = form.getValues()
const resp = await sendSMS({phone, captcha})
if (!resp.success) {
toast.error(resp.message)
refreshCaptcha()
return
}
setCaptchaWait(60)
interval.current = setInterval(() => {
setCaptchaWait((wait) => {
if (wait <= 1) {
clearInterval(interval.current!)
return 0
}
return wait - 1
})
}, 1000)
toast.success(`验证码已发送,请注意查收`)
}
// 清理定时器
useEffect(() => {
return () => {
if (interval.current) {
clearInterval(interval.current)
}
}
}, [])
return ( return (
<Dialog open={actualOpen} onOpenChange={actualOnOpenChange}> <Dialog open={actualOpen} onOpenChange={actualOnOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -155,29 +109,15 @@ export function ChangePasswordDialog({
)} )}
</FormField> </FormField>
{/* 图形验证码 */}
<FormField<Schema> name="captcha" label="验证码">
{({field}) => (
<div className="flex gap-4">
<Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/>
<Button className="p-0 bg-transparent" onClick={refreshCaptcha} type="button">
<img src={captchaUrl} alt="验证码" className="h-10"/>
</Button>
</div>
)}
</FormField>
{/* 短信验证码 */} {/* 短信验证码 */}
<FormField<Schema> name="code" label="短信令牌" className="flex-auto"> <div className="flex gap-4 items-end">
<FormField<Schema> name="code" label="验证码" className="flex-auto">
{({field}) => ( {({field}) => (
<div className="flex gap-4">
<Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/> <Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/>
<Button theme="outline" type="button" className="w-36" onClick={() => sendVerifier()}>
{captchaWait > 0 ? `重新发送(${captchaWait})` : `获取短信令牌`}
</Button>
</div>
)} )}
</FormField> </FormField>
<SendMsgByPhone/>
</div>
{/* 新密码 */} {/* 新密码 */}
<FormField<Schema> name="password" label="新密码" className="flex-auto"> <FormField<Schema> name="password" label="新密码" className="flex-auto">
@@ -212,3 +152,9 @@ export function ChangePasswordDialog({
</Dialog> </Dialog>
) )
} }
function SendMsgByPhone() {
const form = useFormContext<Schema>()
const phone = form.watch('phone')
return <SendMsg phone={phone}/>
}

View File

@@ -1,11 +1,9 @@
'use client' 'use client'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog' import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {useRouter} from 'next/navigation'
import {useState} from 'react' import {useState} from 'react'
import Link from 'next/link' import Link from 'next/link'
interface RealnameAuthDialogProps { interface RealnameAuthDialogProps {
hasAuthenticated: boolean
triggerClassName?: string triggerClassName?: string
open?: boolean open?: boolean
defaultOpen?: boolean defaultOpen?: boolean
@@ -14,23 +12,16 @@ interface RealnameAuthDialogProps {
} }
export function RealnameAuthDialog({ export function RealnameAuthDialog({
hasAuthenticated,
triggerClassName, triggerClassName,
open, open,
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
onSuccess,
}: RealnameAuthDialogProps) { }: RealnameAuthDialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen || false) const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
const router = useRouter()
const actualOpen = open !== undefined ? open : internalOpen const actualOpen = open !== undefined ? open : internalOpen
const actualOnOpenChange = onOpenChange || setInternalOpen const actualOnOpenChange = onOpenChange || setInternalOpen
if (hasAuthenticated) {
return null
}
return ( return (
<Dialog open={actualOpen} onOpenChange={actualOnOpenChange}> <Dialog open={actualOpen} onOpenChange={actualOnOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>

View File

@@ -20,7 +20,7 @@ import {Combobox} from '@/components/ui/combobox'
import cities from './_assets/cities.json' import cities from './_assets/cities.json'
import ExtractDocs from '@/docs/extract.mdx' import ExtractDocs from '@/docs/extract.mdx'
import Link from 'next/link' import Link from 'next/link'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
const schema = z.object({ const schema = z.object({
resource: z.number({required_error: '请选择套餐'}), resource: z.number({required_error: '请选择套餐'}),

View File

@@ -14,7 +14,7 @@ export default function Purchase() {
const tab = params.get('type') as TabType || 'short' const tab = params.get('type') as TabType || 'short'
const updateTab = async (tab: string) => { const updateTab = (tab: string) => {
const newParams = new URLSearchParams(params) const newParams = new URLSearchParams(params)
newParams.set('type', tab) newParams.set('type', tab)
router.push(`${path}?${newParams.toString()}`) router.push(`${path}?${newParams.toString()}`)

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {useContext, useMemo} from 'react' import {Suspense, use, useContext, useMemo} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/short/form' import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '../_assets/alipay.svg' import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg' import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg' import balance from '../_assets/balance.svg'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge' import RechargeModal from '@/components/composites/recharge'
import Pay from '@/components/composites/purchase/pay' import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button' import {buttonVariants} from '@/components/ui/button'
@@ -99,7 +99,24 @@ export default function Right() {
{price} {price}
</span> </span>
</p> </p>
{profile ? ( <Suspense>
<BalanceOrLogin {...{method, price, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
price: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<> <>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6"> <FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => ( {({id, field}) => (
@@ -148,16 +165,17 @@ export default function Right() {
)} )}
</FormField> </FormField>
<Pay <Pay
method={method} method={props.method}
amount={price} balance={profile.balance}
amount={props.price}
resource={{ resource={{
type: 2, type: 2,
long: { long: {
mode: Number(mode), mode: Number(props.mode),
live: Number(live), live: Number(props.live),
daily_limit: dailyLimit, daily_limit: props.dailyLimit,
expire: Number(expire), expire: Number(props.expire),
quota: quota, quota: props.quota,
}, },
}}/> }}/>
</> </>
@@ -165,7 +183,5 @@ export default function Right() {
<Link href="/login" className={buttonVariants()}> <Link href="/login" className={buttonVariants()}>
</Link> </Link>
)}
</Card>
) )
} }

View File

@@ -4,7 +4,7 @@ import {Button} from '@/components/ui/button'
import balance from './_assets/balance.svg' import balance from './_assets/balance.svg'
import Image from 'next/image' import Image from 'next/image'
import {useState} from 'react' import {useState} from 'react'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import {Alert, AlertTitle} from '@/components/ui/alert' import {Alert, AlertTitle} from '@/components/ui/alert'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
@@ -18,13 +18,16 @@ import {PaymentProps} from '@/components/composites/payment/type'
import {usePlatformType} from '@/lib/hooks' import {usePlatformType} from '@/lib/hooks'
export type PayProps = { export type PayProps = {
method: 'alipay' | 'wechat' | 'balance'
amount: string amount: string
resource: Parameters<typeof createResource>[0] resource: Parameters<typeof createResource>[0]
} } & ({
method: 'alipay' | 'wechat'
} | {
method: 'balance'
balance: number
})
export default function Pay(props: PayProps) { export default function Pay(props: PayProps) {
const profile = useProfileStore(store => store.profile)
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [trade, setTrade] = useState<PaymentProps | null>(null) const [trade, setTrade] = useState<PaymentProps | null>(null)
@@ -102,7 +105,7 @@ export default function Pay(props: PayProps) {
} }
} }
const balanceEnough = profile && profile.balance >= Number(props.amount) const balanceEnough = balance >= Number(props.amount)
return ( return (
<> <>
@@ -121,30 +124,25 @@ export default function Pay(props: PayProps) {
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{profile && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-weak text-sm"></span> <span className="text-weak text-sm"></span>
<span className="text-lg"> <span className="text-lg">
{profile.balance} {balance}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-weak text-sm"></span> <span className="text-weak text-sm"></span>
<span className="text-lg text-accent"> <span className="text-lg text-accent">
- - {props.amount}
{props.amount}
</span> </span>
</div> </div>
<hr className="my-2"/> <hr className="my-2"/>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-weak text-sm"></span> <span className="text-weak text-sm"></span>
<span className={`text-lg ${balanceEnough ? 'text-done' : 'text-fail'}`}> <span className={`text-lg ${balanceEnough ? 'text-done' : 'text-fail'}`}>
{(profile.balance - Number(props.amount)).toFixed(2)} {(balance - Number(props.amount)).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
@@ -163,7 +161,6 @@ export default function Pay(props: PayProps) {
</Alert> </Alert>
)} )}
</div> </div>
)}
<DialogFooter> <DialogFooter>
<Button <Button

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {useMemo} from 'react' import {Suspense, use, useMemo} from 'react'
import {Schema} from '@/components/composites/purchase/short/form' import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '../_assets/alipay.svg' import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg' import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg' import balance from '../_assets/balance.svg'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge' import RechargeModal from '@/components/composites/recharge'
import {buttonVariants} from '@/components/ui/button' import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link' import Link from 'next/link'
@@ -18,8 +18,6 @@ import {useFormContext} from 'react-hook-form'
import {Card} from '@/components/ui/card' import {Card} from '@/components/ui/card'
export default function Right() { export default function Right() {
const profile = useProfileStore(store => store.profile)
const form = useFormContext<Schema>() const form = useFormContext<Schema>()
const method = form.watch('pay_type') const method = form.watch('pay_type')
@@ -93,7 +91,24 @@ export default function Right() {
{price} {price}
</span> </span>
</p> </p>
{profile ? ( <Suspense>
<BalanceOrLogin {...{method, price, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
price: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<> <>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6"> <FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => ( {({id, field}) => (
@@ -109,7 +124,7 @@ export default function Right() {
<span className="text-sm text-gray-500"></span> <span className="text-sm text-gray-500"></span>
</p> </p>
<p className="flex justify-between items-center"> <p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span> <span className="text-xl">{profile.balance}</span>
<RechargeModal/> <RechargeModal/>
</p> </p>
</div> </div>
@@ -142,16 +157,17 @@ export default function Right() {
)} )}
</FormField> </FormField>
<Pay <Pay
method={method} method={props.method}
amount={price} balance={profile.balance}
amount={props.price}
resource={{ resource={{
type: 1, type: 1,
short: { short: {
mode: Number(mode), mode: Number(props.mode),
live: Number(live), live: Number(props.live),
quota: quota, quota: props.quota,
expire: Number(expire), expire: Number(props.expire),
daily_limit: dailyLimit, daily_limit: props.dailyLimit,
}, },
}}/> }}/>
</> </>
@@ -159,7 +175,5 @@ export default function Right() {
<Link href="/login" className={buttonVariants()}> <Link href="/login" className={buttonVariants()}>
</Link> </Link>
)}
</Card>
) )
} }

View File

@@ -16,7 +16,7 @@ import {zodResolver} from '@hookform/resolvers/zod'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useState} from 'react' import {useState} from 'react'
import {RechargeComplete, RechargePrepare} from '@/actions/user' import {RechargeComplete, RechargePrepare} from '@/actions/user'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import { import {
TradePlatform, TradePlatform,

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {useProfileStore} from '@/components/stores-provider' import {useProfileStore} from '@/components/stores/profile'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar' import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'
import {LogOutIcon, UserIcon} from 'lucide-react' import {LogOutIcon, UserIcon} from 'lucide-react'
@@ -8,38 +8,27 @@ import {logout} from '@/actions/auth'
import {HoverCard, HoverCardContent, HoverCardTrigger} from '@/components/ui/hover-card' import {HoverCard, HoverCardContent, HoverCardTrigger} from '@/components/ui/hover-card'
import {User} from '@/lib/models' import {User} from '@/lib/models'
type UserCenterProps = { export default function UserCenter(props: {
profile: User profile: User
} }) {
export default function UserCenter(props: UserCenterProps) {
const router = useRouter() const router = useRouter()
const pathname = usePathname()
// 登录控制
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const isAdminPage = pathname.startsWith('/admin') // 判断是否在后台页面
const displayedName = !isAdminPage
? props.profile.username || props.profile.phone.substring(0, 3) + '****' + props.profile.phone.substring(7) || '用户'
: '进入控制台'
const doLogout = async () => { const doLogout = async () => {
const resp = await logout() const resp = await logout()
if (resp.success) { if (resp.success) {
refreshProfile().then() refreshProfile()
router.replace('/') router.replace('/')
} }
} }
// 展示与跳转 const toAdminPage = () => {
const pathname = usePathname()
const isAdminPage = pathname.startsWith('/admin') // 判断是否在后台页面
const displayName = () => {
const {username, email, phone} = props.profile
switch (true) {
case !!username: return username
case !!phone: return `${phone.substring(0, 3)}****${phone.substring(7)}`
case !!email: return email
default: return null
}
}
const handleAvatarClick = () => {
if (!isAdminPage) { if (!isAdminPage) {
router.push('/admin') router.push('/admin')
} }
@@ -51,17 +40,13 @@ export default function UserCenter(props: UserCenterProps) {
<Button <Button
theme="ghost" theme="ghost"
className="flex gap-2 items-center h-12" className="flex gap-2 items-center h-12"
onClick={handleAvatarClick} onClick={toAdminPage}
> >
<Avatar> <Avatar>
<AvatarImage src={props.profile.avatar} alt="avatar"/> <AvatarImage src={props.profile.avatar} alt="avatar"/>
<AvatarFallback className="bg-primary-muted"><UserIcon/></AvatarFallback> <AvatarFallback className="bg-primary-muted"><UserIcon/></AvatarFallback>
</Avatar> </Avatar>
{isAdminPage ? ( <span>{displayedName}</span>
<span>{displayName() || '用户'}</span>
) : (
<span></span>
)}
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-36 p-1" align="end"> <HoverCardContent className="w-36 p-1" align="end">

View File

@@ -0,0 +1,48 @@
'use client'
import {useProfileStore} from '@/components/stores/profile'
import {useRouter} from 'next/navigation'
import {Suspense, use} from 'react'
export default function FreeTrial(props: {
className: string
}) {
return (
<Suspense fallback={<Pending className={props.className}/>} >
<Resolved className={props.className}/>
</Suspense>
)
}
function Resolved(props: {
className: string
}) {
const router = useRouter()
const profile = use(useProfileStore(store => store.profile))
return (
<button
className={props.className}
onClick={async () => {
router.push(profile ? '/admin/purchase' : '/product')
}}
>
</button>
)
}
function Pending(props: {
className: string
}) {
const router = useRouter()
return (
<button
className={props.className}
onClick={async () => {
router.push('/product')
}}
>
</button>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import {useEffect, useRef, useState} from 'react'
import Cap from '@cap.js/widget'
import {sendSMS} from '@/actions/verify'
import {toast} from 'sonner'
import {Button} from '@/components/ui/button'
export default function SendMsg(props: {
phone: string
}) {
const [countdown, setCountdown] = useState(0)
const [progress, setProgress] = useState(0)
const cap = useRef(new Cap({apiEndpoint: '/'}))
cap.current.addEventListener('solve', (event) => {
console.log('captcha solve', event)
})
cap.current.addEventListener('error', (event) => {
console.error('captcha error', event)
})
cap.current.addEventListener('reset', (event) => {
console.log('captcha reset', event)
})
cap.current.addEventListener('progress', (event) => {
setProgress(event.detail.progress)
})
// 计时
useEffect(() => {
const interval = setInterval(() => {
if (countdown > 0) {
setCountdown(countdown - 1)
}
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// 发送验证码
const sendCode = async () => {
try {
// 检查手机号
const valid = /^1\d{10}$/.test(props.phone)
if (!valid) {
throw new Error('请输入正确的手机号')
}
// 完成挑战
const result = await cap.current.solve()
if (!result.success || !cap.current.token) {
throw new Error('人机验证失败')
}
// 发送验证码
const resp = await sendSMS({
phone: props.phone,
captcha: cap.current.token,
})
if (!resp.success) {
throw new Error(`验证码发送失败: ${resp.message}`)
}
setCountdown(60)
toast.success('验证码已发送')
}
catch (e) {
toast.error('验证码发送失败', {
description: (e as Error).message,
})
}
}
return (
<Button
type="button"
theme="outline"
className="whitespace-nowrap h-10"
disabled={countdown > 0}
onClick={sendCode}
>
{cap.current.token ? '1' : '0'}
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
)
}

View File

@@ -1,64 +0,0 @@
'use client'
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'
import {createLayoutStore, LayoutStore} from '@/lib/stores/layout'
import {ClientStore, createClientStore} from '@/lib/stores/client'
const StoreContext = createContext<{
profile?: StoreApi<ProfileStore>
layout?: StoreApi<LayoutStore>
client?: StoreApi<ClientStore>
}>({})
export function useProfileStore<T>(selector: (store: ProfileStore) => T) {
const profile = useContext(StoreContext).profile
if (!profile) {
throw new Error('useProfileStore must be used within a StoreProvider')
}
return useStore(profile, selector)
}
export function useLayoutStore<T>(selector: (store: LayoutStore) => T) {
const layout = useContext(StoreContext).layout
if (!layout) {
throw new Error('useLayoutStore must be used within a StoreProvider')
}
return useStore(layout, selector)
}
export function useClientStore<T>(selector: (store: ClientStore) => T) {
const client = useContext(StoreContext).client
if (!client) {
throw new Error('useClientStore must be used within a StoreProvider')
}
return useStore(client, selector)
}
export type ProfileProviderProps = {
children: ReactNode
}
export default function StoresProvider(props: ProfileProviderProps) {
console.log('init stores')
const [profile] = useState(createProfileStore())
const [layout] = useState(createLayoutStore())
const [client] = useState(createClientStore())
const refreshProfile = useStore(profile, store => store.refreshProfile)
useEffect(() => {
refreshProfile()
}, [refreshProfile])
return (
<StoreContext.Provider value={{
profile,
layout,
client,
}}>
{props.children}
</StoreContext.Provider>
)
}

View File

@@ -1,10 +1,10 @@
import {createStore} from 'zustand/vanilla' 'use client'
import {createStore, StoreApi} from 'zustand/vanilla'
import {persist} from 'zustand/middleware' import {persist} from 'zustand/middleware'
import {createContext, ReactNode, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
export type ClientStore = ClientState & ClientActions // store
type Point = 'sm' | 'md' | 'lg' | 'xl'
export type ClientState = { export type ClientState = {
breakpoint: { breakpoint: {
sm: boolean sm: boolean
@@ -13,10 +13,10 @@ export type ClientState = {
xl: boolean xl: boolean
} }
} }
export type ClientActions = { export type ClientActions = {
setBreakpoints: (breakpoints: Partial<ClientState['breakpoint']>) => void setBreakpoints: (breakpoints: Partial<ClientState['breakpoint']>) => void
} }
export type ClientStore = ClientState & ClientActions
export const createClientStore = () => { export const createClientStore = () => {
return createStore<ClientStore>()(persist( return createStore<ClientStore>()(persist(
@@ -27,7 +27,6 @@ export const createClientStore = () => {
lg: false, lg: false,
xl: false, xl: false,
}, },
setBreakpoints: breakpoints => setState(state => ({ setBreakpoints: breakpoints => setState(state => ({
breakpoint: { breakpoint: {
...state.breakpoint, ...state.breakpoint,
@@ -35,7 +34,6 @@ export const createClientStore = () => {
}, },
})), })),
}), }),
{ {
name: 'client-store', name: 'client-store',
partialize: state => ({ partialize: state => ({
@@ -44,3 +42,24 @@ export const createClientStore = () => {
}, },
)) ))
} }
// provider
const ClientStoreContext = createContext<StoreApi<ClientStore> | null>(null)
export const ClientStoreProvider = (props: {children: ReactNode}) => {
const [store] = useState(() => createClientStore())
return (
<ClientStoreContext.Provider value={store}>
{props.children}
</ClientStoreContext.Provider>
)
}
// consumer
export function useClientStore<T>(selector: (state: ClientStore) => T) {
const store = useContext(ClientStoreContext)
if (!store) {
throw new Error('ClientStoreContext 没有正确初始化')
}
return useStore(store, selector)
}

View File

@@ -0,0 +1,56 @@
'use client'
import {createStore, StoreApi} from 'zustand/vanilla'
import {persist} from 'zustand/middleware'
import {createContext, ReactNode, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
// store
export type LayoutState = {
navbar: boolean
}
export type LayoutActions = {
toggleNavbar: () => void
setNavbar: (navbar: boolean) => void
}
export type LayoutStore = LayoutState & LayoutActions
export const createLayoutStore = () => {
return createStore<LayoutStore>()(persist(
setState => ({
navbar: false,
toggleNavbar: () => setState((state) => {
return {navbar: !state.navbar}
}),
setNavbar: navbar => setState((_) => {
return {navbar}
}),
}),
{
name: 'layout-store',
partialize: state => ({
navbar: state.navbar,
}),
},
))
}
// provider
const LayoutStoreContext = createContext<StoreApi<LayoutStore> | null>(null)
export const LayoutStoreProvider = (props: {children: ReactNode}) => {
const [store] = useState(() => createLayoutStore())
return (
<LayoutStoreContext.Provider value={store}>
{props.children}
</LayoutStoreContext.Provider>
)
}
// consumer
export function useLayoutStore<T>(selector: (state: LayoutStore) => T) {
const context = useContext(LayoutStoreContext)
if (!context) {
throw new Error('LayoutStoreContext 没有正确初始化')
}
return useStore(context, selector)
}

View File

@@ -0,0 +1,48 @@
'use client'
import {User} from '@/lib/models'
import {createStore, StoreApi} from 'zustand/vanilla'
import {getProfile} from '@/actions/auth'
import {createContext, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
// store
export type ProfileState = {
profile: Promise<User | null>
}
export type ProfileActions = {
refreshProfile: () => void
}
export type ProfileStore = ProfileState & ProfileActions
export function createProfileStore(profile: Promise<User | null>) {
return createStore<ProfileStore>()(set => ({
profile,
refreshProfile: () => {
set({profile: getProfile().then(resp => resp.success ? resp.data : null)})
},
}))
}
// provider
export const ProfileStoreContext = createContext<StoreApi<ProfileStore> | null>(null)
export const ProfileStoreProvider = (props: {
profile: Promise<User | null>
children: React.ReactNode
}) => {
const [store] = useState(() => createProfileStore(props.profile))
return (
<ProfileStoreContext.Provider value={store}>
{props.children}
</ProfileStoreContext.Provider>
)
}
// consumer
export function useProfileStore<T>(selector: (state: ProfileStore) => T) {
const context = useContext(ProfileStoreContext)
if (!context) {
throw new Error('ProfileStoreContext 没有正确初始化')
}
return useStore(context, selector)
}

View File

@@ -1,11 +0,0 @@
export type AuthContext = {
payload: Payload
permissions: Record<string, never>
metadata: Record<string, unknown>
}
export type Payload = {
id: number
name: string
avatar: string
}

91
src/lib/cap/index.ts Normal file
View File

@@ -0,0 +1,91 @@
import 'server-only'
import Cap, {ChallengeData} from '@cap.js/server'
import {cookies} from 'next/headers'
import {createHmac, randomBytes, timingSafeEqual} from 'crypto'
import {CLIENT_SECRET} from '@/lib/api'
type Sign = {
data: string
timestamp: number
nonce: string
sign: string
}
function sign(data: string, timestamp: number) {
if (!CLIENT_SECRET) throw new Error('无法完成签名')
const hash = createHmac('sha256', CLIENT_SECRET)
const nonce = String(randomBytes(16))
hash.update(data).update(nonce).update(String(timestamp))
return {
data, timestamp, nonce,
sign: hash.digest('hex'),
}
}
function verify({data, timestamp, nonce, sign}: Sign) {
if (!CLIENT_SECRET) throw new Error('无法完成验证')
const hash = createHmac('sha256', CLIENT_SECRET)
hash.update(data).update(nonce).update(String(timestamp))
const excepted = Buffer.from(sign)
const received = Buffer.from(hash.digest('hex'))
return timingSafeEqual(excepted, received)
}
export async function getCap() {
return new Cap({
disableAutoCleanup: true,
noFSState: true,
storage: {
challenges: {
store: async (token: string, data: ChallengeData) => {
const cookie = await cookies()
const rs = sign(JSON.stringify({token, data}), data.expires)
cookie.set(`challenge:${token}`, JSON.stringify(rs), {
secure: process.env.NODE_ENV === 'production',
expires: data.expires,
sameSite: true,
httpOnly: true,
})
},
read: async (token: string) => {
const cookie = await cookies()
const json = cookie.get(`challenge:${token}`)?.value
if (!json) return null
const sign = JSON.parse(json) as Sign
if (!verify(sign)) return null
return JSON.parse(sign.data)['data']
},
delete: async (token: string) => {
const cookie = await cookies()
cookie.delete(`challenge:${token}`)
},
deleteExpired: async () => {},
},
tokens: {
store: async (token: string, expires: number) => {
const cookie = await cookies()
const rs = sign(JSON.stringify({token, expires}), expires)
cookie.set(token, JSON.stringify(rs), {
secure: process.env.NODE_ENV === 'production',
expires: expires,
sameSite: true,
httpOnly: true,
})
},
get: async (token: string) => {
const cookie = await cookies()
const json = cookie.get(token)?.value
if (!json) return null
const sign = JSON.parse(json) as Sign
if (!verify(sign)) return null
return JSON.parse(sign.data)['expires']
},
delete: async (token: string) => {
const cookie = await cookies()
cookie.delete(`token:${token}`)
},
deleteExpired: async () => {},
},
},
})
}

View File

@@ -6,12 +6,8 @@ type ResourceShort = {
expire_at: Date expire_at: Date
quota: number quota: number
used: number used: number
daily_limit: number
daily_used: number
last_at: Date
created_at: Date
updated_at: Date
daily: number daily: number
last_at?: Date
} }
type ResourceLong = { type ResourceLong = {
@@ -22,12 +18,8 @@ type ResourceLong = {
expire_at: Date expire_at: Date
quota: number quota: number
used: number used: number
daily_limit: number
daily_used: number
last_at: Date
created_at: Date
updated_at: Date
daily: number daily: number
last_at?: Date
} }
export type Resource<T extends 1 | 2 = 1 | 2> = { export type Resource<T extends 1 | 2 = 1 | 2> = {

View File

@@ -1,36 +0,0 @@
import {createStore} from 'zustand/vanilla'
import {persist} from 'zustand/middleware'
export type LayoutStore = LayoutState & LayoutActions
export type LayoutState = {
navbar: boolean
}
export type LayoutActions = {
toggleNavbar: () => void
setNavbar: (navbar: boolean) => void
}
export const createLayoutStore = () => {
return createStore<LayoutStore>()(persist(
setState => ({
navbar: false,
toggleNavbar: () => setState((state) => {
return {navbar: !state.navbar}
}),
setNavbar: navbar => setState((_) => {
return {navbar}
}),
}),
{
name: 'layout-store',
partialize: state => ({
navbar: state.navbar,
}),
},
))
}

View File

@@ -1,23 +0,0 @@
import {User} from '@/lib/models'
import {createStore} from 'zustand/vanilla'
import {getProfile} from '@/actions/auth'
export type ProfileStore = ProfileState & ProfileActions
export type ProfileState = {
profile: User | null
}
export type ProfileActions = {
refreshProfile: () => Promise<void>
}
export const createProfileStore = () => {
return createStore<ProfileStore>()(set => ({
profile: null,
refreshProfile: async () => {
const result = await getProfile()
set({profile: result.success ? result.data : null})
},
}))
}