Files
web/src/app/admin/profile/page.tsx

466 lines
15 KiB
TypeScript
Raw Normal View History

'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="flex-row items-start">
<div className={`flex-3/4 flex flex-col gap-4`}>
{/* banner */}
<section className={`flex-none basis-40 relative rounded-lg overflow-hidden flex 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-none basis-40 flex gap-4`}>
<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 px-8`}>
{!profile?.id_token
? <>
<p className={`text-sm`}>使</p>
<Button theme={`outline`} className={`mx-16 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-auto shrink-0 basis-80 rounded-lg bg-white p-4 flex 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 basis-80">
<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`}>
<h3 className={`font-normal`}></h3>
<Form
form={form}
handler={handler}
className={merge(
`grid grid-cols-2 gap-4 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-52`}/>
)}
</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-52`}/>
)}
</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-52`}/>
)}
</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-52`}/>
)}
</FormField>
<div className={`flex justify-end gap-4 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>
)
}