Initial commit: 网络验证平台
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
<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> = {
|
||||
developer: '开发者',
|
||||
agent: '代理商',
|
||||
admin: '管理员',
|
||||
}
|
||||
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="/developer/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>
|
||||
Reference in New Issue
Block a user