完善个人中心页面,重构信息展示与编辑功能

This commit is contained in:
2025-04-19 12:56:32 +08:00
parent 25dfda87ac
commit 540d1f4c47
3 changed files with 653 additions and 1 deletions

View File

@@ -1,5 +1,7 @@
## TODO
重新设计验证逻辑,通过全局 cache 优化请求效率,使用服务端组件实现验证
提取后刷新提取页套餐可用余量
提取时检查 IP 和实名状态

View File

@@ -53,7 +53,7 @@ export default async function DashboardLayout(props: DashboardLayoutProps) {
)}>
<NavItem href={'/admin'} icon={`🏠`} label={`账户总览`}/>
<NavTitle label={`个人信息`}/>
<NavItem href={`/admin`} icon={`📝`} label={`修改信息`}/>
<NavItem href={`/admin/profile`} icon={`📝`} label={`个人中心`}/>
<NavItem href={`/admin/identify`} icon={`🆔`} label={`实名认证`}/>
<NavItem href={`/admin/whitelist`} icon={`🔒`} label={`白名单`}/>
<NavItem href={`/admin/bills`} icon={`💰`} label={`我的账单`}/>

View File

@@ -0,0 +1,650 @@
'use client'
import {useEffect, useState, useContext, useRef} from 'react'
import Page from '@/components/page'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from '@/components/ui/card'
import {Form, FormField, FormLabel} from '@/components/ui/form'
import {Button} from '@/components/ui/button'
import {Input} from '@/components/ui/input'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod'
import {AuthContext} from '@/components/providers/AuthProvider'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {toast} from 'sonner'
import {Alert, AlertDescription, AlertTitle} from '@/components/ui/alert'
import {CheckCircle, AlertCircle, Shield, Wallet, CreditCard, QrCode, UserIcon} from 'lucide-react'
import * as qrcode from 'qrcode'
import Link from 'next/link'
import RechargeModal from '@/components/composites/purchase/_client/recharge'
import {User} from '@/lib/models'
export type ProfilePageProps = {}
export default function ProfilePage(props: ProfilePageProps) {
const authCtx = useContext(AuthContext)
const profile = authCtx.profile
// 默认选中的Tab
const [activeTab, setActiveTab] = useState('basic')
if (!profile) {
return (
<Page>
<div className="flex justify-center items-center h-full">
<Alert className="max-w-md">
<AlertCircle className="h-4 w-4"/>
<AlertTitle></AlertTitle>
<AlertDescription>...</AlertDescription>
</Alert>
</div>
</Page>
)
}
return (
<Page>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3">
<Tabs defaultValue={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="security"></TabsTrigger>
<TabsTrigger value="balance"></TabsTrigger>
<TabsTrigger value="identify"></TabsTrigger>
</TabsList>
<TabsContent value="basic">
<BasicInfoTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
</TabsContent>
<TabsContent value="security">
<SecurityTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
</TabsContent>
<TabsContent value="balance">
<BalanceTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
</TabsContent>
<TabsContent value="identify">
<IdentifyTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
</TabsContent>
</Tabs>
</div>
{/* 侧边栏:客服经理信息 */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon size={18}/>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{profile.name || profile.username}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{profile.phone ? maskPhone(profile.phone) : '未设置'}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium text-xl">{profile.balance || 0}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="flex items-center gap-2">
{profile.id_token ? (
<><CheckCircle size={16} className="text-green-500"/> </>
) : (
<><AlertCircle size={16} className="text-amber-500"/> </>
)}
</p>
</div>
</CardContent>
</Card>
{profile.contact_wechat && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<QrCode size={18}/>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center space-y-4">
<ServiceManagerQRCode wechat={profile.contact_wechat}/>
<p className="text-sm text-muted-foreground text-center">
<br/>
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Page>
)
}
// 基本信息组件
function BasicInfoTab(props: {
profile: User
refreshProfile: () => Promise<void>
}) {
const {profile, refreshProfile} = props
const basicInfoSchema = z.object({
name: z.string().min(2, '名称至少需要2个字符'),
email: z.string().email('请输入有效的邮箱地址').or(z.string().length(0)),
})
type BasicInfoShema = z.infer<typeof basicInfoSchema>
const form = useForm({
resolver: zodResolver(basicInfoSchema),
defaultValues: {
name: profile.name || '',
email: profile.email || '',
},
})
const [isSubmitting, setIsSubmitting] = useState(false)
const onSubmit = async (data: BasicInfoShema) => {
setIsSubmitting(true)
try {
// 这里会调用更新用户信息的API
// 示例: await updateUserProfile(data)
toast.success('基本信息更新成功')
await refreshProfile()
}
catch (error) {
toast.error('更新失败', {
description: (error as Error).message || '请稍后重试',
})
}
finally {
setIsSubmitting(false)
}
}
return (
<Form form={form} onSubmit={onSubmit} className="space-y-6">
<FormField name="name" label="姓名">
{({id, field}) => (
<Input
{...field}
id={id}
placeholder="请输入您的姓名"
/>
)}
</FormField>
<FormField name="email" label="电子邮箱">
{({id, field}) => (
<Input
{...field}
id={id}
type="email"
placeholder="请输入您的邮箱地址"
/>
)}
</FormField>
<div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '保存中...' : '保存修改'}
</Button>
</div>
</Form>
)
}
// 安全设置组件
function SecurityTab(props: {
profile: User
refreshProfile: () => Promise<void>
}) {
const {profile, refreshProfile} = props
const [showPhoneDialog, setShowPhoneDialog] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
return (
<div className="space-y-6">
<div className="flex items-center justify-between py-3 border-b">
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground">
{profile.phone ? maskPhone(profile.phone) : '未设置手机号'}
</p>
</div>
<Button onClick={() => setShowPhoneDialog(true)}>
{profile.phone ? '修改' : '设置'}
</Button>
</div>
<div className="flex items-center justify-between py-3 border-b">
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button onClick={() => setShowPasswordDialog(true)}></Button>
</div>
<div className="flex items-center justify-between py-3">
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button theme="outline"></Button>
</div>
{/* 修改手机号对话框 */}
<ChangePhoneDialog
open={showPhoneDialog}
onOpenChange={setShowPhoneDialog}
currentPhone={profile.phone}
refreshProfile={refreshProfile}
/>
{/* 修改密码对话框 */}
<ChangePasswordDialog
open={showPasswordDialog}
onOpenChange={setShowPasswordDialog}
refreshProfile={refreshProfile}
/>
</div>
)
}
// 余额管理组件
function BalanceTab(props: {
profile: User
refreshProfile: () => Promise<void>
}) {
const {profile} = props
// 交易历史记录示例数据
const transactions = [
{id: 1, type: '充值', amount: 100, date: '2025-04-18', status: '成功'},
{id: 2, type: '购买套餐', amount: -50, date: '2025-04-15', status: '成功'},
{id: 3, type: '系统赠送', amount: 10, date: '2025-04-10', status: '成功'},
]
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h3 className="text-2xl font-bold">: {profile.balance || 0}</h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<RechargeModal/>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium mb-4"></h3>
<div className="border rounded-md">
<div className="grid grid-cols-4 bg-muted p-3 font-medium">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div className="divide-y">
{transactions.map(tx => (
<div key={tx.id} className="grid grid-cols-4 p-3">
<div>{tx.type}</div>
<div className={tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}>
{tx.amount >= 0 ? `+${tx.amount}` : tx.amount}
</div>
<div>{tx.date}</div>
<div>{tx.status}</div>
</div>
))}
</div>
</div>
<div className="mt-4 text-center">
<Button theme="outline"></Button>
</div>
</div>
</div>
)
}
// 实名认证组件
function IdentifyTab(props: {
profile: User
refreshProfile: () => Promise<void>
}) {
const {profile} = props
return (
<div className="space-y-6">
{profile.id_token ? (
<div className="bg-muted rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4"/>
<h3 className="text-xl font-medium mb-2"></h3>
<p className="text-muted-foreground mb-6">使</p>
<div className="grid grid-cols-2 gap-4 max-w-md mx-auto text-left">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{maskName(profile.name)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="font-medium">{maskIdNumber(profile.id_no)}</p>
</div>
</div>
</div>
) : (
<div className="bg-muted rounded-lg p-6 text-center">
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4"/>
<h3 className="text-xl font-medium mb-2"></h3>
<p className="text-muted-foreground mb-6">
使HTTP代理服务需要先完成实名认证
</p>
<Link href="/admin/identify">
<Button className="w-40"></Button>
</Link>
</div>
)}
<Alert>
<AlertCircle className="h-4 w-4"/>
<AlertTitle></AlertTitle>
<AlertDescription>
使HTTP代理需完成实名认证
</AlertDescription>
</Alert>
</div>
)
}
// 修改手机号对话框
function ChangePhoneDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
currentPhone?: string
refreshProfile: () => Promise<void>
}) {
const {open, onOpenChange, currentPhone, refreshProfile} = props
const phoneChangeSchema = z.object({
newPhone: z.string().length(11, '请输入11位手机号码'),
verifyCode: z.string().min(4, '请输入验证码'),
})
type PhoneChangeSchema = z.infer<typeof phoneChangeSchema>
const form = useForm({
resolver: zodResolver(phoneChangeSchema),
defaultValues: {
newPhone: '',
verifyCode: '',
},
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [countdown, setCountdown] = useState(0)
const onSubmit = async (data: PhoneChangeSchema) => {
setIsSubmitting(true)
try {
// 这里调用修改手机号的API
// 示例: await changePhone(data.newPhone, data.verifyCode)
toast.success('手机号更新成功')
await refreshProfile()
onOpenChange(false)
}
catch (error) {
toast.error('更新失败', {
description: (error as Error).message || '请稍后重试',
})
}
finally {
setIsSubmitting(false)
}
}
const sendVerifyCode = () => {
const newPhone = form.getValues('newPhone')
if (!newPhone || newPhone.length !== 11) {
form.setError('newPhone', {message: '请输入有效的手机号码'})
return
}
// 这里调用发送验证码的API
// 示例: await sendSmsCode(newPhone)
toast.success('验证码已发送')
// 开始倒计时
setCountdown(60)
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Form form={form} onSubmit={onSubmit} className="space-y-4 py-4">
{currentPhone && (
<div className="space-y-1">
<FormLabel></FormLabel>
<Input value={maskPhone(currentPhone)} disabled/>
</div>
)}
<FormField name="newPhone" label="新手机号">
{({id, field}) => (
<Input
{...field}
id={id}
placeholder="请输入新手机号"
/>
)}
</FormField>
<FormField name="verifyCode" label="验证码">
{({id, field}) => (
<div className="flex gap-2">
<Input
{...field}
id={id}
className="flex-1"
placeholder="请输入验证码"
/>
<Button
type="button"
theme="outline"
disabled={countdown > 0}
onClick={sendVerifyCode}
>
{countdown > 0 ? `${countdown}秒后重发` : '获取验证码'}
</Button>
</div>
)}
</FormField>
<DialogFooter className="pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
)
}
// 修改密码对话框
function ChangePasswordDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
refreshProfile: () => Promise<void>
}) {
const {open, onOpenChange, refreshProfile} = props
const passwordChangeSchema = z.object({
oldPassword: z.string().min(6, '密码至少需要6个字符'),
newPassword: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine(data => data.newPassword === data.confirmPassword, {
message: '两次输入的新密码不一致',
path: ['confirmPassword'],
})
type PasswordChangeSchema = z.infer<typeof passwordChangeSchema>
const form = useForm({
resolver: zodResolver(passwordChangeSchema),
defaultValues: {
oldPassword: '',
newPassword: '',
confirmPassword: '',
},
})
const [isSubmitting, setIsSubmitting] = useState(false)
const onSubmit = async (data: PasswordChangeSchema) => {
setIsSubmitting(true)
try {
// 这里调用修改密码的API
// 示例: await changePassword(data.oldPassword, data.newPassword)
toast.success('密码修改成功')
onOpenChange(false)
}
catch (error) {
toast.error('修改失败', {
description: (error as Error).message || '请稍后重试',
})
}
finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Form form={form} onSubmit={onSubmit} className="space-y-4 py-4">
<FormField name="oldPassword" label="当前密码">
{({id, field}) => (
<Input
{...field}
id={id}
type="password"
placeholder="请输入当前密码"
/>
)}
</FormField>
<FormField name="newPassword" label="新密码">
{({id, field}) => (
<Input
{...field}
id={id}
type="password"
placeholder="请输入新密码"
/>
)}
</FormField>
<FormField name="confirmPassword" label="确认新密码">
{({id, field}) => (
<Input
{...field}
id={id}
type="password"
placeholder="请再次输入新密码"
/>
)}
</FormField>
<DialogFooter className="pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
)
}
// 客服经理二维码组件
function ServiceManagerQRCode(props: {
wechat?: string
}) {
const {wechat} = props
const canvasRef = useRef(null)
useEffect(() => {
if (wechat && canvasRef.current) {
qrcode.toCanvas(canvasRef.current, wechat, {
width: 180,
margin: 0,
}).catch(err => {
console.error(err)
})
}
}, [wechat])
return (
<div className="p-2 bg-white rounded-md border">
<canvas ref={canvasRef} width="180" height="180"/>
</div>
)
}
// 工具函数:遮盖手机号
function maskPhone(phone?: string) {
if (!phone) return ''
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 工具函数:遮盖姓名
function maskName(name?: string) {
if (!name) return ''
if (name.length <= 2) {
return name.charAt(0) + '*'
}
const stars = '*'.repeat(name.length - 2)
return name.charAt(0) + stars + name.charAt(name.length - 1)
}
// 工具函数:遮盖身份证号
function maskIdNumber(idNumber?: string) {
if (!idNumber) return ''
return idNumber.replace(/^(.{4})(.*)(.{4})$/, '$1**********$3')
}