更新新用户登录后台修改密码和实名认证提示框和修改控制台展示和取消控制台中的个人中心跳转

This commit is contained in:
Eamon-meng
2025-07-08 13:52:13 +08:00
parent 5cff89b60c
commit b261f1e9aa
7 changed files with 390 additions and 213 deletions

View File

@@ -0,0 +1,70 @@
'use client'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import {useState, useEffect} from 'react'
import {User} from '@/lib/models'
export function PasswordSetupWrapper({profile}: {profile: User}) {
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [showRealnameDialog, setShowRealnameDialog] = useState(false)
useEffect(() => {
// 每次profile变化时都检查是否需要显示弹窗
if (!profile.has_password) {
setShowPasswordDialog(true)
}
else if (!profile.id_token) {
setShowRealnameDialog(true)
}
}, [profile.has_password, profile.id_token])
const handleDismiss = (type: 'password' | 'realname') => {
// 可选使用sessionStorage只在当前会话期间记住关闭状态
if (typeof window !== 'undefined') {
sessionStorage.setItem(`dismissed${type === 'password' ? 'PasswordSetup' : 'RealnameAuth'}`, 'true')
}
}
return (
<>
{showPasswordDialog && (
<ChangePasswordDialog
triggerClassName="hidden"
open={showPasswordDialog}
onOpenChange={(open) => {
setShowPasswordDialog(open)
if (!open) {
handleDismiss('password')
if (!profile.id_token) {
setShowRealnameDialog(true)
}
}
}}
onSuccess={() => {
setShowPasswordDialog(false)
if (!profile.id_token) {
setShowRealnameDialog(true)
}
}}
/>
)}
{showRealnameDialog && (
<RealnameAuthDialog
hasAuthenticated={!!profile.id_token}
triggerClassName="hidden"
open={showRealnameDialog}
onOpenChange={(open) => {
setShowRealnameDialog(open)
if (!open) {
handleDismiss('realname')
}
}}
onSuccess={() => {
setShowRealnameDialog(false)
}}
/>
)}
</>
)
}

View File

@@ -4,6 +4,7 @@ import Navbar from './_client/navbar'
import Layout from './_client/layout'
import {getProfile} from '@/actions/auth'
import {redirect} from 'next/navigation'
import {PasswordSetupWrapper} from './_client/passwordSetupWrapper'
export type AdminLayoutProps = {
children: ReactNode
@@ -12,6 +13,7 @@ export type AdminLayoutProps = {
export default async function AdminLayout(props: AdminLayoutProps) {
const resp = await getProfile()
const profile = resp.success ? resp.data : null
if (!profile) {
redirect('/login')
}
@@ -20,7 +22,15 @@ export default async function AdminLayout(props: AdminLayoutProps) {
<Layout
navbar={<Navbar/>}
header={<Header profile={profile}/>}
content={props.children}
content={(
<>
{props.children}
{/* 需要时显示密码设置和实名认证 */}
{(!profile?.has_password && !profile?.id_token) && (
<PasswordSetupWrapper profile={profile}/>
)}
</>
)}
/>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import {useEffect, useRef, useState} from 'react'
import {useEffect, useRef} from 'react'
import Page from '@/components/page'
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {Form, FormField} from '@/components/ui/form'
@@ -16,17 +16,11 @@ 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 {update} from '@/actions/user'
import RechargeModal from '@/components/composites/recharge'
import {useRouter} from 'next/navigation'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
export type ProfilePageProps = {}
@@ -83,7 +77,10 @@ export default function ProfilePage(props: ProfilePageProps) {
? (
<>
<p className="text-sm">使</p>
<Button theme="outline" className="w-24" onClick={() => router.push('/admin/identify')}></Button>
<RealnameAuthDialog
hasAuthenticated={!!profile?.id_token}
triggerClassName="w-24"
/>
</>
)
: (
@@ -109,7 +106,7 @@ export default function ProfilePage(props: ProfilePageProps) {
<h3 className="font-normal"></h3>
<div className="flex gap-4 items-center">
<p>{profile.phone}</p>
<PasswordForm profile={profile}/>
<ChangePasswordDialog triggerClassName="w-24 h-9"/>
</div>
</div>
@@ -297,196 +294,3 @@ function BasicForm(props: {
</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.refine(
data =>
/^(?=.*[a-z])(?=.*[A-Z]).{6,}$/.test(data.password),
{
message: '密码需包含大小写字母且不少于6位',
path: ['password'],
},
),
),
defaultValues: {
phone: '',
captcha: '',
code: '',
password: '',
confirm_password: '',
},
})
const router = useRouter()
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)
// 立即跳转到登录页
router.replace('/login')
}
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>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import {useEffect, useRef, 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 {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'
interface ChangePasswordDialogProps {
triggerClassName?: string
open?: boolean
onOpenChange?: (open: boolean) => void
onSuccess?: () => void
}
export function ChangePasswordDialog({
triggerClassName,
open,
onOpenChange,
onSuccess,
}: ChangePasswordDialogProps) {
const [internalOpen, setInternalOpen] = useState(false)
const router = useRouter()
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(
schema.refine(
data => /^(?=.*[a-z])(?=.*[A-Z]).{6,}$/.test(data.password),
{
message: '密码需包含大小写字母且不少于6位',
path: ['password'],
},
),
),
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(`保存成功,请重新登录`)
onSuccess?.()
actualOnOpenChange(false)
router.replace('/login')
}
catch (e) {
console.error(e)
toast.error(`保存失败`, {
description: e instanceof Error ? e.message : String(e),
})
}
})
// 验证码相关状态
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>
<Button theme="outline" className={triggerClassName || '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={() => {
actualOnOpenChange(false)
form.reset()
}}>
</Button>
<Button onClick={handler}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,65 @@
'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'
interface RealnameAuthDialogProps {
hasAuthenticated: boolean
triggerClassName?: string
open?: boolean
onOpenChange?: (open: boolean) => void
onSuccess?: () => void
}
export function RealnameAuthDialog({
hasAuthenticated,
triggerClassName,
open,
onOpenChange,
onSuccess,
}: RealnameAuthDialogProps) {
const [internalOpen, setInternalOpen] = useState(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>
<Button theme="outline" className={triggerClassName || 'w-24'}>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
<p>使</p>
<div className="flex justify-end gap-2">
<Button
theme="outline"
onClick={() => actualOnOpenChange(false)}
>
</Button>
<Button
onClick={() => {
router.push('/admin/identify')
onSuccess?.()
}}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,7 +2,7 @@
import {useProfileStore} from '@/components/stores-provider'
import {Button} from '@/components/ui/button'
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'
import {LoaderIcon, LogOutIcon, UserIcon, UserPenIcon} from 'lucide-react'
import {LogOutIcon, UserIcon} from 'lucide-react'
import {usePathname, useRouter} from 'next/navigation'
import {logout} from '@/actions/auth'
import {HoverCard, HoverCardContent, HoverCardTrigger} from '@/components/ui/hover-card'
@@ -27,8 +27,18 @@ export default function UserCenter(props: UserCenterProps) {
// 展示与跳转
const pathname = usePathname()
const toAdminPage = () => {
if (!pathname.startsWith('/admin')) {
const isAdminPage = pathname.startsWith('/admin') // 判断是否在后台页面
const displayName = () => {
if (props.profile.name) return props.profile.name // 优先显示用户名
if (props.profile.phone) {
const phone = props.profile.phone
return `${phone.substring(0, 3)}****${phone.substring(7)}`
}
return null
}
const handleAvatarClick = () => {
if (!isAdminPage) {
router.push('/admin')
}
}
@@ -39,23 +49,28 @@ export default function UserCenter(props: UserCenterProps) {
<Button
theme="ghost"
className="flex gap-2 items-center h-12"
onClick={toAdminPage}
onClick={handleAvatarClick}
>
<Avatar>
<AvatarImage src={props.profile.avatar} alt="avatar"/>
<AvatarFallback className="bg-primary-muted"><UserIcon/></AvatarFallback>
</Avatar>
<span>{props.profile.name}</span>
{/* 根据是否在后台页面显示不同内容 */}
{isAdminPage ? (
<span>{displayName() || '用户'}</span> // 后台显示姓名/脱敏手机号/默认"用户"
) : (
<span></span> // 前台显示"进入控制台"
)}
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-36 p-1" align="end">
<Button
{/* <Button
theme="ghost"
className="w-full justify-start text-sm h-9 px-3"
onClick={() => router.push('/admin/profile')}>
<UserPenIcon/>
个人中心
</Button>
</Button> */}
<Button
theme="ghost"
color="fail"

View File

@@ -4,6 +4,7 @@ export type User = {
id: number
admin_id: number
phone: string
has_password: boolean
username: string
email: string
name: string