Files
verify/frontend/src/pages/profile/index.vue
T

755 lines
26 KiB
Vue
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.
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import api from '@/services/api'
interface UserProfile {
id: number
username: string
email: string
phone: string
avatar: string
role: string
status: string
api_token: string
created_at: string
last_login_at: string
}
interface Subscription {
plan: string
status: string
expire_date: string
api_quota: number
api_used: number
storage_quota: number
storage_used: number
}
interface Transaction {
id: number
type: string
amount: number
description: string
created_at: string
}
const loading = ref(true)
const saving = ref(false)
const changingPassword = ref(false)
const user = ref<UserProfile | null>(null)
const subscription = ref<Subscription | null>(null)
const transactions = ref<Transaction[]>([])
const profileForm = ref({
username: '',
email: '',
phone: '',
})
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const avatarInputRef = ref<HTMLInputElement | null>(null)
const avatarPreview = ref<string | null>(null)
const uploadingAvatar = ref(false)
const showPasswordDialog = ref(false)
const showApiToken = ref(false)
const regeneratingToken = ref(false)
const isProfileFormValid = computed(() => {
return profileForm.value.username && profileForm.value.email
})
const isPasswordFormValid = computed(() => {
return passwordForm.value.currentPassword
&& passwordForm.value.newPassword
&& passwordForm.value.confirmPassword
&& passwordForm.value.newPassword === passwordForm.value.confirmPassword
})
const apiUsagePercent = computed(() => {
if (!subscription.value)
return 0
return Math.min((subscription.value.api_used / subscription.value.api_quota) * 100, 100)
})
const storageUsagePercent = computed(() => {
if (!subscription.value)
return 0
return Math.min((subscription.value.storage_used / subscription.value.storage_quota) * 100, 100)
})
async function fetchProfile() {
loading.value = true
try {
const data = await api.get<{ user: UserProfile, subscription: Subscription, transactions: Transaction[] }>('/dev/profile')
user.value = data?.user || data
subscription.value = data?.subscription || null
transactions.value = data?.transactions || []
if (user.value) {
profileForm.value = {
username: user.value.username,
email: user.value.email || '',
phone: user.value.phone || '',
}
if (user.value.avatar) {
avatarPreview.value = user.value.avatar
}
}
}
catch (error) {
console.error('获取用户信息失败:', error)
toast.error('获取用户信息失败')
}
finally {
loading.value = false
}
}
async function handleUpdateProfile() {
if (!isProfileFormValid.value) {
toast.error('请填写完整信息')
return
}
saving.value = true
try {
await api.put('/dev/profile', {
username: profileForm.value.username,
email: profileForm.value.email,
phone: profileForm.value.phone,
})
toast.success('个人信息更新成功')
if (user.value) {
user.value.username = profileForm.value.username
user.value.email = profileForm.value.email
user.value.phone = profileForm.value.phone
localStorage.setItem('user', JSON.stringify(user.value))
}
}
catch (error: any) {
console.error('更新个人信息失败:', error)
toast.error(error.message || '更新失败')
}
finally {
saving.value = false
}
}
async function handleChangePassword() {
if (!passwordForm.value.currentPassword) {
toast.error('请输入当前密码')
return
}
if (!passwordForm.value.newPassword) {
toast.error('请输入新密码')
return
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
toast.error('两次输入的密码不一致')
return
}
if (passwordForm.value.newPassword.length < 6) {
toast.error('密码长度至少6位')
return
}
changingPassword.value = true
try {
await api.put('/dev/profile/password', {
current_password: passwordForm.value.currentPassword,
new_password: passwordForm.value.newPassword,
})
toast.success('密码修改成功')
showPasswordDialog.value = false
passwordForm.value = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
}
}
catch (error: any) {
console.error('修改密码失败:', error)
toast.error(error.message || '修改密码失败')
}
finally {
changingPassword.value = false
}
}
function handleAvatarClick() {
avatarInputRef.value?.click()
}
async function handleAvatarChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file)
return
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件')
return
}
if (file.size > 2 * 1024 * 1024) {
toast.error('图片大小不能超过2MB')
return
}
const reader = new FileReader()
reader.onload = (e) => {
avatarPreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
uploadingAvatar.value = true
try {
const formData = new FormData()
formData.append('avatar', file)
const data = await api.postFormData<{ avatar: string }>('/dev/profile/avatar', formData)
if (data?.avatar) {
avatarPreview.value = data.avatar
if (user.value) {
user.value.avatar = data.avatar
localStorage.setItem('user', JSON.stringify(user.value))
}
toast.success('头像更新成功')
}
}
catch (error: any) {
console.error('上传头像失败:', error)
toast.error(error.message || '上传头像失败')
}
finally {
uploadingAvatar.value = false
}
}
function formatDate(dateStr: string) {
if (!dateStr)
return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatDateShort(dateStr: string) {
if (!dateStr)
return '-'
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function formatBytes(bytes: number) {
if (!bytes || bytes === 0)
return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`
}
function formatNumber(num: number) {
return (num || 0).toLocaleString()
}
function getRoleName(role: string) {
const roles: Record<string, string> = {
admin: '管理员',
agent: '代理商',
}
return roles[role] || role
}
function getStatusName(status: string) {
const statuses: Record<string, string> = {
active: '正常',
inactive: '未激活',
banned: '已封禁',
}
return statuses[status] || status
}
function getTransactionType(type: string) {
const types: Record<string, string> = {
recharge: '充值',
consume: '消费',
refund: '退款',
}
return types[type] || type
}
function getTransactionTypeColor(type: string) {
const colors: Record<string, string> = {
recharge: 'text-green-500',
consume: 'text-red-500',
refund: 'text-blue-500',
}
return colors[type] || 'text-gray-500'
}
async function handleRegenerateApiToken() {
regeneratingToken.value = true
try {
const data = await api.post<{ api_token: string }>('/dev/profile/api-token')
if (data?.api_token && user.value) {
user.value.api_token = data.api_token
toast.success('API Token 已重新生成')
}
}
catch (error: any) {
console.error('重新生成API Token失败:', error)
toast.error(error.message || '重新生成失败')
}
finally {
regeneratingToken.value = false
}
}
function copyApiToken() {
if (user.value?.api_token) {
navigator.clipboard.writeText(user.value.api_token)
toast.success('API Token 已复制到剪贴板')
}
}
function maskToken(token: string) {
if (!token || token.length < 16)
return token
return `${token.slice(0, 8)}${'•'.repeat(24)}${token.slice(-8)}`
}
onMounted(() => {
fetchProfile()
})
</script>
<template>
<div class="min-h-screen bg-background">
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-8">
<div v-if="loading" class="flex items-center justify-center py-20">
<Icon icon="lucide:loader-2" class="size-10 animate-spin text-muted-foreground" />
</div>
<template v-else>
<div class="relative mb-8">
<div class="h-32 rounded-xl bg-gradient-to-r from-primary/20 via-primary/10 to-primary/5" />
<div class="absolute -bottom-12 left-8 flex items-end gap-5">
<div class="relative">
<div
class="size-28 rounded-2xl bg-background border-4 border-background shadow-xl flex items-center justify-center overflow-hidden cursor-pointer group"
@click="handleAvatarClick"
>
<img
v-if="avatarPreview"
:src="avatarPreview"
alt="Avatar"
class="size-full object-cover"
>
<Icon v-else icon="lucide:user" class="size-12 text-muted-foreground" />
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Icon icon="lucide:camera" class="size-8 text-white" />
</div>
</div>
<div
v-if="uploadingAvatar"
class="absolute inset-0 rounded-2xl bg-black/50 flex items-center justify-center"
>
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-white" />
</div>
</div>
</div>
<div class="absolute -bottom-12 right-8 flex items-center gap-2">
<UiButton variant="outline" size="sm" @click="showPasswordDialog = true">
<Icon icon="lucide:key" class="mr-2 h-4 w-4" />
修改密码
</UiButton>
</div>
<input
ref="avatarInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleAvatarChange"
>
</div>
<div class="pt-16 pb-6 px-8">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">
{{ user?.username }}
</h1>
<UiBadge :class="user?.status === 'active' ? 'bg-green-500/10 text-green-500 border-green-500/20' : ''" variant="outline">
{{ getStatusName(user?.status || '') }}
</UiBadge>
</div>
<div class="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span class="flex items-center gap-1">
<Icon icon="lucide:shield" class="size-4" />
{{ getRoleName(user?.role || '') }}
</span>
<span class="flex items-center gap-1">
<Icon icon="lucide:mail" class="size-4" />
{{ user?.email || '-' }}
</span>
<span class="flex items-center gap-1">
<Icon icon="lucide:calendar" class="size-4" />
加入于 {{ formatDateShort(user?.created_at || '') }}
</span>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<UiCard class="overflow-hidden">
<div class="bg-muted/30 px-6 py-4 border-b">
<h2 class="font-semibold flex items-center gap-2">
<Icon icon="lucide:user" class="size-5 text-primary" />
基本信息
</h2>
</div>
<UiCardContent class="p-6">
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-2">
<UiLabel for="username" class="text-muted-foreground">
用户名
</UiLabel>
<UiInput
id="username"
v-model="profileForm.username"
placeholder="请输入用户名"
class="bg-background"
/>
</div>
<div class="space-y-2">
<UiLabel for="email" class="text-muted-foreground">
邮箱
</UiLabel>
<UiInput
id="email"
v-model="profileForm.email"
type="email"
placeholder="请输入邮箱"
class="bg-background"
/>
</div>
<div class="space-y-2 sm:col-span-2">
<UiLabel for="phone" class="text-muted-foreground">
手机号
</UiLabel>
<UiInput
id="phone"
v-model="profileForm.phone"
type="tel"
placeholder="请输入手机号"
class="bg-background"
/>
</div>
</div>
<div class="flex justify-end mt-6">
<UiButton
:disabled="saving || !isProfileFormValid"
@click="handleUpdateProfile"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
保存修改
</UiButton>
</div>
</UiCardContent>
</UiCard>
<UiCard class="overflow-hidden">
<div class="bg-muted/30 px-6 py-4 border-b">
<h2 class="font-semibold flex items-center gap-2">
<Icon icon="lucide:key" class="size-5 text-primary" />
API Token
</h2>
</div>
<UiCardContent class="p-6">
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
API Token 用于调用开放 API 接口请妥善保管不要泄露给他人
</p>
<div class="flex items-center gap-2">
<code class="flex-1 p-3 bg-muted rounded-lg text-sm font-mono break-all border">
{{ showApiToken ? user?.api_token : maskToken(user?.api_token || '') }}
</code>
<UiTooltipProvider>
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
@click="showApiToken = !showApiToken"
>
<Icon :icon="showApiToken ? 'lucide:eye-off' : 'lucide:eye'" class="size-4" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
{{ showApiToken ? '隐藏' : '显示' }}
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider>
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
variant="outline"
size="icon"
:disabled="!user?.api_token"
@click="copyApiToken"
>
<Icon icon="lucide:copy" class="size-4" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
复制
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div class="flex justify-end">
<UiButton
variant="outline"
:disabled="regeneratingToken"
@click="handleRegenerateApiToken"
>
<Icon v-if="regeneratingToken" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
{{ user?.api_token ? '重新生成' : '生成 Token' }}
</UiButton>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard class="overflow-hidden">
<div class="bg-muted/30 px-6 py-4 border-b flex items-center justify-between">
<h2 class="font-semibold flex items-center gap-2">
<Icon icon="lucide:receipt" class="size-5 text-primary" />
最近交易
</h2>
<UiButton variant="ghost" size="sm" as-child>
<router-link to="/admin/finance" class="text-primary">
查看全部
<Icon icon="lucide:arrow-right" class="ml-1 h-4 w-4" />
</router-link>
</UiButton>
</div>
<UiCardContent class="p-0">
<div v-if="transactions.length > 0">
<div
v-for="(tx, index) in transactions.slice(0, 5)"
:key="tx.id"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-colors"
:class="{ 'border-t': index > 0 }"
>
<div class="flex items-center gap-4">
<div
class="size-10 rounded-xl flex items-center justify-center"
:class="tx.type === 'recharge' ? 'bg-green-500/10' : tx.type === 'refund' ? 'bg-blue-500/10' : 'bg-red-500/10'"
>
<Icon
:icon="tx.type === 'recharge' ? 'lucide:arrow-down-left' : tx.type === 'refund' ? 'lucide:rotate-ccw' : 'lucide:arrow-up-right'"
:class="getTransactionTypeColor(tx.type)"
class="size-5"
/>
</div>
<div>
<p class="font-medium">
{{ tx.description || getTransactionType(tx.type) }}
</p>
<p class="text-xs text-muted-foreground mt-0.5">
{{ formatDate(tx.created_at) }}
</p>
</div>
</div>
<span :class="getTransactionTypeColor(tx.type)" class="font-semibold text-lg">
{{ tx.type === 'recharge' || tx.type === 'refund' ? '+' : '-' }}¥{{ tx.amount.toFixed(2) }}
</span>
</div>
</div>
<div v-else class="text-center py-12 text-muted-foreground">
<Icon icon="lucide:receipt" class="size-16 mx-auto mb-3 opacity-30" />
<p>暂无交易记录</p>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard class="overflow-hidden">
<div class="bg-gradient-to-br from-primary/10 to-primary/5 px-6 py-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-muted-foreground">
当前套餐
</p>
<p class="text-2xl font-bold mt-1">
{{ subscription?.plan || '基础版' }}
</p>
</div>
<div class="size-12 rounded-xl bg-primary/10 flex items-center justify-center">
<Icon icon="lucide:crown" class="size-6 text-primary" />
</div>
</div>
<p class="text-sm text-muted-foreground mt-2">
有效期至 {{ formatDateShort(subscription?.expire_date || '') }}
</p>
<UiButton class="w-full mt-4" as-child>
<router-link to="/billing">
升级套餐
</router-link>
</UiButton>
</div>
<UiCardContent class="p-6 space-y-5">
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground flex items-center gap-1">
<Icon icon="lucide:zap" class="size-4" />
API 调用
</span>
<span class="font-medium">{{ formatNumber(subscription?.api_used || 0) }} / {{ formatNumber(subscription?.api_quota || 10000) }}</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all"
:class="apiUsagePercent > 80 ? 'bg-amber-500' : 'bg-primary'"
:style="{ width: `${apiUsagePercent}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground flex items-center gap-1">
<Icon icon="lucide:hard-drive" class="size-4" />
存储空间
</span>
<span class="font-medium">{{ formatBytes(subscription?.storage_used || 0) }} / {{ formatBytes(subscription?.storage_quota || 100 * 1024 * 1024) }}</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all"
:class="storageUsagePercent > 80 ? 'bg-amber-500' : 'bg-primary'"
:style="{ width: `${storageUsagePercent}%` }"
/>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard class="overflow-hidden">
<div class="bg-muted/30 px-6 py-4 border-b">
<h2 class="font-semibold flex items-center gap-2">
<Icon icon="lucide:activity" class="size-5 text-primary" />
账户统计
</h2>
</div>
<UiCardContent class="p-6">
<div class="space-y-4">
<div class="flex items-center justify-between py-2">
<span class="text-muted-foreground text-sm">上次登录</span>
<span class="text-sm font-medium">{{ formatDate(user?.last_login_at || '') }}</span>
</div>
<div class="flex items-center justify-between py-2 border-t">
<span class="text-muted-foreground text-sm">账户状态</span>
<UiBadge :class="user?.status === 'active' ? 'bg-green-500/10 text-green-500' : ''" variant="outline">
{{ getStatusName(user?.status || '') }}
</UiBadge>
</div>
<div class="flex items-center justify-between py-2 border-t">
<span class="text-muted-foreground text-sm">账户类型</span>
<UiBadge variant="outline">
{{ getRoleName(user?.role || '') }}
</UiBadge>
</div>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</template>
</div>
<UiDialog v-model:open="showPasswordDialog">
<UiDialogContent class="sm:max-w-md">
<UiDialogHeader>
<UiDialogTitle>修改密码</UiDialogTitle>
<UiDialogDescription>
请输入当前密码和新密码密码长度至少6位
</UiDialogDescription>
</UiDialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<UiLabel for="currentPassword">
当前密码
</UiLabel>
<UiInput
id="currentPassword"
v-model="passwordForm.currentPassword"
type="password"
placeholder="请输入当前密码"
/>
</div>
<div class="space-y-2">
<UiLabel for="newPassword">
新密码
</UiLabel>
<UiInput
id="newPassword"
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码"
/>
</div>
<div class="space-y-2">
<UiLabel for="confirmPassword">
确认密码
</UiLabel>
<UiInput
id="confirmPassword"
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
/>
</div>
</div>
<UiDialogFooter>
<UiButton variant="outline" @click="showPasswordDialog = false">
取消
</UiButton>
<UiButton
:disabled="changingPassword || !isPasswordFormValid"
@click="handleChangePassword"
>
<Icon v-if="changingPassword" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
确认修改
</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
</div>
</template>