feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user