开启 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

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,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 31.22 27.99" style="enable-background:new 0 0 31.22 27.99;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="4.0657" y1="0.5307" x2="27.1835" y2="23.1816">
<stop offset="0" style="stop-color:#2470F9"/>
<stop offset="1" style="stop-color:#15BFFF"/>
</linearGradient>
<path class="st0" d="M26.68,14.05c0,0,4.72-7.37,0.03-14.05c0,0-6.79,2.75-8.62,10.61h-4.72c0,0-2.1-8.65-8.78-10.61
c0,0-3.84,4.91-0.22,13.99L0,19.26c0,0,8.65,0.79,13.1,7.08c0,0-0.28-4.98-1.33-6.55c0,0-4.01-0.96-3.91-5.24
c0,0,6.55,0.35,6.81,13.46h2c0,0-0.9-10.44,6.94-13.52c0,0,1.03,3.21-3.95,5.31c0,0-1.77,3.82-1.58,6.5c0,0,5.16-5.92,13.19-7.26
C31.26,19.02,28.54,16.11,26.68,14.05z M7.85,8.41l-0.48,2.58C5.84,9.08,6.42,4.87,6.42,4.87c3.34,1.62,3.34,5.45,3.34,5.45
L7.85,8.41z M24.56,10.99l-0.48-2.58l-1.91,1.91c0,0,0-3.82,3.34-5.45C25.52,4.87,26.09,9.08,24.56,10.99z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 60 27.99" style="enable-background:new 0 0 60 27.99;" xml:space="preserve">
<style type="text/css">
.st0{fill:#2470F9;}
</style>
<g>
<path class="st0" d="M15.62,2.94v0.77h-2.49v0.99h-2.21V3.71H7.19v0.99H4.97V3.71H2.48V2.94h2.49V2.03h2.21v0.91h3.73V2.03h2.21
v0.91H15.62z M14.89,14.21h0.73v0.78H2.48v-0.78h0.74v-2.59c0-0.57,0.28-0.85,0.85-0.85h9.96c0.57,0,0.85,0.28,0.85,0.85V14.21z
M5.42,9.49H3.21V5.77h2.21V9.49z M5.44,14.21h0.94v-2.66H5.65c-0.14,0-0.21,0.07-0.21,0.21V14.21z M8.35,5.23v4.8H6.14v-4.8H8.35z
M9.53,11.54H8.59v2.66h0.94V11.54z M12.06,5.23l-0.29,1.19h3.29V7.2h-0.9l0.76,2.83h-2.21L11.95,7.2h-0.38l-0.71,2.83H8.64
l1.22-4.8H12.06z M12.68,11.75c0-0.14-0.07-0.21-0.21-0.21h-0.73v2.66h0.94V11.75z"/>
<path class="st0" d="M20,4.95c0.07,0.21,0.1,0.43,0.1,0.69v8.52c0,0.67-0.34,1.01-0.99,1.01h-2.13l-0.42-0.77h1
c0.22,0,0.34-0.11,0.34-0.35v-3.96l-1.4,0.8v-0.85l1.4-0.8V5.3l-1.4,0.8V5.24l1.2-0.69l-0.73-2.56h2.2l0.42,1.47L21,2.65V3.5
l-1.22,0.7L20,4.95z M29.63,14.39v0.78l-1.61-0.52c-0.46-0.15-0.71-0.48-0.74-0.98l-0.8-10.37h-0.85l-0.41,10.63
c0,0.18,0.08,0.22,0.24,0.15l0.46-0.18l-0.34-5.14h1.04l0.42,6.4H26l-0.04-0.55l-1.37,0.45c-0.28,0.1-0.52,0.07-0.73-0.07
c-0.21-0.14-0.32-0.36-0.31-0.66l0.46-11.04h-0.5c-0.14,0-0.21,0.07-0.21,0.21v9.51c0,0.35-0.14,0.62-0.42,0.8l-2.12,1.35v-0.95
l0.38-0.25c0.1-0.07,0.14-0.15,0.14-0.27V3.38c0-0.57,0.28-0.85,0.85-0.85h4.5c0.15,0,0.27-0.07,0.34-0.21l0.17-0.32h2.21L29,2.73
c-0.13,0.29-0.35,0.48-0.66,0.55l0.83,10.7c0.01,0.17,0.1,0.28,0.22,0.32L29.63,14.39z"/>
<path class="st0" d="M32.76,6.67h1.81v8.49h-2.21v-8.4l-1.88,0.42V6.39l1.11-0.24c0.24-0.04,0.41-0.18,0.52-0.42l1.67-3.7h2.24
l-1.6,3.66c-0.18,0.46-0.53,0.74-1.02,0.84L32.76,6.67z M43.62,14.33v0.83l-3.28-0.56c-0.74-0.11-1.15-0.55-1.25-1.29L38.1,6.18
h-2.61V5.4h2.49l-0.48-3.36h2.21l0.49,3.36h1.01c0.14,0,0.21-0.07,0.21-0.21V2.43h2.07v2.9c0,0.57-0.28,0.85-0.85,0.85h-2.33
l1.04,7.24c0.04,0.36,0.25,0.57,0.6,0.62L43.62,14.33z"/>
<path class="st0" d="M44.48,2.3h4.82v0.78H48v4.54h1.16v0.78H48v5.04l1.3-0.25v0.85l-4.82,0.92v-0.85l1.3-0.25V8.41h-1.16V7.62
h1.16V3.08h-1.3V2.3z M57.62,14.23v0.8h-8.04v-0.8h2.91v-2.21h-2.66v-0.8h2.66V9.08h-1.81c-0.57,0-0.85-0.28-0.85-0.85V3.01
c0-0.57,0.28-0.85,0.85-0.85h5.83c0.57,0,0.85,0.28,0.85,0.85v5.21c0,0.57-0.28,0.85-0.85,0.85h-1.81v2.14h2.66v0.8h-2.66v2.21
H57.62z M52.16,2.94c-0.14,0-0.21,0.07-0.21,0.21v2.06h0.73V2.94H52.16z M51.95,8.08c0,0.14,0.07,0.21,0.21,0.21h0.52V6.01h-0.73
V8.08z M55.25,3.15c0-0.14-0.07-0.21-0.21-0.21h-0.52v2.27h0.73V3.15z M55.04,8.29c0.14,0,0.21-0.07,0.21-0.21V6.01h-0.73v2.28
H55.04z"/>
</g>
<g>
<path class="st0" d="M0.76,20.25c0.41,1.49,0.65,2.35,0.7,2.63h0.01c0.05-0.26,0.31-1.12,0.76-2.63h0.41
c0.52,1.79,0.68,2.31,0.73,2.56h0.01c0.08-0.33,0.21-0.78,0.73-2.56h0.42l-0.94,3.02H3.14c-0.31-1.08-0.66-2.26-0.72-2.56H2.42
c-0.06,0.31-0.37,1.32-0.74,2.56H1.22l-0.88-3.02H0.76z"/>
<path class="st0" d="M6.25,20.25c0.41,1.49,0.65,2.35,0.7,2.63h0.01c0.05-0.26,0.31-1.12,0.76-2.63h0.41
c0.52,1.79,0.68,2.31,0.73,2.56h0.01c0.08-0.33,0.21-0.78,0.73-2.56h0.42l-0.94,3.02H8.63c-0.31-1.08-0.66-2.26-0.72-2.56H7.91
c-0.06,0.31-0.37,1.32-0.74,2.56H6.71l-0.88-3.02H6.25z"/>
<path class="st0" d="M11.74,20.25c0.41,1.49,0.65,2.35,0.7,2.63h0.01c0.05-0.26,0.31-1.12,0.76-2.63h0.41
c0.52,1.79,0.68,2.31,0.73,2.56h0.01c0.08-0.33,0.21-0.78,0.73-2.56h0.42l-0.94,3.02h-0.44c-0.31-1.08-0.66-2.26-0.72-2.56H13.4
c-0.06,0.31-0.37,1.32-0.74,2.56H12.2l-0.88-3.02H11.74z"/>
<path class="st0" d="M16.7,23.27v-0.82h0.41v0.82H16.7z"/>
<path class="st0" d="M18.93,23.27v-4.41h0.4v4.41H18.93z"/>
<path class="st0" d="M23.41,22.66c0,0.27,0.02,0.52,0.04,0.61h-0.39c-0.02-0.07-0.04-0.21-0.05-0.43c-0.1,0.21-0.36,0.5-0.96,0.5
c-0.71,0-1.01-0.46-1.01-0.91c0-0.67,0.52-0.97,1.42-0.97c0.23,0,0.43,0,0.55,0v-0.29c0-0.29-0.09-0.65-0.72-0.65
c-0.56,0-0.65,0.29-0.71,0.53h-0.39c0.04-0.37,0.27-0.87,1.12-0.87c0.71,0,1.11,0.3,1.11,0.97V22.66z M23.01,21.76
c-0.11,0-0.36,0-0.54,0c-0.64,0-1.01,0.17-1.01,0.65c0,0.35,0.25,0.59,0.65,0.59c0.8,0,0.91-0.54,0.91-1.14V21.76z"/>
<path class="st0" d="M25.38,21.01c0-0.26,0-0.52-0.01-0.76h0.39c0.01,0.09,0.02,0.45,0.02,0.54c0.13-0.29,0.37-0.61,0.98-0.61
c0.55,0,1.01,0.32,1.01,1.14v1.95h-0.4v-1.89c0-0.5-0.2-0.83-0.7-0.83c-0.65,0-0.89,0.52-0.89,1.16v1.57h-0.4V21.01z"/>
<path class="st0" d="M30.13,18.86v1.87c0.16-0.29,0.45-0.55,0.97-0.55c0.45,0,1.02,0.24,1.02,1.17v1.92h-0.4v-1.85
c0-0.56-0.25-0.87-0.72-0.87c-0.57,0-0.87,0.36-0.87,1.05v1.67h-0.4v-4.41H30.13z"/>
<path class="st0" d="M36.38,22.39c0,0.29,0,0.74,0.01,0.88H36c-0.01-0.08-0.02-0.27-0.02-0.49c-0.14,0.35-0.44,0.56-0.95,0.56
c-0.47,0-0.99-0.21-0.99-1.1v-1.99h0.4v1.9c0,0.42,0.13,0.82,0.69,0.82c0.62,0,0.86-0.35,0.86-1.16v-1.56h0.4V22.39z"/>
<path class="st0" d="M38.35,18.86h0.4v0.62h-0.4V18.86z M38.35,20.25h0.4v3.02h-0.4V20.25z"/>
<path class="st0" d="M41.12,22.85v1.61h-0.4v-3.48c0-0.25,0-0.51-0.01-0.74h0.39c0.01,0.11,0.01,0.29,0.01,0.51
c0.18-0.34,0.5-0.58,1.03-0.58c0.71,0,1.21,0.59,1.21,1.5c0,1.08-0.58,1.66-1.31,1.66C41.53,23.34,41.26,23.12,41.12,22.85z
M42.93,21.7c0-0.67-0.32-1.16-0.88-1.16c-0.68,0-0.96,0.43-0.96,1.21c0,0.76,0.22,1.23,0.92,1.23
C42.61,22.98,42.93,22.49,42.93,21.7z"/>
<path class="st0" d="M44.93,23.27v-0.82h0.41v0.82H44.93z"/>
<path class="st0" d="M49.44,22.36c-0.14,0.53-0.49,0.98-1.21,0.98c-0.79,0-1.32-0.58-1.32-1.57c0-0.84,0.46-1.59,1.35-1.59
c0.81,0,1.12,0.58,1.17,0.99h-0.4c-0.08-0.33-0.3-0.63-0.77-0.63c-0.59,0-0.93,0.5-0.93,1.23c0,0.7,0.32,1.23,0.9,1.23
c0.41,0,0.66-0.21,0.8-0.62H49.44z"/>
<path class="st0" d="M53.68,21.75c0,0.85-0.48,1.59-1.39,1.59c-0.84,0-1.35-0.67-1.35-1.58c0-0.88,0.49-1.58,1.38-1.58
C53.14,20.18,53.68,20.81,53.68,21.75z M51.35,21.76c0,0.69,0.36,1.23,0.96,1.23c0.61,0,0.95-0.5,0.95-1.23
c0-0.7-0.34-1.23-0.96-1.23C51.67,20.53,51.35,21.05,51.35,21.76z"/>
<path class="st0" d="M55.41,21.03c0-0.26,0-0.54-0.01-0.78h0.39c0.01,0.1,0.02,0.34,0.02,0.5c0.13-0.29,0.4-0.58,0.89-0.58
c0.42,0,0.74,0.21,0.86,0.57c0.16-0.3,0.45-0.57,0.97-0.57c0.48,0,0.95,0.28,0.95,1.11v1.98h-0.4v-1.94c0-0.38-0.14-0.79-0.66-0.79
c-0.54,0-0.78,0.43-0.78,0.99v1.74h-0.39v-1.92c0-0.42-0.12-0.8-0.64-0.8c-0.54,0-0.8,0.46-0.8,1.06v1.66h-0.4V21.03z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

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