Files
web/src/app/admin/profile/page.tsx
2025-06-11 10:10:32 +08:00

481 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import {useEffect, useRef, useState} from 'react'
import Page from '@/components/page'
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {Form, FormField} from '@/components/ui/form'
import {Button} from '@/components/ui/button'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod'
import {useProfileStore} from '@/components/providers/StoreProvider'
import {toast} from 'sonner'
import {CheckCircle, QrCodeIcon} from 'lucide-react'
import * as qrcode from 'qrcode'
import Image from 'next/image'
import banner from '@/app/admin/identify/_assets/banner.webp'
import {Input} from '@/components/ui/input'
import {merge} from '@/lib/utils'
import {User} from '@/lib/models'
import {update, updatePassword} from '@/actions/user'
import {
Dialog, DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {sendSMS} from '@/actions/verify'
import RechargeModal from '@/components/composites/recharge'
import {useRouter} from 'next/navigation'
export type ProfilePageProps = {}
export default function ProfilePage(props: ProfilePageProps) {
const router = useRouter()
const profile = useProfileStore(store => store.profile)
// ======================
// render
// ======================
if (!profile) {
return (
<Page>
<div className="flex flex-col gap-4">
<h3 className="text-lg font-bold">...</h3>
<p className="text-sm text-gray-600"></p>
</div>
</Page>
)
}
return (
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
<div className="flex-3/4 flex flex-col gap-4">
{/* banner */}
<section className="flex-none relative rounded-lg p-16 pr-4 overflow-hidden flex max-sm:flex-col flex-col gap-4 pl-8 justify-center">
<Image src={banner} alt="背景图" aria-hidden className="absolute inset-0 w-full h-full object-cover"/>
<h3 className="text-lg font-bold z-10 relative">HTTP邀请您参与</h3>
<p className="text-sm text-gray-600 z-10 relative"></p>
</section>
{/* 块信息 */}
<div className="flex gap-4 max-md:flex-col max-sm:flex-col">
<Card className="flex-1 ">
<CardHeader>
<CardTitle className="font-normal"></CardTitle>
</CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8">
<p className="text-xl">{profile?.balance}</p>
<RechargeModal classNames={{
trigger: `h-10 px-6`,
}}/>
</CardContent>
</Card>
<Card className="flex-1">
<CardHeader>
<CardTitle className="font-normal"></CardTitle>
</CardHeader>
<CardContent className="flex-auto flex justify-between items-center gap-4 px-4 pr-8">
{!profile?.id_token
? (
<>
<p className="text-sm">使</p>
<Button theme="outline" className="w-24" onClick={() => router.push('/admin/identify')}></Button>
</>
)
: (
<>
<p className="flex flex-col gap-1">
<span>{profile.name}</span>
<span className="text-sm">{profile.id_no}</span>
</p>
<p className="flex gap-1 items-center">
<CheckCircle className="text-done" size={18}/>
<span></span>
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="flex-none rounded-lg bg-white p-4 flex max-sm:flex-col flex-col gap-8">
{/* 安全信息 */}
<div className="flex flex-col gap-4">
<h3 className="font-normal"></h3>
<div className="flex gap-4 items-center">
<p>{profile.phone}</p>
<PasswordForm profile={profile}/>
</div>
</div>
{/* 基本信息 */}
<BasicForm profile={profile}/>
</div>
</div>
{/* 侧边栏:客服经理信息 */}
<Aftersale profile={profile}/>
</Page>
)
}
function Aftersale(props: {
profile: User
}) {
const admin = props.profile.admin_id
const canvasRef = useRef(null)
useEffect(() => {
if (admin && canvasRef.current) {
qrcode.toCanvas(canvasRef.current, String(admin), {
width: 180,
margin: 0,
}).catch((err) => {
console.error(err)
})
}
}, [admin])
return (
<Card className="flex-none max-sm:flex-col">
<CardHeader>
<CardTitle>
<QrCodeIcon size={18}/>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<p className="text-weak text-sm">
1.100+IP代理资源免费测试使
</p>
<p className="text-weak text-sm">
2.线
</p>
<p className="text-weak text-sm">
3.1V1专属售后答疑7*24线
</p>
</div>
<div className="flex flex-col gap-4 items-center">
<p></p>
<div>
<canvas ref={canvasRef} width="180" height="180" className="mx-auto bg-muted"/>
</div>
<p className="text-xs text-weak">
<br/>
</p>
</div>
</CardContent>
</Card>
)
}
function BasicForm(props: {
profile: User
}) {
const schema = z.object({
username: z.string(),
email: z.string().email('请输入正确的邮箱'),
contact_qq: z.string(),
contact_wechat: z.string(),
})
type Schema = z.infer<typeof schema>
const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
username: props.profile.username || '',
email: props.profile.email || '',
contact_qq: props.profile.contact_qq || '',
contact_wechat: props.profile.contact_wechat || '',
},
})
const handler = form.handleSubmit(async (value) => {
try {
const resp = await update(value)
if (!resp.success) {
throw new Error(resp.message)
}
await refreshProfile()
toast.success(`保存成功`)
}
catch (e) {
console.error(e)
toast.error(`保存失败`, {
description: e instanceof Error ? e.message : String(e),
})
}
})
const refreshProfile = useProfileStore(store => store.refreshProfile)
return (
<div className="flex flex-col gap-4 flex-1 w-full">
<h3 className="font-normal"></h3>
<Form
form={form}
handler={handler}
className={merge(
`grid grid-cols-2 gap-4 max-md:grid-cols-1 items-start`,
)}
>
<FormField<Schema>
name="username"
label={<span className="w-full flex justify-end"></span>}
className="grid grid-cols-[48px_1fr] grid-rows-[auto_auto] gap-x-4"
classNames={{
message: `col-start-2`,
}}
>
{({field}) => (
<Input {...field} placeholder="请输入用户名" className="w-48 max-xl:w-full"/>
)}
</FormField>
<FormField<Schema>
name="email"
label={<span className="w-full flex justify-end"></span>}
className="grid grid-cols-[48px_1fr] grid-rows-[auto_auto] gap-x-4"
classNames={{
message: `col-start-2`,
}}
>
{({field}) => (
<Input {...field} placeholder="请输入邮箱" className="w-48 max-xl:w-full"/>
)}
</FormField>
<FormField<Schema>
name="contact_qq"
label={<span className="w-full flex justify-end">QQ</span>}
className="grid grid-cols-[48px_1fr] grid-rows-[auto_auto] gap-x-4"
classNames={{
message: `col-start-2`,
}}
>
{({field}) => (
<Input {...field} placeholder="请输入QQ号" className="w-48 max-xl:w-full"/>
)}
</FormField>
<FormField<Schema>
name="contact_wechat"
label={<span className="w-full flex justify-end"></span>}
className="grid grid-cols-[48px_1fr] grid-rows-[auto_auto] gap-x-4"
classNames={{
message: `col-start-2`,
}}
>
{({field}) => (
<Input {...field} placeholder="请输入微信号" className="w-48 max-xl:w-full"/>
)}
</FormField>
<div className="flex justify-end gap-4 md:col-span-2 justify-self-stretch">
<Button
theme="outline"
type="button"
onClick={() => form.reset({
username: props.profile.username || '',
email: props.profile.email || '',
contact_qq: props.profile.contact_qq || '',
contact_wechat: props.profile.contact_wechat || '',
})}>
</Button>
<Button></Button>
</div>
</Form>
</div>
)
}
function PasswordForm(props: {
profile: User
}) {
// ======================
// open
// ======================
const [open, setOpen] = useState(false)
// ======================
// form
// ======================
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(schema),
defaultValues: {
phone: '',
captcha: '',
code: '',
password: '',
confirm_password: '',
},
})
const handler = form.handleSubmit(async (value) => {
try {
const resp = await updatePassword({
phone: value.phone,
code: value.code,
password: value.password,
})
if (!resp.success) {
throw new Error(resp.message)
}
toast.success(`保存成功`)
setOpen(false)
}
catch (e) {
console.error(e)
toast.error(`保存失败`, {
description: e instanceof Error ? e.message : String(e),
})
}
})
// ======================
// phone code
// ======================
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(`验证码已发送,请注意查收`)
}
// ======================
// render
// ======================
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button theme="outline" className="w-24 h-9"></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Form form={form} handler={handler} className="flex flex-col gap-4 mt-4">
{/* 手机号 */}
<FormField<Schema> name="phone" label="手机号" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/>
)}
</FormField>
<FormField<Schema> name="captcha" label="验证码">
{({field}) => (
<div className="flex gap-4">
<Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/>
<Button className="p-0 bg-transparent" onClick={refreshCaptcha} type="button">
<img src={captchaUrl} alt="验证码" className="h-10"/>
</Button>
</div>
)}
</FormField>
{/* 短信令牌 */}
<FormField<Schema> name="code" label="短信令牌" className="flex-auto">
{({field}) => (
<div className="flex gap-4">
<Input {...field} placeholder="请输入验证码" autoComplete="one-time-code"/>
<Button theme="outline" type="button" className="w-36" onClick={() => sendVerifier()}>
{captchaWait > 0
? `重新发送(${captchaWait})`
: `获取短信令牌`
}
</Button>
</div>
)}
</FormField>
{/* 新密码 */}
<FormField<Schema> name="password" label="新密码" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
)}
</FormField>
{/* 确认密码 */}
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
)}
</FormField>
</Form>
<DialogFooter>
<Button
theme="outline"
type="button"
onClick={() => {
setOpen(false)
form.reset()
}}>
</Button>
<Button onClick={async (e) => {
const result = await handler(e)
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}