开启 ppr 优化渲染性能
This commit is contained in:
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
8
src/app/(auth)/challenge/route.ts
Normal file
8
src/app/(auth)/challenge/route.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}/>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
9
src/app/(auth)/redeem/route.ts
Normal file
9
src/app/(auth)/redeem/route.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={[
|
||||
<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(' ')}
|
||||
onClick={() => {
|
||||
if (profile) {
|
||||
router.push('/admin/purchase') // 已登录用户跳转购买页
|
||||
}
|
||||
else {
|
||||
router.push('/login?redirect=/admin/purchase') // 未登录跳转登录页并携带重定向路径
|
||||
}
|
||||
}}
|
||||
>
|
||||
免费试用
|
||||
</button>
|
||||
].join(' ')}/>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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'},
|
||||
]}/>
|
||||
<Suspense>
|
||||
<Purchase/>
|
||||
</Suspense>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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,12 +123,8 @@ 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>
|
||||
) : (
|
||||
<Suspense>
|
||||
<IfNotIdentofy>
|
||||
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full">去认证</Button>
|
||||
@@ -179,7 +175,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}/>}
|
||||
>
|
||||
<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}
|
||||
<RealnameAuthDialog
|
||||
hasAuthenticated={!!profile.id_token}
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.id_token}
|
||||
/>
|
||||
<ChangePasswordDialog
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.has_password}
|
||||
/>
|
||||
</Layout>
|
||||
</Content>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
<Suspense>
|
||||
<Purchase/>
|
||||
</Suspense>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
<Suspense>
|
||||
<ShortResource/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
<TabsContent value="long" className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<LongResource/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Page>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -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">
|
||||
<div className="flex gap-4 items-end">
|
||||
<FormField<Schema> name="code" label="验证码" className="flex-auto">
|
||||
{({field}) => (
|
||||
<div className="flex gap-4">
|
||||
<Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/>
|
||||
<Button theme="outline" type="button" className="w-36" onClick={() => sendVerifier()}>
|
||||
{captchaWait > 0 ? `重新发送(${captchaWait})` : `获取短信令牌`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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}/>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '请选择套餐'}),
|
||||
|
||||
@@ -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()}`)
|
||||
|
||||
@@ -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,7 +99,24 @@ export default function Right() {
|
||||
{price}
|
||||
</span>
|
||||
</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">
|
||||
{({id, field}) => (
|
||||
@@ -148,16 +165,17 @@ export default function Right() {
|
||||
)}
|
||||
</FormField>
|
||||
<Pay
|
||||
method={method}
|
||||
amount={price}
|
||||
method={props.method}
|
||||
balance={profile.balance}
|
||||
amount={props.price}
|
||||
resource={{
|
||||
type: 2,
|
||||
long: {
|
||||
mode: Number(mode),
|
||||
live: Number(live),
|
||||
daily_limit: dailyLimit,
|
||||
expire: Number(expire),
|
||||
quota: quota,
|
||||
mode: Number(props.mode),
|
||||
live: Number(props.live),
|
||||
daily_limit: props.dailyLimit,
|
||||
expire: Number(props.expire),
|
||||
quota: props.quota,
|
||||
},
|
||||
}}/>
|
||||
</>
|
||||
@@ -165,7 +183,5 @@ export default function Right() {
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,30 +124,25 @@ 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}
|
||||
元
|
||||
{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}
|
||||
元
|
||||
- {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)}
|
||||
元
|
||||
{(balance - Number(props.amount)).toFixed(2)} 元
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,7 +161,6 @@ export default function Pay(props: PayProps) {
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -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,7 +91,24 @@ export default function Right() {
|
||||
{price}
|
||||
</span>
|
||||
</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">
|
||||
{({id, field}) => (
|
||||
@@ -109,7 +124,7 @@ export default function Right() {
|
||||
<span className="text-sm text-gray-500">账户余额</span>
|
||||
</p>
|
||||
<p className="flex justify-between items-center">
|
||||
<span className="text-xl">{profile?.balance}</span>
|
||||
<span className="text-xl">{profile.balance}</span>
|
||||
<RechargeModal/>
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,16 +157,17 @@ export default function Right() {
|
||||
)}
|
||||
</FormField>
|
||||
<Pay
|
||||
method={method}
|
||||
amount={price}
|
||||
method={props.method}
|
||||
balance={profile.balance}
|
||||
amount={props.price}
|
||||
resource={{
|
||||
type: 1,
|
||||
short: {
|
||||
mode: Number(mode),
|
||||
live: Number(live),
|
||||
quota: quota,
|
||||
expire: Number(expire),
|
||||
daily_limit: dailyLimit,
|
||||
mode: Number(props.mode),
|
||||
live: Number(props.live),
|
||||
quota: props.quota,
|
||||
expire: Number(props.expire),
|
||||
daily_limit: props.dailyLimit,
|
||||
},
|
||||
}}/>
|
||||
</>
|
||||
@@ -159,7 +175,5 @@ export default function Right() {
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
48
src/components/free-trial.tsx
Normal file
48
src/components/free-trial.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/components/send-msg.tsx
Normal file
83
src/components/send-msg.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
56
src/components/stores/layout.tsx
Normal file
56
src/components/stores/layout.tsx
Normal 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)
|
||||
}
|
||||
48
src/components/stores/profile.tsx
Normal file
48
src/components/stores/profile.tsx
Normal 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)
|
||||
}
|
||||
@@ -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
91
src/lib/cap/index.ts
Normal 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 () => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -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})
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user