From b261f1e9aa021c3a5fcaf70df1b77ce2a02adafd Mon Sep 17 00:00:00 2001 From: Eamon-meng <17516219072@163.com> Date: Tue, 8 Jul 2025 13:52:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=90=8E=E5=8F=B0=E4=BF=AE=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E5=AE=9E=E5=90=8D=E8=AE=A4=E8=AF=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=A1=86=E5=92=8C=E4=BF=AE=E6=94=B9=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=E5=B1=95=E7=A4=BA=E5=92=8C=E5=8F=96=E6=B6=88=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E4=B8=AD=E7=9A=84=E4=B8=AA=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/_client/passwordSetupWrapper.tsx | 70 ++++++ src/app/admin/layout.tsx | 12 +- src/app/admin/profile/page.tsx | 214 +----------------- .../dialogs/change-password-dialog.tsx | 212 +++++++++++++++++ .../dialogs/realname-auth-dialog.tsx | 65 ++++++ .../composites/user-center/index.tsx | 29 ++- src/lib/models/models.ts | 1 + 7 files changed, 390 insertions(+), 213 deletions(-) create mode 100644 src/app/admin/_client/passwordSetupWrapper.tsx create mode 100644 src/components/composites/dialogs/change-password-dialog.tsx create mode 100644 src/components/composites/dialogs/realname-auth-dialog.tsx diff --git a/src/app/admin/_client/passwordSetupWrapper.tsx b/src/app/admin/_client/passwordSetupWrapper.tsx new file mode 100644 index 0000000..3564a31 --- /dev/null +++ b/src/app/admin/_client/passwordSetupWrapper.tsx @@ -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 && ( + { + setShowPasswordDialog(open) + if (!open) { + handleDismiss('password') + if (!profile.id_token) { + setShowRealnameDialog(true) + } + } + }} + onSuccess={() => { + setShowPasswordDialog(false) + if (!profile.id_token) { + setShowRealnameDialog(true) + } + }} + /> + )} + + {showRealnameDialog && ( + { + setShowRealnameDialog(open) + if (!open) { + handleDismiss('realname') + } + }} + onSuccess={() => { + setShowRealnameDialog(false) + }} + /> + )} + + ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 1d44a21..395b6ad 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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) { } header={
} - content={props.children} + content={( + <> + {props.children} + {/* 需要时显示密码设置和实名认证 */} + {(!profile?.has_password && !profile?.id_token) && ( + + )} + + )} /> ) } diff --git a/src/app/admin/profile/page.tsx b/src/app/admin/profile/page.tsx index a10788b..5dff2c9 100644 --- a/src/app/admin/profile/page.tsx +++ b/src/app/admin/profile/page.tsx @@ -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) { ? ( <>

为了保障您的账户安全和正常使用服务,请您尽快完成实名认证

- + ) : ( @@ -109,7 +106,7 @@ export default function ProfilePage(props: ProfilePageProps) {

安全信息

{profile.phone}

- +
@@ -297,196 +294,3 @@ function BasicForm(props: { ) } - -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 - - const form = useForm({ - 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(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 ( - - - - - - - 修改密码 - -
- - {/* 手机号 */} - name="phone" label="手机号" className="flex-auto"> - {({field}) => ( - - )} - - - name="captcha" label="验证码"> - {({field}) => ( -
- - -
- )} - - - {/* 短信令牌 */} - name="code" label="短信令牌" className="flex-auto"> - {({field}) => ( -
- - -
- )} - - - {/* 新密码 */} - name="password" label="新密码" className="flex-auto"> - {({field}) => ( - - )} - - - {/* 确认密码 */} - name="confirm_password" label="确认密码" className="flex-auto"> - {({field}) => ( - - )} - - - - - - - -
-
- ) -} diff --git a/src/components/composites/dialogs/change-password-dialog.tsx b/src/components/composites/dialogs/change-password-dialog.tsx new file mode 100644 index 0000000..eac3bcf --- /dev/null +++ b/src/components/composites/dialogs/change-password-dialog.tsx @@ -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 + + // 表单初始化 + const form = useForm({ + 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(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 ( + + + + + + + 修改密码 + +
+ {/* 手机号输入 */} + name="phone" label="手机号" className="flex-auto"> + {({field}) => ( + + )} + + + {/* 图形验证码 */} + name="captcha" label="验证码"> + {({field}) => ( +
+ + +
+ )} + + + {/* 短信验证码 */} + name="code" label="短信令牌" className="flex-auto"> + {({field}) => ( +
+ + +
+ )} + + + {/* 新密码 */} + name="password" label="新密码" className="flex-auto"> + {({field}) => ( + + )} + + + {/* 确认密码 */} + name="confirm_password" label="确认密码" className="flex-auto"> + {({field}) => ( + + )} + + + + + + + +
+
+ ) +} diff --git a/src/components/composites/dialogs/realname-auth-dialog.tsx b/src/components/composites/dialogs/realname-auth-dialog.tsx new file mode 100644 index 0000000..21ec92f --- /dev/null +++ b/src/components/composites/dialogs/realname-auth-dialog.tsx @@ -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 ( + + + + + + + 实名认证 + +
+

为了保障您的账户安全和正常使用服务,请您尽快完成实名认证

+
+ + +
+
+
+
+ ) +} diff --git a/src/components/composites/user-center/index.tsx b/src/components/composites/user-center/index.tsx index 7f570f9..7f56177 100644 --- a/src/components/composites/user-center/index.tsx +++ b/src/components/composites/user-center/index.tsx @@ -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) { - + */}