开启 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]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"git.rebaseWhenSync": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[json]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.defaultFormatter": "vscode.json-language-features"
},
"git.rebaseWhenSync": true
}

10832
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',
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
cacheComponents: true,
reactCompiler: true,
experimental: {
turbopackFileSystemCacheForDev: true,
},
allowedDevOrigins: [
'192.168.3.42',
'192.168.3.14',

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
'use server'
import {ApiResponse} from '@/lib/api'
import {callByDevice} from '@/actions/base'
import {cookies} from 'next/headers'
import crypto from 'crypto'
import {getCap} from '@/lib/cap'
export async function sendSMS(props: {
phone: string
@@ -17,7 +16,9 @@ export async function sendSMS(props: {
message: '请输入验证码',
}
}
const valid = await checkCaptcha(props.captcha)
const cap = await getCap()
const valid = await cap.validateToken(props.captcha)
if (!valid) {
return {
success: false,
@@ -37,32 +38,3 @@ export async function sendSMS(props: {
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 {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {EyeClosedIcon, EyeIcon} from 'lucide-react'
import {useState, ReactNode} from 'react'
import {useState, ReactNode, useEffect, Suspense} from 'react'
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 {toast} from 'sonner'
import {useRouter} from 'next/navigation'
import {login} from '@/actions/auth'
import {useProfileStore} from '@/components/stores-provider'
import Captcha from './captcha'
import {login, LoginMode} from '@/actions/auth'
import {useProfileStore} from '@/components/stores/profile'
import SendMsg from '@/components/send-msg'
import '@cap.js/widget'
const smsSchema = zod.object({
username: zod.string().length(11, '请输入正确的手机号码'),
@@ -31,15 +32,23 @@ const pwdSchema = zod.object({
export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard(props: {
defaultMode?: 'phone_code' | 'password'
redirect?: string
}) {
export default function LoginCard() {
const router = useRouter()
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 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>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: {
@@ -56,9 +65,12 @@ export default function LoginCard(props: {
throw new Error(result.message || '请检查账号和密码/验证码是否正确')
}
const params = new URLSearchParams(window.location.search)
// 登录成功
await refreshProfile()
router.push(props.redirect || '/')
updateLoginMode(mode)
router.push(params.get('redirect') || '/')
toast.success('登录成功', {
description: '欢迎回来!',
})
@@ -114,7 +126,7 @@ export default function LoginCard(props: {
placeholder="请输入验证码"
autoComplete="one-time-code"
/>
<Captcha/>
<SendMsgByUsername/>
</div>
) : (
<div className="relative">
@@ -193,3 +205,9 @@ function Tab(props: {
</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 LoginCard from './login-card'
export type LoginPageProps = {
searchParams: Promise<{
type?: 'phone_code' | 'password'
redirect?: string
}>
}
export type LoginPageProps = {}
export default async function LoginPage(props: LoginPageProps) {
const searchParams = await props.searchParams
return (
<main className={merge(
`relative`,
@@ -27,10 +20,7 @@ export default async function LoginPage(props: LoginPageProps) {
</Link>
{/* 登录表单 */}
<LoginCard
defaultMode={searchParams.type}
redirect={searchParams.redirect}
/>
<LoginCard/>
</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 group from './_assets/Group.webp'
import {merge} from '@/lib/utils'
import {useRouter} from 'next/navigation'
import {useProfileStore} from '@/components/stores-provider'
import FreeTrial from '@/components/free-trial'
const formSchema = z.object({
companyName: z.string().min(2, '企业名称至少2个字符'),
@@ -27,7 +26,6 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>
export default function CollectPage() {
const router = useRouter()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -38,8 +36,6 @@ export default function CollectPage() {
purpose: '',
},
})
// 从store中获取用户信息
const profile = useProfileStore(store => store.profile)
return (
<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">
5000IP
</div>
<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>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}/>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
'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 Image from 'next/image'
import {HeaderContext} from './_components/header/common'
@@ -10,7 +10,7 @@ import MobileMenu from './_components/header/menu-mobile'
import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp'
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 {MenuIcon} from 'lucide-react'
import down from '@/assets/header/down.svg'
@@ -151,33 +151,9 @@ export default function Header(props: HeaderProps) {
</nav>
{/* 登录 */}
<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>
<Suspense>
<ProfileOrLogin/>
</Suspense>
</Wrap>
</div>
@@ -271,3 +247,36 @@ function MenuItem(props: {
</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 Wrap from '@/components/wrap'
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 banner from './_assets/banner.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_2 from './_assets/s4-2-2.webp'
import s4_2_3 from './_assets/s4-2-3.webp'
import FreeTrial from '@/components/free-trial'
export default function Home() {
const router = useRouter()
// 从store中获取用户信息
const profile = useProfileStore(store => store.profile)
return (
<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>
</div>
<button
className={[
`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`,
].join(' ')}
onClick={() => {
if (profile) {
router.push('/admin/purchase') // 已登录用户跳转购买页
}
else {
router.push('/login?redirect=/admin/purchase') // 未登录跳转登录页并携带重定向路径
}
}}
>
</button>
<FreeTrial className={[
`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`,
].join(' ')}/>
</Wrap>
</section>

View File

@@ -1,6 +1,7 @@
import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap'
import Purchase, {TabType} from '@/components/composites/purchase'
import {Suspense} from 'react'
export type ProductPageProps = {
searchParams?: Promise<{
@@ -8,14 +9,16 @@ export type ProductPageProps = {
}>
}
export default async function ProductPage(props: ProductPageProps) {
export default function ProductPage(props: ProductPageProps) {
return (
<main className="mt-20">
<Wrap className="flex flex-col py-8 gap-4">
<BreadCrumb items={[
{label: '产品中心', href: '/product'},
]}/>
<Purchase/>
<Suspense>
<Purchase/>
</Suspense>
</Wrap>
</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'
import {ComponentProps, ReactNode, useState} from 'react'
import {merge} from '@/lib/utils'
import {useLayoutStore} from '@/components/stores-provider'
import Link from 'next/link'
import {ReactNode, Suspense, use, useState} from 'react'
import Image from 'next/image'
import logoAvatar from '../_assets/logo-avatar.svg'
import logoText from '../_assets/logo-text.svg'
import Link from 'next/link'
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 {UserRound} from 'lucide-react'
import {UserRoundPen} from 'lucide-react'
import {IdCard} from 'lucide-react'
import {LockKeyhole} from 'lucide-react'
import {Wallet} from 'lucide-react'
import {ShoppingCart} from 'lucide-react'
import {Package} from 'lucide-react'
import {HardDriveUpload} from 'lucide-react'
import {Eye} from 'lucide-react'
import {Archive} from 'lucide-react'
import {ArchiveRestore} from 'lucide-react'
import {Archive, ArchiveRestore, Eye, HardDriveUpload, IdCard, LockKeyhole, Package, PanelLeftCloseIcon, PanelLeftOpenIcon, ShoppingCart, UserRound, UserRoundPen, Wallet} from 'lucide-react'
import {merge} from '@/lib/utils'
import logoAvatar from '@/assets/logo-avatar.svg'
import logoText from '@/assets/logo-text.svg'
import {useLayoutStore} from '@/components/stores/layout'
import {useProfileStore} from '@/components/stores/profile'
import {User} from '@/lib/models'
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)
return (

View File

@@ -9,9 +9,9 @@ import zod from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import {Identify} from '@/actions/user'
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 {useProfileStore} from '@/components/stores-provider'
import {useProfileStore} from '@/components/stores/profile'
import {merge} from '@/lib/utils'
import banner from './_assets/banner.webp'
import personal from './_assets/personal.webp'
@@ -123,63 +123,60 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</p>
</div>
{profile?.id_token ? (
<p className="flex gap-2 items-center">
<CheckCircleIcon className="text-done"/>
<span></span>
</p>
) : (
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild>
<Button className="w-full"></Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
{step === 'form' ? `实名认证` : `扫码完成认证`}
</DialogTitle>
{step === 'form' && (
<Form form={form} handler={handler} className="flex flex-col gap-4">
<FormField<Schema> name="name" label="姓名">
{({id, field}) => (
<input
{...field}
id={id}
placeholder="请输入姓名"
className="border rounded p-2 w-full"
autoComplete="name"
/>
)}
</FormField>
<FormField<Schema> name="iden_no" label="身份证号">
{({id, field}) => (
<input
{...field}
id={id}
placeholder="请输入身份证号"
className="border rounded p-2 w-full"
/>
)}
</FormField>
<DialogFooter>
<Button type="submit" className="w-full mt-4"></Button>
</DialogFooter>
</Form>
)}
{step === 'scan' && (
<div className="flex flex-col gap-4 items-center">
<canvas ref={canvas} width={256} height={256}/>
<p className="text-sm text-gray-600"></p>
<Button onClick={async () => {
await refreshProfile()
setOpenDialog(false)
}}>
</Button>
</div>
)}
</DialogContent>
</Dialog>
)}
<Suspense>
<IfNotIdentofy>
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild>
<Button className="w-full"></Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
{step === 'form' ? `实名认证` : `扫码完成认证`}
</DialogTitle>
{step === 'form' && (
<Form form={form} handler={handler} className="flex flex-col gap-4">
<FormField<Schema> name="name" label="姓名">
{({id, field}) => (
<input
{...field}
id={id}
placeholder="请输入姓名"
className="border rounded p-2 w-full"
autoComplete="name"
/>
)}
</FormField>
<FormField<Schema> name="iden_no" label="身份证号">
{({id, field}) => (
<input
{...field}
id={id}
placeholder="请输入身份证号"
className="border rounded p-2 w-full"
/>
)}
</FormField>
<DialogFooter>
<Button type="submit" className="w-full mt-4"></Button>
</DialogFooter>
</Form>
)}
{step === 'scan' && (
<div className="flex flex-col gap-4 items-center">
<canvas ref={canvas} width={256} height={256}/>
<p className="text-sm text-gray-600"></p>
<Button onClick={async () => {
await refreshProfile()
setOpenDialog(false)
}}>
</Button>
</div>
)}
</DialogContent>
</Dialog>
</IfNotIdentofy>
</Suspense>
</section>
</div>
</div>
@@ -235,3 +232,15 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</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 Header from './_client/header'
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'
import {Shell, Content, Header, Navbar, Mask} from './clients'
export type AdminLayoutProps = {
export default function Template(props: {
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 (
<Layout
navbar={<Navbar/>}
header={<Header profile={profile}/>}
>
{props.children}
<RealnameAuthDialog
hasAuthenticated={!!profile.id_token}
triggerClassName="hidden"
defaultOpen={!profile.id_token}
/>
<ChangePasswordDialog
triggerClassName="hidden"
defaultOpen={!profile.has_password}
/>
</Layout>
<div className="relative h-dvh overflow-hidden">
{/* 外壳 */}
<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}
</Content>
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
'use client'
import {ReactNode, useEffect, useState} from 'react'
import {useCallback, useEffect, useState} from 'react'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
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 {Box, Eraser, Search, Timer} from 'lucide-react'
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 {ExtraResp, PageRecord} from '@/lib/api'
import {Resource} from '@/lib/models'
import {ExtraResp} from '@/lib/api'
import {listResourceShort} from '@/actions/resource'
import zod from 'zod'
import {useSearchParams} from 'next/navigation'
import {useForm} from 'react-hook-form'
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 [data, setData] = useState<ExtraResp<typeof listResourceShort>>({
page: 1,
@@ -34,7 +37,30 @@ export default function ShortResource(props: ShortResourceProps) {
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')
try {
const type = {
@@ -63,54 +89,19 @@ export default function ShortResource(props: ShortResourceProps) {
setStatus('done')
}
else {
throw new Error('Failed to load short resource')
throw new Error(`Failed to load short resource`)
}
}
catch (e) {
setStatus('fail')
}
}
}, [form, setStatus])
useEffect(() => {
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 (
<>
@@ -166,7 +157,7 @@ export default function ShortResource(props: ShortResourceProps) {
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm">使</Label>
<Label className="text-sm"></Label>
<div className="flex items-center">
<FormField name="expire_after">
{({field}) => (
@@ -253,11 +244,7 @@ export default function ShortResource(props: ShortResourceProps) {
},
{
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span>
{row.original.short.live / 60}
{' '}
</span>
<span>{row.original.short.live / 60}</span>
),
},
{
@@ -270,15 +257,12 @@ export default function ShortResource(props: ShortResourceProps) {
: <span className="text-red-500"></span>}
<span>|</span>
<span>
{row.original.short.last_at
&& new Date(row.original.short.last_at).toDateString() === new Date().toDateString()
{row.original.short.last_at && isSameDay(row.original.short.expire_at, new Date())
? row.original.short.daily
: 0}/{row.original.short.quota}
: 0
}/{row.original.short.quota}
</span>
<span>|</span>
<span>
{intlFormatDistance(row.original.short.expire_at, new Date())}
</span>
</div>
) : row.original.short.type === 2 ? (
<div className="flex gap-1">
@@ -301,28 +285,12 @@ export default function ShortResource(props: ShortResourceProps) {
),
},
{
accessorKey: 'last_at',
header: '最近使用时间',
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}) => row.original.short.last_at
? format(row.original.short.last_at, 'yyyy-MM-dd HH:mm')
: '-',
},
{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 ShortResource from '@/app/admin/resources/_client/short'
import LongResource from '@/app/admin/resources/_client/long'
import {Suspense} from 'react'
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>
</TabsList>
<TabsContent value="short" className="flex flex-col gap-4">
<ShortResource/>
<Suspense>
<ShortResource/>
</Suspense>
</TabsContent>
<TabsContent value="long" className="flex flex-col gap-4">
<LongResource/>
<Suspense>
<LongResource/>
</Suspense>
</TabsContent>
</Tabs>
</Page>

View File

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

View File

@@ -1,10 +1,12 @@
'use server'
import './globals.css'
import {ReactNode} from 'react'
import {Metadata} from 'next'
import './globals.css'
import {Toaster} from '@/components/ui/sonner'
import StoresProvider from '@/components/stores-provider'
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> {
return {
@@ -12,19 +14,29 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
export default async function RootLayout({
children,
}: Readonly<{
export default async function RootLayout(props: Readonly<{
children: ReactNode
}>) {
return (
<html lang="zh-CN">
<body>
<StoresProvider>
<Effects>{children}</Effects>
</StoresProvider>
<StoreProviders>
<Effects>{props.children}</Effects>
</StoreProviders>
<Toaster position="top-center" richColors expand/>
</body>
</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'
import {useEffect, useRef, useState} from 'react'
import {useState} from 'react'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form'
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 * as z from 'zod'
import {toast} from 'sonner'
import {useRouter} from 'next/navigation'
import {sendSMS} from '@/actions/verify'
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 {
triggerClassName?: string
@@ -33,20 +47,6 @@ export function ChangePasswordDialog({
const actualOpen = open !== undefined ? open : internalOpen
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>({
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 (
<Dialog open={actualOpen} onOpenChange={actualOnOpenChange}>
<DialogTrigger asChild>
@@ -155,29 +109,15 @@ export function ChangePasswordDialog({
)}
</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">
{({field}) => (
<div className="flex gap-4">
<div className="flex gap-4 items-end">
<FormField<Schema> name="code" label="验证码" className="flex-auto">
{({field}) => (
<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">
@@ -212,3 +152,9 @@ export function ChangePasswordDialog({
</Dialog>
)
}
function SendMsgByPhone() {
const form = useFormContext<Schema>()
const phone = form.watch('phone')
return <SendMsg phone={phone}/>
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
'use client'
import {useContext, useMemo} from 'react'
import {Suspense, use, useContext, useMemo} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.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 Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button'
@@ -99,73 +99,89 @@ export default function Right() {
{price}
</span>
</p>
{profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
<div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={method}
amount={price}
resource={{
type: 2,
long: {
mode: Number(mode),
live: Number(live),
daily_limit: dailyLimit,
expire: Number(expire),
quota: quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)}
<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">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
<div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={props.method}
balance={profile.balance}
amount={props.price}
resource={{
type: 2,
long: {
mode: Number(props.mode),
live: Number(props.live),
daily_limit: props.dailyLimit,
expire: Number(props.expire),
quota: props.quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

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

View File

@@ -1,5 +1,5 @@
'use client'
import {useMemo} from 'react'
import {Suspense, use, useMemo} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
@@ -8,7 +8,7 @@ import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.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 {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
@@ -18,8 +18,6 @@ import {useFormContext} from 'react-hook-form'
import {Card} from '@/components/ui/card'
export default function Right() {
const profile = useProfileStore(store => store.profile)
const form = useFormContext<Schema>()
const method = form.watch('pay_type')
@@ -93,73 +91,89 @@ export default function Right() {
{price}
</span>
</p>
{profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
<div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={method}
amount={price}
resource={{
type: 1,
short: {
mode: Number(mode),
live: Number(live),
quota: quota,
expire: Number(expire),
daily_limit: dailyLimit,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)}
<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">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
<div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={props.method}
balance={profile.balance}
amount={props.price}
resource={{
type: 1,
short: {
mode: Number(props.mode),
live: Number(props.live),
quota: props.quota,
expire: Number(props.expire),
daily_limit: props.dailyLimit,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

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

View File

@@ -1,5 +1,5 @@
'use client'
import {useProfileStore} from '@/components/stores-provider'
import {useProfileStore} from '@/components/stores/profile'
import {Button} from '@/components/ui/button'
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'
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 {User} from '@/lib/models'
type UserCenterProps = {
export default function UserCenter(props: {
profile: User
}
export default function UserCenter(props: UserCenterProps) {
}) {
const router = useRouter()
// 登录控制
const pathname = usePathname()
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 resp = await logout()
if (resp.success) {
refreshProfile().then()
refreshProfile()
router.replace('/')
}
}
// 展示与跳转
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 = () => {
const toAdminPage = () => {
if (!isAdminPage) {
router.push('/admin')
}
@@ -51,17 +40,13 @@ export default function UserCenter(props: UserCenterProps) {
<Button
theme="ghost"
className="flex gap-2 items-center h-12"
onClick={handleAvatarClick}
onClick={toAdminPage}
>
<Avatar>
<AvatarImage src={props.profile.avatar} alt="avatar"/>
<AvatarFallback className="bg-primary-muted"><UserIcon/></AvatarFallback>
</Avatar>
{isAdminPage ? (
<span>{displayName() || '用户'}</span>
) : (
<span></span>
)}
<span>{displayedName}</span>
</Button>
</HoverCardTrigger>
<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 {createContext, ReactNode, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
export type ClientStore = ClientState & ClientActions
type Point = 'sm' | 'md' | 'lg' | 'xl'
// store
export type ClientState = {
breakpoint: {
sm: boolean
@@ -13,10 +13,10 @@ export type ClientState = {
xl: boolean
}
}
export type ClientActions = {
setBreakpoints: (breakpoints: Partial<ClientState['breakpoint']>) => void
}
export type ClientStore = ClientState & ClientActions
export const createClientStore = () => {
return createStore<ClientStore>()(persist(
@@ -27,7 +27,6 @@ export const createClientStore = () => {
lg: false,
xl: false,
},
setBreakpoints: breakpoints => setState(state => ({
breakpoint: {
...state.breakpoint,
@@ -35,7 +34,6 @@ export const createClientStore = () => {
},
})),
}),
{
name: 'client-store',
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
quota: number
used: number
daily_limit: number
daily_used: number
last_at: Date
created_at: Date
updated_at: Date
daily: number
last_at?: Date
}
type ResourceLong = {
@@ -22,12 +18,8 @@ type ResourceLong = {
expire_at: Date
quota: number
used: number
daily_limit: number
daily_used: number
last_at: Date
created_at: Date
updated_at: Date
daily: number
last_at?: Date
}
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})
},
}))
}