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

651 lines
20 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, 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')
}