Files
BYW-VoiceHub/app/pages/account/index.vue
T

941 lines
40 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.
<template>
<div class="min-h-screen bg-bg-primary text-text-primary pb-24">
<!-- 顶部导航栏 -->
<div
class="sticky top-0 z-30 bg-bg-primary-80 backdrop-blur-xl border-b border-border-secondary-50 px-4 py-4 mb-8"
>
<div class="max-w-[1200px] mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<button
class="p-2 hover:bg-bg-secondary rounded-xl transition-all text-text-tertiary hover:text-text-primary"
@click="goBack"
>
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-xl font-black text-text-primary tracking-tight">{{ locale.title }}</h1>
<p class="text-[10px] text-text-tertiary font-medium uppercase tracking-widest mt-0.5">
{{ locale.subtitle }}
</p>
</div>
</div>
</div>
</div>
<div class="max-w-[1200px] mx-auto px-4">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 左侧:用户信息概览 (PC端占据 4/12) -->
<div class="lg:col-span-4 space-y-6">
<section :class="sectionClass" class="flex flex-col items-center text-center">
<div class="relative group">
<div
class="w-32 h-32 rounded-full overflow-hidden bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-text-primary text-4xl font-black shadow-2xl shadow-[var(--primary-glow)] mb-6 group-hover:scale-105 transition-transform duration-500"
>
<img
v-if="auth.user.value?.avatar && !avatarError"
:src="auth.user.value.avatar"
class="w-full h-full object-cover"
@error="avatarError = true"
>
<span v-else>{{ userInitials }}</span>
</div>
<div
class="absolute -bottom-1 -right-1 p-2 bg-bg-secondary border border-border-secondary rounded-full text-primary shadow-xl flex items-center justify-center"
>
<User :size="16" />
</div>
</div>
<div class="space-y-2">
<h2 class="text-2xl font-black text-text-primary tracking-tight">
{{ auth.user.value?.name || auth.user.value?.username }}
</h2>
<p class="text-sm font-medium text-text-tertiary">@{{ auth.user.value?.username }}</p>
</div>
<div class="flex flex-wrap justify-center gap-2 mt-6">
<span
class="px-3 py-1 bg-primary-10 border border-primary-20 text-primary text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ roleName }}
</span>
<span
v-if="auth.user.value?.grade"
class="px-3 py-1 bg-bg-tertiary text-text-tertiary text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ auth.user.value?.grade }}
</span>
<span
v-if="auth.user.value?.class"
class="px-3 py-1 bg-bg-tertiary text-text-tertiary text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ auth.user.value?.class }}
</span>
</div>
</section>
</div>
<!-- 右侧:详细设置 (PC端占据 8/12) -->
<div class="lg:col-span-8 space-y-8">
<!-- 第三方登录绑定 -->
<section v-if="hasOAuthProviders" :class="sectionClass">
<div class="flex items-center gap-3 border-b border-border-secondary-50 pb-5 mb-6">
<div class="p-2.5 bg-info-10 rounded-xl flex items-center justify-center">
<LinkIcon :size="20" class="text-info" />
</div>
<div>
<h2 class="text-base font-black text-text-primary">{{ locale.oauthBinding }}</h2>
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.oauthBindingDesc }}</p>
</div>
</div>
<AuthOAuthBindingCard />
</section>
<!-- 社交账号绑定 -->
<AccountSocialBindings />
<!-- 个人 API Key -->
<section :class="sectionClass">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border-secondary-50 pb-5 mb-6">
<div class="flex items-center gap-3">
<div class="p-2.5 bg-success-10 rounded-xl flex items-center justify-center">
<KeyRound :size="20" class="text-success" />
</div>
<div>
<h2 class="text-base font-black text-text-primary">{{ locale.personalApiKey.title }}</h2>
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.personalApiKey.desc }}</p>
</div>
</div>
<button
class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-success hover:bg-success text-text-primary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="apiKeyLoading || apiKeyCreating"
@click="createPersonalApiKey"
>
<RefreshCw v-if="apiKeyCreating" :size="14" class="animate-spin" />
<Plus v-else :size="14" />
{{ locale.personalApiKey.create }}
</button>
</div>
<div v-if="apiKeyLoading" class="flex items-center justify-center gap-2 py-8 text-xs text-text-tertiary text-center">
<RefreshCw :size="16" class="animate-spin" />
<span>{{ locale.personalApiKey.loading }}</span>
</div>
<div
v-else-if="personalApiKeys.length === 0"
class="rounded-2xl border border-dashed border-border-secondary bg-bg-primary-40 px-5 py-8 text-center"
>
<KeyRound :size="28" class="mx-auto text-text-secondary mb-3" />
<p class="text-sm font-bold text-text-secondary">{{ locale.personalApiKey.emptyTitle }}</p>
<p class="text-xs text-text-disabled mt-2 leading-relaxed">
{{ locale.personalApiKey.emptyDesc }}
</p>
</div>
<div v-else class="space-y-3">
<div
v-for="key in personalApiKeys"
:key="key.id"
class="rounded-2xl border border-border-secondary-70 bg-bg-primary-45 p-4"
>
<div class="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-black text-text-primary">{{ key.name }}</h3>
<span
class="px-2 py-0.5 rounded text-[10px] font-black border"
:class="getApiKeyStatusClass(key.status)"
>
{{ getApiKeyStatusLabel(key.status) }}
</span>
</div>
<p class="text-xs text-text-tertiary mt-1">{{ key.description || locale.personalApiKey.noDescription }}</p>
</div>
<button
class="inline-flex items-center justify-center gap-2 px-3 py-2 border border-error-20 bg-error-10 text-error hover:bg-error-15 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="apiKeyDeletingId === key.id"
@click="deletePersonalApiKey(key)"
>
<RefreshCw v-if="apiKeyDeletingId === key.id" :size="13" class="animate-spin" />
<Trash2 v-else :size="13" />
{{ locale.personalApiKey.delete }}
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mt-5">
<div class="space-y-1">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.keyPrefix }}</p>
<p class="font-mono text-xs text-primary">{{ key.keyPrefix }}...</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.createdAt }}</p>
<p class="text-xs text-text-tertiary">{{ formatDate(key.createdAt) }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.lastUsedAt }}</p>
<p class="text-xs text-text-tertiary">{{ key.lastUsedAt ? formatDate(key.lastUsedAt) : locale.personalApiKey.neverUsed }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.usageCount }}</p>
<button
class="text-xs font-bold text-success hover:text-success-hover transition-colors disabled:cursor-default disabled:opacity-60"
@click="openPersonalApiKeyLogs(key)"
>
{{ key.usageCount || 0 }}
</button>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.expiresAt }}</p>
<p class="text-xs text-text-tertiary">{{ key.expiresAt ? formatDate(key.expiresAt) : locale.personalApiKey.neverExpires }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- 音源设置(ChKSz) -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-border-secondary-50 pb-5 mb-6">
<div class="p-2.5 bg-warning-10 rounded-xl flex items-center justify-center">
<ListMusic :size="20" class="text-warning" />
</div>
<div>
<h2 class="text-base font-black text-text-primary">{{ locale.musicSource.title }}</h2>
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.musicSource.desc }}</p>
</div>
</div>
<div class="max-w-xl space-y-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest" for="chksz-apikey-input">
{{ locale.musicSource.keyLabel }}
</label>
<div class="flex items-stretch gap-2">
<div class="flex-1 min-w-0 flex items-center rounded-xl border border-border-secondary bg-bg-primary-45 focus-within:border-border-tertiary transition-colors">
<input
id="chksz-apikey-input"
v-model="chkszKeyInput"
:type="showChkszKey ? 'text' : 'password'"
:placeholder="locale.musicSource.keyPlaceholder"
autocomplete="off"
class="flex-1 min-w-0 bg-transparent px-4 py-2.5 font-mono text-xs text-text-primary outline-none"
>
<button
type="button"
class="px-3 text-text-tertiary hover:text-text-primary transition-colors shrink-0"
:title="showChkszKey ? locale.musicSource.hideKey : locale.musicSource.showKey"
@click="showChkszKey = !showChkszKey"
>
<EyeOff v-if="showChkszKey" :size="15" />
<Eye v-else :size="15" />
</button>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="chkszTesting || chkszKeyInput.trim() === chkszApiKey"
@click="saveChkszKey"
>
<Check :size="14" />
{{ locale.musicSource.save }}
</button>
<button
class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-border-secondary bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="chkszTesting || (!chkszApiKey && !chkszKeyInput)"
@click="clearChkszKeySetting"
>
<Trash2 :size="14" />
{{ locale.musicSource.clear }}
</button>
<button
class="inline-flex items-center justify-center gap-2 px-4 py-2 border border-border-secondary bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="chkszTesting || (!chkszKeyInput.trim() && !chkszApiKey)"
@click="testChkszKey"
>
<RefreshCw v-if="chkszTesting" :size="14" class="animate-spin" />
<Play v-else :size="14" />
{{ locale.musicSource.test }}
</button>
</div>
<p
v-if="chkszTestResult"
class="text-xs font-bold leading-relaxed"
:class="chkszTestResult.valid ? 'text-success' : 'text-error'"
>
{{ chkszTestResult.text }}
</p>
<p class="text-xs text-text-tertiary leading-relaxed">
{{ locale.musicSource.hint }}
<a
href="https://api.chksz.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-hover font-bold underline underline-offset-2"
>api.chksz.com</a>
</p>
</div>
</section>
<!-- 修改密码 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-border-secondary-50 pb-5 mb-6">
<div class="p-2.5 bg-primary-10 rounded-xl flex items-center justify-center">
<Lock :size="20" class="text-primary" />
</div>
<div>
<h2 class="text-base font-black text-text-primary">{{ locale.changePassword }}</h2>
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.changePasswordDesc }}</p>
</div>
</div>
<div class="max-w-md">
<AuthChangePasswordForm />
</div>
</section>
<!-- 双重认证 -->
<section :class="sectionClass">
<AuthTwoFactorSetup :initial-enabled="auth.user.value?.has2FA" />
</section>
<!-- 登录会话 -->
<section :class="sectionClass">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border-secondary-50 pb-5 mb-6">
<div class="flex items-center gap-3">
<div class="p-2.5 bg-info-10 rounded-xl flex items-center justify-center"><Monitor :size="20" class="text-info" /></div>
<div>
<h2 class="text-base font-black text-text-primary">{{ locale.sessions.title }}</h2>
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.sessions.desc }}</p>
</div>
</div>
<button class="inline-flex items-center justify-center gap-2 px-3 py-2 border border-border-secondary bg-bg-primary-40 hover:bg-bg-tertiary text-text-secondary text-xs font-bold rounded-xl transition-all disabled:opacity-50" :disabled="sessionsLoading || sessionsRevoking" @click="loadSessions">
<RefreshCw :size="14" :class="{ 'animate-spin': sessionsLoading }" /> {{ locale.sessions.refresh }}
</button>
</div>
<div v-if="sessionsLoading" class="flex items-center justify-center gap-2 py-8 text-xs text-text-tertiary"><RefreshCw :size="16" class="animate-spin" />{{ locale.sessions.loading }}</div>
<div v-else-if="sessions.length === 0" class="py-8 text-center text-xs text-text-tertiary">{{ locale.sessions.empty }}</div>
<div v-else class="space-y-3">
<div v-for="session in sessions" :key="session.id" class="flex flex-col md:flex-row md:items-center justify-between gap-4 rounded-2xl border border-border-secondary-70 bg-bg-primary-45 p-4">
<div class="flex items-start gap-3 min-w-0">
<div class="w-10 h-10 shrink-0 rounded-xl bg-bg-tertiary flex items-center justify-center text-text-secondary"><Monitor :size="19" /></div>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"><p class="text-sm font-black text-text-primary">{{ session.browser }} · {{ session.operatingSystem }}</p><span v-if="session.current" class="px-2 py-0.5 rounded-full bg-success-10 text-success text-[10px] font-bold">{{ locale.sessions.current }}</span></div>
<p class="text-xs text-text-tertiary mt-1">{{ locale.sessions.ip }}: {{ session.ipAddress || '-' }} · {{ locale.sessions.loginMethod }}: {{ getSessionMethod(session.loginMethod) }}</p>
<p class="text-xs text-text-tertiary mt-1">{{ locale.sessions.lastActive }}: {{ formatDate(session.lastActiveAt) }} · {{ locale.sessions.expiresAt }}: {{ formatDate(session.expiresAt) }}</p>
</div>
</div>
<button class="inline-flex items-center justify-center gap-2 shrink-0 px-3 py-2 border border-error-20 bg-error-10 hover:bg-error-15 text-error text-xs font-bold rounded-xl transition-all disabled:opacity-50" :disabled="sessionsRevoking" @click="revokeSession(session)"><LogOut :size="14" />{{ locale.sessions.logout }}</button>
</div>
<button v-if="sessions.length > 1" class="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-error-20 bg-error-10 hover:bg-error-15 text-error text-xs font-bold rounded-xl transition-all disabled:opacity-50" :disabled="sessionsRevoking" @click="revokeOtherSessions"><LogOut :size="14" />{{ locale.sessions.logoutOthers }}</button>
</div>
</section>
</div>
</div>
</div>
<ConfirmDialog
v-model:show="showDeleteConfirmDialog"
type="danger"
:title="locale.personalApiKey.deleteTitle"
:message="deleteConfirmMessage"
:confirm-text="locale.personalApiKey.delete"
:cancel-text="locale.personalApiKey.cancel"
:loading="apiKeyDeletingId !== null"
@confirm="confirmDeletePersonalApiKey"
@cancel="cancelDeletePersonalApiKey"
/>
<Teleport to="body">
<Transition name="modal">
<div
v-if="createdApiKey"
class="fixed inset-0 z-[1000] flex items-center justify-center p-4 bg-bg-primary-70 backdrop-blur-sm"
>
<div class="w-full max-w-xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden">
<div class="p-6 border-b border-border-secondary flex items-center justify-between">
<div>
<h3 class="text-lg font-black text-text-primary">{{ locale.personalApiKey.createdTitle }}</h3>
<p class="text-xs text-text-tertiary mt-1">{{ locale.personalApiKey.createdDesc }}</p>
</div>
<button class="text-text-tertiary hover:text-text-primary transition-colors" @click="closeCreatedApiKey">
<X :size="20" />
</button>
</div>
<div class="p-6 space-y-5">
<div class="flex items-start gap-3 rounded-2xl border border-warning-20 bg-warning-10 p-4 text-warning">
<AlertTriangle :size="18" class="shrink-0 mt-0.5" />
<p class="text-xs font-bold leading-relaxed">
{{ locale.personalApiKey.copyWarning }}
</p>
</div>
<div class="space-y-2">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.personalApiKey.fullKey }}</p>
<div class="flex items-stretch gap-2">
<div class="flex-1 min-w-0 rounded-xl border border-border-secondary bg-bg-primary px-4 py-3 font-mono text-xs text-primary break-all select-all">
{{ createdApiKey.apiKey }}
</div>
<button
class="w-12 rounded-xl flex items-center justify-center transition-all"
:class="apiKeyCopied ? 'bg-success text-text-primary' : 'bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary'"
@click="copyApiKey(createdApiKey.apiKey)"
>
<Check v-if="apiKeyCopied" :size="16" />
<Copy v-else :size="16" />
</button>
</div>
</div>
</div>
<div class="p-6 border-t border-border-secondary">
<button
class="w-full py-3 bg-bg-primary border border-border-secondary hover:border-border-tertiary text-text-primary text-xs font-black rounded-xl transition-all"
@click="closeCreatedApiKey"
>
{{ locale.personalApiKey.closeSaved }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<Transition name="modal">
<div
v-if="showApiKeyLogsModal"
class="fixed inset-0 z-[1000] flex items-center justify-center p-4 bg-bg-primary-70 backdrop-blur-sm"
>
<div class="w-full max-w-4xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden">
<div class="p-6 border-b border-border-secondary flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-black text-text-primary">{{ locale.personalApiKey.logsTitle }}</h3>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.personalApiKey.logsSubtitle(selectedApiKeyForLogs?.name || locale.personalApiKey.defaultName, apiKeyLogsPagination.total) }}
</p>
</div>
<button class="text-text-tertiary hover:text-text-primary transition-colors" @click="closePersonalApiKeyLogs">
<X :size="20" />
</button>
</div>
<div class="p-6">
<div v-if="apiKeyLogsLoading" class="flex items-center justify-center gap-2 py-10 text-xs text-text-tertiary">
<RefreshCw :size="16" class="animate-spin" />
<span>{{ locale.personalApiKey.loadingLogs }}</span>
</div>
<div v-else-if="apiKeyLogs.length === 0" class="py-10 text-center">
<p class="text-sm font-bold text-text-secondary">{{ locale.personalApiKey.noLogs }}</p>
<p class="text-xs text-text-disabled mt-2">{{ locale.personalApiKey.noLogsDesc }}</p>
</div>
<div v-else class="space-y-4">
<div class="overflow-hidden rounded-2xl border border-border-secondary">
<div class="max-h-[60vh] overflow-auto">
<table class="min-w-full text-left">
<thead class="sticky top-0 bg-bg-primary-95 backdrop-blur border-b border-border-secondary">
<tr class="text-[10px] font-black uppercase tracking-widest text-text-tertiary">
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.time }}</th>
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.method }}</th>
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.endpoint }}</th>
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.status }}</th>
<th class="px-4 py-3">IP</th>
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.duration }}</th>
<th class="px-4 py-3">{{ locale.personalApiKey.logColumns.error }}</th>
</tr>
</thead>
<tbody>
<tr v-for="log in apiKeyLogs" :key="log.id" class="border-b border-border-secondary last:border-0">
<td class="px-4 py-3 text-xs text-text-tertiary whitespace-nowrap">{{ formatDate(log.createdAt) }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black border"
:class="getApiMethodClass(log.method)"
>
{{ log.method }}
</span>
</td>
<td class="px-4 py-3 text-xs text-text-secondary break-all">{{ log.endpoint }}</td>
<td class="px-4 py-3 text-xs font-bold" :class="getApiStatusClass(log.statusCode)">
{{ log.statusCode }}
</td>
<td class="px-4 py-3 text-xs text-text-tertiary whitespace-nowrap">{{ log.ipAddress }}</td>
<td class="px-4 py-3 text-xs text-text-tertiary whitespace-nowrap">{{ log.responseTimeMs }} ms</td>
<td class="px-4 py-3 text-xs text-text-tertiary break-all">
{{ log.errorMessage || locale.personalApiKey.none }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-text-tertiary">
{{ locale.personalApiKey.pageInfo(apiKeyLogsPagination.page, apiKeyLogsPagination.totalPages || 1) }}
</p>
<div class="flex items-center gap-2">
<button
class="px-3 py-2 rounded-xl border border-border-secondary text-xs font-bold text-text-secondary disabled:opacity-40"
:disabled="apiKeyLogsPagination.page <= 1 || apiKeyLogsLoading"
@click="changePersonalApiKeyLogsPage(apiKeyLogsPagination.page - 1)"
>
{{ locale.personalApiKey.previousPage }}
</button>
<button
class="px-3 py-2 rounded-xl border border-border-secondary text-xs font-bold text-text-secondary disabled:opacity-40"
:disabled="apiKeyLogsPagination.page >= apiKeyLogsPagination.totalPages || apiKeyLogsLoading"
@click="changePersonalApiKeyLogsPage(apiKeyLogsPagination.page + 1)"
>
{{ locale.personalApiKey.nextPage }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import {
AlertTriangle,
ArrowLeft,
Check,
Copy,
Eye,
EyeOff,
KeyRound,
Link as LinkIcon,
ListMusic,
Lock,
LogOut,
Monitor,
Play,
Plus,
RefreshCw,
Trash2,
User,
X
} from '@lucide/vue'
import AccountSocialBindings from '~/components/Account/SocialBindings.vue'
import { useAuth } from '~/composables/useAuth'
import { useChkszSource } from '~/composables/useChkszSource'
import { useToast } from '~/composables/useToast'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
import { getProviderDisplayName } from '~/utils/oauth'
import { useScrollMemory } from '~/composables/useScrollMemory'
const auth = useAuth()
const router = useRouter()
const route = useRoute()
const { showToast } = useToast()
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
const { currentLocale, pages } = useLocale()
const { localize: localizeServerError } = useServerErrors()
const locale = computed(() => pages.value?.account || {})
// 记忆并恢复页面滚动位置
useScrollMemory()
const getAccountText = (path, ...args) => {
const value = String(path).split('.').reduce((target, key) => target?.[key], locale.value?.personalApiKey)
return formatLocaleValue(value, ...args)
}
const hasOAuthProviders = computed(() => {
return oauthProviders.value.length > 0
})
const avatarError = ref(false)
const personalApiKeys = ref([])
const apiKeyLoading = ref(false)
const apiKeyCreating = ref(false)
const apiKeyDeletingId = ref(null)
const createdApiKey = ref(null)
const apiKeyCopied = ref(false)
const showDeleteConfirmDialog = ref(false)
const pendingDeleteApiKey = ref(null)
const showApiKeyLogsModal = ref(false)
const selectedApiKeyForLogs = ref(null)
const apiKeyLogs = ref([])
const apiKeyLogsLoading = ref(false)
const apiKeyLogsPagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0
})
const sessions = ref([])
const sessionsLoading = ref(false)
const sessionsRevoking = ref(false)
// 音源设置(ChKSz apikey,仅存本浏览器 localStorage)
const { chkszApiKey, clearChkszKey, verifyChkszKey } = useChkszSource()
const chkszKeyInput = ref('')
const showChkszKey = ref(false)
const chkszTesting = ref(false)
const chkszTestResult = ref(null)
const saveChkszKey = () => {
const trimmed = chkszKeyInput.value.trim()
if (!trimmed) return
chkszApiKey.value = trimmed
showToast(locale.value.musicSource.saveSuccess, 'success')
}
const clearChkszKeySetting = () => {
clearChkszKey()
chkszKeyInput.value = ''
chkszTestResult.value = null
showToast(locale.value.musicSource.clearSuccess, 'success')
}
const testChkszKey = async () => {
chkszTesting.value = true
chkszTestResult.value = null
const keyToTest = chkszKeyInput.value.trim() || chkszApiKey.value
try {
const result = await verifyChkszKey(keyToTest)
const textMap = {
ok: locale.value.musicSource.testSuccess,
empty: locale.value.musicSource.testEmpty,
timeout: locale.value.musicSource.testTimeout,
network: locale.value.musicSource.testNetwork,
api_error: locale.value.musicSource.testInvalid,
http_401: locale.value.musicSource.testInvalid,
http_403: locale.value.musicSource.testInvalid,
http_429: locale.value.musicSource.testInvalid
}
let text = textMap[result.message] || locale.value.musicSource.testNetwork
if (!result.valid && result.detail) {
text += `(${result.detail})`
}
chkszTestResult.value = {
valid: result.valid,
text
}
} finally {
chkszTesting.value = false
}
}
// 监听用户头像变化,重置错误状态
watch(
() => auth.user.value?.avatar,
() => {
avatarError.value = false
}
)
// 处理来自 OAuth 回调的消息
onMounted(() => {
refreshSiteConfig()
loadPersonalApiKeys()
loadSessions()
chkszKeyInput.value = chkszApiKey.value || ''
if (route.query.message) {
showToast(route.query.message, 'success')
router.replace({ query: { ...route.query, message: undefined, error: undefined } })
}
if (route.query.error) {
showToast(route.query.error, 'error')
router.replace({ query: { ...route.query, message: undefined, error: undefined } })
}
})
const loadSessions = async () => {
sessionsLoading.value = true
try {
const response = await $fetch('/api/user/sessions')
sessions.value = response.data || []
} catch (error) {
console.error('加载登录会话失败:', error)
showToast(localizeServerError(error, locale.value.sessions.loadFailed), 'error')
} finally {
sessionsLoading.value = false
}
}
const revokeSession = async (session) => {
sessionsRevoking.value = true
try {
await $fetch('/api/user/sessions', { method: 'DELETE', query: { id: session.id } })
if (session.current) {
await auth.logout()
return
}
showToast(locale.value.sessions.logoutSuccess, 'success')
await loadSessions()
} catch (error) {
showToast(localizeServerError(error, locale.value.sessions.logoutFailed), 'error')
} finally {
sessionsRevoking.value = false
}
}
const revokeOtherSessions = async () => {
sessionsRevoking.value = true
try {
await $fetch('/api/user/sessions', { method: 'DELETE' })
showToast(locale.value.sessions.logoutOthersSuccess, 'success')
await loadSessions()
} catch (error) {
showToast(localizeServerError(error, locale.value.sessions.logoutFailed), 'error')
} finally {
sessionsRevoking.value = false
}
}
const getSessionMethod = (method) => {
if (!method) return '-'
const baseMap = {
password: locale.value.sessions.methods.password,
'2fa': locale.value.sessions.methods.twoFactor,
webauthn: locale.value.sessions.methods.webauthn,
legacy: locale.value.sessions.methods.legacy
}
if (baseMap[method]) return baseMap[method]
return getProviderDisplayName(method)
}
// 样式类常量
const sectionClass = 'bg-bg-secondary-40 border border-border-secondary rounded-3xl p-6 md:p-8 shadow-2xl'
const userInitials = computed(() => {
const name = auth.user.value?.name || auth.user.value?.username || 'U'
return name.charAt(0).toUpperCase()
})
const roleName = computed(() => {
const role = auth.user.value?.role
return locale.value.roles[role] || role
})
const goBack = () => {
navigateTo('/')
}
const loadPersonalApiKeys = async () => {
apiKeyLoading.value = true
try {
const response = await $fetch('/api/user/api-keys')
if (response.success) {
personalApiKeys.value = response.data || []
}
} catch (error) {
console.error('加载个人 API Key 失败:', error)
showToast(getErrorMessage(error) || getAccountText('loadFailed'), 'error')
} finally {
apiKeyLoading.value = false
}
}
const createPersonalApiKey = async () => {
apiKeyCreating.value = true
try {
const response = await $fetch('/api/user/api-keys', {
method: 'POST',
body: {
name: locale.value.personalApiKey.defaultName,
description: locale.value.personalApiKey.defaultDescription
}
})
if (response.success) {
createdApiKey.value = response.data
showToast(getAccountText('createSuccess'), 'success')
await loadPersonalApiKeys()
}
} catch (error) {
console.error('创建个人 API Key 失败:', error)
showToast(getErrorMessage(error) || getAccountText('createFailed'), 'error')
} finally {
apiKeyCreating.value = false
}
}
const deletePersonalApiKey = async (key) => {
pendingDeleteApiKey.value = key
showDeleteConfirmDialog.value = true
}
const confirmDeletePersonalApiKey = async () => {
const key = pendingDeleteApiKey.value
if (!key) {
showDeleteConfirmDialog.value = false
return
}
apiKeyDeletingId.value = key.id
try {
const response = await $fetch(`/api/user/api-keys/${key.id}`, {
method: 'DELETE'
})
if (response.success) {
showToast(getAccountText('deleteSuccess'), 'success')
await loadPersonalApiKeys()
}
} catch (error) {
console.error('删除个人 API Key 失败:', error)
showToast(getErrorMessage(error) || getAccountText('deleteFailed'), 'error')
} finally {
apiKeyDeletingId.value = null
showDeleteConfirmDialog.value = false
pendingDeleteApiKey.value = null
}
}
const cancelDeletePersonalApiKey = () => {
showDeleteConfirmDialog.value = false
pendingDeleteApiKey.value = null
}
const deleteConfirmMessage = computed(() => {
const key = pendingDeleteApiKey.value
if (!key) {
return getAccountText('deleteMessageDefault')
}
return getAccountText('deleteMessage', key.name)
})
const copyApiKey = async (text) => {
try {
await navigator.clipboard.writeText(text)
apiKeyCopied.value = true
showToast(getAccountText('copied'), 'success')
setTimeout(() => {
apiKeyCopied.value = false
}, 2000)
} catch (error) {
console.error('复制 API Key 失败:', error)
showToast(getAccountText('copyFailed'), 'error')
}
}
const closeCreatedApiKey = () => {
createdApiKey.value = null
apiKeyCopied.value = false
}
const openPersonalApiKeyLogs = async (key) => {
if (!key) {
return
}
selectedApiKeyForLogs.value = key
showApiKeyLogsModal.value = true
apiKeyLogsPagination.value = {
page: 1,
limit: 10,
total: 0,
totalPages: 0
}
await loadPersonalApiKeyLogs(1)
}
const loadPersonalApiKeyLogs = async (page = 1) => {
const key = selectedApiKeyForLogs.value
if (!key) return
apiKeyLogsLoading.value = true
try {
const response = await $fetch(`/api/user/api-keys/${key.id}/logs`, {
query: {
page,
limit: apiKeyLogsPagination.value.limit
}
})
if (response.success) {
apiKeyLogs.value = response.data?.logs || []
apiKeyLogsPagination.value = response.data?.pagination || {
page,
limit: apiKeyLogsPagination.value.limit,
total: 0,
totalPages: 0
}
}
} catch (error) {
console.error('加载个人 API Key 调用记录失败:', error)
showToast(getErrorMessage(error) || getAccountText('logsFailed'), 'error')
apiKeyLogs.value = []
} finally {
apiKeyLogsLoading.value = false
}
}
const changePersonalApiKeyLogsPage = async (page) => {
if (page < 1) return
if (apiKeyLogsPagination.value.totalPages > 0 && page > apiKeyLogsPagination.value.totalPages) return
await loadPersonalApiKeyLogs(page)
}
const closePersonalApiKeyLogs = () => {
showApiKeyLogsModal.value = false
selectedApiKeyForLogs.value = null
apiKeyLogs.value = []
apiKeyLogsLoading.value = false
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString(currentLocale.value === 'en-US' ? 'en-US' : 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const getApiKeyStatusLabel = (status) => {
const map = {
active: getAccountText('status.active'),
inactive: getAccountText('status.inactive'),
expired: getAccountText('status.expired')
}
return map[status] || status
}
const getApiKeyStatusClass = (status) => {
const map = {
active: 'bg-success-10 text-success border-success-20',
inactive: 'bg-bg-tertiary text-text-tertiary border-border-tertiary-50',
expired: 'bg-error-10 text-error border-error-20'
}
return map[status] || 'bg-bg-tertiary text-text-tertiary border-border-tertiary-50'
}
const getApiMethodClass = (method) => {
const map = {
GET: 'bg-success-10 text-success border-success-20',
POST: 'bg-primary-10 text-primary border-primary-20',
PUT: 'bg-warning-10 text-warning border-warning-20',
DELETE: 'bg-error-10 text-error border-error-20'
}
return map[method] || 'bg-bg-tertiary text-text-tertiary border-border-tertiary-50'
}
const getApiStatusClass = (statusCode) => {
if (statusCode >= 200 && statusCode < 300) return 'text-success'
if (statusCode >= 300 && statusCode < 400) return 'text-warning'
return 'text-error'
}
</script>