|
|
|
@@ -1,9 +1,10 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { Activity, ArrowDownLeft, ArrowRight, ArrowUpRight, Calendar, Camera, Copy, Crown, Eye, EyeOff, HardDrive, Key, Loader2, Mail, Receipt, RefreshCw, RotateCcw, Save, Shield, User, Zap } from 'lucide-vue-next'
|
|
|
|
|
import { Camera, Check, Copy, Eye, EyeOff, Key, Loader2, Mail, RefreshCw, Shield, User } from 'lucide-vue-next'
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
import { toast } from 'vue-sonner'
|
|
|
|
|
|
|
|
|
|
import { BasicPage } from '@/components/global-layout'
|
|
|
|
|
import api from '@/services/api'
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
@@ -21,30 +22,10 @@ interface UserProfile {
|
|
|
|
|
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: '',
|
|
|
|
@@ -65,10 +46,6 @@ 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
|
|
|
|
@@ -76,25 +53,19 @@ const isPasswordFormValid = computed(() => {
|
|
|
|
|
&& 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)
|
|
|
|
|
const roleLabel = computed(() => {
|
|
|
|
|
const roles: Record<string, string> = {
|
|
|
|
|
admin: t('profile.roleAdmin'),
|
|
|
|
|
agent: t('profile.roleAgent'),
|
|
|
|
|
}
|
|
|
|
|
return roles[user.value?.role || ''] || user.value?.role || '-'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function fetchProfile() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const data = await api.get<{ user: UserProfile, subscription: Subscription, transactions: Transaction[] }>('/dev/profile')
|
|
|
|
|
const data = await api.get<{ user: UserProfile }>('/dev/profile')
|
|
|
|
|
user.value = data?.user || data
|
|
|
|
|
subscription.value = data?.subscription || null
|
|
|
|
|
transactions.value = data?.transactions || []
|
|
|
|
|
|
|
|
|
|
if (user.value) {
|
|
|
|
|
profileForm.value = {
|
|
|
|
@@ -107,8 +78,7 @@ async function fetchProfile() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
console.error('Fetch profile failed:', error)
|
|
|
|
|
catch {
|
|
|
|
|
toast.error(t('profile.loadUserFailed'))
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
@@ -117,7 +87,7 @@ async function fetchProfile() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleUpdateProfile() {
|
|
|
|
|
if (!isProfileFormValid.value) {
|
|
|
|
|
if (!profileForm.value.username || !profileForm.value.email) {
|
|
|
|
|
toast.error(t('profile.fillComplete'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
@@ -139,7 +109,6 @@ async function handleUpdateProfile() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error: any) {
|
|
|
|
|
console.error('Update profile failed:', error)
|
|
|
|
|
toast.error(error.message || t('profile.profileUpdateFailed'))
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
@@ -173,14 +142,9 @@ async function handleChangePassword() {
|
|
|
|
|
})
|
|
|
|
|
toast.success(t('profile.passwordChangeSuccess'))
|
|
|
|
|
showPasswordDialog.value = false
|
|
|
|
|
passwordForm.value = {
|
|
|
|
|
currentPassword: '',
|
|
|
|
|
newPassword: '',
|
|
|
|
|
confirmPassword: '',
|
|
|
|
|
}
|
|
|
|
|
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
|
|
|
|
}
|
|
|
|
|
catch (error: any) {
|
|
|
|
|
console.error('Change password failed:', error)
|
|
|
|
|
toast.error(error.message || t('profile.passwordChangeFailed'))
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
@@ -195,14 +159,12 @@ function handleAvatarClick() {
|
|
|
|
|
async function handleAvatarChange(event: Event) {
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
const file = input.files?.[0]
|
|
|
|
|
if (!file)
|
|
|
|
|
return
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
|
|
|
toast.error(t('profile.selectImageFile'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
|
|
|
toast.error(t('profile.imageSizeExceeded'))
|
|
|
|
|
return
|
|
|
|
@@ -218,9 +180,7 @@ async function handleAvatarChange(event: Event) {
|
|
|
|
|
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) {
|
|
|
|
@@ -231,7 +191,6 @@ async function handleAvatarChange(event: Event) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error: any) {
|
|
|
|
|
console.error('Upload avatar failed:', error)
|
|
|
|
|
toast.error(error.message || t('profile.avatarUpdateFailed'))
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
@@ -239,76 +198,6 @@ async function handleAvatarChange(event: Event) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: t('profile.roleAdmin'),
|
|
|
|
|
agent: t('profile.roleAgent'),
|
|
|
|
|
}
|
|
|
|
|
return roles[role] || role
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatusName(status: string) {
|
|
|
|
|
const statuses: Record<string, string> = {
|
|
|
|
|
active: t('profile.statusActive'),
|
|
|
|
|
inactive: t('profile.statusInactive'),
|
|
|
|
|
banned: t('profile.statusBanned'),
|
|
|
|
|
}
|
|
|
|
|
return statuses[status] || status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTransactionType(type: string) {
|
|
|
|
|
const types: Record<string, string> = {
|
|
|
|
|
recharge: t('profile.txRecharge'),
|
|
|
|
|
consume: t('profile.txConsume'),
|
|
|
|
|
refund: t('profile.txRefund'),
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
@@ -319,7 +208,6 @@ async function handleRegenerateApiToken() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error: any) {
|
|
|
|
|
console.error('Regenerate API token failed:', error)
|
|
|
|
|
toast.error(error.message || t('profile.tokenRegenerateFailed'))
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
@@ -335,392 +223,213 @@ function copyApiToken() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function maskToken(token: string) {
|
|
|
|
|
if (!token || token.length < 16)
|
|
|
|
|
return token
|
|
|
|
|
if (!token || token.length < 16) return token
|
|
|
|
|
return `${token.slice(0, 8)}${'•'.repeat(24)}${token.slice(-8)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
<Loader2 class="size-10 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
<BasicPage
|
|
|
|
|
:title="t('profile.title')"
|
|
|
|
|
:description="t('profile.description')"
|
|
|
|
|
>
|
|
|
|
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
|
|
|
<Loader2 class="size-8 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"
|
|
|
|
|
<div v-else class="grid gap-6 lg:grid-cols-3">
|
|
|
|
|
<div class="lg:col-span-2 space-y-6">
|
|
|
|
|
<UiCard>
|
|
|
|
|
<UiCardHeader>
|
|
|
|
|
<UiCardTitle class="flex items-center gap-2">
|
|
|
|
|
<User class="size-5" />
|
|
|
|
|
{{ t('profile.basicInfo') }}
|
|
|
|
|
</UiCardTitle>
|
|
|
|
|
<UiCardDescription>
|
|
|
|
|
<span class="flex items-center gap-2 mt-1">
|
|
|
|
|
<UiBadge variant="outline" class="gap-1">
|
|
|
|
|
<Shield class="size-3" />
|
|
|
|
|
{{ roleLabel }}
|
|
|
|
|
</UiBadge>
|
|
|
|
|
<span class="text-muted-foreground">{{ t('profile.joinedAt') }} {{ formatDate(user?.created_at || '') }}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</UiCardDescription>
|
|
|
|
|
</UiCardHeader>
|
|
|
|
|
<UiCardContent class="space-y-6">
|
|
|
|
|
<div class="flex items-start gap-6">
|
|
|
|
|
<div class="relative shrink-0">
|
|
|
|
|
<div
|
|
|
|
|
class="size-24 rounded-full bg-muted flex items-center justify-center overflow-hidden cursor-pointer group border"
|
|
|
|
|
@click="handleAvatarClick"
|
|
|
|
|
>
|
|
|
|
|
<User v-else 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">
|
|
|
|
|
<Camera class="size-8 text-white" />
|
|
|
|
|
<img
|
|
|
|
|
v-if="avatarPreview"
|
|
|
|
|
:src="avatarPreview"
|
|
|
|
|
alt="Avatar"
|
|
|
|
|
class="size-full object-cover"
|
|
|
|
|
>
|
|
|
|
|
<User v-else class="size-10 text-muted-foreground" />
|
|
|
|
|
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-full">
|
|
|
|
|
<Camera class="size-6 text-white" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-if="uploadingAvatar"
|
|
|
|
|
class="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<Loader2 class="size-6 animate-spin text-white" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-if="uploadingAvatar"
|
|
|
|
|
class="absolute inset-0 rounded-2xl bg-black/50 flex items-center justify-center"
|
|
|
|
|
<input
|
|
|
|
|
ref="avatarInputRef"
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/*"
|
|
|
|
|
class="hidden"
|
|
|
|
|
@change="handleAvatarChange"
|
|
|
|
|
>
|
|
|
|
|
<Loader2 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">
|
|
|
|
|
<Key class="mr-2 h-4 w-4" />
|
|
|
|
|
{{ t('profile.changePassword') }}
|
|
|
|
|
</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">
|
|
|
|
|
<Shield class="size-4" />
|
|
|
|
|
{{ getRoleName(user?.role || '') }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="flex items-center gap-1">
|
|
|
|
|
<Mail class="size-4" />
|
|
|
|
|
{{ user?.email || '-' }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="flex items-center gap-1">
|
|
|
|
|
<Calendar class="size-4" />
|
|
|
|
|
{{ t('profile.joinedAt') }} {{ 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">
|
|
|
|
|
<User class="size-5 text-primary" />
|
|
|
|
|
{{ t('profile.basicInfo') }}
|
|
|
|
|
</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">
|
|
|
|
|
{{ t('profile.username') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="username"
|
|
|
|
|
v-model="profileForm.username"
|
|
|
|
|
:placeholder="t('profile.usernamePlaceholder')"
|
|
|
|
|
class="bg-background"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="email" class="text-muted-foreground">
|
|
|
|
|
{{ t('profile.email') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="email"
|
|
|
|
|
v-model="profileForm.email"
|
|
|
|
|
type="email"
|
|
|
|
|
:placeholder="t('profile.emailPlaceholder')"
|
|
|
|
|
class="bg-background"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2 sm:col-span-2">
|
|
|
|
|
<UiLabel for="phone" class="text-muted-foreground">
|
|
|
|
|
{{ t('profile.phone') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="phone"
|
|
|
|
|
v-model="profileForm.phone"
|
|
|
|
|
type="tel"
|
|
|
|
|
:placeholder="t('profile.phonePlaceholder')"
|
|
|
|
|
class="bg-background"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-1 grid gap-4 sm:grid-cols-2">
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="username">{{ t('profile.username') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="username"
|
|
|
|
|
v-model="profileForm.username"
|
|
|
|
|
:placeholder="t('profile.usernamePlaceholder')"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-end mt-6">
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="email">{{ t('profile.email') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="email"
|
|
|
|
|
v-model="profileForm.email"
|
|
|
|
|
type="email"
|
|
|
|
|
:placeholder="t('profile.emailPlaceholder')"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="phone">{{ t('profile.phone') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="phone"
|
|
|
|
|
v-model="profileForm.phone"
|
|
|
|
|
type="tel"
|
|
|
|
|
:placeholder="t('profile.phonePlaceholder')"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-end gap-2">
|
|
|
|
|
<UiButton
|
|
|
|
|
:disabled="saving || !isProfileFormValid"
|
|
|
|
|
:disabled="saving"
|
|
|
|
|
@click="handleUpdateProfile"
|
|
|
|
|
>
|
|
|
|
|
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
<Save v-else class="mr-2 h-4 w-4" />
|
|
|
|
|
<Check v-else class="mr-2 h-4 w-4" />
|
|
|
|
|
{{ t('profile.saveChanges') }}
|
|
|
|
|
</UiButton>
|
|
|
|
|
<UiButton variant="outline" @click="showPasswordDialog = true">
|
|
|
|
|
<Key class="mr-2 h-4 w-4" />
|
|
|
|
|
{{ t('profile.changePassword') }}
|
|
|
|
|
</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">
|
|
|
|
|
<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">
|
|
|
|
|
{{ t('profile.apiTokenDesc') }}
|
|
|
|
|
</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"
|
|
|
|
|
>
|
|
|
|
|
<EyeOff v-if="showApiToken" class="size-4" />
|
|
|
|
|
<Eye v-else class="size-4" />
|
|
|
|
|
</UiButton>
|
|
|
|
|
</UiTooltipTrigger>
|
|
|
|
|
<UiTooltipContent>
|
|
|
|
|
{{ showApiToken ? t('profile.hide') : t('profile.show') }}
|
|
|
|
|
</UiTooltipContent>
|
|
|
|
|
</UiTooltip>
|
|
|
|
|
</UiTooltipProvider>
|
|
|
|
|
<UiTooltipProvider>
|
|
|
|
|
<UiTooltip>
|
|
|
|
|
<UiTooltipTrigger as-child>
|
|
|
|
|
<UiButton
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="icon"
|
|
|
|
|
:disabled="!user?.api_token"
|
|
|
|
|
@click="copyApiToken"
|
|
|
|
|
>
|
|
|
|
|
<Copy class="size-4" />
|
|
|
|
|
</UiButton>
|
|
|
|
|
</UiTooltipTrigger>
|
|
|
|
|
<UiTooltipContent>
|
|
|
|
|
{{ t('profile.copy') }}
|
|
|
|
|
</UiTooltipContent>
|
|
|
|
|
</UiTooltip>
|
|
|
|
|
</UiTooltipProvider>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex justify-end">
|
|
|
|
|
<UiButton
|
|
|
|
|
variant="outline"
|
|
|
|
|
:disabled="regeneratingToken"
|
|
|
|
|
@click="handleRegenerateApiToken"
|
|
|
|
|
>
|
|
|
|
|
<Loader2 v-if="regeneratingToken" class="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
<RefreshCw v-else class="mr-2 h-4 w-4" />
|
|
|
|
|
{{ user?.api_token ? t('profile.regenerateToken') : t('profile.generateToken') }}
|
|
|
|
|
</UiButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</UiCardContent>
|
|
|
|
|
</UiCard>
|
|
|
|
|
</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">
|
|
|
|
|
<Receipt class="size-5 text-primary" />
|
|
|
|
|
{{ t('profile.recentTransactions') }}
|
|
|
|
|
</h2>
|
|
|
|
|
<UiButton variant="ghost" size="sm" as-child>
|
|
|
|
|
<router-link to="/admin/finance" class="text-primary">
|
|
|
|
|
{{ t('profile.viewAll') }}
|
|
|
|
|
<ArrowRight class="ml-1 h-4 w-4" />
|
|
|
|
|
</router-link>
|
|
|
|
|
</UiButton>
|
|
|
|
|
<UiCard>
|
|
|
|
|
<UiCardHeader>
|
|
|
|
|
<UiCardTitle class="flex items-center gap-2">
|
|
|
|
|
<Key class="size-5" />
|
|
|
|
|
API Token
|
|
|
|
|
</UiCardTitle>
|
|
|
|
|
<UiCardDescription>{{ t('profile.apiTokenDesc') }}</UiCardDescription>
|
|
|
|
|
</UiCardHeader>
|
|
|
|
|
<UiCardContent class="space-y-4">
|
|
|
|
|
<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>
|
|
|
|
|
<UiButton variant="outline" size="icon" @click="showApiToken = !showApiToken">
|
|
|
|
|
<EyeOff v-if="showApiToken" class="size-4" />
|
|
|
|
|
<Eye v-else class="size-4" />
|
|
|
|
|
</UiButton>
|
|
|
|
|
<UiButton variant="outline" size="icon" :disabled="!user?.api_token" @click="copyApiToken">
|
|
|
|
|
<Copy class="size-4" />
|
|
|
|
|
</UiButton>
|
|
|
|
|
</div>
|
|
|
|
|
<UiButton
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
:disabled="regeneratingToken"
|
|
|
|
|
@click="handleRegenerateApiToken"
|
|
|
|
|
>
|
|
|
|
|
<Loader2 v-if="regeneratingToken" class="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
<RefreshCw v-else class="mr-2 h-4 w-4" />
|
|
|
|
|
{{ user?.api_token ? t('profile.regenerateToken') : t('profile.generateToken') }}
|
|
|
|
|
</UiButton>
|
|
|
|
|
</UiCardContent>
|
|
|
|
|
</UiCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
<UiCard>
|
|
|
|
|
<UiCardHeader>
|
|
|
|
|
<UiCardTitle class="flex items-center gap-2">
|
|
|
|
|
<Shield class="size-5" />
|
|
|
|
|
{{ t('profile.accountOverview') }}
|
|
|
|
|
</UiCardTitle>
|
|
|
|
|
</UiCardHeader>
|
|
|
|
|
<UiCardContent>
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
<div class="flex items-center justify-between py-2">
|
|
|
|
|
<span class="text-sm text-muted-foreground">{{ t('profile.accountType') }}</span>
|
|
|
|
|
<UiBadge variant="outline">{{ roleLabel }}</UiBadge>
|
|
|
|
|
</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'"
|
|
|
|
|
>
|
|
|
|
|
<ArrowDownLeft
|
|
|
|
|
v-if="tx.type === 'recharge'"
|
|
|
|
|
:class="getTransactionTypeColor(tx.type)"
|
|
|
|
|
class="size-5"
|
|
|
|
|
/>
|
|
|
|
|
<RotateCcw
|
|
|
|
|
v-else-if="tx.type === 'refund'"
|
|
|
|
|
:class="getTransactionTypeColor(tx.type)"
|
|
|
|
|
class="size-5"
|
|
|
|
|
/>
|
|
|
|
|
<ArrowUpRight
|
|
|
|
|
v-else
|
|
|
|
|
: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">
|
|
|
|
|
<Receipt class="size-16 mx-auto mb-3 opacity-30" />
|
|
|
|
|
<p>{{ t('profile.noTransactions') }}</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">
|
|
|
|
|
{{ t('profile.currentPlan') }}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="text-2xl font-bold mt-1">
|
|
|
|
|
{{ subscription?.plan || t('profile.basicPlan') }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="size-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
|
|
|
|
<Crown class="size-6 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-sm text-muted-foreground mt-2">
|
|
|
|
|
{{ t('profile.expiresAt') }} {{ formatDateShort(subscription?.expire_date || '') }}
|
|
|
|
|
</p>
|
|
|
|
|
<UiButton class="w-full mt-4" as-child>
|
|
|
|
|
<router-link to="/billing">
|
|
|
|
|
{{ t('profile.upgradePlan') }}
|
|
|
|
|
</router-link>
|
|
|
|
|
</UiButton>
|
|
|
|
|
<div class="flex items-center justify-between py-2 border-t">
|
|
|
|
|
<span class="text-sm text-muted-foreground">{{ t('profile.accountStatus') }}</span>
|
|
|
|
|
<UiBadge :class="user?.status === 'active' ? 'bg-green-500/10 text-green-500' : ''" variant="outline">
|
|
|
|
|
{{ user?.status === 'active' ? t('profile.statusActive') : user?.status }}
|
|
|
|
|
</UiBadge>
|
|
|
|
|
</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">
|
|
|
|
|
<Zap class="size-4" />
|
|
|
|
|
{{ t('profile.apiCalls') }}
|
|
|
|
|
</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">
|
|
|
|
|
<HardDrive class="size-4" />
|
|
|
|
|
{{ t('profile.storage') }}
|
|
|
|
|
</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">
|
|
|
|
|
<Activity class="size-5 text-primary" />
|
|
|
|
|
{{ t('profile.accountStats') }}
|
|
|
|
|
</h2>
|
|
|
|
|
<div class="flex items-center justify-between py-2 border-t">
|
|
|
|
|
<span class="text-sm text-muted-foreground">{{ t('profile.email') }}</span>
|
|
|
|
|
<span class="text-sm font-medium flex items-center gap-1">
|
|
|
|
|
<Mail class="size-3.5 text-muted-foreground" />
|
|
|
|
|
{{ user?.email || '-' }}
|
|
|
|
|
</span>
|
|
|
|
|
</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">{{ t('profile.lastLogin') }}</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">{{ t('profile.accountStatus') }}</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">{{ t('profile.accountType') }}</span>
|
|
|
|
|
<UiBadge variant="outline">
|
|
|
|
|
{{ getRoleName(user?.role || '') }}
|
|
|
|
|
</UiBadge>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</UiCardContent>
|
|
|
|
|
</UiCard>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<div v-if="user?.last_login_at" class="flex items-center justify-between py-2 border-t">
|
|
|
|
|
<span class="text-sm text-muted-foreground">{{ t('profile.lastLogin') }}</span>
|
|
|
|
|
<span class="text-sm font-medium">{{ formatDate(user.last_login_at) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</UiCardContent>
|
|
|
|
|
</UiCard>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<UiDialog v-model:open="showPasswordDialog">
|
|
|
|
|
<UiDialogContent class="sm:max-w-md">
|
|
|
|
|
<UiDialogHeader>
|
|
|
|
|
<UiDialogTitle>{{ t('profile.changePassword') }}</UiDialogTitle>
|
|
|
|
|
<UiDialogDescription>
|
|
|
|
|
{{ t('profile.changePasswordDesc') }}
|
|
|
|
|
</UiDialogDescription>
|
|
|
|
|
<UiDialogDescription>{{ t('profile.changePasswordDesc') }}</UiDialogDescription>
|
|
|
|
|
</UiDialogHeader>
|
|
|
|
|
<div class="space-y-4 py-4">
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="currentPassword">
|
|
|
|
|
{{ t('profile.currentPassword') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiLabel for="currentPassword">{{ t('profile.currentPassword') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="currentPassword"
|
|
|
|
|
v-model="passwordForm.currentPassword"
|
|
|
|
@@ -729,9 +438,7 @@ onMounted(() => {
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="newPassword">
|
|
|
|
|
{{ t('profile.newPassword') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiLabel for="newPassword">{{ t('profile.newPassword') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="newPassword"
|
|
|
|
|
v-model="passwordForm.newPassword"
|
|
|
|
@@ -740,9 +447,7 @@ onMounted(() => {
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<UiLabel for="confirmPassword">
|
|
|
|
|
{{ t('profile.confirmPassword') }}
|
|
|
|
|
</UiLabel>
|
|
|
|
|
<UiLabel for="confirmPassword">{{ t('profile.confirmPassword') }}</UiLabel>
|
|
|
|
|
<UiInput
|
|
|
|
|
id="confirmPassword"
|
|
|
|
|
v-model="passwordForm.confirmPassword"
|
|
|
|
@@ -755,15 +460,12 @@ onMounted(() => {
|
|
|
|
|
<UiButton variant="outline" @click="showPasswordDialog = false">
|
|
|
|
|
{{ t('profile.cancel') }}
|
|
|
|
|
</UiButton>
|
|
|
|
|
<UiButton
|
|
|
|
|
:disabled="changingPassword || !isPasswordFormValid"
|
|
|
|
|
@click="handleChangePassword"
|
|
|
|
|
>
|
|
|
|
|
<UiButton :disabled="changingPassword || !isPasswordFormValid" @click="handleChangePassword">
|
|
|
|
|
<Loader2 v-if="changingPassword" class="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
{{ t('profile.confirmChange') }}
|
|
|
|
|
</UiButton>
|
|
|
|
|
</UiDialogFooter>
|
|
|
|
|
</UiDialogContent>
|
|
|
|
|
</UiDialog>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</BasicPage>
|
|
|
|
|
</template>
|
|
|
|
|